defuss-morph 0.1.1 → 0.3.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/dist/stats.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "package": "defuss-morph",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "files": {
5
5
  "dist/index.mjs": {
6
- "bytes": 41197,
7
- "gzip": 9762
6
+ "bytes": 48836,
7
+ "gzip": 12403
8
8
  },
9
9
  "dist/index.cjs": {
10
- "bytes": 42228,
11
- "gzip": 9912
10
+ "bytes": 50151,
11
+ "gzip": 12591
12
12
  },
13
13
  "dist/all.js": {
14
- "bytes": 42634,
15
- "gzip": 10082
14
+ "bytes": 50501,
15
+ "gzip": 12573
16
16
  },
17
17
  "dist/all.min.js": {
18
- "bytes": 19416,
19
- "gzip": 6951
18
+ "bytes": 23740,
19
+ "gzip": 8699
20
20
  }
21
21
  }
22
22
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "defuss-morph",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
+ "packageManager": "bun@1.4.3",
5
6
  "publishConfig": {
6
7
  "access": "public"
7
8
  },
8
9
  "license": "MIT",
9
- "description": "The defuss DOM morphing algorithm is a standalone package: morph an HTML string or JSX object into an existing DOM tree using native APIs and smart DOM diffing. Key/id-aware node matching, move-not-replace, in-place attribute patching, form-state preservation and delegated event cleanup. Allows for isomorphic rendering and patching of server-rendered HTML in the browser. Does not need any framework or virtual DOM. Can be used with any framework or library, or without any. No compile/transpile step required. Extremely lightweight. Just 6 KiB minified and gzipped. Written in TypeScript.",
10
+ "description": "Morph an HTML string or JSX/VNodes into an existing DOM tree with native APIs: key/id-aware matching, move-not-replace, in-place attribute patching, form-state preservation and delegated event cleanup. Store-driven reactive rendering (reactive(), <Reactive>), refs and a JSX runtime on top. Works with any framework or none, in the browser and on the server, without a virtual DOM or build step. Zero dependencies; written in TypeScript.",
10
11
  "keywords": [
11
12
  "dom",
12
13
  "morph",
@@ -21,9 +22,13 @@
21
22
  "ai-prototyping",
22
23
  "for-ai-agents"
23
24
  ],
25
+ "homepage": "https://morph.defuss.org/",
24
26
  "repository": {
25
- "url": "git+https://github.com/kyr0/defuss.git",
26
- "type": "git"
27
+ "type": "git",
28
+ "url": "git+https://github.com/kyr0/defuss-morph.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/kyr0/defuss-morph/issues"
27
32
  },
28
33
  "scripts": {
29
34
  "clean": "rm -rf ./coverage ./dist ./node_modules/.vite",
@@ -32,16 +37,17 @@
32
37
  "bundle": "bun scripts/bundle.ts",
33
38
  "minify": "bun scripts/minify.ts",
34
39
  "stats": "bun scripts/stats.ts",
35
- "verify": "bun scripts/verify.ts",
36
- "lint": "oxlint src scripts",
37
- "typecheck": "tsc --noEmit --emitDeclarationOnly false -p tsconfig.json",
40
+ "docs": "bun scripts/docs.ts",
41
+ "check:dist": "bun scripts/check-dist.ts",
42
+ "lint": "oxlint --deny-warnings --ignore-pattern 'docs/assets/defuss-morph.*' --ignore-pattern docs/assets/shiki.js --ignore-pattern docs/assets/demos.js --ignore-pattern docs/assets/router-demo.js --ignore-pattern docs/assets/bench.js --ignore-pattern 'docs/assets/vendor/**' src scripts docs/assets docs/demos",
43
+ "typecheck": "tsc --noEmit --emitDeclarationOnly false -p tsconfig.json && tsc -p docs/demos/tsconfig.json",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "coverage": "vitest run --coverage --project node --project 'browser (chromium)' --reporter=default --reporter=json --outputFile.json=coverage/tests.json",
47
+ "e2e": "make e2e",
48
+ "verify": "make verify",
38
49
  "purge-cdn": "bun scripts/purge-cdn.ts",
39
- "test": "vitest run --coverage",
40
- "test:watch": "vitest --coverage",
41
- "pretest:browser": "bunx playwright install chromium",
42
- "test:browser": "vitest run --project browser",
43
- "e2e": "bun run test:browser",
44
- "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run clean && bun run build && bun run bundle && bun run minify && bun run stats && bun run verify"
50
+ "prepublishOnly": "make verify"
45
51
  },
46
52
  "author": "Aron Homberg <info@aron-homberg.de>",
47
53
  "sideEffects": [
@@ -60,6 +66,26 @@
60
66
  "types": "./dist/index.d.ts",
61
67
  "default": "./dist/index.mjs"
62
68
  }
