ajo 0.1.29 → 0.1.31

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/html.js ADDED
@@ -0,0 +1,142 @@
1
+ import { Context, current } from 'ajo/context'
2
+
3
+ const Void = new Set(['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
4
+
5
+ const Args = Symbol.for('ajo.args')
6
+
7
+ const escape = s => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
8
+
9
+ const noop = () => { }
10
+
11
+ export const render = h => [...html(h)].join('')
12
+
13
+ export const html = function* (h) {
14
+
15
+ for (h of walk(h)) {
16
+
17
+ if (typeof h == 'string') yield escape(h)
18
+
19
+ else yield* element(h)
20
+ }
21
+ }
22
+
23
+ const element = function* ({ nodeName, children, ...h }) {
24
+
25
+ let attrs = ''
26
+
27
+ for (const key in h) {
28
+
29
+ if (key.startsWith('set:') || h[key] == null || h[key] === false) continue
30
+
31
+ if (h[key] === true) attrs += ` ${key}`
32
+
33
+ else attrs += ` ${key}="${escape(String(h[key]))}"`
34
+ }
35
+
36
+ if (Void.has(nodeName)) yield `<${nodeName}${attrs}>`
37
+
38
+ else {
39
+
40
+ yield `<${nodeName}${attrs}>`
41
+
42
+ if (children != null) yield* html(children)
43
+
44
+ yield `</${nodeName}>`
45
+ }
46
+ }
47
+
48
+ const walk = function* (h) {
49
+
50
+ if (h == null) return
51
+
52
+ const type = typeof h
53
+
54
+ if (type == 'boolean') return
55
+
56
+ if (type == 'string') yield h
57
+
58
+ else if (type == 'number' || type == 'bigint') yield String(h)
59
+
60
+ else if (Symbol.iterator in h) for (h of h) yield* walk(h)
61
+
62
+ else if ('nodeName' in h) typeof h.nodeName == 'function' ? yield* run(h) : yield vdom(h)
63
+
64
+ else yield String(h)
65
+ }
66
+
67
+ const run = function* ({ nodeName, fallback = nodeName.fallback, ...h }) {
68
+
69
+ if (nodeName.constructor.name == 'GeneratorFunction') yield runGenerator(nodeName, h)
70
+
71
+ else yield* walk(nodeName(h))
72
+ }
73
+
74
+ const runGenerator = (fn, h) => {
75
+
76
+ const attrs = { ...fn.attrs }, args = { ...fn.args }
77
+
78
+ for (const key in h) {
79
+
80
+ if (key.startsWith('attr:')) attrs[key.slice(5)] = h[key]
81
+
82
+ else if (key == 'key' || key == 'skip' || key == 'memo' || key == 'ref' || key.startsWith('set:')) attrs[key] = h[key]
83
+
84
+ else args[key] = h[key]
85
+ }
86
+
87
+ const controller = new AbortController()
88
+
89
+ const instance = {
90
+
91
+ [Context]: Object.create(current()?.[Context] ?? null),
92
+
93
+ [Args]: args,
94
+
95
+ signal: controller.signal,
96
+
97
+ next: noop,
98
+
99
+ return: noop,
100
+
101
+ throw: value => { throw value }
102
+ }
103
+
104
+ const iterator = fn.call(instance, args)
105
+
106
+ const parent = current()
107
+
108
+ current(instance)
109
+
110
+ const result = children => ({ ...attrs, nodeName: fn.is ?? 'div', ...vdom({ children }) })
111
+
112
+ try {
113
+
114
+ return result(iterator.next().value)
115
+
116
+ } catch (error) {
117
+
118
+ return result(iterator.throw(error).value)
119
+
120
+ } finally {
121
+
122
+ iterator.return()
123
+
124
+ controller.abort()
125
+
126
+ current(parent)
127
+ }
128
+ }
129
+
130
+ const vdom = ({ key, skip, memo, ref, ...h }) => {
131
+
132
+ if ('children' in h) {
133
+
134
+ const children = [...walk(h.children)]
135
+
136
+ if (children.length) h.children = children.length == 1 ? children[0] : children
137
+
138
+ else delete h.children
139
+ }
140
+
141
+ return h
142
+ }
package/index.js ADDED
@@ -0,0 +1,286 @@
1
+ import { Context, current } from 'ajo/context'
2
+
3
+ const Key = Symbol.for('ajo.key')
4
+ const Memo = Symbol.for('ajo.memo')
5
+ const Ref = Symbol.for('ajo.ref')
6
+ const Cache = Symbol.for('ajo.cache')
7
+ const Generator = Symbol.for('ajo.generator')
8
+ const Iterator = Symbol.for('ajo.iterator')
9
+ const Render = Symbol.for('ajo.render')
10
+ const Args = Symbol.for('ajo.args')
11
+ const Controller = Symbol.for('ajo.controller')
12
+
13
+ export const Fragment = props => props.children
14
+
15
+ export const h = (type, props, ...children) => {
16
+
17
+ (props ??= {}).nodeName = type
18
+
19
+ if (!('children' in props) && children.length) props.children = children.length == 1 ? children[0] : children
20
+
21
+ return props
22
+ }
23
+
24
+ export const render = (h, el, child = el.firstChild, ref = null) => {
25
+
26
+ for (h of walk(h)) {
27
+
28
+ const node = reconcile(h, el, child)
29
+
30
+ if (child == null) {
31
+
32
+ before(el, node, ref)
33
+
34
+ } else if (node == child) {
35
+
36
+ child = node.nextSibling
37
+
38
+ } else if (node == child.nextSibling) {
39
+
40
+ before(el, child, ref)
41
+
42
+ child = node.nextSibling
43
+
44
+ } else {
45
+
46
+ before(el, node, child)
47
+ }
48
+ }
49
+
50
+ while (child != ref) {
51
+
52
+ const node = child.nextSibling
53
+
54
+ if (child.nodeType == 1) unref(child)
55
+
56
+ el.removeChild(child)
57
+
58
+ child = node
59
+ }
60
+ }
61
+
62
+ const walk = function* (h) {
63
+
64
+ if (h == null) return
65
+
66
+ const type = typeof h
67
+
68
+ if (type == 'boolean') return
69
+
70
+ if (type == 'string') yield h
71
+
72
+ else if (type == 'number' || type == 'bigint') yield String(h)
73
+
74
+ else if (Symbol.iterator in h) for (h of h) yield* walk(h)
75
+
76
+ else if ('nodeName' in h) typeof h.nodeName == 'function' ? yield* run(h) : yield h
77
+
78
+ else yield String(h)
79
+ }
80
+
81
+ const run = function* ({ nodeName, ...h }) {
82
+
83
+ if (nodeName.constructor.name == 'GeneratorFunction') yield runGenerator(nodeName, h)
84
+
85
+ else yield* walk(nodeName(h))
86
+ }
87
+
88
+ const runGenerator = (fn, h) => {
89
+
90
+ const attrs = { ...fn.attrs }, args = { ...fn.args }
91
+
92
+ for (const key in h) {
93
+
94
+ if (key.startsWith('attr:')) attrs[key.slice(5)] = h[key]
95
+
96
+ else if (key == 'key' || key == 'skip' || key == 'memo' || key == 'ref' || key.startsWith('set:')) attrs[key] = h[key]
97
+
98
+ else args[key] = h[key]
99
+ }
100
+
101
+ return { ...attrs, nodeName: fn.is ?? 'div', [Generator]: fn, [Args]: args }
102
+ }
103
+
104
+ const reconcile = (h, el, node) => typeof h == 'string' ? text(h, node) : element(h, el, node)
105
+
106
+ const text = (h, node) => {
107
+
108
+ while (node && node.nodeType != 3) node = node.nextSibling
109
+
110
+ node ? node.data != h && (node.data = h) : node = document.createTextNode(h)
111
+
112
+ return node
113
+ }
114
+
115
+ const element = ({ nodeName, children, key, skip, memo, ref, [Generator]: gen, [Args]: args, ...h }, el, node) => {
116
+
117
+ while (node && (
118
+
119
+ (node.localName != nodeName) ||
120
+
121
+ (node[Key] != null && node[Key] != key) ||
122
+
123
+ (node[Generator] && node[Generator] != gen)
124
+
125
+ )) node = node.nextSibling
126
+
127
+ node ??= document.createElementNS(h.xmlns ?? el.namespaceURI, nodeName)
128
+
129
+ if (key != null) node[Key] = key
130
+
131
+ if (memo == null || some(node[Memo], node[Memo] = memo)) {
132
+
133
+ attrs(node[Cache] ?? extract(node), node[Cache] = h, node)
134
+
135
+ if (!skip) gen ? next(gen, args, node) : render(children, node)
136
+
137
+ if (typeof ref == 'function') (node[Ref] = ref)(node)
138
+ }
139
+
140
+ return node
141
+ }
142
+
143
+ const attrs = (cache, h, node) => {
144
+
145
+ for (const key in { ...cache, ...h }) {
146
+
147
+ if (cache[key] === h[key]) continue
148
+
149
+ if (key.startsWith('set:')) node[key.slice(4)] = h[key]
150
+
151
+ else if (h[key] == null || h[key] === false) node.removeAttribute(key)
152
+
153
+ else node.setAttribute(key, h[key] === true ? '' : h[key])
154
+ }
155
+ }
156
+
157
+ const some = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.some((v, i) => v !== b[i]) : a !== b
158
+
159
+ const extract = el => Array.from(el.attributes).reduce((out, attr) => (out[attr.name] = attr.value, out), {})
160
+
161
+ const before = (el, node, child) => {
162
+
163
+ if (node.contains(document.activeElement)) {
164
+
165
+ const ref = node.nextSibling
166
+
167
+ while (child && child != node) {
168
+
169
+ const next = child.nextSibling
170
+
171
+ el.insertBefore(child, ref)
172
+
173
+ child = next
174
+ }
175
+
176
+ } else el.insertBefore(node, child)
177
+ }
178
+
179
+ const unref = node => {
180
+
181
+ for (const child of node.children) unref(child)
182
+
183
+ if (typeof node.return == 'function') node.return()
184
+
185
+ node[Ref]?.(null)
186
+ }
187
+
188
+ const next = (fn, args, el) => {
189
+
190
+ el[Generator] ??= (attach(el), fn)
191
+
192
+ Object.assign(el[Args] ??= {}, args)
193
+
194
+ el[Render]()
195
+ }
196
+
197
+ const attach = el => {
198
+
199
+ Object.assign(el, methods)
200
+
201
+ el[Context] = Object.create(current()?.[Context] ?? null)
202
+ }
203
+
204
+ const methods = {
205
+
206
+ [Render]() {
207
+
208
+ const parent = current()
209
+
210
+ current(this)
211
+
212
+ try {
213
+
214
+ if (!this[Iterator]) {
215
+
216
+ this.signal = (this[Controller] = new AbortController()).signal
217
+
218
+ this[Iterator] = this[Generator].call(this, this[Args])
219
+ }
220
+
221
+ const { value, done } = this[Iterator].next()
222
+
223
+ render(value, this)
224
+
225
+ this[Ref]?.(this)
226
+
227
+ if (done) this.return()
228
+
229
+ } catch (e) {
230
+
231
+ this.throw(e)
232
+
233
+ } finally {
234
+
235
+ current(parent)
236
+ }
237
+ },
238
+
239
+ next(fn, result) {
240
+
241
+ try {
242
+
243
+ if (typeof fn == 'function') result = fn.call(this, this[Args])
244
+
245
+ } catch (e) {
246
+
247
+ return this.throw(e)
248
+ }
249
+
250
+ if (!current()?.contains(this)) this[Render]()
251
+
252
+ return result
253
+ },
254
+
255
+ throw(value) {
256
+
257
+ for (let el = this; el; el = el.parentNode) if (el[Iterator]?.throw) try {
258
+
259
+ return render(el[Iterator].throw(value).value, el)
260
+
261
+ } catch (e) {
262
+
263
+ value = new Error(e?.message ?? e, { cause: value })
264
+ }
265
+
266
+ throw value
267
+ },
268
+
269
+ return() {
270
+
271
+ try {
272
+
273
+ this[Iterator]?.return()
274
+
275
+ } catch (e) {
276
+
277
+ this.throw(e)
278
+
279
+ } finally {
280
+
281
+ this[Iterator] = null
282
+
283
+ this[Controller]?.abort()
284
+ }
285
+ }
286
+ }
package/license CHANGED
@@ -1,6 +1,6 @@
1
1
  ISC License
2
2
 
3
- Copyright (c) 2022-2025, Cristian Falcone
3
+ Copyright (c) 2022-2026, Cristian Falcone
4
4
 
5
5
  Permission to use, copy, modify, and/or distribute this software for any
6
6
  purpose with or without fee is hereby granted, provided that the above
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ajo",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "ajo is a JavaScript view library for building user interfaces",
5
5
  "type": "module",
6
6
  "types": "./types.ts",
@@ -21,23 +21,22 @@
21
21
  "types": "./types.ts",
22
22
  "import": "./dist/html.js",
23
23
  "require": "./dist/html.cjs"
24
- },
25
- "./stream": {
26
- "types": "./types.ts",
27
- "import": "./dist/stream.js",
28
- "require": "./dist/stream.cjs"
29
24
  }
30
25
  },
