@matthesketh/utopia-runtime 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Hesketh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @matthesketh/utopia-runtime
2
+
3
+ DOM renderer, directives, component lifecycle, scheduler, and hydration for UtopiaJS. This is the client-side runtime that compiled `.utopia` components import from.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @matthesketh/utopia-runtime
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { mount } from '@matthesketh/utopia-runtime';
15
+ import App from './App.utopia';
16
+
17
+ mount(App, '#app');
18
+ ```
19
+
20
+ For hydrating server-rendered HTML:
21
+
22
+ ```ts
23
+ import { hydrate } from '@matthesketh/utopia-runtime';
24
+ import App from './App.utopia';
25
+
26
+ hydrate(App, '#app');
27
+ ```
28
+
29
+ ## API
30
+
31
+ **DOM helpers** (used by compiled template output):
32
+
33
+ | Export | Description |
34
+ |--------|-------------|
35
+ | `createElement(tag)` | Create a DOM element |
36
+ | `createTextNode(text)` | Create a text node |
37
+ | `createComment(text)` | Create a comment node |
38
+ | `setText(node, text)` | Set text content |
39
+ | `setAttr(el, name, value)` | Set an attribute |
40
+ | `addEventListener(el, event, handler)` | Attach an event listener |
41
+ | `appendChild(parent, child)` | Append a child node |
42
+ | `insertBefore(parent, node, ref)` | Insert before a reference node |
43
+ | `removeNode(node)` | Remove a node from the DOM |
44
+
45
+ **Directives** (used by compiled control-flow):
46
+
47
+ | Export | Description |
48
+ |--------|-------------|
49
+ | `createIf(anchor, cond, trueBranch, falseBranch?)` | Conditional rendering |
50
+ | `createFor(anchor, list, renderFn)` | List rendering |
51
+ | `createComponent(def, props?)` | Component instantiation |
52
+
53
+ **Lifecycle:**
54
+
55
+ | Export | Description |
56
+ |--------|-------------|
57
+ | `mount(component, target)` | Mount a component to the DOM |
58
+ | `createComponentInstance(def, props?)` | Create a component instance |
59
+ | `hydrate(component, target)` | Hydrate server-rendered HTML |
60
+
61
+ **Scheduler:**
62
+
63
+ | Export | Description |
64
+ |--------|-------------|
65
+ | `queueJob(fn)` | Queue a microtask job |
66
+ | `nextTick()` | Wait for the next flush |
67
+
68
+ **Re-exports from `@matthesketh/utopia-core`:** `signal`, `computed`, `effect`, `batch`, `untrack`, `createEffect`.
69
+
70
+ See [docs/architecture.md](../../docs/architecture.md) and [docs/ssr.md](../../docs/ssr.md) for full details.
71
+
72
+ ## License
73
+
74
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,425 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ addEventListener: () => addEventListener,
24
+ appendChild: () => appendChild,
25
+ batch: () => import_utopia_core2.batch,
26
+ computed: () => import_utopia_core2.computed,
27
+ createComment: () => createComment,
28
+ createComponent: () => createComponent,
29
+ createComponentInstance: () => createComponentInstance,
30
+ createEffect: () => import_utopia_core3.effect,
31
+ createElement: () => createElement,
32
+ createFor: () => createFor,
33
+ createIf: () => createIf,
34
+ createTextNode: () => createTextNode,
35
+ effect: () => import_utopia_core2.effect,
36
+ hydrate: () => hydrate,
37
+ insertBefore: () => insertBefore,
38
+ mount: () => mount,
39
+ nextTick: () => nextTick,
40
+ queueJob: () => queueJob,
41
+ removeNode: () => removeNode,
42
+ setAttr: () => setAttr,
43
+ setText: () => setText,
44
+ signal: () => import_utopia_core2.signal,
45
+ untrack: () => import_utopia_core2.untrack
46
+ });
47
+ module.exports = __toCommonJS(index_exports);
48
+
49
+ // src/component.ts
50
+ function createComponentInstance(definition, props) {
51
+ let styleElement = null;
52
+ const instance = {
53
+ el: null,
54
+ props: props ?? {},
55
+ slots: {},
56
+ mount(target, anchor) {
57
+ if (instance.el) {
58
+ target.insertBefore(instance.el, anchor ?? null);
59
+ return;
60
+ }
61
+ const ctx = definition.setup ? definition.setup(instance.props) : {};
62
+ const renderCtx = {
63
+ ...ctx,
64
+ $slots: instance.slots
65
+ };
66
+ instance.el = definition.render(renderCtx);
67
+ target.insertBefore(instance.el, anchor ?? null);
68
+ if (definition.styles && !styleElement) {
69
+ styleElement = document.createElement("style");
70
+ styleElement.textContent = definition.styles;
71
+ document.head.appendChild(styleElement);
72
+ }
73
+ },
74
+ unmount() {
75
+ if (instance.el && instance.el.parentNode) {
76
+ instance.el.parentNode.removeChild(instance.el);
77
+ }
78
+ instance.el = null;
79
+ if (styleElement && styleElement.parentNode) {
80
+ styleElement.parentNode.removeChild(styleElement);
81
+ styleElement = null;
82
+ }
83
+ }
84
+ };
85
+ return instance;
86
+ }
87
+ function mount(component, target) {
88
+ const el = typeof target === "string" ? document.querySelector(target) : target;
89
+ if (!el) {
90
+ throw new Error(
91
+ `[utopia] Mount target not found: ${typeof target === "string" ? target : "Element"}`
92
+ );
93
+ }
94
+ const instance = createComponentInstance(component);
95
+ instance.mount(el);
96
+ return instance;
97
+ }
98
+
99
+ // src/hydration.ts
100
+ var isHydrating = false;
101
+ var hydrateNode = null;
102
+ var cursorStack = [];
103
+ function claimNode() {
104
+ const node = hydrateNode;
105
+ if (node) {
106
+ hydrateNode = node.nextSibling;
107
+ }
108
+ return node;
109
+ }
110
+ function enterNode(el) {
111
+ cursorStack.push(hydrateNode);
112
+ hydrateNode = el.firstChild;
113
+ }
114
+ function exitNode() {
115
+ hydrateNode = cursorStack.pop() ?? null;
116
+ }
117
+ function hydrate(component, target) {
118
+ const el = typeof target === "string" ? document.querySelector(target) : target;
119
+ if (!el) {
120
+ throw new Error(
121
+ `[utopia] Hydration target not found: ${typeof target === "string" ? target : "Element"}`
122
+ );
123
+ }
124
+ isHydrating = true;
125
+ hydrateNode = el.firstChild;
126
+ try {
127
+ const instance = createComponentInstance(component);
128
+ const ctx = component.setup ? component.setup(instance.props) : {};
129
+ const renderCtx = {
130
+ ...ctx,
131
+ $slots: instance.slots
132
+ };
133
+ instance.el = component.render(renderCtx);
134
+ if (component.styles) {
135
+ const style = document.createElement("style");
136
+ style.textContent = component.styles;
137
+ document.head.appendChild(style);
138
+ }
139
+ } finally {
140
+ isHydrating = false;
141
+ hydrateNode = null;
142
+ cursorStack.length = 0;
143
+ }
144
+ }
145
+
146
+ // src/dom.ts
147
+ function createElement(tag) {
148
+ if (isHydrating) {
149
+ const node = claimNode();
150
+ if (node && node.nodeType === 1) {
151
+ enterNode(node);
152
+ return node;
153
+ }
154
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
155
+ console.warn(`[utopia] Hydration mismatch: expected <${tag}>, got`, node);
156
+ }
157
+ }
158
+ return document.createElement(tag);
159
+ }
160
+ function createTextNode(text) {
161
+ if (isHydrating) {
162
+ const node = claimNode();
163
+ if (node && node.nodeType === 3) {
164
+ return node;
165
+ }
166
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
167
+ console.warn(`[utopia] Hydration mismatch: expected text node, got`, node);
168
+ }
169
+ }
170
+ return document.createTextNode(String(text));
171
+ }
172
+ function setText(node, value) {
173
+ const text = value == null ? "" : String(value);
174
+ if (node.data !== text) {
175
+ node.data = text;
176
+ }
177
+ }
178
+ function setAttr(el, name, value) {
179
+ if (name === "class") {
180
+ if (value == null || value === false) {
181
+ el.removeAttribute("class");
182
+ } else if (typeof value === "string") {
183
+ el.className = value;
184
+ } else if (typeof value === "object") {
185
+ const classes = [];
186
+ for (const key of Object.keys(value)) {
187
+ if (value[key]) {
188
+ classes.push(key);
189
+ }
190
+ }
191
+ el.className = classes.join(" ");
192
+ }
193
+ return;
194
+ }
195
+ if (name === "style") {
196
+ const htmlEl = el;
197
+ if (value == null || value === false) {
198
+ htmlEl.removeAttribute("style");
199
+ } else if (typeof value === "string") {
200
+ htmlEl.style.cssText = value;
201
+ } else if (typeof value === "object") {
202
+ htmlEl.style.cssText = "";
203
+ for (const prop of Object.keys(value)) {
204
+ const val = value[prop];
205
+ if (val != null) {
206
+ htmlEl.style.setProperty(
207
+ prop.replace(/([A-Z])/g, "-$1").toLowerCase(),
208
+ String(val)
209
+ );
210
+ }
211
+ }
212
+ }
213
+ return;
214
+ }
215
+ const BOOLEAN_ATTRS = /* @__PURE__ */ new Set([
216
+ "disabled",
217
+ "checked",
218
+ "readonly",
219
+ "hidden",
220
+ "selected",
221
+ "required",
222
+ "multiple",
223
+ "autofocus",
224
+ "autoplay",
225
+ "controls",
226
+ "loop",
227
+ "muted",
228
+ "open",
229
+ "novalidate"
230
+ ]);
231
+ if (BOOLEAN_ATTRS.has(name)) {
232
+ if (value) {
233
+ el.setAttribute(name, "");
234
+ if (name in el) {
235
+ el[name] = true;
236
+ }
237
+ } else {
238
+ el.removeAttribute(name);
239
+ if (name in el) {
240
+ el[name] = false;
241
+ }
242
+ }
243
+ return;
244
+ }
245
+ if (name.startsWith("data-")) {
246
+ const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
247
+ el.dataset[key] = value == null ? "" : String(value);
248
+ return;
249
+ }
250
+ if (value == null || value === false) {
251
+ el.removeAttribute(name);
252
+ } else {
253
+ el.setAttribute(name, value === true ? "" : String(value));
254
+ }
255
+ }
256
+ function addEventListener(el, event, handler) {
257
+ el.addEventListener(event, handler);
258
+ return () => {
259
+ el.removeEventListener(event, handler);
260
+ };
261
+ }
262
+ function insertBefore(parent, node, anchor) {
263
+ parent.insertBefore(node, anchor);
264
+ }
265
+ function removeNode(node) {
266
+ if (node.parentNode) {
267
+ node.parentNode.removeChild(node);
268
+ }
269
+ }
270
+ function appendChild(parent, child) {
271
+ if (isHydrating) {
272
+ if (child.nodeType === 1) {
273
+ exitNode();
274
+ }
275
+ return;
276
+ }
277
+ parent.appendChild(child);
278
+ }
279
+ function createComment(text) {
280
+ if (isHydrating) {
281
+ const node = claimNode();
282
+ if (node && node.nodeType === 8) {
283
+ return node;
284
+ }
285
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
286
+ console.warn(`[utopia] Hydration mismatch: expected comment node, got`, node);
287
+ }
288
+ }
289
+ return document.createComment(text);
290
+ }
291
+
292
+ // src/directives.ts
293
+ var import_utopia_core = require("@matthesketh/utopia-core");
294
+ function clearNodes(nodes) {
295
+ for (const node of nodes) {
296
+ removeNode(node);
297
+ }
298
+ nodes.length = 0;
299
+ }
300
+ function createIf(anchor, condition, renderTrue, renderFalse) {
301
+ let currentNodes = [];
302
+ let lastConditionTruthy;
303
+ const parent = anchor.parentNode;
304
+ const dispose = (0, import_utopia_core.effect)(() => {
305
+ const truthy = !!condition();
306
+ if (truthy === lastConditionTruthy) {
307
+ return;
308
+ }
309
+ lastConditionTruthy = truthy;
310
+ clearNodes(currentNodes);
311
+ if (truthy) {
312
+ const node = renderTrue();
313
+ currentNodes.push(node);
314
+ insertBefore(parent, node, anchor);
315
+ } else if (renderFalse) {
316
+ const node = renderFalse();
317
+ currentNodes.push(node);
318
+ insertBefore(parent, node, anchor);
319
+ }
320
+ });
321
+ return () => {
322
+ dispose();
323
+ clearNodes(currentNodes);
324
+ };
325
+ }
326
+ function createFor(anchor, list, renderItem, key) {
327
+ let currentNodes = [];
328
+ const parent = anchor.parentNode;
329
+ void key;
330
+ const dispose = (0, import_utopia_core.effect)(() => {
331
+ const items = list();
332
+ clearNodes(currentNodes);
333
+ for (let i = 0; i < items.length; i++) {
334
+ const node = renderItem(items[i], i);
335
+ currentNodes.push(node);
336
+ insertBefore(parent, node, anchor);
337
+ }
338
+ });
339
+ return () => {
340
+ dispose();
341
+ clearNodes(currentNodes);
342
+ };
343
+ }
344
+ function createComponent(Component, props, children) {
345
+ const instance = createComponentInstance(Component, props);
346
+ if (children) {
347
+ for (const slotName of Object.keys(children)) {
348
+ instance.slots[slotName] = children[slotName];
349
+ }
350
+ }
351
+ const ctx = Component.setup ? Component.setup(instance.props) : {};
352
+ const renderCtx = {
353
+ ...ctx,
354
+ $slots: instance.slots
355
+ };
356
+ instance.el = Component.render(renderCtx);
357
+ if (Component.styles) {
358
+ const style = document.createElement("style");
359
+ style.textContent = Component.styles;
360
+ document.head.appendChild(style);
361
+ }
362
+ return instance.el;
363
+ }
364
+
365
+ // src/scheduler.ts
366
+ var queue = /* @__PURE__ */ new Set();
367
+ var isFlushing = false;
368
+ var isFlushPending = false;
369
+ var resolvedPromise = Promise.resolve();
370
+ function queueJob(job) {
371
+ queue.add(job);
372
+ if (!isFlushPending && !isFlushing) {
373
+ isFlushPending = true;
374
+ resolvedPromise.then(flushJobs);
375
+ }
376
+ }
377
+ function nextTick() {
378
+ return resolvedPromise.then();
379
+ }
380
+ function flushJobs() {
381
+ isFlushPending = false;
382
+ isFlushing = true;
383
+ try {
384
+ for (const job of queue) {
385
+ queue.delete(job);
386
+ job();
387
+ }
388
+ } finally {
389
+ isFlushing = false;
390
+ if (queue.size > 0) {
391
+ isFlushPending = true;
392
+ resolvedPromise.then(flushJobs);
393
+ }
394
+ }
395
+ }
396
+
397
+ // src/index.ts
398
+ var import_utopia_core2 = require("@matthesketh/utopia-core");
399
+ var import_utopia_core3 = require("@matthesketh/utopia-core");
400
+ // Annotate the CommonJS export names for ESM import in node:
401
+ 0 && (module.exports = {
402
+ addEventListener,
403
+ appendChild,
404
+ batch,
405
+ computed,
406
+ createComment,
407
+ createComponent,
408
+ createComponentInstance,
409
+ createEffect,
410
+ createElement,
411
+ createFor,
412
+ createIf,
413
+ createTextNode,
414
+ effect,
415
+ hydrate,
416
+ insertBefore,
417
+ mount,
418
+ nextTick,
419
+ queueJob,
420
+ removeNode,
421
+ setAttr,
422
+ setText,
423
+ signal,
424
+ untrack
425
+ });
@@ -0,0 +1,172 @@
1
+ export { batch, computed, effect as createEffect, effect, signal, untrack } from '@matthesketh/utopia-core';
2
+
3
+ /**
4
+ * @matthesketh/utopia-runtime — Low-level DOM helpers
5
+ *
6
+ * These thin wrappers are the only layer between compiled .utopia template
7
+ * output and the real DOM. Keeping them minimal makes tree-shaking effective
8
+ * and keeps the runtime footprint small.
9
+ */
10
+ /** Create a real DOM element for the given tag name. */
11
+ declare function createElement(tag: string): HTMLElement;
12
+ /** Create a DOM text node. */
13
+ declare function createTextNode(text: string): Text;
14
+ /**
15
+ * Set the text content of a Text node. The compiler wraps calls to this
16
+ * function inside an `effect()` so the DOM stays in sync with signals.
17
+ */
18
+ declare function setText(node: Text, value: any): void;
19
+ /**
20
+ * Set an attribute on an element, handling the many special cases that arise
21
+ * in real-world templates:
22
+ *
23
+ * - **class**: accepts a string or an object `{ active: true, hidden: false }`
24
+ * - **style**: accepts a string or an object `{ color: 'red', fontSize: '14px' }`
25
+ * - **Boolean attributes** (`disabled`, `checked`, `readonly`, `hidden`,
26
+ * `selected`, `required`, `multiple`, `autofocus`, `autoplay`, `controls`,
27
+ * `loop`, `muted`, `open`, `novalidate`): set/remove the attribute based on
28
+ * truthiness, and also set the IDL property where applicable.
29
+ * - **data-* attributes**: set via `el.dataset`
30
+ * - Everything else: plain `setAttribute` / `removeAttribute`.
31
+ */
32
+ declare function setAttr(el: Element, name: string, value: any): void;
33
+ /**
34
+ * Add an event listener to an element and return a cleanup function that
35
+ * removes it.
36
+ */
37
+ declare function addEventListener(el: Element, event: string, handler: EventListener): () => void;
38
+ /** Insert `node` into `parent` before the given `anchor` (or append if null). */
39
+ declare function insertBefore(parent: Node, node: Node, anchor: Node | null): void;
40
+ /** Remove a node from its parent. No-op if the node has no parent. */
41
+ declare function removeNode(node: Node): void;
42
+ /** Append a child node to a parent. */
43
+ declare function appendChild(parent: Node, child: Node): void;
44
+ /** Create a DOM comment node. */
45
+ declare function createComment(text: string): Comment;
46
+
47
+ /**
48
+ * @matthesketh/utopia-runtime — Component lifecycle
49
+ *
50
+ * Provides the primitives for instantiating and mounting compiled .utopia
51
+ * component definitions.
52
+ */
53
+ /**
54
+ * A ComponentDefinition is the object the compiler produces for each .utopia
55
+ * single-file component.
56
+ */
57
+ interface ComponentDefinition {
58
+ /** The `<script>` block compiled into a setup function. */
59
+ setup?: (props: Record<string, any>) => Record<string, any>;
60
+ /** The `<template>` block compiled into a render function. */
61
+ render: (ctx: Record<string, any>) => Node;
62
+ /** Scoped CSS extracted from the `<style>` block, if any. */
63
+ styles?: string;
64
+ }
65
+ /**
66
+ * A live instance of a mounted component.
67
+ */
68
+ interface ComponentInstance {
69
+ /** The root DOM node produced by `render()`. */
70
+ el: Node | null;
71
+ /** The reactive props passed into this component. */
72
+ props: Record<string, any>;
73
+ /** Named slots (each value is a factory that returns a DOM subtree). */
74
+ slots: Record<string, () => Node>;
75
+ /** Mount the component's root node into the given target element. */
76
+ mount(target: Element, anchor?: Node): void;
77
+ /** Remove the component's root node from the DOM and clean up styles. */
78
+ unmount(): void;
79
+ }
80
+ /**
81
+ * Create a `ComponentInstance` from a compiled component definition.
82
+ *
83
+ * The instance is **not** automatically mounted — call `instance.mount()`
84
+ * to attach it to the DOM.
85
+ */
86
+ declare function createComponentInstance(definition: ComponentDefinition, props?: Record<string, any>): ComponentInstance;
87
+ /**
88
+ * Mount a root component to the page.
89
+ *
90
+ * ```ts
91
+ * import App from './App.utopia'
92
+ * import { mount } from '@matthesketh/utopia-runtime'
93
+ *
94
+ * mount(App, '#app')
95
+ * ```
96
+ *
97
+ * @param component The compiled root component definition.
98
+ * @param target A CSS selector string or a DOM Element to mount into.
99
+ * @returns The `ComponentInstance`, allowing later `unmount()`.
100
+ */
101
+ declare function mount(component: ComponentDefinition, target: string | Element): ComponentInstance;
102
+
103
+ /**
104
+ * @matthesketh/utopia-runtime — Runtime directive implementations
105
+ *
106
+ * These functions are called by the code the compiler emits for control-flow
107
+ * constructs (`@if`, `@for`) and child components in .utopia templates.
108
+ */
109
+
110
+ /**
111
+ * Conditional rendering directive.
112
+ *
113
+ * @param anchor A Comment node already in the DOM that marks the insertion
114
+ * point. All branch nodes are inserted immediately before it.
115
+ * @param condition A function that returns a truthy/falsy value (typically
116
+ * reading a signal so the effect tracks it).
117
+ * @param renderTrue Factory that produces the DOM subtree for the "true" branch.
118
+ * @param renderFalse Optional factory for the "false" / else branch.
119
+ * @returns A dispose function that tears down the effect and removes nodes.
120
+ */
121
+ declare function createIf(anchor: Comment, condition: () => any, renderTrue: () => Node, renderFalse?: () => Node): () => void;
122
+ /**
123
+ * List rendering directive.
124
+ *
125
+ * @param anchor Comment node marking the insertion point.
126
+ * @param list Function returning the current array (reads signals).
127
+ * @param renderItem Factory `(item, index) => Node` for each element.
128
+ * @param key Optional key extractor for future keyed-diffing optimisation.
129
+ * @returns A dispose function.
130
+ */
131
+ declare function createFor<T>(anchor: Comment, list: () => T[], renderItem: (item: T, index: number) => Node, key?: (item: T, index: number) => any): () => void;
132
+ /**
133
+ * Mount a child component at the given anchor position.
134
+ *
135
+ * @param Component The compiled component definition (has `setup`, `render`,
136
+ * and optional `styles`).
137
+ * @param props Props object to pass to the component's setup function.
138
+ * @param children Optional slot/children map. Each key maps to a function
139
+ * that returns a DOM node for that slot.
140
+ * @returns The root DOM node of the mounted component.
141
+ */
142
+ declare function createComponent(Component: ComponentDefinition, props?: Record<string, any>, children?: Record<string, () => Node>): Node;
143
+
144
+ /**
145
+ * @matthesketh/utopia-runtime — Microtask-based update scheduler
146
+ *
147
+ * Batches DOM update jobs so that multiple signal changes within the same
148
+ * synchronous tick only trigger a single DOM update pass.
149
+ */
150
+ /**
151
+ * Queue a job for the next microtask flush. Duplicate references to the same
152
+ * function are automatically de-duplicated because we store them in a Set.
153
+ */
154
+ declare function queueJob(job: () => void): void;
155
+ /**
156
+ * Returns a promise that resolves after the current pending flush completes.
157
+ * Useful for tests and any code that needs to wait for DOM updates.
158
+ */
159
+ declare function nextTick(): Promise<void>;
160
+
161
+ /**
162
+ * Hydrate a server-rendered component. Instead of creating new DOM nodes,
163
+ * the runtime claims the existing nodes in the target element and attaches
164
+ * event listeners and reactive effects.
165
+ *
166
+ * @param component - The compiled component definition
167
+ * @param target - A CSS selector string or DOM Element containing the
168
+ * server-rendered HTML
169
+ */
170
+ declare function hydrate(component: ComponentDefinition, target: string | Element): void;
171
+
172
+ export { type ComponentDefinition, type ComponentInstance, addEventListener, appendChild, createComment, createComponent, createComponentInstance, createElement, createFor, createIf, createTextNode, hydrate, insertBefore, mount, nextTick, queueJob, removeNode, setAttr, setText };
@@ -0,0 +1,172 @@
1
+ export { batch, computed, effect as createEffect, effect, signal, untrack } from '@matthesketh/utopia-core';
2
+
3
+ /**
4
+ * @matthesketh/utopia-runtime — Low-level DOM helpers
5
+ *
6
+ * These thin wrappers are the only layer between compiled .utopia template
7
+ * output and the real DOM. Keeping them minimal makes tree-shaking effective
8
+ * and keeps the runtime footprint small.
9
+ */
10
+ /** Create a real DOM element for the given tag name. */
11
+ declare function createElement(tag: string): HTMLElement;
12
+ /** Create a DOM text node. */
13
+ declare function createTextNode(text: string): Text;
14
+ /**
15
+ * Set the text content of a Text node. The compiler wraps calls to this
16
+ * function inside an `effect()` so the DOM stays in sync with signals.
17
+ */
18
+ declare function setText(node: Text, value: any): void;
19
+ /**
20
+ * Set an attribute on an element, handling the many special cases that arise
21
+ * in real-world templates:
22
+ *
23
+ * - **class**: accepts a string or an object `{ active: true, hidden: false }`
24
+ * - **style**: accepts a string or an object `{ color: 'red', fontSize: '14px' }`
25
+ * - **Boolean attributes** (`disabled`, `checked`, `readonly`, `hidden`,
26
+ * `selected`, `required`, `multiple`, `autofocus`, `autoplay`, `controls`,
27
+ * `loop`, `muted`, `open`, `novalidate`): set/remove the attribute based on
28
+ * truthiness, and also set the IDL property where applicable.
29
+ * - **data-* attributes**: set via `el.dataset`
30
+ * - Everything else: plain `setAttribute` / `removeAttribute`.
31
+ */
32
+ declare function setAttr(el: Element, name: string, value: any): void;
33
+ /**
34
+ * Add an event listener to an element and return a cleanup function that
35
+ * removes it.
36
+ */
37
+ declare function addEventListener(el: Element, event: string, handler: EventListener): () => void;
38
+ /** Insert `node` into `parent` before the given `anchor` (or append if null). */
39
+ declare function insertBefore(parent: Node, node: Node, anchor: Node | null): void;
40
+ /** Remove a node from its parent. No-op if the node has no parent. */
41
+ declare function removeNode(node: Node): void;
42
+ /** Append a child node to a parent. */
43
+ declare function appendChild(parent: Node, child: Node): void;
44
+ /** Create a DOM comment node. */
45
+ declare function createComment(text: string): Comment;
46
+
47
+ /**
48
+ * @matthesketh/utopia-runtime — Component lifecycle
49
+ *
50
+ * Provides the primitives for instantiating and mounting compiled .utopia
51
+ * component definitions.
52
+ */
53
+ /**
54
+ * A ComponentDefinition is the object the compiler produces for each .utopia
55
+ * single-file component.
56
+ */
57
+ interface ComponentDefinition {
58
+ /** The `<script>` block compiled into a setup function. */
59
+ setup?: (props: Record<string, any>) => Record<string, any>;
60
+ /** The `<template>` block compiled into a render function. */
61
+ render: (ctx: Record<string, any>) => Node;
62
+ /** Scoped CSS extracted from the `<style>` block, if any. */
63
+ styles?: string;
64
+ }
65
+ /**
66
+ * A live instance of a mounted component.
67
+ */
68
+ interface ComponentInstance {
69
+ /** The root DOM node produced by `render()`. */
70
+ el: Node | null;
71
+ /** The reactive props passed into this component. */
72
+ props: Record<string, any>;
73
+ /** Named slots (each value is a factory that returns a DOM subtree). */
74
+ slots: Record<string, () => Node>;
75
+ /** Mount the component's root node into the given target element. */
76
+ mount(target: Element, anchor?: Node): void;
77
+ /** Remove the component's root node from the DOM and clean up styles. */
78
+ unmount(): void;
79
+ }
80
+ /**
81
+ * Create a `ComponentInstance` from a compiled component definition.
82
+ *
83
+ * The instance is **not** automatically mounted — call `instance.mount()`
84
+ * to attach it to the DOM.
85
+ */
86
+ declare function createComponentInstance(definition: ComponentDefinition, props?: Record<string, any>): ComponentInstance;
87
+ /**
88
+ * Mount a root component to the page.
89
+ *
90
+ * ```ts
91
+ * import App from './App.utopia'
92
+ * import { mount } from '@matthesketh/utopia-runtime'
93
+ *
94
+ * mount(App, '#app')
95
+ * ```
96
+ *
97
+ * @param component The compiled root component definition.
98
+ * @param target A CSS selector string or a DOM Element to mount into.
99
+ * @returns The `ComponentInstance`, allowing later `unmount()`.
100
+ */
101
+ declare function mount(component: ComponentDefinition, target: string | Element): ComponentInstance;
102
+
103
+ /**
104
+ * @matthesketh/utopia-runtime — Runtime directive implementations
105
+ *
106
+ * These functions are called by the code the compiler emits for control-flow
107
+ * constructs (`@if`, `@for`) and child components in .utopia templates.
108
+ */
109
+
110
+ /**
111
+ * Conditional rendering directive.
112
+ *
113
+ * @param anchor A Comment node already in the DOM that marks the insertion
114
+ * point. All branch nodes are inserted immediately before it.
115
+ * @param condition A function that returns a truthy/falsy value (typically
116
+ * reading a signal so the effect tracks it).
117
+ * @param renderTrue Factory that produces the DOM subtree for the "true" branch.
118
+ * @param renderFalse Optional factory for the "false" / else branch.
119
+ * @returns A dispose function that tears down the effect and removes nodes.
120
+ */
121
+ declare function createIf(anchor: Comment, condition: () => any, renderTrue: () => Node, renderFalse?: () => Node): () => void;
122
+ /**
123
+ * List rendering directive.
124
+ *
125
+ * @param anchor Comment node marking the insertion point.
126
+ * @param list Function returning the current array (reads signals).
127
+ * @param renderItem Factory `(item, index) => Node` for each element.
128
+ * @param key Optional key extractor for future keyed-diffing optimisation.
129
+ * @returns A dispose function.
130
+ */
131
+ declare function createFor<T>(anchor: Comment, list: () => T[], renderItem: (item: T, index: number) => Node, key?: (item: T, index: number) => any): () => void;
132
+ /**
133
+ * Mount a child component at the given anchor position.
134
+ *
135
+ * @param Component The compiled component definition (has `setup`, `render`,
136
+ * and optional `styles`).
137
+ * @param props Props object to pass to the component's setup function.
138
+ * @param children Optional slot/children map. Each key maps to a function
139
+ * that returns a DOM node for that slot.
140
+ * @returns The root DOM node of the mounted component.
141
+ */
142
+ declare function createComponent(Component: ComponentDefinition, props?: Record<string, any>, children?: Record<string, () => Node>): Node;
143
+
144
+ /**
145
+ * @matthesketh/utopia-runtime — Microtask-based update scheduler
146
+ *
147
+ * Batches DOM update jobs so that multiple signal changes within the same
148
+ * synchronous tick only trigger a single DOM update pass.
149
+ */
150
+ /**
151
+ * Queue a job for the next microtask flush. Duplicate references to the same
152
+ * function are automatically de-duplicated because we store them in a Set.
153
+ */
154
+ declare function queueJob(job: () => void): void;
155
+ /**
156
+ * Returns a promise that resolves after the current pending flush completes.
157
+ * Useful for tests and any code that needs to wait for DOM updates.
158
+ */
159
+ declare function nextTick(): Promise<void>;
160
+
161
+ /**
162
+ * Hydrate a server-rendered component. Instead of creating new DOM nodes,
163
+ * the runtime claims the existing nodes in the target element and attaches
164
+ * event listeners and reactive effects.
165
+ *
166
+ * @param component - The compiled component definition
167
+ * @param target - A CSS selector string or DOM Element containing the
168
+ * server-rendered HTML
169
+ */
170
+ declare function hydrate(component: ComponentDefinition, target: string | Element): void;
171
+
172
+ export { type ComponentDefinition, type ComponentInstance, addEventListener, appendChild, createComment, createComponent, createComponentInstance, createElement, createFor, createIf, createTextNode, hydrate, insertBefore, mount, nextTick, queueJob, removeNode, setAttr, setText };
package/dist/index.js ADDED
@@ -0,0 +1,376 @@
1
+ // src/component.ts
2
+ function createComponentInstance(definition, props) {
3
+ let styleElement = null;
4
+ const instance = {
5
+ el: null,
6
+ props: props ?? {},
7
+ slots: {},
8
+ mount(target, anchor) {
9
+ if (instance.el) {
10
+ target.insertBefore(instance.el, anchor ?? null);
11
+ return;
12
+ }
13
+ const ctx = definition.setup ? definition.setup(instance.props) : {};
14
+ const renderCtx = {
15
+ ...ctx,
16
+ $slots: instance.slots
17
+ };
18
+ instance.el = definition.render(renderCtx);
19
+ target.insertBefore(instance.el, anchor ?? null);
20
+ if (definition.styles && !styleElement) {
21
+ styleElement = document.createElement("style");
22
+ styleElement.textContent = definition.styles;
23
+ document.head.appendChild(styleElement);
24
+ }
25
+ },
26
+ unmount() {
27
+ if (instance.el && instance.el.parentNode) {
28
+ instance.el.parentNode.removeChild(instance.el);
29
+ }
30
+ instance.el = null;
31
+ if (styleElement && styleElement.parentNode) {
32
+ styleElement.parentNode.removeChild(styleElement);
33
+ styleElement = null;
34
+ }
35
+ }
36
+ };
37
+ return instance;
38
+ }
39
+ function mount(component, target) {
40
+ const el = typeof target === "string" ? document.querySelector(target) : target;
41
+ if (!el) {
42
+ throw new Error(
43
+ `[utopia] Mount target not found: ${typeof target === "string" ? target : "Element"}`
44
+ );
45
+ }
46
+ const instance = createComponentInstance(component);
47
+ instance.mount(el);
48
+ return instance;
49
+ }
50
+
51
+ // src/hydration.ts
52
+ var isHydrating = false;
53
+ var hydrateNode = null;
54
+ var cursorStack = [];
55
+ function claimNode() {
56
+ const node = hydrateNode;
57
+ if (node) {
58
+ hydrateNode = node.nextSibling;
59
+ }
60
+ return node;
61
+ }
62
+ function enterNode(el) {
63
+ cursorStack.push(hydrateNode);
64
+ hydrateNode = el.firstChild;
65
+ }
66
+ function exitNode() {
67
+ hydrateNode = cursorStack.pop() ?? null;
68
+ }
69
+ function hydrate(component, target) {
70
+ const el = typeof target === "string" ? document.querySelector(target) : target;
71
+ if (!el) {
72
+ throw new Error(
73
+ `[utopia] Hydration target not found: ${typeof target === "string" ? target : "Element"}`
74
+ );
75
+ }
76
+ isHydrating = true;
77
+ hydrateNode = el.firstChild;
78
+ try {
79
+ const instance = createComponentInstance(component);
80
+ const ctx = component.setup ? component.setup(instance.props) : {};
81
+ const renderCtx = {
82
+ ...ctx,
83
+ $slots: instance.slots
84
+ };
85
+ instance.el = component.render(renderCtx);
86
+ if (component.styles) {
87
+ const style = document.createElement("style");
88
+ style.textContent = component.styles;
89
+ document.head.appendChild(style);
90
+ }
91
+ } finally {
92
+ isHydrating = false;
93
+ hydrateNode = null;
94
+ cursorStack.length = 0;
95
+ }
96
+ }
97
+
98
+ // src/dom.ts
99
+ function createElement(tag) {
100
+ if (isHydrating) {
101
+ const node = claimNode();
102
+ if (node && node.nodeType === 1) {
103
+ enterNode(node);
104
+ return node;
105
+ }
106
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
107
+ console.warn(`[utopia] Hydration mismatch: expected <${tag}>, got`, node);
108
+ }
109
+ }
110
+ return document.createElement(tag);
111
+ }
112
+ function createTextNode(text) {
113
+ if (isHydrating) {
114
+ const node = claimNode();
115
+ if (node && node.nodeType === 3) {
116
+ return node;
117
+ }
118
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
119
+ console.warn(`[utopia] Hydration mismatch: expected text node, got`, node);
120
+ }
121
+ }
122
+ return document.createTextNode(String(text));
123
+ }
124
+ function setText(node, value) {
125
+ const text = value == null ? "" : String(value);
126
+ if (node.data !== text) {
127
+ node.data = text;
128
+ }
129
+ }
130
+ function setAttr(el, name, value) {
131
+ if (name === "class") {
132
+ if (value == null || value === false) {
133
+ el.removeAttribute("class");
134
+ } else if (typeof value === "string") {
135
+ el.className = value;
136
+ } else if (typeof value === "object") {
137
+ const classes = [];
138
+ for (const key of Object.keys(value)) {
139
+ if (value[key]) {
140
+ classes.push(key);
141
+ }
142
+ }
143
+ el.className = classes.join(" ");
144
+ }
145
+ return;
146
+ }
147
+ if (name === "style") {
148
+ const htmlEl = el;
149
+ if (value == null || value === false) {
150
+ htmlEl.removeAttribute("style");
151
+ } else if (typeof value === "string") {
152
+ htmlEl.style.cssText = value;
153
+ } else if (typeof value === "object") {
154
+ htmlEl.style.cssText = "";
155
+ for (const prop of Object.keys(value)) {
156
+ const val = value[prop];
157
+ if (val != null) {
158
+ htmlEl.style.setProperty(
159
+ prop.replace(/([A-Z])/g, "-$1").toLowerCase(),
160
+ String(val)
161
+ );
162
+ }
163
+ }
164
+ }
165
+ return;
166
+ }
167
+ const BOOLEAN_ATTRS = /* @__PURE__ */ new Set([
168
+ "disabled",
169
+ "checked",
170
+ "readonly",
171
+ "hidden",
172
+ "selected",
173
+ "required",
174
+ "multiple",
175
+ "autofocus",
176
+ "autoplay",
177
+ "controls",
178
+ "loop",
179
+ "muted",
180
+ "open",
181
+ "novalidate"
182
+ ]);
183
+ if (BOOLEAN_ATTRS.has(name)) {
184
+ if (value) {
185
+ el.setAttribute(name, "");
186
+ if (name in el) {
187
+ el[name] = true;
188
+ }
189
+ } else {
190
+ el.removeAttribute(name);
191
+ if (name in el) {
192
+ el[name] = false;
193
+ }
194
+ }
195
+ return;
196
+ }
197
+ if (name.startsWith("data-")) {
198
+ const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
199
+ el.dataset[key] = value == null ? "" : String(value);
200
+ return;
201
+ }
202
+ if (value == null || value === false) {
203
+ el.removeAttribute(name);
204
+ } else {
205
+ el.setAttribute(name, value === true ? "" : String(value));
206
+ }
207
+ }
208
+ function addEventListener(el, event, handler) {
209
+ el.addEventListener(event, handler);
210
+ return () => {
211
+ el.removeEventListener(event, handler);
212
+ };
213
+ }
214
+ function insertBefore(parent, node, anchor) {
215
+ parent.insertBefore(node, anchor);
216
+ }
217
+ function removeNode(node) {
218
+ if (node.parentNode) {
219
+ node.parentNode.removeChild(node);
220
+ }
221
+ }
222
+ function appendChild(parent, child) {
223
+ if (isHydrating) {
224
+ if (child.nodeType === 1) {
225
+ exitNode();
226
+ }
227
+ return;
228
+ }
229
+ parent.appendChild(child);
230
+ }
231
+ function createComment(text) {
232
+ if (isHydrating) {
233
+ const node = claimNode();
234
+ if (node && node.nodeType === 8) {
235
+ return node;
236
+ }
237
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
238
+ console.warn(`[utopia] Hydration mismatch: expected comment node, got`, node);
239
+ }
240
+ }
241
+ return document.createComment(text);
242
+ }
243
+
244
+ // src/directives.ts
245
+ import { effect } from "@matthesketh/utopia-core";
246
+ function clearNodes(nodes) {
247
+ for (const node of nodes) {
248
+ removeNode(node);
249
+ }
250
+ nodes.length = 0;
251
+ }
252
+ function createIf(anchor, condition, renderTrue, renderFalse) {
253
+ let currentNodes = [];
254
+ let lastConditionTruthy;
255
+ const parent = anchor.parentNode;
256
+ const dispose = effect(() => {
257
+ const truthy = !!condition();
258
+ if (truthy === lastConditionTruthy) {
259
+ return;
260
+ }
261
+ lastConditionTruthy = truthy;
262
+ clearNodes(currentNodes);
263
+ if (truthy) {
264
+ const node = renderTrue();
265
+ currentNodes.push(node);
266
+ insertBefore(parent, node, anchor);
267
+ } else if (renderFalse) {
268
+ const node = renderFalse();
269
+ currentNodes.push(node);
270
+ insertBefore(parent, node, anchor);
271
+ }
272
+ });
273
+ return () => {
274
+ dispose();
275
+ clearNodes(currentNodes);
276
+ };
277
+ }
278
+ function createFor(anchor, list, renderItem, key) {
279
+ let currentNodes = [];
280
+ const parent = anchor.parentNode;
281
+ void key;
282
+ const dispose = effect(() => {
283
+ const items = list();
284
+ clearNodes(currentNodes);
285
+ for (let i = 0; i < items.length; i++) {
286
+ const node = renderItem(items[i], i);
287
+ currentNodes.push(node);
288
+ insertBefore(parent, node, anchor);
289
+ }
290
+ });
291
+ return () => {
292
+ dispose();
293
+ clearNodes(currentNodes);
294
+ };
295
+ }
296
+ function createComponent(Component, props, children) {
297
+ const instance = createComponentInstance(Component, props);
298
+ if (children) {
299
+ for (const slotName of Object.keys(children)) {
300
+ instance.slots[slotName] = children[slotName];
301
+ }
302
+ }
303
+ const ctx = Component.setup ? Component.setup(instance.props) : {};
304
+ const renderCtx = {
305
+ ...ctx,
306
+ $slots: instance.slots
307
+ };
308
+ instance.el = Component.render(renderCtx);
309
+ if (Component.styles) {
310
+ const style = document.createElement("style");
311
+ style.textContent = Component.styles;
312
+ document.head.appendChild(style);
313
+ }
314
+ return instance.el;
315
+ }
316
+
317
+ // src/scheduler.ts
318
+ var queue = /* @__PURE__ */ new Set();
319
+ var isFlushing = false;
320
+ var isFlushPending = false;
321
+ var resolvedPromise = Promise.resolve();
322
+ function queueJob(job) {
323
+ queue.add(job);
324
+ if (!isFlushPending && !isFlushing) {
325
+ isFlushPending = true;
326
+ resolvedPromise.then(flushJobs);
327
+ }
328
+ }
329
+ function nextTick() {
330
+ return resolvedPromise.then();
331
+ }
332
+ function flushJobs() {
333
+ isFlushPending = false;
334
+ isFlushing = true;
335
+ try {
336
+ for (const job of queue) {
337
+ queue.delete(job);
338
+ job();
339
+ }
340
+ } finally {
341
+ isFlushing = false;
342
+ if (queue.size > 0) {
343
+ isFlushPending = true;
344
+ resolvedPromise.then(flushJobs);
345
+ }
346
+ }
347
+ }
348
+
349
+ // src/index.ts
350
+ import { signal, computed, effect as effect2, batch, untrack } from "@matthesketh/utopia-core";
351
+ import { effect as effect3 } from "@matthesketh/utopia-core";
352
+ export {
353
+ addEventListener,
354
+ appendChild,
355
+ batch,
356
+ computed,
357
+ createComment,
358
+ createComponent,
359
+ createComponentInstance,
360
+ effect3 as createEffect,
361
+ createElement,
362
+ createFor,
363
+ createIf,
364
+ createTextNode,
365
+ effect2 as effect,
366
+ hydrate,
367
+ insertBefore,
368
+ mount,
369
+ nextTick,
370
+ queueJob,
371
+ removeNode,
372
+ setAttr,
373
+ setText,
374
+ signal,
375
+ untrack
376
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@matthesketh/utopia-runtime",
3
+ "version": "0.0.1",
4
+ "description": "DOM renderer and component lifecycle for UtopiaJS",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Matt <matt@matthesketh.pro>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/wrxck/utopiajs.git",
11
+ "directory": "packages/runtime"
12
+ },
13
+ "homepage": "https://github.com/wrxck/utopiajs/tree/main/packages/runtime",
14
+ "keywords": [
15
+ "runtime",
16
+ "dom",
17
+ "renderer",
18
+ "lifecycle",
19
+ "directives",
20
+ "utopiajs"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20.0.0"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "sideEffects": false,
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js",
36
+ "require": "./dist/index.cjs"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist"
41
+ ],
42
+ "dependencies": {
43
+ "@matthesketh/utopia-core": "0.0.1"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup src/index.ts --format esm,cjs --dts",
47
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch"
48
+ }
49
+ }