@blueshed/railroad 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jsx.ts ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * JSX Runtime — real DOM elements backed by signals
3
+ *
4
+ * createElement(tag, props, ...children)
5
+ * - tag: string → creates HTML element (or SVG element inside <svg>)
6
+ * - tag: function → calls component function(props)
7
+ * - props: attributes, event handlers (onclick etc), ref
8
+ * - children: string, number, Node, Signal<T>, arrays, null/undefined
9
+ *
10
+ * When a Signal is used as a child, an effect auto-updates the text node.
11
+ * When a Signal is used as a prop value, an effect auto-updates the attribute.
12
+ *
13
+ * SVG support:
14
+ * <svg> is created with the SVG namespace. Any HTML children appended to
15
+ * an SVG-namespaced parent are automatically adopted into the SVG namespace.
16
+ * This handles the JSX bottom-up evaluation order transparently — you can
17
+ * write <svg><g><circle /></g></svg> and it just works.
18
+ *
19
+ * Reactive helpers:
20
+ * when(signal, truthy, falsy?) — conditional rendering, swaps DOM nodes
21
+ * list(signal, keyFn, render) — keyed reactive list, render receives Signal<T>
22
+ * list(signal, render) — index-based reactive list, render receives raw T
23
+ * text(fn) — reactive text from computed expression
24
+ */
25
+
26
+ import { Signal, signal, effect, computed } from "./signals";
27
+ import type { Dispose } from "./signals";
28
+
29
+ // === SVG namespace ===
30
+
31
+ const SVG_NS = "http://www.w3.org/2000/svg";
32
+ const storedProps = new WeakMap<Element, Record<string, any>>();
33
+
34
+ // === Dispose scope management ===
35
+
36
+ const disposeStack: Dispose[][] = [];
37
+
38
+ export function pushDisposeScope(): void {
39
+ disposeStack.push([]);
40
+ }
41
+
42
+ export function popDisposeScope(): Dispose {
43
+ const disposers = disposeStack.pop() || [];
44
+ return () => disposers.forEach((d) => d());
45
+ }
46
+
47
+ function trackDispose(d: Dispose): void {
48
+ const scope = disposeStack[disposeStack.length - 1];
49
+ if (scope) scope.push(d);
50
+ }
51
+
52
+ // === Fragment ===
53
+
54
+ export function Fragment(props: any): DocumentFragment {
55
+ const frag = document.createDocumentFragment();
56
+ const children = props?.children
57
+ ? (Array.isArray(props.children) ? props.children : [props.children])
58
+ : [];
59
+ appendChildren(frag, children);
60
+ return frag;
61
+ }
62
+
63
+ // === Props application ===
64
+
65
+ function applyProps(el: Element, props: Record<string, any>): void {
66
+ for (const [key, value] of Object.entries(props)) {
67
+ if (key === "ref") {
68
+ if (typeof value === "function") value(el);
69
+ } else if (key === "innerHTML") {
70
+ if (value instanceof Signal) {
71
+ trackDispose(effect(() => { el.innerHTML = value.get(); }));
72
+ } else {
73
+ el.innerHTML = value;
74
+ }
75
+ } else if (key === "className" || key === "class") {
76
+ if (value instanceof Signal) {
77
+ trackDispose(effect(() => { el.setAttribute("class", value.get()); }));
78
+ } else {
79
+ el.setAttribute("class", value);
80
+ }
81
+ } else if (key === "value" || key === "checked" || key === "disabled" || key === "selected" || key === "srcdoc" || key === "src") {
82
+ if (value instanceof Signal) {
83
+ trackDispose(effect(() => { (el as any)[key] = value.get(); }));
84
+ } else {
85
+ (el as any)[key] = value;
86
+ }
87
+ } else if (key === "style" && typeof value === "object" && !(value instanceof Signal)) {
88
+ Object.assign((el as any).style, value);
89
+ } else if (key.startsWith("on")) {
90
+ el.addEventListener(key.slice(2).toLowerCase(), value);
91
+ } else {
92
+ if (value instanceof Signal) {
93
+ trackDispose(effect(() => {
94
+ const v = value.get();
95
+ if (v === false || v == null) el.removeAttribute(key);
96
+ else el.setAttribute(key, String(v));
97
+ }));
98
+ } else if (value !== false && value != null) {
99
+ el.setAttribute(key, String(value));
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ // === createElement ===
106
+
107
+ export function createElement(
108
+ tag: string | Function,
109
+ props: Record<string, any> | null,
110
+ ...children: any[]
111
+ ): Node {
112
+ if (typeof tag === "function") {
113
+ const componentProps = { ...props, children };
114
+ return tag(componentProps);
115
+ }
116
+
117
+ // SVG root element is always created with the SVG namespace.
118
+ // Child SVG elements are adopted in appendChildren when appended
119
+ // to an SVG-namespaced parent.
120
+ const el = tag === "svg"
121
+ ? document.createElementNS(SVG_NS, tag)
122
+ : document.createElement(tag);
123
+
124
+ if (props) {
125
+ storedProps.set(el, props);
126
+ applyProps(el, props);
127
+ }
128
+
129
+ appendChildren(el, children);
130
+ return el;
131
+ }
132
+
133
+ // === SVG adoption ===
134
+
135
+ /**
136
+ * Recursively adopt an HTML element into the SVG namespace.
137
+ * Creates a new SVG element, re-applies stored props (or copies
138
+ * attributes), and recursively adopts all children.
139
+ */
140
+ function adoptSvg(node: Node): Node {
141
+ if (node instanceof Text || node instanceof Comment) return node;
142
+ if (!(node instanceof Element) || node.namespaceURI === SVG_NS) return node;
143
+
144
+ const svgEl = document.createElementNS(SVG_NS, node.localName);
145
+ const props = storedProps.get(node);
146
+
147
+ if (props) {
148
+ storedProps.set(svgEl, props);
149
+ applyProps(svgEl, props);
150
+ } else {
151
+ // No stored props — copy attributes directly
152
+ for (let i = 0; i < node.attributes.length; i++) {
153
+ const attr = node.attributes[i]!;
154
+ svgEl.setAttribute(attr.name, attr.value);
155
+ }
156
+ }
157
+
158
+ // Adopt children recursively
159
+ while (node.firstChild) {
160
+ svgEl.appendChild(adoptSvg(node.removeChild(node.firstChild)));
161
+ }
162
+
163
+ return svgEl;
164
+ }
165
+
166
+ // === Child rendering ===
167
+
168
+ function appendChildren(parent: Node, children: any[]): void {
169
+ const isSvgParent = parent instanceof Element &&
170
+ parent.namespaceURI === SVG_NS;
171
+
172
+ for (const child of children.flat(Infinity)) {
173
+ if (child == null || child === false || child === true) continue;
174
+
175
+ if (child instanceof Signal) {
176
+ const text = document.createTextNode(String(child.peek()));
177
+ trackDispose(effect(() => {
178
+ text.textContent = String(child.get());
179
+ }));
180
+ parent.appendChild(text);
181
+ } else if (child instanceof Node) {
182
+ // Adopt HTML elements into SVG namespace when parent is SVG
183
+ if (isSvgParent &&
184
+ child instanceof Element &&
185
+ child.namespaceURI !== SVG_NS) {
186
+ parent.appendChild(adoptSvg(child));
187
+ } else {
188
+ parent.appendChild(child);
189
+ }
190
+ } else {
191
+ parent.appendChild(document.createTextNode(String(child)));
192
+ }
193
+ }
194
+ }
195
+
196
+ // === text() — reactive computed text node ===
197
+ // Use for expressions: text(() => count.get() > 5 ? "High" : "Low")
198
+
199
+ export function text(fn: () => string): Node {
200
+ const value = computed(fn);
201
+ const node = document.createTextNode(value.peek());
202
+ trackDispose(effect(() => {
203
+ node.textContent = value.get();
204
+ }));
205
+ return node;
206
+ }
207
+
208
+ // === when() — conditional rendering ===
209
+ // Swaps DOM nodes only when truthiness transitions (falsy↔truthy).
210
+ // Value changes within the same branch (e.g. "a" → "b") do NOT re-render.
211
+ // Components inside each branch should use signals to react to value changes.
212
+ // when(isLoggedIn, () => <Dashboard />, () => <Login />)
213
+
214
+ export function when(
215
+ condition: Signal<any> | (() => any),
216
+ truthy: () => Node,
217
+ falsy?: () => Node,
218
+ ): Node {
219
+ const anchor = document.createComment("when");
220
+ let current: Node | null = null;
221
+ let currentDispose: Dispose | null = null;
222
+ let wasTruthy: boolean | undefined = undefined;
223
+
224
+ const sig = typeof condition === "function" ? computed(condition) : condition;
225
+
226
+ function swap() {
227
+ const val = sig.get();
228
+ const isTruthy = !!val;
229
+
230
+ // Only swap when truthiness actually changes
231
+ if (isTruthy === wasTruthy) return;
232
+ wasTruthy = isTruthy;
233
+
234
+ if (currentDispose) currentDispose();
235
+ if (current && anchor.parentNode) {
236
+ anchor.parentNode.removeChild(current);
237
+ }
238
+
239
+ pushDisposeScope();
240
+ current = isTruthy ? truthy() : (falsy ? falsy() : null);
241
+ currentDispose = popDisposeScope();
242
+
243
+ if (current && anchor.parentNode) {
244
+ anchor.parentNode.insertBefore(current, anchor.nextSibling);
245
+ }
246
+ }
247
+
248
+ trackDispose(effect(() => {
249
+ sig.get(); // track
250
+ if (!anchor.parentNode) {
251
+ queueMicrotask(swap);
252
+ } else {
253
+ swap();
254
+ }
255
+ }));
256
+
257
+ // Return a fragment: anchor + initial content
258
+ const frag = document.createDocumentFragment();
259
+ frag.appendChild(anchor);
260
+ if (current) frag.appendChild(current);
261
+ return frag;
262
+ }
263
+
264
+ // === list() — keyed reactive list rendering ===
265
+ // Diffs by key to preserve DOM nodes across updates.
266
+ //
267
+ // Keyed form — render receives Signal<T> and Signal<number> so item
268
+ // updates flow into existing DOM without re-creating nodes:
269
+ // list(items, (t) => t.id, (item, index) => <li>{text(() => item.get().name)}</li>)
270
+ //
271
+ // Non-keyed form (index-based, raw values):
272
+ // list(items, (item, index) => <li>{item}</li>)
273
+
274
+ export function list<T>(
275
+ items: Signal<T[]>,
276
+ keyFnOrRender: ((item: T) => string | number) | ((item: T, index: number) => Node),
277
+ maybeRender?: (item: Signal<T>, index: Signal<number>) => Node,
278
+ ): Node {
279
+ const hasKeyFn = maybeRender !== undefined;
280
+ const keyFn = hasKeyFn ? keyFnOrRender as (item: T) => string | number : null;
281
+
282
+ type Entry = { node: Node; dispose: Dispose; item?: Signal<T>; index?: Signal<number> };
283
+ const anchor = document.createComment("list");
284
+ let entries: Map<string | number, Entry> = new Map();
285
+ let order: (string | number)[] = [];
286
+
287
+ function removeEntry(key: string | number) {
288
+ const entry = entries.get(key);
289
+ if (entry) {
290
+ entry.dispose();
291
+ entry.node.parentNode?.removeChild(entry.node);
292
+ entries.delete(key);
293
+ }
294
+ }
295
+
296
+ function clearAll() {
297
+ for (const [, entry] of entries) {
298
+ entry.dispose();
299
+ entry.node.parentNode?.removeChild(entry.node);
300
+ }
301
+ entries = new Map();
302
+ order = [];
303
+ }
304
+
305
+ function sync() {
306
+ const arr = items.get();
307
+ const parent = anchor.parentNode;
308
+ if (!parent) return;
309
+
310
+ const newKeys = arr.map((item, i) => keyFn ? keyFn(item) : i);
311
+ const newKeySet = new Set(newKeys);
312
+
313
+ // Remove entries no longer in the list
314
+ for (const key of order) {
315
+ if (!newKeySet.has(key)) removeEntry(key);
316
+ }
317
+
318
+ // Add or reorder entries
319
+ let insertBefore: Node = anchor;
320
+ for (let i = newKeys.length - 1; i >= 0; i--) {
321
+ const key = newKeys[i]!;
322
+ let entry = entries.get(key);
323
+
324
+ if (!entry) {
325
+ // New item — create
326
+ pushDisposeScope();
327
+ let node: Node;
328
+ if (hasKeyFn) {
329
+ const itemSig = signal(arr[i]!);
330
+ const indexSig = signal(i);
331
+ node = maybeRender!(itemSig, indexSig);
332
+ const dispose = popDisposeScope();
333
+ entry = { node, dispose, item: itemSig, index: indexSig };
334
+ } else {
335
+ node = (keyFnOrRender as (item: T, index: number) => Node)(arr[i]!, i);
336
+ const dispose = popDisposeScope();
337
+ entry = { node, dispose };
338
+ }
339
+ entries.set(key, entry);
340
+ } else if (hasKeyFn) {
341
+ // Existing keyed item — push new value into its signal
342
+ entry.item!.set(arr[i]!);
343
+ entry.index!.set(i);
344
+ }
345
+
346
+ // Move or insert into correct position
347
+ if (entry.node.nextSibling !== insertBefore) {
348
+ parent.insertBefore(entry.node, insertBefore);
349
+ }
350
+ insertBefore = entry.node;
351
+ }
352
+
353
+ order = newKeys;
354
+ }
355
+
356
+ trackDispose(effect(() => {
357
+ items.get(); // track
358
+ if (!anchor.parentNode) {
359
+ queueMicrotask(sync);
360
+ } else {
361
+ sync();
362
+ }
363
+ }));
364
+
365
+ trackDispose(() => clearAll());
366
+
367
+ const frag = document.createDocumentFragment();
368
+ frag.appendChild(anchor);
369
+ return frag;
370
+ }
371
+
372
+ // === JSX namespace for TypeScript ===
373
+
374
+ declare global {
375
+ namespace JSX {
376
+ type Element = globalThis.Node;
377
+ interface IntrinsicElements {
378
+ [tag: string]: any;
379
+ }
380
+ }
381
+ }
package/logger.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Logger — colored, timestamped, level-gated console output.
3
+ *
4
+ * Usage:
5
+ * import { createLogger, setLogLevel, loggedRequest } from "@blueshed/railroad";
6
+ *
7
+ * const log = createLogger("[server]");
8
+ * log.info("listening on :3000"); // 12:34:56.789 INFO [server] listening on :3000
9
+ * log.debug("tick"); // only shown when level is "debug"
10
+ * log.warn("slow query"); // yellow
11
+ * log.error("connection failed"); // red, always shown
12
+ *
13
+ * setLogLevel("debug"); // show everything
14
+ *
15
+ * const handler = loggedRequest("[api]", myHandler); // wrap a route with access logging
16
+ */
17
+
18
+ export type LogLevel = "error" | "warn" | "info" | "debug";
19
+
20
+ const LEVELS: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 };
21
+
22
+ let current: LogLevel =
23
+ (typeof process !== "undefined" && process.env?.LOG_LEVEL as LogLevel) || "info";
24
+
25
+ export function setLogLevel(level: LogLevel) {
26
+ current = level;
27
+ }
28
+
29
+ export function getLogLevel(): LogLevel {
30
+ return current;
31
+ }
32
+
33
+ function shouldLog(level: LogLevel): boolean {
34
+ return LEVELS[current] >= LEVELS[level];
35
+ }
36
+
37
+ // === Colors ===
38
+
39
+ const gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
40
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
41
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
42
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
43
+
44
+ const color: Record<LogLevel, (s: string) => string> = {
45
+ error: red,
46
+ warn: yellow,
47
+ info: gray,
48
+ debug: dim,
49
+ };
50
+
51
+ function timestamp() {
52
+ return new Date().toISOString().slice(11, 23);
53
+ }
54
+
55
+ function fmt(level: LogLevel, tag: string, msg: string) {
56
+ return `${gray(timestamp())} ${color[level](level.toUpperCase().padEnd(5))} ${tag} ${msg}`;
57
+ }
58
+
59
+ /** Create a tagged logger instance. */
60
+ export function createLogger(tag: string) {
61
+ return {
62
+ info: (msg: string) => { if (shouldLog("info")) console.log(fmt("info", tag, msg)); },
63
+ warn: (msg: string) => { if (shouldLog("warn")) console.warn(fmt("warn", tag, msg)); },
64
+ error: (msg: string) => { console.error(fmt("error", tag, msg)); },
65
+ debug: (msg: string) => { if (shouldLog("debug")) console.log(fmt("debug", tag, msg)); },
66
+ };
67
+ }
68
+
69
+ type Handler = (req: Request) => Response | Promise<Response>;
70
+
71
+ /** Wrap a route handler with access logging. */
72
+ export function loggedRequest(tag: string, handler: Handler): Handler {
73
+ const log = createLogger(tag);
74
+ return async (req: Request) => {
75
+ const start = performance.now();
76
+ try {
77
+ const res = await handler(req);
78
+ const ms = (performance.now() - start).toFixed(1);
79
+ log.info(
80
+ `${req.method} ${new URL(req.url).pathname} → ${res.status} (${ms}ms)`,
81
+ );
82
+ return res;
83
+ } catch (err: any) {
84
+ const ms = (performance.now() - start).toFixed(1);
85
+ log.error(
86
+ `${req.method} ${new URL(req.url).pathname} threw (${ms}ms): ${err.message}`,
87
+ );
88
+ throw err;
89
+ }
90
+ };
91
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@blueshed/railroad",
3
+ "version": "0.2.0",
4
+ "description": "Signals, JSX, and routes — a micro UI framework for Bun",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "types": "index.ts",
8
+ "exports": {
9
+ ".": "./index.ts",
10
+ "./signals": "./signals.ts",
11
+ "./jsx": "./jsx.ts",
12
+ "./routes": "./routes.ts",
13
+ "./shared": "./shared.ts",
14
+ "./jsx-runtime": "./jsx-runtime.ts",
15
+ "./jsx-dev-runtime": "./jsx-dev-runtime.ts"
16
+ },
17
+ "files": [
18
+ "*.ts",
19
+ "!*.test.ts",
20
+ "README.md",
21
+ "LICENSE",
22
+ ".claude/skills"
23
+ ],
24
+ "scripts": {
25
+ "check": "bunx tsc --noEmit",
26
+ "test": "bun test"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "^1.3.11",
30
+ "typescript": "^5.9.3"
31
+ },
32
+ "author": "Peter Bunyan <peter@blueshed.co.uk> (https://blueshed.co.uk)",
33
+ "license": "MIT",
34
+ "homepage": "https://github.com/blueshed/railroad",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/blueshed/railroad"
38
+ },
39
+ "keywords": ["signals", "jsx", "router", "bun", "ui", "framework"]
40
+ }
package/routes.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Routes — Hash-based client router built on signals
3
+ *
4
+ * API:
5
+ * routes(target, table) — declarative hash router, swaps target content
6
+ * route<T>(pattern) — reactive route: Signal<T | null>, null when unmatched
7
+ * navigate(path) — set location.hash programmatically
8
+ * matchRoute(pattern, path) — pure pattern matcher, returns params or null
9
+ *
10
+ * Handlers return a Node to render. The router manages cleanup automatically.
11
+ * routes(app, {
12
+ * "/": () => <Home />,
13
+ * "/site/:id": ({ id }) => <SiteDetail id={id} />,
14
+ * });
15
+ */
16
+
17
+ import { Signal, computed, effect } from "./signals";
18
+ import type { Dispose } from "./signals";
19
+ import { pushDisposeScope, popDisposeScope } from "./jsx";
20
+
21
+ let hashSignal: Signal<string> | null = null;
22
+
23
+ function getHash(): Signal<string> {
24
+ if (!hashSignal) {
25
+ hashSignal = new Signal(location.hash.slice(1) || "/");
26
+ window.addEventListener("hashchange", () => {
27
+ hashSignal!.set(location.hash.slice(1) || "/");
28
+ });
29
+ }
30
+ return hashSignal;
31
+ }
32
+
33
+ export function matchRoute(
34
+ pattern: string,
35
+ path: string,
36
+ ): Record<string, string> | null {
37
+ const pp = pattern.split("/");
38
+ const hp = path.split("/");
39
+ if (pp.length !== hp.length) return null;
40
+ const params: Record<string, string> = {};
41
+ for (let i = 0; i < pp.length; i++) {
42
+ if (pp[i]!.startsWith(":")) {
43
+ try {
44
+ params[pp[i]!.slice(1)] = decodeURIComponent(hp[i]!);
45
+ } catch {
46
+ return null;
47
+ }
48
+ } else if (pp[i] !== hp[i]) return null;
49
+ }
50
+ return params;
51
+ }
52
+
53
+ export function route<
54
+ T extends Record<string, string> = Record<string, string>,
55
+ >(pattern: string): Signal<T | null> {
56
+ const hash = getHash();
57
+ return computed(() => matchRoute(pattern, hash.get()) as T | null);
58
+ }
59
+
60
+ export function navigate(path: string): void {
61
+ location.hash = path;
62
+ }
63
+
64
+ type RouteHandler = (params: Record<string, string>) => Node;
65
+
66
+ export function routes(
67
+ target: HTMLElement,
68
+ table: Record<string, RouteHandler>,
69
+ ): Dispose {
70
+ const hash = getHash();
71
+ let activePattern: string | null = null;
72
+ let activeDispose: Dispose | null = null;
73
+
74
+ function teardown() {
75
+ if (activeDispose) activeDispose();
76
+ activeDispose = null;
77
+ activePattern = null;
78
+ target.replaceChildren();
79
+ }
80
+
81
+ function run(handler: RouteHandler, params: Record<string, string>) {
82
+ pushDisposeScope();
83
+ const node = handler(params);
84
+ activeDispose = popDisposeScope();
85
+ target.appendChild(node);
86
+ }
87
+
88
+ return effect(() => {
89
+ const path = hash.get();
90
+ for (const [pattern, handler] of Object.entries(table)) {
91
+ const params = matchRoute(pattern, path);
92
+ if (params) {
93
+ if (pattern === activePattern) return;
94
+ teardown();
95
+ activePattern = pattern;
96
+ run(handler, params);
97
+ return;
98
+ }
99
+ }
100
+ if (table["*"]) {
101
+ if (activePattern !== "*") {
102
+ teardown();
103
+ activePattern = "*";
104
+ run(table["*"], {});
105
+ }
106
+ return;
107
+ }
108
+ teardown();
109
+ });
110
+ }
package/shared.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared — typed provide / inject for dependency sharing.
3
+ *
4
+ * Any module can provide a value under a typed key.
5
+ * Any other module can inject it without prop-threading.
6
+ *
7
+ * const STORE = key<Store>("store");
8
+ * provide(STORE, createStore()); // in home.ts
9
+ * const store = inject(STORE); // anywhere
10
+ */
11
+
12
+ const registry = new Map<symbol, unknown>();
13
+
14
+ export type Key<T> = symbol & { __brand: T };
15
+
16
+ export function key<T>(name: string): Key<T> {
17
+ return Symbol(name) as Key<T>;
18
+ }
19
+
20
+ export function provide<T>(k: Key<T>, value: T): void {
21
+ registry.set(k, value);
22
+ }
23
+
24
+ export function inject<T>(k: Key<T>): T {
25
+ const v = registry.get(k);
26
+ if (v === undefined) throw new Error(`No provider for ${k.description}`);
27
+ return v as T;
28
+ }
29
+
30
+ export function tryInject<T>(k: Key<T>): T | undefined {
31
+ return registry.get(k) as T | undefined;
32
+ }