31
26
  "files": [
32
27
  "dist",
28
+ "context.js",
29
+ "html.js",
30
+ "index.js",
31
+ "LLMs.md",
33
32
  "types.ts"
34
33
  ],
35
34
  "devDependencies": {
36
- "@types/node": "25.0.9",
37
- "happy-dom": "20.3.1",
35
+ "@types/node": "25.2.3",
36
+ "happy-dom": "20.6.1",
38
37
  "vite": "7.3.1",
39
- "vite-tsconfig-paths": "6.0.4",
40
- "vitest": "4.0.17"
38
+ "vite-tsconfig-paths": "6.1.1",
39
+ "vitest": "4.0.18"
41
40
  },
42
41
  "keywords": [
43
42
  "ui",
package/readme.md CHANGED
@@ -13,11 +13,10 @@
13
13
  </a>
14
14
  </div>
15
15
 
16
- A modern JavaScript library for building user interfaces with generator-based state management, efficient DOM updates, and streaming server-side rendering.
16
+ A modern JavaScript library for building user interfaces with generator-based state management and efficient DOM updates.
17
17
 
18
18
  - **Generator-Based Components**: Use `function*` for stateful components with built-in lifecycle
19
19
  - **Efficient DOM Updates**: In-place reconciliation minimizes DOM manipulation
20
- - **Streaming SSR**: Progressive rendering with selective hydration (islands)
21
20
 
22
21
  ## Quick Start
23
22
 
@@ -29,6 +28,7 @@ npm install ajo
29
28
  import { render } from 'ajo'
30
29
 
31
30
  function* Counter() {
31
+
32
32
  let count = 0
33
33
 
34
34
  while (true) yield (
@@ -91,6 +91,7 @@ Generator functions with automatic wrapper elements. The structure provides a na
91
91
 
92
92
  ```javascript
93
93
  function* TodoList() {
94
+
94
95
  let todos = []
95
96
  let text = ''
96
97
 
@@ -102,6 +103,7 @@ function* TodoList() {
102
103
  })
103
104
 
104
105
  while (true) {
106
+
105
107
  const count = todos.length
106
108
 
107
109
  yield (
@@ -123,10 +125,11 @@ function* TodoList() {
123
125
 
124
126
  ### Re-rendering with `this.next()`
125
127
 
126
- Call `this.next()` to trigger a re-render. The optional callback receives current props, useful when props may have changed:
128
+ Call `this.next()` to trigger a re-render. The optional callback receives current props and its return value is passed through:
127
129
 
128
130
  ```javascript
129
131
  function* Stepper(args) {
132
+
130
133
  let count = 0
131
134
 
132
135
  // Access current props in callback
@@ -155,11 +158,28 @@ function* Good(args) {
155
158
 
156
159
  ### Lifecycle and Cleanup
157
160
 
158
- Use `try...finally` for cleanup when the component unmounts:
161
+ Every stateful component has a `this.signal` (AbortSignal) that aborts when the component unmounts. Use it with any API that accepts a signal:
162
+
163
+ ```javascript
164
+ function* MouseTracker() {
165
+
166
+ let pos = { x: 0, y: 0 }
167
+
168
+ document.addEventListener('mousemove', e => this.next(() => {
169
+ pos = { x: e.clientX, y: e.clientY }
170
+ }), { signal: this.signal }) // auto-removed on unmount
171
+
172
+ while (true) yield <p>{pos.x}, {pos.y}</p>
173
+ }
174
+ ```
175
+
176
+ For APIs that don't accept a signal, use `try...finally`:
159
177
 
160
178
  ```javascript
161
179
  function* Clock() {
180
+
162
181
  let time = new Date()
182
+
163
183
  const interval = setInterval(() => this.next(() => time = new Date()), 1000)
164
184
 
165
185
  try {
@@ -223,6 +243,7 @@ function* ErrorBoundary(args) {
223
243
 
224
244
  ```javascript
225
245
  function* AutoFocus() {
246
+
226
247
  let input = null
227
248
 
228
249
  while (true) yield (
@@ -251,6 +272,7 @@ timer?.next() // trigger re-render from outside
251
272
 
252
273
  ```javascript
253
274
  function* Chart(args) {
275
+
254
276
  let chart = null
255
277
 
256
278
  while (true) yield (
@@ -292,6 +314,7 @@ const Card = ({ title }) => {
292
314
 
293
315
  // Stateful - write inside loop (updates each render)
294
316
  function* ThemeProvider(args) {
317
+
295
318
  let theme = 'light'
296
319
 
297
320
  while (true) {
@@ -318,9 +341,10 @@ function* FixedTheme(args) {
318
341
 
319
342
  ```javascript
320
343
  function* UserProfile(args) {
344
+
321
345
  let data = null, error = null, loading = true
322
346
 
323
- fetch(`/api/users/${args.id}`)
347
+ fetch(`/api/users/${args.id}`, { signal: this.signal })
324
348
  .then(r => r.json())
325
349
  .then(d => this.next(() => { data = d; loading = false }))
326
350
  .catch(e => this.next(() => { error = e; loading = false }))
@@ -336,41 +360,8 @@ function* UserProfile(args) {
336
360
  ## Server-Side Rendering
337
361
 
338
362
  ```javascript
339
- // Static
340
363
  import { render } from 'ajo/html'
341
364
  const html = render(<App />)
342
-
343
- // Streaming
344
- import { stream } from 'ajo/stream'
345
- for await (const chunk of stream(<App />)) res.write(chunk)
346
-
347
- // Hydration (client-side)
348
- import { hydrate } from 'ajo/stream'
349
- window.$stream = { push: hydrate }
350
- ```
351
-
352
- ### Islands Architecture
353
-
354
- ```javascript
355
- function* Interactive() {
356
- let count = 0
357
- while (true) yield (
358
- <button set:onclick={() => this.next(() => count++)}>
359
- {count}
360
- </button>
361
- )
362
- }
363
-
364
- Interactive.src = '/islands/interactive.js' // hydrate on client
365
-
366
- const Page = () => (
367
- <html>
368
- <body>
369
- <p>Static content</p>
370
- <Interactive fallback={<button>0</button>} />
371
- </body>
372
- </html>
373
- )
374
365
  ```
375
366
 
376
367
  ## TypeScript
@@ -388,6 +379,7 @@ const Card: Stateless<CardProps> = ({ title, children }) => (
388
379
  type CounterProps = { initial: number; step?: number }
389
380
 
390
381
  const Counter: Stateful<CounterProps, 'section'> = function* (args) {
382
+
391
383
  let count = args.initial
392
384
 
393
385
  while (true) {
@@ -423,16 +415,11 @@ let ref: ThisParameterType<typeof Counter> | null = null
423
415
  |--------|-------------|
424
416
  | `render(children)` | Render to HTML string |
425
417
 
426
- ### `ajo/stream`
427
- | Export | Description |
428
- |--------|-------------|
429
- | `stream(children)` | Async iterator for streaming SSR |
430
- | `hydrate(patch)` | Apply streamed patch on client |
431
-
432
418
  ### Stateful `this`
433
- | Method | Description |
434
- |--------|-------------|
435
- | `this.next(fn?)` | Re-render. Callback receives current args. |
419
+ | Property | Description |
420
+ |----------|-------------|
421
+ | `this.signal` | AbortSignal that aborts on unmount. Pass to `fetch()`, `addEventListener()`, etc. |
422
+ | `this.next(fn?)` | Re-render. Callback receives current args. Returns callback's result. |
436
423
  | `this.throw(error)` | Throw to parent boundary |
437
424
  | `this.return()` | Terminate generator |
438
425
 
package/types.ts CHANGED
@@ -53,7 +53,8 @@ declare module 'ajo' {
53
53
  TArguments
54
54
 
55
55
  type StatefulElement<TArguments, TTag> = ElementType<TTag> & {
56
- next: (fn?: (this: StatefulElement<TArguments, TTag>, args: StatefulArgs<TArguments, TTag>) => void) => void,
56
+ signal: AbortSignal,
57
+ next: <R>(fn?: (this: StatefulElement<TArguments, TTag>, args: StatefulArgs<TArguments, TTag>) => R) => R,
57
58
  throw: (value?: unknown) => void,
58
59
  return: () => void,
59
60
  }
@@ -80,27 +81,8 @@ declare module 'ajo/context' {
80
81
  }
81
82
 
82
83
  declare module 'ajo/html' {
83
-
84
- type Patch = {
85
- id: string,
86
- h: import('ajo').Children,
87
- src?: string,
88
- done: boolean,
89
- }
90
-
91
- type Hooks = {
92
- alloc?: (parentId: string) => string,
93
- placeholder?: (id: string, children: import('ajo').Children) => unknown,
94
- push?: (patch: Patch) => void,
95
- }
96
-
97
84
  function render(h: import('ajo').Children): string
98
- function html(h: import('ajo').Children, hooks?: Hooks): IterableIterator<string>
99
- }
100
-
101
- declare module 'ajo/stream' {
102
- function stream(h: import('ajo').Children): AsyncIterableIterator<string>
103
- function hydrate(patch: import('ajo/html').Patch): Promise<void>
85
+ function html(h: import('ajo').Children): IterableIterator<string>
104
86
  }
105
87
 
106
88
  declare namespace JSX {
package/dist/stream.cjs DELETED
@@ -1 +0,0 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const a=require("./index.cjs"),m=require("./html.cjs"),y=async function*(o,n=""){const s=new Map([[n,0]]),t=new Set,i=[],r=(e=n)=>(s.set(e,(s.get(e)??0)+1),e?`${e}:${s.get(e)-1}`:String(s.get(e)-1)),h=(e,c)=>({nodeName:"div","data-ssr":e,children:c}),f=e=>{const c=Promise.resolve(`<script>window.$stream?.push(${JSON.stringify(e)})<\/script>`);t.add(c),c.then(u=>i.push(u)).finally(()=>t.delete(c))};for(const e of m.html(o,{alloc:r,placeholder:h,push:f}))yield e;for(;t.size||i.length;){for(;i.length;)yield i.shift();t.size&&await Promise.race(t)}},d=new Set;async function l({id:o,src:n,h:s}){const t=document.querySelector(`[data-ssr="${o}"]`);if(!t)return d.add({id:o,src:n,h:s});n?a.render(a.h((await import(n)).default,s),t):a.render(s,t);const i=o+":";for(const r of d)r.id.startsWith(i)&&(d.delete(r),l(r))}exports.hydrate=l;exports.stream=y;