@abide/abide 0.32.1 → 0.32.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # abide
2
2
 
3
+ ## 0.32.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - sync examples and ui README to current API ([`923bb86`](https://github.com/briancray/abide/commit/923bb8674814e8efcacc954beac0f1c7982b0e2c))
8
+
9
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - reject a static import in a nested <template> script ([`b9e7c76`](https://github.com/briancray/abide/commit/b9e7c76509d2401484d3f997b0cb904af4b5080c))
10
+
11
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - merge valueAtPath + pathExists into a single-pass walkPath ([`bad087b`](https://github.com/briancray/abide/commit/bad087b39c4b5427bf44fec6c3f2eb1fdbccf9c2))
12
+
13
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - remap any HTML-element-named component wrapper, not just void ([`bbbbe35`](https://github.com/briancray/abide/commit/bbbbe353e22b5d016cd47e81eb8bc9a786fbe336))
14
+
15
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - extract the static-clone template cache, keyed per document ([`d35beaa`](https://github.com/briancray/abide/commit/d35beaa956a449b79a5be63034473262226ad44d))
16
+
3
17
  ## 0.32.1
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.32.1",
3
+ "version": "0.32.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -46,8 +46,8 @@ every page, and `.abide` files are the only component format.
46
46
  `<template await>`/`then`/`catch`, `<template switch>`/`case`/`default`.
47
47
  - **Components** are capitalised tags (`<Layout title="…">`); children fill the
48
48
  child's `<slot>`. Props are reactive (passed as thunks).
49
- - **Scoped styles**: a `<style>` block is scoped per component via a
50
- `[data-b-<hash>]` attribute.
49
+ - **Scoped styles**: a `<style>` block is scoped via a `[data-a-<hash>]`
50
+ attribute — per component, or per control-flow branch when nested in one.
51
51
 
52
52
  ## Substrate (why it's fast)
53
53
 
@@ -0,0 +1,132 @@
1
+ /*
2
+ Every standard HTML element name (lowercase), plus the two foreign-content roots
3
+ `svg`/`math`. A component wrapper tag that collides with one of these is a real
4
+ element with a content model and parser quirks — `<button>`/`<a>` reject interactive
5
+ descendants, table/list/select families foster or auto-close non-conforming children,
6
+ void elements self-close, and `<svg>`/`<math>` switch the parser into SVG/MathML
7
+ namespace so the wrapper's HTML children break out (foster-parent) of it entirely —
8
+ so the parser reparents the component's own markup out of the wrapper and hydration
9
+ claims `null`. `componentWrapperTag` remaps any such name to a transparent custom
10
+ element instead. Pure SVG/MathML descendant names (`circle`, `path`, `mrow`, …) are
11
+ NOT here: in HTML context they are inert unknown elements that hold children fine, so
12
+ a `<Circle>` component stays as-is like any non-element name. Superset of VOID_TAGS
13
+ (which the parser/SSR still use for self-closing); this set is only the
14
+ wrapper-safety check.
15
+ */
16
+ export const HTML_TAGS: ReadonlySet<string> = new Set([
17
+ 'a',
18
+ 'abbr',
19
+ 'address',
20
+ 'area',
21
+ 'article',
22
+ 'aside',
23
+ 'audio',
24
+ 'b',
25
+ 'base',
26
+ 'bdi',
27
+ 'bdo',
28
+ 'blockquote',
29
+ 'body',
30
+ 'br',
31
+ 'button',
32
+ 'canvas',
33
+ 'caption',
34
+ 'cite',
35
+ 'code',
36
+ 'col',
37
+ 'colgroup',
38
+ 'data',
39
+ 'datalist',
40
+ 'dd',
41
+ 'del',
42
+ 'details',
43
+ 'dfn',
44
+ 'dialog',
45
+ 'div',
46
+ 'dl',
47
+ 'dt',
48
+ 'em',
49
+ 'embed',
50
+ 'fieldset',
51
+ 'figcaption',
52
+ 'figure',
53
+ 'footer',
54
+ 'form',
55
+ 'h1',
56
+ 'h2',
57
+ 'h3',
58
+ 'h4',
59
+ 'h5',
60
+ 'h6',
61
+ 'head',
62
+ 'header',
63
+ 'hgroup',
64
+ 'hr',
65
+ 'html',
66
+ 'i',
67
+ 'iframe',
68
+ 'img',
69
+ 'input',
70
+ 'ins',
71
+ 'kbd',
72
+ 'label',
73
+ 'legend',
74
+ 'li',
75
+ 'link',
76
+ 'main',
77
+ 'map',
78
+ 'mark',
79
+ 'math',
80
+ 'menu',
81
+ 'meta',
82
+ 'meter',
83
+ 'nav',
84
+ 'noscript',
85
+ 'object',
86
+ 'ol',
87
+ 'optgroup',
88
+ 'option',
89
+ 'output',
90
+ 'p',
91
+ 'param',
92
+ 'picture',
93
+ 'pre',
94
+ 'progress',
95
+ 'q',
96
+ 'rp',
97
+ 'rt',
98
+ 'ruby',
99
+ 's',
100
+ 'samp',
101
+ 'script',
102
+ 'search',
103
+ 'section',
104
+ 'select',
105
+ 'slot',
106
+ 'small',
107
+ 'source',
108
+ 'span',
109
+ 'strong',
110
+ 'style',
111
+ 'sub',
112
+ 'summary',
113
+ 'sup',
114
+ 'svg',
115
+ 'table',
116
+ 'tbody',
117
+ 'td',
118
+ 'template',
119
+ 'textarea',
120
+ 'tfoot',
121
+ 'th',
122
+ 'thead',
123
+ 'time',
124
+ 'title',
125
+ 'tr',
126
+ 'track',
127
+ 'u',
128
+ 'ul',
129
+ 'var',
130
+ 'video',
131
+ 'wbr',
132
+ ])
@@ -1,20 +1,23 @@
1
- import { VOID_TAGS } from './VOID_TAGS.ts'
1
+ import { HTML_TAGS } from './HTML_TAGS.ts'
2
2
 
3
3
  /*
4
4
  The element tag a component instance mounts into. Normally the component name
5
5
  lowercased — readable in devtools, a real box like any abide wrapper. But a name
6
- that lowercases to a VOID element (`Input`→`input`, `Img`→`img`) would yield a
7
- wrapper the HTML parser self-closes, reparenting the component's own markup as the
8
- wrapper's siblings so on hydration `openChild` finds the wrapper empty, claims
9
- `null`, and `attr` throws on it. Those names map to a hyphenated custom-element tag
10
- (a custom element is never void) made layout-transparent with `display:contents`,
11
- so the component's real root still lays out as a direct child of the parent the way
12
- the (parse-broken) void wrapper effectively did. Both back-ends call this so the SSR
13
- string and the client build agree on the wrapper.
6
+ that lowercases to a real HTML element (`Button`→`button`, `Input`→`input`) yields a
7
+ wrapper with a content model the parser enforces: void elements self-close, and
8
+ `<button>`/`<a>`/table/list/select families reject or foster the component's own
9
+ markup as the wrapper's siblings so on hydration `openChild` finds the wrapper
10
+ empty, claims `null`, and `attr` throws on it. Those names map to a hyphenated
11
+ custom-element tag (a custom element is never void and has no content model) made
12
+ layout-transparent with `display:contents`, so the component's real root still lays
13
+ out as a direct child of the parent the way the (parse-broken) wrapper would have.
14
+ A name that is NOT a known HTML element (the common case — `Card`, `Dropdown`) is an
15
+ inert unknown tag that holds any content untouched, so it stays as-is. Both back-ends
16
+ call this so the SSR string and the client build agree on the wrapper.
14
17
  */
15
18
  export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
16
19
  const lower = name.toLowerCase()
17
- return VOID_TAGS.has(lower)
20
+ return HTML_TAGS.has(lower)
18
21
  ? { tag: `abide-${lower}`, transparent: true }
19
22
  : { tag: lower, transparent: false }
20
23
  }
@@ -20,6 +20,12 @@ text, never mistaken for a real style. Keeping it in the tree lets the front-end
20
20
  scope it to its sibling subtree (`analyzeComponent`); the node emits no DOM/markup.
21
21
  */
22
22
 
23
+ /* A line-leading static `import` in a nested script body. The `(?=\s)` requires
24
+ whitespace after the keyword (sparing `import.meta` and no-space `import(...)`),
25
+ and `(?!\s*\()` spares a dynamic `import (...)` written with whitespace before the
26
+ paren — both legitimate lazy paths — so only a true static import statement matches. */
27
+ const NESTED_STATIC_IMPORT = /^[ \t]*import(?=\s)(?!\s*\()/m
28
+
23
29
  /* A braced template expression with the absolute source offset of its first
24
30
  (post-trim) character, so the type-checking shadow can map a diagnostic back. */
25
31
  type Braced = { code: string; loc: number }
@@ -179,6 +185,18 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
179
185
  const end = close === -1 ? source.length : close
180
186
  const code = source.slice(cursor, end)
181
187
  cursor = close === -1 ? source.length : end + '</script>'.length
188
+ /* A static `import` can't live here: a nested script compiles INTO the
189
+ branch's render-function body, where an import is illegal — and an
190
+ import nested in a branch falsely implies conditional/lazy loading ES
191
+ imports can't do (they hoist module-wide and load unconditionally). The
192
+ leading `<script>` hoists imports to module scope for the whole template,
193
+ so they belong there. The pattern spares dynamic `import(...)` (with or
194
+ without whitespace) and `import.meta` — the real lazy paths. */
195
+ if (NESTED_STATIC_IMPORT.test(code)) {
196
+ throw new Error(
197
+ "import statements must live in the component's leading <script>, not a nested <template> script — they hoist to module scope for the whole template. For lazy loading, use a dynamic import() inside an effect.",
198
+ )
199
+ }
182
200
  return { kind: 'script', code }
183
201
  }
184
202
  /* A capitalised tag is a child component; its attributes become props and
@@ -1,22 +1,6 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
-
4
- /*
5
- Parsed-once `<template>` per unique static-skeleton string, reused across every
6
- mount. A `<template>` (not a detached `<div>`) so table/select content parses by
7
- the real content model, exactly as the browser parsed the server markup.
8
- */
9
- const TEMPLATES = new Map<string, HTMLTemplateElement>()
10
-
11
- function templateFor(html: string): HTMLTemplateElement {
12
- let template = TEMPLATES.get(html)
13
- if (template === undefined) {
14
- template = document.createElement('template')
15
- template.innerHTML = html
16
- TEMPLATES.set(html, template)
17
- }
18
- return template
19
- }
3
+ import { templateFor } from './templateFor.ts'
20
4
 
21
5
  /*
22
6
  Appends a fully-static subtree (one or more top-level nodes) under `parent` — what
@@ -0,0 +1,28 @@
1
+ /*
2
+ Parsed-once `<template>` per unique static-skeleton string, reused across every
3
+ mount. A `<template>` (not a detached `<div>`) so table/select content parses by
4
+ the real content model, exactly as the browser parsed the server markup. Backs
5
+ `cloneStatic`'s skeleton-string→template cache.
6
+
7
+ The cache is keyed by the owning `document`: a template belongs to the document
8
+ that created it, and its clones must land in that same document. In production there
9
+ is one document, so this is one inner map; under the test harness, which installs a
10
+ fresh `document` per file, it keeps each file's templates (and their node class)
11
+ from leaking into the next.
12
+ */
13
+ const CACHES = new WeakMap<object, Map<string, HTMLTemplateElement>>()
14
+
15
+ export function templateFor(html: string): HTMLTemplateElement {
16
+ let cache = CACHES.get(document)
17
+ if (cache === undefined) {
18
+ cache = new Map()
19
+ CACHES.set(document, cache)
20
+ }
21
+ let template = cache.get(html)
22
+ if (template === undefined) {
23
+ template = document.createElement('template')
24
+ template.innerHTML = html
25
+ cache.set(html, template)
26
+ }
27
+ return template
28
+ }
@@ -1,7 +1,6 @@
1
1
  import { applyPatchToTree } from './applyPatchToTree.ts'
2
2
  import { createSignalNode } from './createSignalNode.ts'
3
3
  import { flushEffects } from './flushEffects.ts'
4
- import { pathExists } from './pathExists.ts'
5
4
  import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
6
5
  import { readNode } from './readNode.ts'
7
6
  import { trigger } from './trigger.ts'
@@ -9,7 +8,7 @@ import type { Cell } from './types/Cell.ts'
9
8
  import type { Doc } from './types/Doc.ts'
10
9
  import type { Patch } from './types/Patch.ts'
11
10
  import type { ReactiveNode } from './types/ReactiveNode.ts'
12
- import { valueAtPath } from './valueAtPath.ts'
11
+ import { walkPath } from './walkPath.ts'
13
12
  import { writeNode } from './writeNode.ts'
14
13
 
15
14
  /*
@@ -39,7 +38,7 @@ export function createDoc(initial: unknown): Doc {
39
38
  function nodeFor(path: string): ReactiveNode {
40
39
  let node = nodes.get(path)
41
40
  if (node === undefined) {
42
- node = createSignalNode(valueAtPath(tree, path))
41
+ node = createSignalNode(walkPath(tree, path).value)
43
42
  nodes.set(path, node)
44
43
  }
45
44
  return node
@@ -60,7 +59,7 @@ export function createDoc(initial: unknown): Doc {
60
59
  paying a scan over every minted node.
61
60
  */
62
61
  function wakeSubtree(rootPath: string, force: boolean, descend: boolean): void {
63
- const rootValue = valueAtPath(tree, rootPath)
62
+ const rootValue = walkPath(tree, rootPath).value
64
63
  const rootNode = nodes.get(rootPath)
65
64
  if (rootNode !== undefined) {
66
65
  if (force) {
@@ -82,8 +81,9 @@ export function createDoc(initial: unknown): Doc {
82
81
  and this very descend scan degrades linearly with it. The woken
83
82
  reader re-mints a fresh node on its flush if the path ever returns.
84
83
  Deleting the current entry mid-iteration is safe on a Map. */
85
- if (pathExists(tree, candidate)) {
86
- writeNode(node, valueAtPath(tree, candidate))
84
+ const walk = walkPath(tree, candidate)
85
+ if (walk.exists) {
86
+ writeNode(node, walk.value)
87
87
  } else {
88
88
  writeNode(node, undefined)
89
89
  nodes.delete(candidate)
@@ -95,8 +95,11 @@ export function createDoc(initial: unknown): Doc {
95
95
  function apply(patch: Patch): void {
96
96
  const segments = patch.path === '' ? [] : patch.path.split('/')
97
97
  tree = applyPatchToTree(tree, patch, segments)
98
- const parentPath = segments.slice(0, -1).join('/')
99
- const parentValue = valueAtPath(tree, parentPath)
98
+ /* parentPath is patch.path minus its last segment — the same string
99
+ `segments.slice(0, -1).join('/')` rebuilds, taken by one slice instead. */
100
+ const lastSlash = patch.path.lastIndexOf('/')
101
+ const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
102
+ const parentValue = walkPath(tree, parentPath).value
100
103
  const leafKey = segments[segments.length - 1] as string | undefined
101
104
  /* A structural change (add/remove, or an array element replaced by index)
102
105
  reshapes the parent; a plain value replace reshapes only its own path. */
@@ -0,0 +1,10 @@
1
+ /*
2
+ The result of walking a `/`-joined path through a tree: whether the path still
3
+ resolves to an own slot at every segment, and the value it holds. `exists` is
4
+ false when a segment is missing (a deleted key, an out-of-range index); `value`
5
+ is then `undefined`. Separates a missing path from one holding a real `undefined`.
6
+ */
7
+ export type PathWalk = {
8
+ exists: boolean
9
+ value: unknown
10
+ }
@@ -0,0 +1,27 @@
1
+ import type { PathWalk } from './types/PathWalk.ts'
2
+
3
+ /*
4
+ Walks a `/`-joined path through a plain tree in one pass, returning both whether
5
+ the path still resolves (`exists`) and the value it holds (`value`). `''` is the
6
+ root. Arrays index by their numeric segment as a string — JS array access coerces
7
+ the key, and `in` covers an own index in range or `length`.
8
+
9
+ The two answers are inseparable on the eviction path (`createDoc`'s descend),
10
+ which must distinguish a path the tree no longer has (a deleted key, an
11
+ out-of-range index after a shrink) from one holding a genuine `undefined` — a
12
+ distinction the value alone can't make. Returning both walks the path once where
13
+ a separate value-read + existence-check would walk it twice.
14
+ */
15
+ export function walkPath(tree: unknown, path: string): PathWalk {
16
+ if (path === '') {
17
+ return { exists: tree !== undefined, value: tree }
18
+ }
19
+ let current: unknown = tree
20
+ for (const segment of path.split('/')) {
21
+ if (current === null || typeof current !== 'object' || !(segment in current)) {
22
+ return { exists: false, value: undefined }
23
+ }
24
+ current = (current as Record<string, unknown>)[segment]
25
+ }
26
+ return { exists: true, value: current }
27
+ }
@@ -1,23 +0,0 @@
1
- /*
2
- Whether a `/`-joined path still resolves through `tree` — every segment present
3
- in its container. The `in` operator covers both shapes: an object key, an array's
4
- own index (in range) or its `length`. Distinguishes a path the tree no longer has
5
- (a deleted key, an out-of-range index after a shrink) from one holding a genuine
6
- `undefined`, which `valueAtPath` alone can't — used to evict dead reactive nodes.
7
- */
8
- export function pathExists(tree: unknown, path: string): boolean {
9
- if (path === '') {
10
- return tree !== undefined
11
- }
12
- let current: unknown = tree
13
- for (const segment of path.split('/')) {
14
- if (current === null || typeof current !== 'object') {
15
- return false
16
- }
17
- if (!(segment in current)) {
18
- return false
19
- }
20
- current = (current as Record<string, unknown>)[segment]
21
- }
22
- return true
23
- }
@@ -1,18 +0,0 @@
1
- /*
2
- Reads the value at a `/`-joined path in a plain tree. `''` is the root. Returns
3
- undefined if any segment is missing — arrays index by their numeric segment as a
4
- string, which works because JS array access coerces the key.
5
- */
6
- export function valueAtPath(tree: unknown, path: string): unknown {
7
- if (path === '') {
8
- return tree
9
- }
10
- let current: unknown = tree
11
- for (const segment of path.split('/')) {
12
- if (current === null || typeof current !== 'object') {
13
- return undefined
14
- }
15
- current = (current as Record<string, unknown>)[segment]
16
- }
17
- return current
18
- }