@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 +14 -0
- package/package.json +1 -1
- package/src/lib/ui/README.md +2 -2
- package/src/lib/ui/compile/HTML_TAGS.ts +132 -0
- package/src/lib/ui/compile/componentWrapperTag.ts +13 -10
- package/src/lib/ui/compile/parseTemplate.ts +18 -0
- package/src/lib/ui/dom/cloneStatic.ts +1 -17
- package/src/lib/ui/dom/templateFor.ts +28 -0
- package/src/lib/ui/runtime/createDoc.ts +11 -8
- package/src/lib/ui/runtime/types/PathWalk.ts +10 -0
- package/src/lib/ui/runtime/walkPath.ts +27 -0
- package/src/lib/ui/runtime/pathExists.ts +0 -23
- package/src/lib/ui/runtime/valueAtPath.ts +0 -18
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
package/src/lib/ui/README.md
CHANGED
|
@@ -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
|
|
50
|
-
|
|
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 {
|
|
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
|
|
7
|
-
wrapper
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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 {
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
}
|