@pfern/elements 0.1.11 → 0.2.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/env.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Ambient type declarations for the build environment.
3
+ *
4
+ * This project is JS-first; these declarations exist solely to provide a
5
+ * type-rich editor experience and to keep `tsc --checkJs` strict.
6
+ */
7
+
8
+ declare module '*?raw' {
9
+ const content: string
10
+ export default content
11
+ }
12
+
13
+ interface ImportMetaEnv {
14
+ readonly DEV?: boolean
15
+ }
16
+
17
+ interface ImportMeta {
18
+ readonly env: ImportMetaEnv
19
+ }
20
+
21
+ interface Window {
22
+ x3dom?: any
23
+ }
24
+
package/mathml.js ADDED
@@ -0,0 +1 @@
1
+ export * from './src/mathml.js'
package/package.json CHANGED
@@ -1,21 +1,38 @@
1
1
  {
2
2
  "name": "@pfern/elements",
3
- "version": "0.1.11",
3
+ "version": "0.2.1",
4
4
  "description": "A minimalist, pure functional declarative UI toolkit.",
5
+ "author": "Paul Fernandez",
6
+ "license": "MIT",
5
7
  "type": "module",
6
- "main": "elements.js",
7
8
  "types": "./types/elements.d.ts",
8
9
  "exports": {
9
10
  ".": {
10
11
  "types": "./types/elements.d.ts",
11
12
  "import": "./elements.js",
12
13
  "default": "./elements.js"
14
+ },
15
+ "./mathml": {
16
+ "types": "./types/mathml.d.ts",
17
+ "import": "./mathml.js",
18
+ "default": "./mathml.js"
13
19
  }
14
20
  },
15
21
  "files": [
16
22
  "./elements.js",
23
+ "./mathml.js",
24
+ "./env.d.ts",
25
+ "./src",
17
26
  "./types"
18
27
  ],
28
+ "scripts": {
29
+ "clean:types": "node scripts/clean-types.js",
30
+ "build:types": "node scripts/build-types.js",
31
+ "typecheck": "node scripts/typecheck.js",
32
+ "prepack": "node ../../scripts/sync-readmes.js",
33
+ "test": "node --test test/*.test.* --test-reporter spec",
34
+ "test:coverage": "node scripts/test-coverage.js"
35
+ },
19
36
  "keywords": [
20
37
  "ui",
21
38
  "functional",
@@ -26,25 +43,5 @@
26
43
  "stateless",
27
44
  "recursive",
28
45
  "html"
29
- ],
30
- "scripts": {
31
- "dev": "vite",
32
- "build": "vite build",
33
- "build:types": "tsc",
34
- "preview": "vite preview",
35
- "typecheck": "tsc --noEmit",
36
- "test": "node --test test/*.test.* --test-reporter spec"
37
- },
38
- "repository": {
39
- "type": "git",
40
- "url": "git+https://github.com/pfernandez/elements.git"
41
- },
42
- "author": "Paul Fernandez",
43
- "license": "MIT",
44
- "devDependencies": {
45
- "@types/node": "^25.0.10",
46
- "eslint": "^7.32.0",
47
- "typescript": "^5.9.3",
48
- "vite": "^7.3.1"
49
- }
46
+ ]
50
47
  }