69
+ },
70
+ "./jsx-runtime": {
71
+ "require": {
72
+ "types": "./dist/index.d.ts",
73
+ "default": "./dist/index.cjs"
74
+ },
75
+ "import": {
76
+ "types": "./dist/index.d.ts",
77
+ "default": "./dist/index.mjs"
78
+ }
79
+ },
80
+ "./jsx-dev-runtime": {
81
+ "require": {
82
+ "types": "./dist/index.d.ts",
83
+ "default": "./dist/index.cjs"
84
+ },
85
+ "import": {
86
+ "types": "./dist/index.d.ts",
87
+ "default": "./dist/index.mjs"
88
+ }
63
89
  }
64
90
  },
65
91
  "main": "./dist/index.cjs",
@@ -67,7 +93,9 @@
67
93
  "types": "./dist/index.d.ts",
68
94
  "files": [
69
95
  "dist",
96
+ "skills",
70
97
  "README.md",
98
+ "CHANGELOG.md",
71
99
  "LICENSE"
72
100
  ],
73
101
  "engines": {
@@ -78,11 +106,16 @@
78
106
  "@vitest/browser": "^5.0.0",
79
107
  "@vitest/browser-playwright": "^5.0.0",
80
108
  "@vitest/coverage-v8": "^5.0.0",
109
+ "defuss-dom-router": "0.1.1",
110
+ "defuss-i18n": "0.2.0",
111
+ "defuss-query": "0.2.0",
112
+ "defuss-store": "0.1.0",
81
113
  "happy-dom": "^20.5.0",
82
114
  "oxc-minify": "^0.149.0",
83
115
  "oxlint": "^1.81.0",
84
116
  "pkgroll": "^2.27.0",
85
117
  "playwright": "^1.58.2",
118
+ "shiki": "4.5.0",
86
119
  "typescript": "^5.9.3",
87
120
  "vitest": "^5.0.0"
88
121
  }
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: defuss-morph
3
+ description: Write DOM updates with defuss-morph - morph an HTML string or JSX into the live DOM instead of assigning innerHTML, so focus, typed input and event listeners survive; re-render a region from a store with reactive(), <Reactive> or df$(selector).reactive(); refs and the forwardRef pattern; defuss-query, defuss-store, defuss-i18n and defuss-dom-router together. Use when code renders or updates HTML in the browser or on a server with happy-dom, in a bundled app or a standalone HTML page, without a virtual DOM.
4
+ ---
5
+
6
+ # defuss-morph 0.3.0 - Agent Skill
7
+
8
+ defuss-morph compares new content (an HTML string, JSX or VNodes) with the children of a live element and patches only the difference with native DOM calls. A node that exists before and after stays the same node, so its focus, caret, typed value, scroll position and listeners survive. There is no component tree, no scheduler and no virtual DOM: each call reads the live DOM as the previous state.
9
+
10
+ `reactive()`, `<Reactive>`, `installReactive()`, `createRef()` and the JSX runtime exist since 0.3.0. Check the installed version (`node_modules/defuss-morph/package.json`) before using them.
11
+
12
+ ## 1. Install: pick one path
13
+
14
+ **A package manager** (the project has a `package.json`): `bun add defuss-morph` (or the project's own manager). Add `defuss-store` for stores, `defuss-query` for `df$`, `defuss-i18n` and `defuss-dom-router` as the task needs them. For JSX, set the compiler's import source; TypeScript:
15
+
16
+ ```json
17
+ { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "defuss-morph" } }
18
+ ```
19
+
20
+ Bun, esbuild and Vite read the same two options. `defuss-morph/jsx-runtime` resolves to the package entry, so a bundle holds one copy.
21
+
22
+ **A standalone HTML page** (no build step): one module script registers the whole API as `window.df$`. Load defuss-query after it when the page uses `df$(selector)`:
23
+
24
+ ```html
25
+ <script type="module" src="https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js"></script>
26
+ <script type="module" src="https://cdn.jsdelivr.net/npm/defuss-query@0.2.0/dist/all.min.js"></script>
27
+ <script type="module">
28
+ import { createStore } from "https://cdn.jsdelivr.net/npm/defuss-store@0.1.0/dist/browser.min.js";
29
+ df$.installReactive(df$); // morph loaded before query: give selections .reactive()
30
+ df$.morph(document.getElementById("app"), "<p>Hello</p>");
31
+ </script>
32
+ ```
33
+
34
+ Module scripts run in document order, so the inline module sees both. Without a compiler, write JSX as `h()` calls: `h("button", { onClick }, "Save")` is `<button onClick={onClick}>Save</button>`.
35
+
36
+ ## 2. Rules
37
+
38
+ 1. **Re-render by morphing the complete next state** into a container: `morph(container, next)`. Never assign `innerHTML` to update a region; it rebuilds every node and loses focus, typed input and listeners.
39
+ 2. **Key every list item** with a stable ID: `key="..."` in HTML, `key={item.id}` in JSX, never the index. Keyed nodes move; unkeyed siblings match by tag in order and can swap content.
40
+ 3. **A component's `key` lands on the element it returns**, so `items.map((item) => <Row key={item.id} item={item} />)` is keyed.
41
+ 4. **Markup that omits something means "leave it alone".** An HTML string cannot uncheck a box, clear a typed `value` or remove a handler. To set such state, send a VNode: `morph(form, [{ attributes: { id: "terms", checked: false } }], { diff: true })`.
42
+ 5. **Escape text you put into an HTML string**, or pass it as a VNode child (`h("li", null, userText)`, defuss-query's `.text()`). morph renders its input and does not sanitize it; a `<script>` in an HTML string runs.
43
+ 6. **Diff mode (`{ diff: true }`) patches and appends only**: every item needs a `key` or `id`; it never removes or reorders. Use a full `morph()` for that.
44
+ 7. **`morph()` is synchronous**, unless it has a `transition` option: then it returns a Promise. `await` it before the next animated morph.
45
+ 8. **A component function runs again on every render.** There are no hooks. State that must outlive a render lives in a store created outside the component, or on the component's ref (`ref.state`).
46
+ 9. **Lifecycle timing.** morph sets refs while it renders, so `ref.current` is set when `morph()` returns. `onMount` runs in a microtask after insertion, and again after each morph that patches the element with an `onMount`. `onUnmount` runs once the element leaves the document and receives the element.
47
+ 10. **One copy of defuss-morph per page.** Your code, defuss-query and defuss-i18n must share it (one bundle, or one import map for ES modules); each copy keeps its own handler registry, and a second copy does not tear down the first one's handlers.
48
+
49
+ ## 3. The four patterns
50
+
51
+ Full examples with JSX and HTML variants: [references/patterns.md](references/patterns.md).
52
+
53
+ **Reactive rendering.** `reactive({ store, render }, target)` renders `render()` into `target` now and morphs it in again after every store change. `target` is an element, a ref or a list of elements. It returns `dispose()`.
54
+
55
+ ```ts
56
+ import { reactive } from "defuss-morph";
57
+ import { createStore } from "defuss-store";
58
+
59
+ const cart = createStore({ count: 0 });
60
+ const dispose = reactive({ store: cart, render: () => `<p>${cart.value.count} items</p>` }, cartEl);
61
+ cart.set("count", 2); // the same <p>, new text
62
+ ```
63
+
64
+ In JSX, `<Reactive store={cart} render={() => <p>{cart.value.count}</p>} />` renders a wrapper (`tag`, default `div`) and stays subscribed while the wrapper is in the document. For markup already in the page, `df$("#cart").reactive({ store, render, signal })` after `installReactive(df$)`; end it with an `AbortController`.
65
+
66
+ **Ref.** `const field = createRef<HTMLInputElement>()`, then `<input ref={field} />`; morph puts the element into `field.current` and keeps it there while the element survives.
67
+
68
+ **forwardRef.** There is no `forwardRef()` function. A component takes an optional `ref` prop with a default and puts its API on `ref.state`:
69
+
70
+ ```tsx
71
+ const Stepper = ({ ref = createRef<HTMLElement, StepperApi>() }: { ref?: Ref<HTMLElement, StepperApi> }) => {
72
+ // createStepper() returns { count: createStore({ n: 0 }), increment, reset }: references/patterns.md
73
+ const api = (ref.state ??= createStepper()); // state on the ref outlives a parent re-render
74
+ return <Reactive ref={ref} store={api.count} render={() => <output>{api.count.value.n}</output>} />;
75
+ };
76
+ ```
77
+
78
+ **Query references (HTML-first pages).** Name the parts with `data-ref` and select each once: `const list = df$("[data-ref=list]")`. `list.html(markup)` morphs; `.on()` listens natively; one listener on a container serves buttons rendered from HTML strings (`data-action` attributes), because an HTML string cannot carry handlers.
79
+
80
+ ## 4. Integrations
81
+
82
+ Details and the CDN variants: [references/integrations.md](references/integrations.md).
83
+
84
+ - **defuss-store**: `createStore(initial)`, `.value`, `.set(path, value)`, `.update(fn)`, `.subscribe()`; `computed(store, fn)` notifies only when its result changes. Any object with `subscribe(listener)` returning an unsubscribe function works as a store.
85
+ - **defuss-query**: `import df$ from "defuss-query"`; `.html()`, `.text()`, `.morph()` morph; `.form()` returns `FormData`; `installReactive(df$)` adds `.reactive()`.
86
+ - **defuss-i18n**: translations are `<template data-i18n-for data-i18n-locale>` beside the markup; `bindI18n(root, createI18n({ locale: "en", fallback: ["en"] }))`, then `setLocale("de")` morphs the marked parts and keeps typed input.
87
+ - **defuss-dom-router**: `createRouter({ routes, prepare: ({ to }) => ({ commit: () => morph(outlet, view(to)) }) })`, `router.attachLinks(document)`, `await router.start()`. Give each view's root a key per record (`key="project-7"`), so another record does not inherit typed input.
88
+
89
+ ## 5. Check your work
90
+
91
+ - Unit tests run in happy-dom: `morph(el, html)` works on any element of a happy-dom `Window`; `reactive()` re-renders as soon as the store notifies (defuss-store notifies synchronously); `<Reactive>` subscribes in a microtask, so `await new Promise((r) => setTimeout(r, 0))` before changing a store in a test.
92
+ - Assert identity, not only text: keep a reference to a node, morph, and check the node is still the one in the DOM.
93
+ - In a browser test, type into an input, trigger the re-render, and check the value and `document.activeElement`.
94
+
95
+ ## 6. References
96
+
97
+ - [references/api.md](references/api.md): every export with its signature and options.
98
+ - [references/patterns.md](references/patterns.md): reactive rendering, ref, forwardRef and query references, in JSX and HTML.
99
+ - [references/integrations.md](references/integrations.md): defuss-query, defuss-store, defuss-i18n, defuss-dom-router; bundler and CDN setups.
100
+ - [references/pitfalls.md](references/pitfalls.md): mistakes that compile and run but break state, and their fixes.
101
+ - Website with live demos of every pattern: https://morph.defuss.org/
@@ -0,0 +1,86 @@
1
+ # defuss-morph API
2
+
3
+ Everything below is exported from `defuss-morph` (ES module and CommonJS) and, in the CDN bundle, assigned to `window.df$`. Declarations: `node_modules/defuss-morph/dist/index.d.ts`.
4
+
5
+ ## morph
6
+
7
+ ```ts
8
+ morph(el: Element, content: RenderInput, options?: MorphOptions): void | Promise<void>
9
+ ```
10
+
11
+ Makes the children of `el` match `content`: an HTML string, a VNode, an array of VNodes, or text. Without `transition` it finishes synchronously and returns `undefined`; with one it returns a Promise that resolves when the animation has ended.
12
+
13
+ - Strings are parsed with the `DOMParser` of `el.ownerDocument`, as `<template>` content, so `<tr>`, `<td>` and `<option>` survive at the top level. A full document (starting with a doctype, `<html>`, `<head>` or `<body>`) is parsed as a document.
14
+ - Children match by `key`, then `id`, then tag. Only matches out of order move (the longest run already in order stays). A match is moved with `moveBefore()` where the browser has it (focus and selection stay), else `insertBefore()` with focus and selection put back, and patched in place. Unmatched new children are created; unclaimed old ones are removed after their handlers are torn down.
15
+ - `key` is stored on the element and not written to the DOM.
16
+ - An element with an open shadow root is morphed inside it; a custom element (hyphen in the tag) keeps its light DOM as the target. A `<template>` is morphed in its `.content`.
17
+ - A morph started during another morph on the same element or a descendant is queued; the latest request runs when the active one returns.
18
+
19
+ | Option | Type | Default | Meaning |
20
+ | --- | --- | --- | --- |
21
+ | `diff` | `boolean` | `false` | The input is a change-set for the direct children: items addressed by `key` or `id` are patched (attributes merged), unmatched ones appended. Never removes or moves. An item without `key`/`id` throws. |
22
+ | `transition.type` | `"fade" \| "slide-left" \| "slide-right" \| "shake" \| "none"` | `"fade"` | `none` keeps the call synchronous. |
23
+ | `transition.duration` | `number` (ms) | `300` | Per phase. |
24
+ | `transition.easing` | `string` | `"ease-in-out"` | Any CSS timing function. |
25
+ | `transition.delay` | `number` (ms) | `0` | Before the transition starts. |
26
+ | `transition.target` | `"parent" \| "self"` | `"parent"` | The animated element. |
27
+ | `transition.styles` | `{ enter, enterActive, exit, exitActive }` | by type | Own style maps (not used by `fade`). |
28
+
29
+ ## VNodes and JSX
30
+
31
+ A VNode is `{ type, attributes, children }`; a comment is `{ type: "#comment", value }`. Attributes a VNode can carry that HTML cannot: explicit booleans (`checked: false`), live values (`value: ""`), event props (`onClick`, `onClickCapture`), `ref`, `key`, `onMount`, `onUnmount`, `className`, `class` as an array, `style` as an object, `dangerouslySetInnerHTML`.
32
+
33
+ | Export | Use |
34
+ | --- | --- |
35
+ | `jsx(type, props, key?)`, `jsxs`, `jsxDEV`, `Fragment` | The automatic runtime for `jsxImportSource: "defuss-morph"`. `defuss-morph/jsx-runtime` and `defuss-morph/jsx-dev-runtime` resolve to the package entry. |
36
+ | `h(type, props, ...children)` | The classic factory: `jsxFactory: "h"`, or pages without a build step. |
37
+ | `type Component<P>` | `(props: P) => RenderInput`. Called at once by `jsx()`; a `key` given to it lands on the element it returns. |
38
+ | `namespace JSX` | Loose JSX types: any tag, any attribute; a JSX expression is a `RenderInput`. |
39
+
40
+ Event props listen on the element itself (one listener per element, event and phase), so propagation is native: order, `stopPropagation()`, `currentTarget`, non-bubbling events such as `toggle`. A morph that drops an event prop removes the handler; an HTML string never removes registered handlers, because it cannot declare them.
41
+
42
+ ## Reactive rendering
43
+
44
+ ```ts
45
+ reactive(config: ReactiveConfig, target: Element | RefLike | ArrayLike<Element>): () => void
46
+ ```
47
+
48
+ Renders `config.render()` into each target element now, then again after every store change. Returns `dispose()`: it unsubscribes from every store and runs `cleanup` once. A ref target is read on every render and must hold an element when `reactive()` is called (otherwise a `TypeError`); a list is a snapshot; an empty list renders and subscribes nothing.
49
+
50
+ | Config | Type | Meaning |
51
+ | --- | --- | --- |
52
+ | `store` | `Subscribable \| Subscribable[]` | A change in any of them renders again. `Subscribable` is `{ subscribe(listener): unsubscribe }`; a returned object with `unsubscribe()` works too. |
53
+ | `render` | `() => RenderInput` | JSX, VNodes or an HTML string. Pure: it may run more often than the stores change. |
54
+ | `cleanup?` | `() => void` | Runs once when the region is disposed. |
55
+ | `signal?` | `AbortSignal` | Disposes on abort. An already aborted signal renders nothing. |
56
+
57
+ ```tsx
58
+ <Reactive store={store} render={() => …} tag="section" className="x" ref={ref} cleanup={…} {...attributes} />
59
+ ```
60
+
61
+ `Reactive(props)` returns a VNode: the wrapper element with the first `render()` result as its children. Once morph has mounted the wrapper (`onMount`, a microtask later) it subscribes and renders once more; a parent re-render moves the subscription to the latest `render` and `store`; leaving the document unsubscribes and runs `cleanup`. A plain-text render needs no `DOMParser`; an HTML string inside `<Reactive>` needs a global one (any browser).
62
+
63
+ `installReactive(df$)` adds `.reactive(config)` to defuss-query's selections (`df$.fn`) and returns `df$`. The method calls `reactive(config, selection)` and returns the selection. The CDN bundle calls it itself when it loads after defuss-query.
64
+
65
+ ## Refs
66
+
67
+ ```ts
68
+ createRef<T extends Element = HTMLElement, S = unknown>(state?: S): Ref<T, S> // { current: T | null, state?: S }
69
+ isRef(value): value is RefLike
70
+ ```
71
+
72
+ morph sets `current` when it creates or patches the element that carries `ref={…}`.
73
+
74
+ ## Low-level primitives
75
+
76
+ For framework integration (defuss-query is built on them); `globals` is optional everywhere and derived from the element's document.
77
+
78
+ | Export | What it does |
79
+ | --- | --- |
80
+ | `updateDomWithVdom(el, vnodes, globals?, mode?)` | The guarded VNode morph; `mode` is `"replace"` or `"diff"`. |
81
+ | `replaceDomWithVdom(el, vnodes, globals?)` | Removes every child (with handler teardown) and renders from scratch. |
82
+ | `htmlStringToVNodes(html, DOMParser)`, `domNodeToVNode(node)` | Markup or a DOM node as VNodes. |
83
+ | `getRenderer(document)` | Creates DOM from VNodes. |
84
+ | `registerDelegatedEvent(el, type, handler, options?)`, `removeDelegatedEvent`, `clearDelegatedEventsDeep` | The handler registry JSX event props use. |
85
+ | `onClearDelegatedEvents(hook)` | Runs `hook(element)` whenever morph removes or replaces an element; returns an unregister function. |
86
+ | `performTransition(el, update, config)` | The transition runner. |
@@ -0,0 +1,116 @@
1
+ # Integrations
2
+
3
+ Each package does one job and leaves rendering to defuss-morph. defuss-morph 0.3.0 is tested with defuss-query 0.2.0, defuss-store 0.1.0, defuss-i18n 0.2.0 and defuss-dom-router 0.1.1 (`devDependencies` in its `package.json`); the website runs all four.
4
+
5
+ ## Setup
6
+
7
+ **With a bundler**: install what the task needs and import by name. The bundle then holds one copy of defuss-morph, which defuss-query and defuss-i18n import too.
8
+
9
+ ```sh
10
+ bun add defuss-morph defuss-store defuss-query defuss-i18n defuss-dom-router
11
+ ```
12
+
13
+ defuss-i18n 0.2.0 declares peer ranges older than these releases (`defuss-morph ^0.1.1`, `defuss-query ^0.1.0`); the package manager warns, and it works with defuss-morph 0.3.0 and defuss-query 0.2.0 in the tests.
14
+
15
+ **ES modules without a bundler**: an import map gives every package one URL, so all of them share one copy of defuss-morph. The website does this with copies of the published files:
16
+
17
+ ```html
18
+ <script type="importmap">
19
+ { "imports": {
20
+ "defuss-morph": "./assets/defuss-morph.mjs",
21
+ "defuss-morph/jsx-runtime": "./assets/defuss-morph.mjs",
22
+ "defuss-query": "./assets/vendor/defuss-query/index.js",
23
+ "defuss-store": "./assets/vendor/defuss-store/browser.js",
24
+ "defuss-i18n": "./assets/vendor/defuss-i18n/index.js",
25
+ "defuss-dom-router": "./assets/vendor/defuss-dom-router/index.js"
26
+ } }
27
+ </script>
28
+ ```
29
+
30
+ **Classic CDN globals**: defuss-morph's `all.min.js` first, then defuss-query's `all.min.js` (it turns `window.df$` into the query function and keeps morph's API on it), then `df$.installReactive(df$)`. Loaded the other way round, defuss-morph installs `.reactive()` itself.
31
+
32
+ ## defuss-store
33
+
34
+ ```ts
35
+ import { computed, createStore } from "defuss-store";
36
+
37
+ const profile = createStore({ name: "Ada", visits: 0 });
38
+ profile.value.name; // read
39
+ profile.set("visits", 1); // path write, notifies synchronously
40
+ profile.update((p) => ({ ...p, name: "Grace" }));
41
+ const greeting = computed(profile, (p) => `Hello, ${p.name}`); // notifies only when the string changes
42
+
43
+ reactive({ store: greeting, render: () => `<p>${esc(greeting.value)}</p>` }, el);
44
+ ```
45
+
46
+ - Snapshots are immutable: write with `set` or `update`, never mutate `store.value`.
47
+ - `reactive()` accepts any `{ subscribe(listener) }` that returns an unsubscribe function, so another store library works as well.
48
+ - ESM only; Node 22.12 or newer on the server.
49
+
50
+ ## defuss-query
51
+
52
+ ```js
53
+ import df$ from "defuss-query";
54
+
55
+ df$("#list").html(markup); // morph; returns the selection
56
+ await df$("#card").html(next, { transition: { type: "fade", duration: 200, target: "self" } });
57
+ df$("#list").html(tags.map((tag) => ({ type: "li", attributes: { key: tag }, children: [tag] }))); // user text as text
58
+ df$("#form").on("submit", function (event) { event.preventDefault(); const data = df$(this).form(); });
59
+ df$("#name").text(userInput); // literal text, never parsed
60
+ ```
61
+
62
+ - `df$(selector)` returns a snapshot Array of elements; `.find()`, `.closest()`, `.attr()`, `.prop()`, `.val()`, `.addClass()` work as in jQuery.
63
+ - `.on()` listens on each element natively; inside the handler use `this` for the element.
64
+ - `installReactive(df$)` from defuss-morph adds `.reactive(config)`.
65
+
66
+ ## defuss-i18n
67
+
68
+ ```html
69
+ <section data-i18n-component>
70
+ <h2 data-i18n-target="title">Your order</h2>
71
+ <template data-i18n-for="title" data-i18n-locale="en">Your order</template>
72
+ <template data-i18n-for="title" data-i18n-locale="de">Ihre Bestellung</template>
73
+ <input placeholder="Delivery note" data-i18n-placeholder-en="Delivery note" data-i18n-placeholder-de="Lieferhinweis">
74
+ </section>
75
+ ```
76
+
77
+ ```ts
78
+ import { bindI18n, createI18n } from "defuss-i18n";
79
+
80
+ const locale = createI18n({ locale: "en", fallback: ["en"] });
81
+ const binding = bindI18n(document.querySelector("[data-i18n-component]")!, locale);
82
+ locale.setLocale("de"); // synchronous: the <h2> and the placeholder are German, the typed note is kept
83
+ binding.dispose(); // when the component leaves the page
84
+ ```
85
+
86
+ - Templates sit beside their target, never inside it.
87
+ - Attributes translate through `data-i18n-{attribute}-{locale}`.
88
+ - Markup that holds i18n templates may itself be rendered by morph: morph keeps a `<template>`'s content in `.content` (since 0.3.0).
89
+
90
+ ## defuss-dom-router
91
+
92
+ ```ts
93
+ import { morph } from "defuss-morph";
94
+ import { createRouter } from "defuss-dom-router";
95
+
96
+ const outlet = document.querySelector<HTMLElement>("main")!;
97
+ const esc = (text: string) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
98
+ const router = createRouter({
99
+ routes: [{ id: "home", path: "/" }, { id: "project", path: "/projects/:id" }, { id: "missing", path: "*" }],
100
+ prepare: ({ to }) => {
101
+ const id = esc(to.params.id ?? ""); // params come from the URL: escape them like any user input
102
+ const markup = to.routeId === "project"
103
+ ? `<section key="project-${id}"><h1>Project ${id}</h1><input aria-label="Note"></section>`
104
+ : `<section key="${to.routeId}"><h1>${to.routeId}</h1></section>`;
105
+ return { commit: () => morph(outlet, markup) };
106
+ },
107
+ });
108
+ router.attachLinks(document); // <a data-router-link href="/projects/7">
109
+ await router.start();
110
+ ```
111
+
112
+ - `prepare()` builds the next view without touching the page; `commit()` renders it.
113
+ - The router catches what `prepare()` and `commit()` throw and reports it only to `onError`: pass one, or a view that throws fails without a trace.
114
+ - The view's root key names the record: query-only changes (`?tab=files`) keep typed input, another record gets fresh fields.
115
+ - Without a server that answers deep links with the app's HTML, use `mode: "hash"` and `href="#/projects/7"`.
116
+ - Keep UI that outlives routes (header, search) outside the outlet.
@@ -0,0 +1,190 @@
1
+ # Patterns
2
+
3
+ Start from these four patterns. Each runs live on https://morph.defuss.org/#patterns, from the sources in the repository's `docs/demos/` folder.
4
+
5
+ ## Reactive rendering
6
+
7
+ One store, three ways to bind it. The store (defuss-store):
8
+
9
+ ```ts
10
+ import { createStore } from "defuss-store";
11
+
12
+ export const cart = createStore({ items: [{ id: "tea", name: "Tea", qty: 1 }] });
13
+ export const add = (id: string) =>
14
+ cart.update(({ items }) => ({ items: items.map((item) => (item.id === id ? { ...item, qty: item.qty + 1 } : item)) }));
15
+ ```
16
+
17
+ ### JSX: `<Reactive>`
18
+
19
+ ```tsx
20
+ import { Reactive, morph } from "defuss-morph";
21
+
22
+ const CartList = () => (
23
+ <Reactive tag="ul" store={cart} render={() =>
24
+ cart.value.items.map((item) => (
25
+ <li key={item.id}>
26
+ {item.name} × {item.qty}
27
+ <input placeholder="note" />
28
+ <button type="button" onClick={() => add(item.id)}>+1</button>
29
+ </li>
30
+ ))
31
+ } />
32
+ );
33
+
34
+ morph(app, <CartList />);
35
+ ```
36
+
37
+ The wrapper is `<ul>`; each store change morphs the new rows in. A note typed into a row stays, and moves with the row when the order changes, because the rows are keyed and the input has no `value` in the markup.
38
+
39
+ ### HTML: `reactive()`
40
+
41
+ ```ts
42
+ import { reactive } from "defuss-morph";
43
+
44
+ const esc = (text: string) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
45
+
46
+ // one listener on the container: HTML strings carry no handlers
47
+ app.addEventListener("click", (event) => {
48
+ const id = (event.target as Element).closest<HTMLElement>("[data-add]")?.dataset.add;
49
+ if (id) add(id);
50
+ });
51
+
52
+ const dispose = reactive({
53
+ store: cart,
54
+ render: () => `<ol>${cart.value.items.map((item) =>
55
+ `<li key="${item.id}">${esc(item.name)} × ${item.qty} <button type="button" data-add="${item.id}">+1</button></li>`).join("")}</ol>`,
56
+ }, app);
57
+ ```
58
+
59
+ Call `dispose()` when the region goes away. `reactive()` renders synchronously: when `cart.set()` returns, the DOM shows the change.
60
+
61
+ ### defuss-query: `df$(selector).reactive()`
62
+
63
+ ```js
64
+ import df$ from "defuss-query";
65
+ import { installReactive } from "defuss-morph";
66
+
67
+ installReactive(df$);
68
+
69
+ const stop = new AbortController();
70
+ df$("[data-ref=cart-total]").reactive({
71
+ store: cart,
72
+ render: () => `<strong>${cart.value.items.reduce((sum, item) => sum + item.qty, 0)}</strong> items`,
73
+ signal: stop.signal, // stop.abort() ends the binding; the method returns the selection
74
+ });
75
+ ```
76
+
77
+ In TypeScript, `reactive(config, df$(selector))` is the typed form of the same call.
78
+
79
+ ### Several stores, derived values
80
+
81
+ `store: [a, b]` re-renders on a change in either. `computed(store, (value) => value.name)` from defuss-store notifies only when its result changes: pass it as the store when a region depends on one field of a large store.
82
+
83
+ ## Ref
84
+
85
+ ```tsx
86
+ import { createRef, morph } from "defuss-morph";
87
+
88
+ const field = createRef<HTMLInputElement>();
89
+
90
+ const Search = () => (
91
+ <form>
92
+ <input ref={field} name="q" />
93
+ <button type="button" onClick={() => field.current?.focus()}>Focus</button>
94
+ </form>
95
+ );
96
+
97
+ morph(app, <Search />);
98
+ field.current?.select(); // set: morph assigns refs while it renders
99
+ ```
100
+
101
+ A morph that keeps the `<input>` keeps `field.current` on the same node. In the component body, `field.current` still holds the previous render's element (or `null` on the first render): use it in handlers, in `onMount`, or after `morph()` returns.
102
+
103
+ A ref is also a `reactive()` target: `reactive({ store, render }, field)` reads `field.current` on each render.
104
+
105
+ ## forwardRef
106
+
107
+ ```tsx
108
+ import { Reactive, type Ref, createRef, morph } from "defuss-morph";
109
+ import { createStore } from "defuss-store";
110
+
111
+ const createStepper = () => {
112
+ const count = createStore({ n: 0 });
113
+ return { count, increment: () => count.set("n", count.value.n + 1), reset: () => count.set("n", 0) };
114
+ };
115
+ export type StepperApi = ReturnType<typeof createStepper>;
116
+
117
+ const Stepper = ({ label, ref = createRef<HTMLElement, StepperApi>() }: { label: string; ref?: Ref<HTMLElement, StepperApi> }) => {
118
+ const api = (ref.state ??= createStepper());
119
+ return (
120
+ <Reactive ref={ref} store={api.count} render={() => (
121
+ <>
122
+ {label}: <output>{api.count.value.n}</output>
123
+ <button type="button" onClick={api.increment}>+1</button>
124
+ </>
125
+ )} />
126
+ );
127
+ };
128
+
129
+ const steps = createRef<HTMLElement, StepperApi>();
130
+ morph(app, (
131
+ <div>
132
+ <Stepper label="Steps" ref={steps} />
133
+ <button type="button" onClick={() => steps.state?.reset()}>Reset</button>
134
+ </div>
135
+ ));
136
+ ```
137
+
138
+ - The default `createRef()` makes the component work without a parent ref.
139
+ - The API and the state live on `ref.state`, created on the first render (`??=`), so a parent re-render, which calls `Stepper` again, keeps the count. A component without a parent ref gets a fresh ref, and so fresh state, on each parent render.
140
+ - `ref.current` is the wrapper element; `ref.state` is the API.
141
+
142
+ ## Query references (HTML-first pages)
143
+
144
+ When the markup comes from a server, a CMS or a template, do not re-create it in JavaScript. Name its parts and select each once:
145
+
146
+ ```html
147
+ <input type="search" data-ref="filter" aria-label="Filter">
148
+ <ul data-ref="list">
149
+ <li key="ts">TypeScript</li>
150
+ <li key="py">Python</li>
151
+ </ul>
152
+ <p data-ref="count">2 of 2</p>
153
+ ```
154
+
155
+ ```js
156
+ import df$ from "defuss-query";
157
+
158
+ const ui = { filter: df$("[data-ref=filter]"), list: df$("[data-ref=list]"), count: df$("[data-ref=count]") };
159
+ const items = ui.list.find("li").map((li) => ({ key: li.getAttribute("key"), name: li.textContent }));
160
+ const esc = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
161
+
162
+ ui.filter.on("input", function () {
163
+ const shown = items.filter((item) => item.name.toLowerCase().includes(this.value.toLowerCase()));
164
+ ui.list.html(shown.map((item) => `<li key="${item.key}">${esc(item.name)}</li>`).join(""));
165
+ ui.count.text(`${shown.length} of ${items.length}`);
166
+ });
167
+ ```
168
+
169
+ - `key` attributes in server markup are read by morph, so the server-rendered items match the re-rendered ones.
170
+ - A selection is a snapshot Array: select once at start for parts that are never replaced, select again for parts a morph creates.
171
+ - Buttons rendered from HTML strings get one listener on a stable container, dispatching on `data-action` or `data-*` attributes.
172
+
173
+ ## Without a build step
174
+
175
+ All of the above works in a plain page with the CDN bundle. JSX becomes `h()`:
176
+
177
+ ```html
178
+ <script type="module" src="https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js"></script>
179
+ <script type="module">
180
+ import { createStore } from "https://cdn.jsdelivr.net/npm/defuss-store@0.1.0/dist/browser.min.js";
181
+ const { h, Reactive, morph } = df$;
182
+ const counter = createStore({ n: 0 });
183
+ morph(document.getElementById("app"), h(Reactive, {
184
+ store: counter,
185
+ render: () => h("button", { type: "button", onClick: () => counter.set("n", counter.value.n + 1) }, `clicked ${counter.value.n}`),
186
+ }));
187
+ </script>
188
+ ```
189
+
190
+ The repository's `examples/reactive-html.html` and `examples/reactive-jsx.html` are complete pages.