@abide/abide 0.32.1 → 0.33.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.
Files changed (54) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +93 -63
  3. package/package.json +6 -2
  4. package/src/lib/server/runtime/buildCacheSnapshot.ts +5 -4
  5. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  6. package/src/lib/shared/cache.ts +43 -29
  7. package/src/lib/shared/types/CacheEntry.ts +12 -12
  8. package/src/lib/shared/types/CacheOptions.ts +17 -13
  9. package/src/lib/ui/README.md +3 -3
  10. package/src/lib/ui/compile/HTML_TAGS.ts +132 -0
  11. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +4 -1
  12. package/src/lib/ui/compile/componentWrapperTag.ts +13 -10
  13. package/src/lib/ui/compile/generateBuild.ts +265 -121
  14. package/src/lib/ui/compile/generateSSR.ts +78 -37
  15. package/src/lib/ui/compile/parseTemplate.ts +52 -0
  16. package/src/lib/ui/compile/skeletonable.ts +80 -0
  17. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +6 -0
  18. package/src/lib/ui/dom/SVG_NAMESPACE.ts +7 -0
  19. package/src/lib/ui/dom/anchorCursor.ts +24 -0
  20. package/src/lib/ui/dom/appendSnippet.ts +1 -1
  21. package/src/lib/ui/dom/appendTextAt.ts +70 -0
  22. package/src/lib/ui/dom/awaitBlock.ts +27 -7
  23. package/src/lib/ui/dom/cloneStatic.ts +15 -24
  24. package/src/lib/ui/dom/each.ts +44 -25
  25. package/src/lib/ui/dom/eachAsync.ts +6 -2
  26. package/src/lib/ui/dom/effectiveChildNamespace.ts +13 -0
  27. package/src/lib/ui/dom/enterNamespace.ts +20 -0
  28. package/src/lib/ui/dom/fillBefore.ts +20 -3
  29. package/src/lib/ui/dom/foreignWrapperTag.ts +22 -0
  30. package/src/lib/ui/dom/hydrate.ts +1 -1
  31. package/src/lib/ui/dom/inheritedNamespace.ts +19 -0
  32. package/src/lib/ui/dom/mountSlot.ts +32 -0
  33. package/src/lib/ui/dom/openMarker.ts +4 -2
  34. package/src/lib/ui/dom/skeleton.ts +202 -0
  35. package/src/lib/ui/dom/switchBlock.ts +10 -3
  36. package/src/lib/ui/dom/templateFor.ts +28 -0
  37. package/src/lib/ui/dom/tryBlock.ts +7 -5
  38. package/src/lib/ui/dom/types/SkeletonHoles.ts +8 -0
  39. package/src/lib/ui/dom/when.ts +6 -2
  40. package/src/lib/ui/installHotBridge.ts +8 -2
  41. package/src/lib/ui/runtime/HOLE_ATTRIBUTE.ts +9 -0
  42. package/src/lib/ui/runtime/RENDER.ts +7 -0
  43. package/src/lib/ui/runtime/createDoc.ts +11 -8
  44. package/src/lib/ui/runtime/types/PathWalk.ts +10 -0
  45. package/src/lib/ui/runtime/types/UiProps.ts +3 -4
  46. package/src/lib/ui/runtime/walkPath.ts +27 -0
  47. package/template/src/ui/pages/about/page.abide +4 -6
  48. package/template/src/ui/pages/layout.abide +21 -0
  49. package/template/src/ui/pages/page.abide +5 -8
  50. package/src/lib/ui/compile/partitionSlots.ts +0 -36
  51. package/src/lib/ui/dom/openChild.ts +0 -22
  52. package/src/lib/ui/runtime/pathExists.ts +0 -23
  53. package/src/lib/ui/runtime/valueAtPath.ts +0 -18
  54. package/template/src/ui/Layout.abide +0 -19