@@ -0,0 +1,513 @@
1
+ /** expressive/elements.js
2
+ *
3
+ * Minimalist declarative UI framework based on pure functional composition.
4
+ *
5
+ * Purpose:
6
+ * - All UI defined as pure functions that return declarative arrays.
7
+ * - Directly composable into a symbolic tree compatible with Lisp-like
8
+ * dialects.
9
+ * - No internal mutable state required: DOM itself is the substrate for state.
10
+ * - No JSX, no keys, no reconciler heuristics — just pure structure +
11
+ * replacement.
12
+ *
13
+ */
14
+
15
+ import { assignProperties, removeMissingProps } from './props.js'
16
+ import { htmlTagNames, svgTagNames } from './tags.js'
17
+
18
+ export * from './types.js'
19
+
20
+ export const DEBUG =
21
+ typeof process !== 'undefined'
22
+ && process.env
23
+ && (process.env.ELEMENTSJS_DEBUG?.toLowerCase() === 'true'
24
+ || process.env.NODE_ENV === 'development')
25
+
26
+ const svgNS = 'http://www.w3.org/2000/svg'
27
+ const mathNS = 'http://www.w3.org/1998/Math/MathML'
28
+
29
+ /**
30
+ * Maps vnode instances to their current root DOM element,
31
+ * allowing accurate replacement when the same vnode is re-invoked.
32
+ */
33
+ const rootMap = new WeakMap()
34
+
35
+ /**
36
+ * Maps component-returned vnode roots to their component instance object.
37
+ *
38
+ * This keeps the public vnode representation “pure” (no wrapper nodes) while
39
+ * still allowing:
40
+ * - event updates to replace the nearest component boundary, and
41
+ * - programmatic updates to patch the correct DOM subtree for an instance.
42
+ */
43
+ const componentRoots = new WeakMap()
44
+
45
+ const getVNode = el => el?.__vnode
46
+ const setVNode = (el, vnode) => el.__vnode = vnode
47
+ const isRoot = el => !!el?.__root
48
+ const setRoot = el => el.__root = true
49
+
50
+ const isNodeEnv = () => typeof document === 'undefined'
51
+
52
+ let componentUpdateDepth = 0
53
+ let currentEventRoot = null
54
+
55
+ const getCurrentEventRoot = () => currentEventRoot
56
+ const setCurrentEventRoot = el => currentEventRoot = el
57
+
58
+ const isObject = x =>
59
+ typeof x === 'object'
60
+ && x !== null
61
+
62
+ const shallowObjectEqual = (a, b) => {
63
+ if (a === b) return true
64
+ if (!isObject(a) || !isObject(b)) return false
65
+ const aKeys = Object.keys(a)
66
+ const bKeys = Object.keys(b)
67
+ if (aKeys.length !== bKeys.length) return false
68
+ for (let i = 0; i < aKeys.length; i++) {
69
+ const key = aKeys[i]
70
+ if (!(key in b) || a[key] !== b[key]) return false
71
+ }
72
+ return true
73
+ }
74
+
75
+ const propsEqual = (a, b) => {
76
+ if (a === b) return true
77
+ if (!isObject(a) || !isObject(b)) return false
78
+ const aKeys = Object.keys(a)
79
+ const bKeys = Object.keys(b)
80
+ if (aKeys.length !== bKeys.length) return false
81
+ for (let i = 0; i < aKeys.length; i++) {
82
+ const key = aKeys[i]
83
+ if (!(key in b)) return false
84
+ const av = a[key]
85
+ const bv = b[key]
86
+ if (key === 'style' && isObject(av) && isObject(bv)) {
87
+ if (!shallowObjectEqual(av, bv)) return false
88
+ } else if (av !== bv) return false
89
+ }
90
+ return true
91
+ }
92
+
93
+ /**
94
+ * Determines whether two nodes have changed enough to require replacement.
95
+ * Compares type, string value, or element tag.
96
+ *
97
+ * @param {*} a - Previous vnode
98
+ * @param {*} b - New vnode
99
+ * @returns {boolean} - True if nodes are meaningfully different
100
+ */
101
+ const changed = (a, b) =>
102
+ typeof a !== typeof b
103
+ || typeof a === 'string' && a !== b
104
+ || typeof a === 'number' && a !== b
105
+ || Array.isArray(a) && Array.isArray(b) && a[0] !== b[0]
106
+
107
+ /**
108
+ * Computes a patch object describing how to transform tree `a` into tree `b`.
109
+ * Used by `render` to apply minimal updates to the DOM.
110
+ *
111
+ * @param {*} a - Previous vnode
112
+ * @param {*} b - New vnode
113
+ * @returns {Object} - Patch object with type and content
114
+ */
115
+ const diffTree = (a, b) => {
116
+ if (a == null) return { type: 'CREATE', newNode: b }
117
+ if (b == null) return { type: 'REMOVE' }
118
+ if (changed(a, b)) return { type: 'REPLACE', newNode: b }
119
+ if (!Array.isArray(a) || !Array.isArray(b)) return
120
+
121
+ const prevProps = a[1] || {}
122
+ const nextProps = b[1] || {}
123
+ const propsChanged = !propsEqual(prevProps, nextProps)
124
+ const children = diffChildren(a, b)
125
+
126
+ return !propsChanged && !children
127
+ ? undefined
128
+ : {
129
+ type: 'UPDATE',
130
+ prevProps: propsChanged ? prevProps : null,
131
+ props: propsChanged ? nextProps : null,
132
+ children
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Compares the children of two vnodes and returns patch list.
138
+ *
139
+ * @param {any[]} a - Previous vnode
140
+ * @param {any[]} b - New vnode
141
+ * @returns {Array<[number, Object]> | null} patches - Sparse patch list
142
+ */
143
+ const diffChildren = (a, b) => {
144
+ const aLen = Math.max(0, a.length - 2)
145
+ const bLen = Math.max(0, b.length - 2)
146
+ const len = Math.max(aLen, bLen)
147
+ /** @type {Array<[number, Object]>} */
148
+ const patches = []
149
+
150
+ for (let i = 0; i < len; i++) {
151
+ const aIndex = i + 2
152
+ const bIndex = i + 2
153
+ const aHasChild = aIndex < a.length
154
+ const bHasChild = bIndex < b.length
155
+
156
+ if (!aHasChild && !bHasChild) continue
157
+
158
+ const prevChild = aHasChild ? a[aIndex] : undefined
159
+ const nextChild = bHasChild ? b[bIndex] : undefined
160
+
161
+ // If a child position is explicitly present (even as null/undefined),
162
+ // renderTree() will create a placeholder comment node. Treat transitions
163
+ // between empty and non-empty as REPLACE so indices stay aligned.
164
+ let patch
165
+ if (!aHasChild && bHasChild) {
166
+ patch = { type: 'CREATE', newNode: nextChild }
167
+ } else if (aHasChild && !bHasChild) {
168
+ patch = { type: 'REMOVE' }
169
+ } else if (prevChild == null && nextChild == null) {
170
+ patch = undefined
171
+ } else if (prevChild == null || nextChild == null) {
172
+ patch = { type: 'REPLACE', newNode: nextChild }
173
+ } else {
174
+ patch = diffTree(prevChild, nextChild)
175
+ }
176
+
177
+ patch && patches.push([i, /** @type {Object} */ patch])
178
+ }
179
+
180
+ return patches.length ? patches : null
181
+ }
182
+
183
+
184
+ const propsEnv = {
185
+ svgNS,
186
+ debug: DEBUG,
187
+ isRoot,
188
+ renderTree: null,
189
+ getCurrentEventRoot,
190
+ setCurrentEventRoot
191
+ }
192
+
193
+ /**
194
+ * Recursively builds a real DOM tree from a declarative vnode.
195
+ * Marks root nodes and tracks state/element associations.
196
+ *
197
+ * @param {*} node - Vnode to render
198
+ * @param {boolean} isRoot - Whether this is a root component
199
+ * @returns {any} - Real DOM node
200
+ */
201
+ const renderTree = (node, isRoot = true, namespaceURI = null) => {
202
+ const type = typeof node
203
+
204
+ if (type === 'string' || type === 'number')
205
+ return isNodeEnv() ? node : document.createTextNode(String(node))
206
+
207
+ if (!node || node.length === 0) return document.createComment('Empty vnode')
208
+
209
+ if (!Array.isArray(node)) {
210
+ console.error('Malformed vnode (not an array):', node)
211
+ return document.createComment('Invalid vnode')
212
+ }
213
+
214
+ const componentInstance = componentRoots.get(node)
215
+ componentInstance && (isRoot = true)
216
+
217
+ const tag = node[0]
218
+
219
+ if (typeof tag !== 'string') {
220
+ console.error('Malformed vnode (non-string tag):', node)
221
+ return document.createComment('Invalid vnode')
222
+ }
223
+
224
+ const props = node[1] || {}
225
+ const isSpecialRootTag = tag === 'html' || tag === 'head' || tag === 'body'
226
+
227
+ const elNamespaceURI =
228
+ tag === 'svg' || namespaceURI === svgNS
229
+ ? svgNS
230
+ : tag === 'math' || namespaceURI === mathNS
231
+ ? mathNS
232
+ : null
233
+
234
+ const isAnnotationXmlHtml =
235
+ tag === 'annotation-xml'
236
+ && typeof props?.encoding === 'string'
237
+ && /(^|\/)(xhtml\+xml|xhtml|html)(;|$)/i.test(props.encoding)
238
+
239
+ const childNamespaceURI =
240
+ tag === 'foreignObject'
241
+ ? null
242
+ : tag === 'annotation-xml'
243
+ ? isAnnotationXmlHtml ? null : namespaceURI
244
+ : tag === 'svg'
245
+ ? svgNS
246
+ : tag === 'math'
247
+ ? mathNS
248
+ : namespaceURI
249
+
250
+ let el = null
251
+ if (tag === 'html') el = document.documentElement
252
+ else if (tag === 'head') el = document.head
253
+ else if (tag === 'body') el = document.body
254
+ else el = elNamespaceURI
255
+ ? document.createElementNS(elNamespaceURI, tag)
256
+ : document.createElement(tag)
257
+
258
+ if (!el && (tag === 'head' || tag === 'body')) {
259
+ el = document.createElement(tag)
260
+ document.documentElement.appendChild(el)
261
+ }
262
+
263
+ setVNode(el, node)
264
+
265
+ if (isRoot && !isSpecialRootTag) {
266
+ setRoot(el)
267
+ rootMap.set(node, el)
268
+ }
269
+
270
+ componentInstance && rootMap.set(componentInstance, el)
271
+
272
+ assignProperties(el, props, propsEnv)
273
+
274
+ for (let i = 2; i < node.length; i++) {
275
+ el.appendChild(renderTree(node[i], false, childNamespaceURI))
276
+ }
277
+
278
+ return el
279
+ }
280
+
281
+ propsEnv.renderTree = renderTree
282
+
283
+ const applyPropsUpdate = (el, prevProps, nextProps) =>
284
+ nextProps == null
285
+ ? undefined
286
+ : (removeMissingProps(el, prevProps || {}, nextProps),
287
+ assignProperties(el, nextProps, propsEnv))
288
+
289
+ /**
290
+ * Applies a patch object to a DOM subtree.
291
+ * Handles creation, removal, replacement, and child updates.
292
+ *
293
+ * @param {any} parent - DOM node to mutate
294
+ * @param {Object} patch - Patch object from diffTree
295
+ * @param {number} [index=0] - Child index to apply update to
296
+ */
297
+ const applyPatch = (parent, patch, index = 0, isRootBoundary = false) => {
298
+ if (!patch) return
299
+ const child = parent.childNodes[index]
300
+
301
+ switch (patch.type) {
302
+ case 'CREATE': {
303
+ const newEl = renderTree(patch.newNode, isRootBoundary)
304
+ child ? parent.insertBefore(newEl, child) : parent.appendChild(newEl)
305
+ break
306
+ }
307
+ case 'REMOVE':
308
+ if (child) parent.removeChild(child)
309
+ break
310
+ case 'REPLACE': {
311
+ const newEl = renderTree(patch.newNode, isRootBoundary)
312
+ parent.replaceChild(newEl, child)
313
+ break
314
+ }
315
+ case 'UPDATE':
316
+ if (child) {
317
+ applyPropsUpdate(child, patch.prevProps, patch.props)
318
+ if (patch.children) {
319
+ for (let i = patch.children.length - 1; i >= 0; i--) {
320
+ const [childIndex, childPatch] = patch.children[i]
321
+ applyPatch(child, childPatch, childIndex, false)
322
+ }
323
+ }
324
+ }
325
+ break
326
+ }
327
+ }
328
+
329
+
330
+ const clearChildren = el => {
331
+ if (!el) return
332
+
333
+ // Prefer the standard DOM API.
334
+ if (typeof el.firstChild !== 'undefined') {
335
+ while (el.firstChild) el.removeChild(el.firstChild)
336
+ return
337
+ }
338
+
339
+ // Fake DOM fallback.
340
+ while (el.childNodes?.length) el.removeChild(el.childNodes[0])
341
+ }
342
+
343
+ /**
344
+ * Render a vnode into the DOM.
345
+ *
346
+ * If `vtree[0]` is `html`, `head`, or `body`, no container is required.
347
+ *
348
+ * @param {import('./types.js').ElementsVNode} vtree
349
+ * @param {HTMLElement | null} [container]
350
+ */
351
+ export const render = (vtree, container = null, { replace = false } = {}) => {
352
+ const target =
353
+ !container && Array.isArray(vtree) && vtree[0] === 'html'
354
+ ? document.documentElement
355
+ : container
356
+
357
+ if (!target) {
358
+ throw new Error('render() requires a container for non-html() root')
359
+ }
360
+
361
+ const prevVNode = replace ? null : getVNode(target)
362
+
363
+ if (!prevVNode) {
364
+ replace && clearChildren(target)
365
+ const dom = renderTree(vtree)
366
+ if (target === document.documentElement) {
367
+ if (dom !== document.documentElement) {
368
+ document.replaceChild(dom, document.documentElement)
369
+ }
370
+ } else {
371
+ target.appendChild(dom)
372
+ }
373
+ } else {
374
+ const patch = diffTree(prevVNode, vtree)
375
+ applyPatch(target, patch, 0, true)
376
+ }
377
+
378
+ setVNode(target, vtree)
379
+ rootMap.set(vtree, target)
380
+ }
381
+
382
+
383
+ const getChildIndex = (parent, child) => {
384
+ const nodes = parent?.childNodes
385
+ if (!nodes) return -1
386
+ for (let i = 0; i < nodes.length; i++) {
387
+ if (nodes[i] === child) return i
388
+ }
389
+ return -1
390
+ }
391
+
392
+ /**
393
+ * Wrap a pure function component so it participates in reconciliation.
394
+ *
395
+ * @template {any[]} Args
396
+ * @param {(...args: Args) => import('./types.js').ElementsVNode} fn
397
+ * @returns {(...args: Args) => import('./types.js').ElementsVNode}
398
+ */
399
+ export const component = fn => {
400
+ const instance = {}
401
+ return (...args) => {
402
+ try {
403
+ const prevEl = rootMap.get(instance)
404
+ const canUpdateInPlace =
405
+ !!prevEl?.parentNode
406
+ && componentUpdateDepth === 0
407
+ && !currentEventRoot
408
+
409
+ componentUpdateDepth++
410
+ let vnode
411
+ try {
412
+ vnode = fn(...args)
413
+ } finally {
414
+ componentUpdateDepth--
415
+ }
416
+
417
+ // Record the root vnode for this component instance so the renderer can
418
+ // treat it as a boundary without introducing wrapper nodes into the
419
+ // public AST.
420
+ Array.isArray(vnode) && componentRoots.set(vnode, instance)
421
+
422
+ if (canUpdateInPlace) {
423
+ const prevVNode = getVNode(prevEl)
424
+ const patch = diffTree(prevVNode, vnode)
425
+
426
+ if (!patch) {
427
+ setVNode(prevEl, vnode)
428
+ return vnode
429
+ }
430
+
431
+ if (patch.type === 'REPLACE') {
432
+ const replacement = renderTree(vnode, true)
433
+ prevEl.parentNode.replaceChild(replacement, prevEl)
434
+ return getVNode(replacement)
435
+ }
436
+
437
+ const parent = prevEl.parentNode
438
+ const index = getChildIndex(parent, prevEl)
439
+ if (index !== -1) applyPatch(parent, patch, index)
440
+
441
+ if (patch.type === 'REMOVE') {
442
+ rootMap.delete(instance)
443
+ return vnode
444
+ }
445
+
446
+ const nextEl = index === -1 ? prevEl : parent.childNodes[index] || prevEl
447
+ setVNode(nextEl, vnode)
448
+ rootMap.set(instance, nextEl)
449
+ return vnode
450
+ }
451
+
452
+ return vnode
453
+ } catch (err) {
454
+ console.error('Component error:', err)
455
+ return ['div', {}, `Error: ${err.message}`]
456
+ }
457
+ }
458
+ }
459
+
460
+ const tagNames = [...htmlTagNames, ...svgTagNames]
461
+ const isPropsObject = x =>
462
+ typeof x === 'object'
463
+ && x !== null
464
+ && !Array.isArray(x)
465
+ && !(typeof Node !== 'undefined' && x instanceof Node)
466
+
467
+ /**
468
+ * @param {string} tag
469
+ * @returns {import('./types.js').ElementsElementHelper<any>}
470
+ */
471
+ const createElementHelper = tag => (...args) => {
472
+ const hasFirstArg = args.length > 0
473
+ const [propsOrChild, ...children] = args
474
+ const props = hasFirstArg && isPropsObject(propsOrChild) ? propsOrChild : {}
475
+ const actualChildren = !hasFirstArg
476
+ ? []
477
+ : props === propsOrChild
478
+ ? children
479
+ : [propsOrChild, ...children]
480
+ return /** @type {import('./types.js').ElementsVNode} */ (
481
+ [tag, props, ...actualChildren]
482
+ )
483
+ }
484
+
485
+ /**
486
+ * A map of supported HTML and SVG element helpers.
487
+ *
488
+ * Each helper is a function that accepts optional props as first argument
489
+ * and children as subsequent arguments.
490
+ *
491
+ * Example:
492
+ *
493
+ * ```js
494
+ * div({ id: 'foo' }, 'Hello World')
495
+ * ```
496
+ *
497
+ * Produces:
498
+ *
499
+ * ```js
500
+ * ['div', { id: 'foo' }, 'Hello World']
501
+ * ```
502
+ *
503
+ * The following helpers are included:
504
+ * `div`, `span`, `button`, `svg`, `circle`, etc.
505
+ */
506
+ /** @type {import('./types.js').ElementsElementMap} */
507
+ export const elements = (() => {
508
+ /** @type {Record<string, import('./types.js').ElementsElementHelper<any>>} */
509
+ const acc = {}
510
+ acc.fragment = createElementHelper('fragment')
511
+ for (const tag of tagNames) acc[tag] = createElementHelper(tag)
512
+ return /** @type {import('./types.js').ElementsElementMap} */ (acc)
513
+ })()