@@ -24,6 +24,7 @@ export function when(
24
24
  condition: () => unknown,
25
25
  render: (parent: Node) => void,
26
26
  renderElse?: (parent: Node) => void,
27
+ before: Node | null = null,
27
28
  ): void {
28
29
  const hydration = RENDER.hydration
29
30
  const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
@@ -31,7 +32,10 @@ export function when(
31
32
  let activeBranch: 'then' | 'else'
32
33
  let end: Comment
33
34
 
34
- const start = openMarker(parent, '[')
35
+ /* `before` (a static node located by the skeleton) places the range among siblings on
36
+ create, so the block sits before a static suffix rather than at the parent's end.
37
+ Hydrate ignores it — the claim cursor (positioned past the prefix) drives placement. */
38
+ const start = openMarker(parent, '[', before)
35
39
  if (hydration !== undefined) {
36
40
  activeBranch = condition() ? 'then' : 'else'
37
41
  const chosen = chosenFor(activeBranch)
@@ -40,7 +44,7 @@ export function when(
40
44
  }
41
45
  end = openMarker(parent, ']')
42
46
  } else {
43
- end = openMarker(parent, ']')
47
+ end = openMarker(parent, ']', before)
44
48
  activeBranch = condition() ? 'then' : 'else'
45
49
  const chosen = chosenFor(activeBranch)
46
50
  if (chosen !== undefined) {
@@ -2,9 +2,11 @@ import { html } from '../shared/html.ts'
2
2
  import { snippet } from '../shared/snippet.ts'
3
3
  import { derived } from './derived.ts'
4
4
  import { doc } from './doc.ts'
5
+ import { anchorCursor } from './dom/anchorCursor.ts'
5
6
  import { appendSnippet } from './dom/appendSnippet.ts'
6
7
  import { appendStatic } from './dom/appendStatic.ts'
7
8
  import { appendText } from './dom/appendText.ts'
9
+ import { appendTextAt } from './dom/appendTextAt.ts'
8
10
  import { attach } from './dom/attach.ts'
9
11
  import { attr } from './dom/attr.ts'
10
12
  import { awaitBlock } from './dom/awaitBlock.ts'
@@ -14,8 +16,9 @@ import { eachAsync } from './dom/eachAsync.ts'
14
16
  import { hydrate } from './dom/hydrate.ts'
15
17
  import { mount } from './dom/mount.ts'
16
18
  import { mountChild } from './dom/mountChild.ts'
19
+ import { mountSlot } from './dom/mountSlot.ts'
17
20
  import { on } from './dom/on.ts'
18
- import { openChild } from './dom/openChild.ts'
21
+ import { skeleton } from './dom/skeleton.ts'
19
22
  import { switchBlock } from './dom/switchBlock.ts'
20
23
  import { tryBlock } from './dom/tryBlock.ts'
21
24
  import { when } from './dom/when.ts'
@@ -48,11 +51,13 @@ export function installHotBridge(): void {
48
51
  derived,
49
52
  effect,
50
53
  mount,
51
- openChild,
52
54
  appendText,
55
+ appendTextAt,
53
56
  appendSnippet,
54
57
  appendStatic,
55
58
  cloneStatic,
59
+ skeleton,
60
+ anchorCursor,
56
61
  attr,
57
62
  on,
58
63
  attach,
@@ -62,6 +67,7 @@ export function installHotBridge(): void {
62
67
  awaitBlock,
63
68
  tryBlock,
64
69
  switchBlock,
70
+ mountSlot,
65
71
  mountChild,
66
72
  hydrate,
67
73
  nextBlockId,
@@ -0,0 +1,9 @@
1
+ /*
2
+ The marker attribute the client back-end stamps on each hole element in a skeleton
3
+ string — an element carrying reactive attributes/listeners/binds whose live node the
4
+ build must wire up. Its value is the hole's index, so attach code addresses holes by
5
+ number regardless of document order. `skeleton` records each marked node's path,
6
+ strips the attribute, and returns the holes indexed by it. One source of truth so the
7
+ compiler's emit and the runtime's read can't drift.
8
+ */
9
+ export const HOLE_ATTRIBUTE = 'data-abide-hole'
@@ -11,13 +11,20 @@ child components it inlines, so ids are globally unique within one render pass (
11
11
  SSR stream and client hydration agree on them — `RESUME` is keyed by id). `depth`
12
12
  tracks nesting so the OUTERMOST render/mount resets the counter and a child render/
13
13
  mount continues it. See `enterRenderPass`/`nextBlockId`.
14
+
15
+ `namespace` is the ambient foreign-content namespace (SVG/MathML) a control-flow block
16
+ sets from its insertion parent while building into a detached fragment, so foreign
17
+ elements built there get the right namespace — the fragment itself carries none. It is
18
+ undefined outside foreign content. See `enterNamespace`/`effectiveChildNamespace`.
14
19
  */
15
20
  export const RENDER: {
16
21
  hydration: { next: Map<Node, Node | null> } | undefined
17
22
  blockId: number
18
23
  depth: number
24
+ namespace: string | undefined
19
25
  } = {
20
26
  hydration: undefined,
21
27
  blockId: 0,
22
28
  depth: 0,
29
+ namespace: undefined,
23
30
  }
@@ -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
+ }
@@ -3,13 +3,12 @@ What a component is invoked with. Two real shapes flow through the one parameter
3
3
  A top-level page/layout is called by the router (client) and `renderChain` (SSR)
4
4
  with its decoded route params — a plain string map. A nested child is called by
5
5
  the compiler-emitted `mountChild` with a map of reactive thunks (each authored
6
- prop, read in the body as `$props[name]?.()`) plus optional slot builders:
7
- `$children` for default-slot markup and `$slots[name]` for named slots, each
8
- mounting into the host element it is handed.
6
+ prop, read in the body as `$props[name]?.()`) plus an optional `$children` slot
7
+ builder carrying the component's `<slot>` markup, mounting into the host element
8
+ it is handed.
9
9
  */
10
10
  export type UiProps =
11
11
  | Record<string, string>
12
12
  | (Record<string, () => unknown> & {
13
13
  $children?: (host: Element) => void
14
- $slots?: Record<string, (host: Element) => void>
15
14
  })
@@ -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,9 +1,7 @@
1
1
  <script>
2
- /* A second page — served at GET /about. Folder name becomes the URL segment. */
3
- import Layout from '../../Layout.abide'
2
+ /* A second page — served at GET /about. Folder name becomes the URL segment; the
3
+ root layout.abide wraps it, same as the home page. */
4
4
  </script>
5
5
 
6
- <Layout>
7
- <h1>About</h1>
8
- <p>This is a barebones abide app.</p>
9
- </Layout>
6
+ <h1>About</h1>
7
+ <p>This is a barebones abide app.</p>
@@ -0,0 +1,21 @@
1
+ <script>
2
+ /*
3
+ Root layout. A layout.abide wraps every page at or below its folder — here
4
+ src/ui/pages/, so it wraps the whole app. The page lands in the <slot/> outlet;
5
+ the resolver finds it by filename (no per-page import), and the client router
6
+ keeps it mounted across navigation, so this chrome never re-mounts. A nested
7
+ layout.abide (in a subfolder) wraps inside this one. Runs on the server during
8
+ SSR and on the client after hydration.
9
+ */
10
+ import '../app.css'
11
+ </script>
12
+
13
+ <header>
14
+ <nav>
15
+ <a href="/">Home</a>
16
+ <a href="/about">About</a>
17
+ </nav>
18
+ </header>
19
+ <main>
20
+ <slot></slot>
21
+ </main>
@@ -1,7 +1,7 @@
1
1
  <script>
2
2
  /*
3
3
  Root page — served at GET /. Every folder under src/ui/pages/ that contains a
4
- page.abide mounts at that folder's URL.
4
+ page.abide mounts at that folder's URL; the root layout.abide wraps it.
5
5
 
6
6
  The blocking await-block below (the `then` attribute sits ON the <template await>)
7
7
  resolves on the server during SSR and renders inline — no pending placeholder. The
@@ -11,12 +11,9 @@ instead would stream the resolution in out of order.
11
11
  */
12
12
  import { cache } from '@abide/abide/shared/cache'
13
13
  import { getHello } from '$server/rpc/getHello.ts'
14
- import Layout from '../Layout.abide'
15
14
  </script>
16
15
 
17
- <Layout>
18
- <template await={cache(getHello)()} then="hello">
19
- <h1>{hello.message}</h1>
20
- </template>
21
- <p>Edit <code>src/ui/pages/page.abide</code> and the page hot-reloads.</p>
22
- </Layout>
16
+ <template await={cache(getHello)()} then="hello">
17
+ <h1>{hello.message}</h1>
18
+ </template>
19
+ <p>Edit <code>src/ui/pages/page.abide</code> and the page hot-reloads.</p>
@@ -1,36 +0,0 @@
1
- import { staticAttrValue } from './staticAttrValue.ts'
2
- import type { TemplateNode } from './types/TemplateNode.ts'
3
-
4
- /* A component's slotted children split by destination slot. */
5
- export type SlotGroups = {
6
- default: TemplateNode[]
7
- named: { name: string; nodes: TemplateNode[] }[]
8
- }
9
-
10
- /*
11
- Partitions a component's children by their `slot="name"` attribute: an element
12
- carrying one goes to that named group (with the directive attr stripped so it
13
- never renders as a real attribute), everything else forms the default slot. Both
14
- back-ends partition identically, so SSR and client agree on which markup lands in
15
- which `<slot>`.
16
- */
17
- export function partitionSlots(children: TemplateNode[]): SlotGroups {
18
- const defaults: TemplateNode[] = []
19
- const named = new Map<string, TemplateNode[]>()
20
- for (const child of children) {
21
- const name = child.kind === 'element' ? staticAttrValue(child, 'slot') : undefined
22
- if (child.kind !== 'element' || name === undefined) {
23
- defaults.push(child)
24
- continue
25
- }
26
- const stripped = {
27
- ...child,
28
- attrs: child.attrs.filter((attr) => !(attr.kind === 'static' && attr.name === 'slot')),
29
- }
30
- named.set(name, [...(named.get(name) ?? []), stripped])
31
- }
32
- return {
33
- default: defaults,
34
- named: [...named].map(([name, nodes]) => ({ name, nodes })),
35
- }
36
- }
@@ -1,22 +0,0 @@
1
- import { claimChild } from '../runtime/claimChild.ts'
2
- import { RENDER } from '../runtime/RENDER.ts'
3
-
4
- /*
5
- Opens a child element of `parent`: creates and appends it (create mode), or claims
6
- the existing server-rendered node at the parent's current build position (hydrate
7
- mode), advancing the claim pointer. Returns the element so bindings and children
8
- attach to it. The compiler emits this for every element so the same build code
9
- serves both modes.
10
- */
11
- // @readme plumbing
12
- export function openChild(parent: Node, tag: string): Element {
13
- const hydration = RENDER.hydration
14
- if (hydration !== undefined) {
15
- const current = claimChild(hydration, parent)
16
- hydration.next.set(parent, current === null ? null : current.nextSibling)
17
- return current as unknown as Element
18
- }
19
- const element = document.createElement(tag)
20
- parent.appendChild(element)
21
- return element
22
- }
@@ -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
- }
@@ -1,19 +0,0 @@
1
- <script>
2
- /*
3
- Userland root layout. abide has no framework layout resolution — a layout is just
4
- a component a page wraps its body in (<Layout>…</Layout>), and the body lands in
5
- the <slot>. Import and wrap this from each page.abide to share chrome. Runs on the
6
- server during SSR and on the client after hydration.
7
- */
8
- import './app.css'
9
- </script>
10
-
11
- <header>
12
- <nav>
13
- <a href="/">Home</a>
14
- <a href="/about">About</a>
15
- </nav>
16
- </header>
17
- <main>
18
- <slot></slot>
19
- </main>