@barefootjs/client 0.1.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 (108) hide show
  1. package/dist/build.d.ts +56 -0
  2. package/dist/build.d.ts.map +1 -0
  3. package/dist/build.js +76 -0
  4. package/dist/context.d.ts +25 -0
  5. package/dist/context.d.ts.map +1 -0
  6. package/dist/csr-adapter.d.ts +26 -0
  7. package/dist/csr-adapter.d.ts.map +1 -0
  8. package/dist/forward-props.d.ts +17 -0
  9. package/dist/forward-props.d.ts.map +1 -0
  10. package/dist/index.d.ts +8 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +154 -0
  13. package/dist/reactive.d.ts +150 -0
  14. package/dist/reactive.d.ts.map +1 -0
  15. package/dist/reactive.js +215 -0
  16. package/dist/runtime/apply-rest-attrs.d.ts +16 -0
  17. package/dist/runtime/apply-rest-attrs.d.ts.map +1 -0
  18. package/dist/runtime/branch-slot.d.ts +22 -0
  19. package/dist/runtime/branch-slot.d.ts.map +1 -0
  20. package/dist/runtime/client-marker.d.ts +21 -0
  21. package/dist/runtime/client-marker.d.ts.map +1 -0
  22. package/dist/runtime/component.d.ts +99 -0
  23. package/dist/runtime/component.d.ts.map +1 -0
  24. package/dist/runtime/context.d.ts +40 -0
  25. package/dist/runtime/context.d.ts.map +1 -0
  26. package/dist/runtime/hydrate.d.ts +100 -0
  27. package/dist/runtime/hydrate.d.ts.map +1 -0
  28. package/dist/runtime/hydration-state.d.ts +13 -0
  29. package/dist/runtime/hydration-state.d.ts.map +1 -0
  30. package/dist/runtime/index.d.ts +27 -0
  31. package/dist/runtime/index.d.ts.map +1 -0
  32. package/dist/runtime/index.js +2093 -0
  33. package/dist/runtime/insert.d.ts +75 -0
  34. package/dist/runtime/insert.d.ts.map +1 -0
  35. package/dist/runtime/list.d.ts +21 -0
  36. package/dist/runtime/list.d.ts.map +1 -0
  37. package/dist/runtime/map-array.d.ts +32 -0
  38. package/dist/runtime/map-array.d.ts.map +1 -0
  39. package/dist/runtime/portal.d.ts +96 -0
  40. package/dist/runtime/portal.d.ts.map +1 -0
  41. package/dist/runtime/qsa-item.d.ts +52 -0
  42. package/dist/runtime/qsa-item.d.ts.map +1 -0
  43. package/dist/runtime/query.d.ts +86 -0
  44. package/dist/runtime/query.d.ts.map +1 -0
  45. package/dist/runtime/reconcile-elements.d.ts +44 -0
  46. package/dist/runtime/reconcile-elements.d.ts.map +1 -0
  47. package/dist/runtime/registry.d.ts +53 -0
  48. package/dist/runtime/registry.d.ts.map +1 -0
  49. package/dist/runtime/render.d.ts +35 -0
  50. package/dist/runtime/render.d.ts.map +1 -0
  51. package/dist/runtime/scope.d.ts +28 -0
  52. package/dist/runtime/scope.d.ts.map +1 -0
  53. package/dist/runtime/slot-resolver.d.ts +36 -0
  54. package/dist/runtime/slot-resolver.d.ts.map +1 -0
  55. package/dist/runtime/spread-attrs.d.ts +19 -0
  56. package/dist/runtime/spread-attrs.d.ts.map +1 -0
  57. package/dist/runtime/standalone.js +2278 -0
  58. package/dist/runtime/streaming.d.ts +36 -0
  59. package/dist/runtime/streaming.d.ts.map +1 -0
  60. package/dist/runtime/style.d.ts +17 -0
  61. package/dist/runtime/style.d.ts.map +1 -0
  62. package/dist/runtime/template.d.ts +39 -0
  63. package/dist/runtime/template.d.ts.map +1 -0
  64. package/dist/runtime/types.d.ts +26 -0
  65. package/dist/runtime/types.d.ts.map +1 -0
  66. package/dist/shims.d.ts +21 -0
  67. package/dist/shims.d.ts.map +1 -0
  68. package/dist/slot.d.ts +14 -0
  69. package/dist/slot.d.ts.map +1 -0
  70. package/dist/split-props.d.ts +26 -0
  71. package/dist/split-props.d.ts.map +1 -0
  72. package/dist/unwrap.d.ts +16 -0
  73. package/dist/unwrap.d.ts.map +1 -0
  74. package/package.json +71 -0
  75. package/src/build.ts +92 -0
  76. package/src/context.ts +33 -0
  77. package/src/csr-adapter.ts +134 -0
  78. package/src/forward-props.ts +43 -0
  79. package/src/index.ts +42 -0
  80. package/src/reactive.ts +411 -0
  81. package/src/runtime/apply-rest-attrs.ts +109 -0
  82. package/src/runtime/branch-slot.ts +32 -0
  83. package/src/runtime/client-marker.ts +46 -0
  84. package/src/runtime/component.ts +501 -0
  85. package/src/runtime/context.ts +111 -0
  86. package/src/runtime/hydrate.ts +311 -0
  87. package/src/runtime/hydration-state.ts +13 -0
  88. package/src/runtime/index.ts +96 -0
  89. package/src/runtime/insert.ts +407 -0
  90. package/src/runtime/list.ts +47 -0
  91. package/src/runtime/map-array.ts +381 -0
  92. package/src/runtime/portal.ts +174 -0
  93. package/src/runtime/qsa-item.ts +128 -0
  94. package/src/runtime/query.ts +632 -0
  95. package/src/runtime/reconcile-elements.ts +391 -0
  96. package/src/runtime/registry.ts +160 -0
  97. package/src/runtime/render.ts +105 -0
  98. package/src/runtime/scope.ts +46 -0
  99. package/src/runtime/slot-resolver.ts +66 -0
  100. package/src/runtime/spread-attrs.ts +88 -0
  101. package/src/runtime/streaming.ts +65 -0
  102. package/src/runtime/style.ts +27 -0
  103. package/src/runtime/template.ts +53 -0
  104. package/src/runtime/types.ts +27 -0
  105. package/src/shims.ts +54 -0
  106. package/src/slot.ts +23 -0
  107. package/src/split-props.ts +86 -0
  108. package/src/unwrap.ts +18 -0
@@ -0,0 +1,632 @@
1
+ /**
2
+ * BarefootJS - DOM Query Helpers
3
+ *
4
+ * Scope-aware DOM query utilities for compiler-generated ClientJS.
5
+ * These helpers find elements within component scopes, respecting
6
+ * nested scope boundaries and comment-based scopes.
7
+ */
8
+
9
+ import { commentScopeRegistry, getCommentScopeBoundary } from './scope'
10
+ import { hydratedScopes } from './hydration-state'
11
+ import { BF_SCOPE, BF_SLOT, BF_PORTAL_OWNER, BF_PARENT_OWNED_PREFIX, BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
12
+
13
+ /** CSS attribute-value escape with a fallback for environments lacking CSS.escape. */
14
+ export const cssEscape: (s: string) => string =
15
+ typeof CSS !== 'undefined' && (CSS as { escape?: (s: string) => string }).escape
16
+ ? (s) => CSS.escape(s)
17
+ : (s) => s.replace(/"/g, '\\"')
18
+
19
+ // --- helpers ---
20
+
21
+ /** Read bf-s attribute. Returns null when absent.
22
+ * Per #1249, bf-s is the bare addressable id — no stripping needed. */
23
+ function getScopeId(el: Element | null): string | null {
24
+ return el?.getAttribute(BF_SCOPE) ?? null
25
+ }
26
+
27
+ /** Comments already processed by findScopeByComment. */
28
+ const initializedComments = new WeakSet<Comment>()
29
+
30
+ /**
31
+ * Parse scope ID from a comment value like "bf-scope:Name_xxx|propsJson".
32
+ * Strips the prefix and props JSON suffix.
33
+ */
34
+ function parseCommentScopeId(value: string, prefix: string): string | null {
35
+ if (!value.startsWith(prefix)) return null
36
+ let id = value.slice(prefix.length)
37
+ const pipeIdx = id.indexOf('|')
38
+ if (pipeIdx >= 0) id = id.slice(0, pipeIdx)
39
+ return id
40
+ }
41
+
42
+ /** Find the first Element sibling after a node. */
43
+ function nextElementSibling(node: Node): Element | null {
44
+ let sibling: Node | null = node.nextSibling
45
+ while (sibling) {
46
+ if (sibling.nodeType === Node.ELEMENT_NODE) return sibling as Element
47
+ sibling = sibling.nextSibling
48
+ }
49
+ return null
50
+ }
51
+
52
+ // --- findScope ---
53
+
54
+ /**
55
+ * Find component scope element for hydration.
56
+ * Supports unique instance IDs (e.g., ComponentName_abc123).
57
+ *
58
+ * @param name - Component name prefix to search for
59
+ * @param idx - Instance index (for multiple instances)
60
+ * @param parent - Parent element or scope element to search within
61
+ * @param comment - When true, fall back to comment-based scope search (fragment roots only)
62
+ * @returns The scope element or null if not found
63
+ */
64
+ export function findScope(
65
+ name: string,
66
+ idx: number,
67
+ parent: Element | Document | null,
68
+ comment?: boolean
69
+ ): Element | null {
70
+ const parentEl = parent as HTMLElement
71
+
72
+ // Check comment scope registry first.
73
+ // For fragment root components, the scope is identified by a comment marker,
74
+ // not by the bf-s attribute on the proxy element.
75
+ // This must be checked before the bf-s check to prevent the proxy element
76
+ // from being incorrectly accepted and marked as hydrated,
77
+ // which would block child component initialization via initChild.
78
+ if (parentEl) {
79
+ const commentInfo = commentScopeRegistry.get(parentEl)
80
+ if (commentInfo && commentInfo.scopeId.startsWith(`${name}_`)) {
81
+ return parentEl
82
+ }
83
+ }
84
+
85
+ // Check if parent is the scope element itself.
86
+ // Two cases:
87
+ // 1. Scope ID starts with component name (e.g., "AddTodoForm_abc123")
88
+ // 2. Scope ID is from parent component via initChild (e.g., "TodoApp_xyz_s5")
89
+ // — initChild already found the correct element, so trust it
90
+ const scopeId = getScopeId(parentEl)
91
+ if (scopeId) {
92
+ if (
93
+ scopeId.startsWith(`${name}_`) ||
94
+ (/_s\d/.test(scopeId) && parent !== document)
95
+ ) {
96
+ hydratedScopes.add(parentEl)
97
+ return parent as Element
98
+ }
99
+ }
100
+
101
+ // Search for scope elements with prefix matching
102
+ const searchRoot = parent || document
103
+ const allScopes = Array.from(
104
+ searchRoot.querySelectorAll(`[${BF_SCOPE}^="${name}_"]`)
105
+ )
106
+ const uninitializedScopes = allScopes.filter(
107
+ s => !hydratedScopes.has(s)
108
+ )
109
+ const scope = uninitializedScopes[idx] || null
110
+
111
+ if (scope) {
112
+ hydratedScopes.add(scope)
113
+ return scope
114
+ }
115
+
116
+ // Only fall back to comment-based search when explicitly flagged (fragment roots)
117
+ if (comment) {
118
+ return findScopeByComment(name, idx, searchRoot)
119
+ }
120
+ return null
121
+ }
122
+
123
+ /**
124
+ * Find a scope element by walking comment nodes for bf-scope: markers.
125
+ * Returns the first element sibling after the comment (or parent element).
126
+ */
127
+ function findScopeByComment(
128
+ name: string,
129
+ idx: number,
130
+ searchRoot: Element | Document
131
+ ): Element | null {
132
+ const prefix = BF_SCOPE_COMMENT_PREFIX
133
+ const walker = document.createTreeWalker(
134
+ searchRoot,
135
+ NodeFilter.SHOW_COMMENT
136
+ )
137
+ let matchIdx = 0
138
+
139
+ while (walker.nextNode()) {
140
+ const comment = walker.currentNode as Comment
141
+ const value = comment.nodeValue
142
+ if (!value?.startsWith(prefix)) continue
143
+
144
+ const scopeId = parseCommentScopeId(value, prefix)
145
+ if (!scopeId?.startsWith(`${name}_`)) continue
146
+ if (initializedComments.has(comment)) continue
147
+
148
+ if (matchIdx === idx) {
149
+ initializedComments.add(comment)
150
+
151
+ // Proxy element: first element sibling after the comment, or parent
152
+ const proxyEl = nextElementSibling(comment) ?? comment.parentElement
153
+ if (proxyEl) {
154
+ commentScopeRegistry.set(proxyEl, { commentNode: comment, scopeId })
155
+ }
156
+ return proxyEl
157
+ }
158
+ matchIdx++
159
+ }
160
+
161
+ return null
162
+ }
163
+
164
+ // --- candidate enumeration ---
165
+
166
+ /**
167
+ * Lazily enumerate DOM elements matching `selector` within a scope's DOM range.
168
+ * Covers comment-range siblings (for fragment roots), regular descendants,
169
+ * and fragment siblings. Portals are searched separately via findInPortals.
170
+ *
171
+ * This generator separates "where to search" from "how to filter",
172
+ * allowing find() and findDirectChild() to share enumeration logic
173
+ * while applying different acceptance criteria.
174
+ */
175
+ function* candidatesInScope(scope: Element, selector: string): Generator<Element> {
176
+ const commentInfo = commentScopeRegistry.get(scope)
177
+
178
+ if (commentInfo) {
179
+ // Comment-based scope: walk siblings in the comment range
180
+ const boundary = getCommentScopeBoundary(commentInfo.commentNode)
181
+ let node: Node | null = commentInfo.commentNode.nextSibling
182
+ while (node && node !== boundary) {
183
+ if (node.nodeType === Node.ELEMENT_NODE) {
184
+ const el = node as Element
185
+ if (el.matches?.(selector)) yield el
186
+ yield* el.querySelectorAll(selector)
187
+ }
188
+ node = node.nextSibling
189
+ }
190
+ return
191
+ }
192
+
193
+ // Regular scope: descendants then fragment siblings
194
+ yield* scope.querySelectorAll(selector)
195
+
196
+ const scopeId = scope.getAttribute(BF_SCOPE)
197
+ if (!scopeId) return
198
+ const parent = scope.parentElement
199
+ if (!parent) return
200
+ const siblings = parent.querySelectorAll(`[${BF_SCOPE}="${scopeId}"]`)
201
+ for (const sibling of siblings) {
202
+ if (sibling === scope) continue
203
+ if (sibling.matches?.(selector)) yield sibling
204
+ yield* sibling.querySelectorAll(selector)
205
+ }
206
+ }
207
+
208
+ // --- scope membership ---
209
+
210
+ /**
211
+ * Check if a slot element belongs directly to a scope (not in a nested scope).
212
+ * Returns true only if the element's nearest scope is exactly the given scope.
213
+ * Elements inside nested child scopes (which have their own bf-s) return false.
214
+ *
215
+ * Only used for slot element searches (bf="sN" selectors) in regular scopes.
216
+ * Child scope searches ($c) use findChildScope() which bypasses this check.
217
+ * Comment scope filtering is handled inline in find().
218
+ */
219
+ function belongsToScope(element: Element, scope: Element): boolean {
220
+ // Elements with their own scope are component roots — never a slot match
221
+ if (element.getAttribute(BF_SCOPE)) return false
222
+
223
+ // Check if nearest scope matches
224
+ const nearestScope = element.closest(`[${BF_SCOPE}]`)
225
+ return nearestScope === scope
226
+ }
227
+
228
+ /**
229
+ * Check if an element is within the range of a comment-based scope.
230
+ * The range is from the comment node to the next bf-scope: comment (or end of parent).
231
+ */
232
+ function isInCommentScopeRange(element: Element, commentNode: Comment): boolean {
233
+ const boundary = getCommentScopeBoundary(commentNode)
234
+ let node: Node | null = commentNode.nextSibling
235
+ while (node && node !== boundary) {
236
+ if (node === element || (node.nodeType === Node.ELEMENT_NODE && (node as Element).contains(element))) {
237
+ return true
238
+ }
239
+ node = node.nextSibling
240
+ }
241
+ return false
242
+ }
243
+
244
+ // --- find ---
245
+
246
+ /**
247
+ * Find an element within a scope.
248
+ * Enumerates candidates via candidatesInScope generator, then applies
249
+ * context-specific filtering (scope-aware, ignoreScope, or comment-scope).
250
+ * Portals are searched as a final fallback via findInPortals.
251
+ *
252
+ * @param scope - The scope element to search within
253
+ * @param selector - CSS selector to match
254
+ * @param ignoreScope - Skip scope boundary checks (for parent-owned ^-prefixed slots)
255
+ * @returns The matching element or null
256
+ */
257
+ export function find(
258
+ scope: Element | null,
259
+ selector: string,
260
+ ignoreScope?: boolean
261
+ ): Element | null {
262
+ if (!scope) return null
263
+
264
+ const commentInfo = commentScopeRegistry.get(scope)
265
+
266
+ // Self-match: check scope element first (for non-comment scopes)
267
+ if (!commentInfo && scope.matches?.(selector)) return scope
268
+
269
+ // Enumerate candidates and apply filter
270
+ for (const candidate of candidatesInScope(scope, selector)) {
271
+ if (ignoreScope) return candidate
272
+ if (commentInfo) {
273
+ // Comment scope: top-level siblings in the comment range are always
274
+ // accepted (even if they have bf-s, like proxy elements). Descendants
275
+ // are accepted only if not inside a nested bf-s scope.
276
+ if (candidate.parentElement === commentInfo.commentNode.parentElement) return candidate
277
+ if (!candidate.closest(`[${BF_SCOPE}]`)) return candidate
278
+ } else {
279
+ if (belongsToScope(candidate, scope)) return candidate
280
+ }
281
+ }
282
+
283
+ // Portal search (outside scope's DOM subtree)
284
+ const scopeId = commentInfo?.scopeId ?? getScopeId(scope)
285
+ if (scopeId) return findInPortals(scopeId, selector)
286
+
287
+ return null
288
+ }
289
+
290
+ /**
291
+ * Search in portals owned by a scope.
292
+ */
293
+ function findInPortals(scopeId: string, selector: string): Element | null {
294
+ const portals = document.querySelectorAll(`[${BF_PORTAL_OWNER}="${scopeId}"]`)
295
+ for (const portal of portals) {
296
+ if (portal.matches?.(selector)) return portal
297
+ // Search within portal, excluding elements inside nested component scopes
298
+ const matches = portal.querySelectorAll(selector)
299
+ for (const match of matches) {
300
+ const nearestScope = match.closest(`[${BF_SCOPE}]`)
301
+ if (!nearestScope) {
302
+ return match
303
+ }
304
+ }
305
+ }
306
+ return null
307
+ }
308
+
309
+ // --- shorthand finders ---
310
+
311
+ /**
312
+ * Find an element matching a selector, checking the element itself first,
313
+ * then its descendants. Unlike querySelector() which only searches descendants,
314
+ * this also matches the root element.
315
+ *
316
+ * Used by compiler-generated code for event binding and attribute updates
317
+ * on loop items where the target may be the loop item's root element itself.
318
+ */
319
+ export function qsa(el: Element | null, selector: string): Element | null {
320
+ if (!el) return null
321
+
322
+ // Comma-separated selectors are tried in priority order (left-to-right)
323
+ // rather than relying on `querySelector`'s document-order semantics —
324
+ // the compiler-emitted slot-child selector
325
+ // `[bf-h="X"][bf-m="sN"], [bf-s$="_sN"]` resolves to the most specific
326
+ // match (#1249).
327
+ if (selector.includes(',')) {
328
+ for (const clause of splitTopLevelCommas(selector)) {
329
+ const c = clause.trim()
330
+ if (!c) continue
331
+ const hit = qsa(el, c)
332
+ if (hit) return hit
333
+ }
334
+ return null
335
+ }
336
+
337
+ // #1220 cross-binding skip: bare slot-suffix lookups defer to
338
+ // `qsaChildScope` so candidates whose bf-s already carries a deeper
339
+ // `_sN_sN` path (synthesized child's nested scope) are skipped.
340
+ if (SLOT_SUFFIX_SELECTOR.test(selector)) {
341
+ return qsaChildScope(el, selector)
342
+ }
343
+ if (el.matches(selector)) return el
344
+ return el.querySelector(selector)
345
+ }
346
+
347
+ /** Split a CSS selector list on top-level commas, ignoring commas inside
348
+ * `[…]` attribute selectors or `(…)` pseudo-class arguments. */
349
+ function splitTopLevelCommas(selector: string): string[] {
350
+ const out: string[] = []
351
+ let depth = 0
352
+ let start = 0
353
+ for (let i = 0; i < selector.length; i++) {
354
+ const ch = selector.charCodeAt(i)
355
+ if (ch === 0x5b /* [ */ || ch === 0x28 /* ( */) depth++
356
+ else if (ch === 0x5d /* ] */ || ch === 0x29 /* ) */) depth--
357
+ else if (ch === 0x2c /* , */ && depth === 0) {
358
+ out.push(selector.slice(start, i))
359
+ start = i + 1
360
+ }
361
+ }
362
+ out.push(selector.slice(start))
363
+ return out
364
+ }
365
+
366
+ /**
367
+ * Selector form `[bf-s$="_sN"]` — emitted by the compiler for bare child-
368
+ * component scope lookups. Used to gate the #1220 nested-slot skip so the
369
+ * filter only fires for these compiled bf-s suffix lookups (not for
370
+ * unrelated selectors that happen to match).
371
+ */
372
+ const SLOT_SUFFIX_SELECTOR = /^\[bf-s\$="_s\d+"\]$/
373
+
374
+ /**
375
+ * Recognises bf-s values whose final segment is a nested-slot path
376
+ * (`…_sM_sN`). These show up when a synthesized component (e.g.
377
+ * `BFInlineJsxCallback`) renders descendants whose own internal scope
378
+ * happens to end in `_sN`, coincidentally matching a sibling slot's loose
379
+ * suffix selector. The slot-suffix lookup helpers skip these so the wrong
380
+ * `initChild` never fires (#1220).
381
+ *
382
+ * Why this is a safe filter: legitimate child shapes anchored on a
383
+ * stateful intermediate parent (e.g. `Card_<rand>_<slot>`) have exactly
384
+ * one trailing `_sN`. The two-segment shape only arises when a
385
+ * stateless-only stack of intermediate components nests further, which
386
+ * never happens by design — `_parentScopeId` is set only by `insert()`
387
+ * (whose owning component carries client interactivity and therefore a
388
+ * fresh `${name}_<rand>` scope) and by `render()` (top-level entry).
389
+ */
390
+ const NESTED_SLOT_SUFFIX = /_s\d+_s\d+$/
391
+
392
+ /**
393
+ * `querySelector` variant that skips #1220 cross-binding candidates: any
394
+ * descendant whose bf-s already carries a deeper nested-slot path is
395
+ * ignored. Falls back to the standalone match-or-descendant semantics
396
+ * (mirrors `qsa`'s self-match) when no candidate qualifies.
397
+ *
398
+ * Compiler-generated static-array child-init code calls this in place of
399
+ * a bare `containerVar.querySelector(...)` so the filter runs even on
400
+ * paths that don't pass through `qsa` (#1220 review feedback).
401
+ */
402
+ export function qsaChildScope(scope: Element, selector: string): Element | null {
403
+ if (scope.matches(selector)) {
404
+ const bfs = scope.getAttribute(BF_SCOPE) || ''
405
+ if (!NESTED_SLOT_SUFFIX.test(bfs)) return scope
406
+ }
407
+ for (const candidate of scope.querySelectorAll(selector)) {
408
+ const bfs = candidate.getAttribute(BF_SCOPE) || ''
409
+ if (!NESTED_SLOT_SUFFIX.test(bfs)) return candidate
410
+ }
411
+ return null
412
+ }
413
+
414
+ /**
415
+ * `querySelectorAll` variant with the same #1220 filter. Returns the
416
+ * matching descendants in document order, with nested-slot collisions
417
+ * dropped so the caller's `forEach((el, idx) => …)` pairs scope
418
+ * elements with array items by position correctly.
419
+ */
420
+ export function qsaChildScopes(scope: Element, selector: string): Element[] {
421
+ const out: Element[] = []
422
+ for (const candidate of scope.querySelectorAll(selector)) {
423
+ const bfs = candidate.getAttribute(BF_SCOPE) || ''
424
+ if (!NESTED_SLOT_SUFFIX.test(bfs)) out.push(candidate)
425
+ }
426
+ return out
427
+ }
428
+
429
+ /**
430
+ * Find elements within a scope by slot IDs.
431
+ * Used by compiler-generated code for regular slot element references.
432
+ * Always returns an array — callers use destructuring.
433
+ *
434
+ * For parent-owned slots (^-prefixed IDs like '^s3'), searches all descendants
435
+ * ignoring scope boundaries. This handles elements passed as children to child
436
+ * components — they are owned by the parent but rendered inside the child's scope.
437
+ */
438
+ export function $(scope: Element | null, ...ids: string[]): (Element | null)[] {
439
+ return ids.map(id => {
440
+ // Parent-owned slots (^-prefixed) search all descendants ignoring scope boundaries,
441
+ // because the ^ prefix guarantees the element is owned by the calling scope.
442
+ const ignoreScope = id.startsWith(BF_PARENT_OWNED_PREFIX)
443
+ return find(scope, `[${BF_SLOT}="${id}"]`, ignoreScope || undefined)
444
+ })
445
+ }
446
+
447
+ /**
448
+ * Find child component scope elements by slot ID or component name.
449
+ * - Slot ID (e.g., 's1'): uses suffix match [bf-s$="_s1"]
450
+ * - Component name (e.g., 'Counter'): uses prefix match [bf-s^="Counter_"]
451
+ * Always returns an array — callers use destructuring.
452
+ */
453
+ export function $c(scope: Element | null, ...ids: string[]): (Element | null)[] {
454
+ return ids.map(id => $cSingle(scope, id))
455
+ }
456
+
457
+ /**
458
+ * Resolve a single child component scope by slot ID or component name.
459
+ *
460
+ * Two ID formats:
461
+ * - Slot ID ('s0', 's1', ...): Uses parent scope ID for precise suffix match.
462
+ * e.g., [bf-s$="Parent_abc_s3"] — matches "Parent_abc_s3" but NOT "Parent_abc_s4_s3".
463
+ * - Component name ('Counter'): Prefix match [bf-s^="Counter_"]. Unambiguous.
464
+ *
465
+ * Uses candidatesInScope directly (not find()) because child scope searches
466
+ * don't need slot-level scope boundary checks — the CSS selector itself is
467
+ * precise enough to identify the correct element.
468
+ *
469
+ * Dual-scope: A proxy element can host both a comment scope (fragment-root parent)
470
+ * and a bf-s scope (proxied child). getDualScopeIds() returns both IDs so the
471
+ * search tries each parent identity.
472
+ */
473
+ function $cSingle(scope: Element | null, id: string): Element | null {
474
+ if (!scope) return null
475
+ // Strip ^ prefix defensively — component slot IDs should never have it,
476
+ // but guard against compiler edge cases to avoid silent initialization failures.
477
+ const cleanId = id.startsWith(BF_PARENT_OWNED_PREFIX) ? id.slice(1) : id
478
+
479
+ // --- Component name path (unambiguous) ---
480
+ if (!/^s\d/.test(cleanId)) {
481
+ return findChildScope(scope, `[${BF_SCOPE}^="${cleanId}_"]`)
482
+ }
483
+
484
+ // --- Slot ID path: precise suffix match using parent scope ID ---
485
+ const parentScopeIds = getDualScopeIds(scope)
486
+
487
+ if (parentScopeIds.length > 0) {
488
+ for (const parentId of parentScopeIds) {
489
+ const result = findChildScope(scope, `[${BF_SCOPE}$="${parentId}_${cleanId}"]`)
490
+ if (result) return result
491
+ }
492
+ // Precise match found nothing. Check if scope itself matches the short suffix
493
+ // (fragment root / inlined component where scope IS the child).
494
+ if (scope.matches?.(`[${BF_SCOPE}$="_${cleanId}"]`)) return scope
495
+ return null
496
+ }
497
+
498
+ // Fallback: no parent scope ID available — use short suffix match (best-effort)
499
+ return findChildScope(scope, `[${BF_SCOPE}$="_${cleanId}"]`)
500
+ }
501
+
502
+ /**
503
+ * Find a child scope element using candidatesInScope + portal search.
504
+ * Unlike find(), this accepts any matching candidate without slot-level
505
+ * scope boundary checks — the selector is assumed to be precise enough.
506
+ */
507
+ function findChildScope(scope: Element, selector: string): Element | null {
508
+ // Check scope itself (handles self-match for fragment root / inlined components)
509
+ if (scope.matches?.(selector)) return scope
510
+
511
+ for (const candidate of candidatesInScope(scope, selector)) {
512
+ return candidate
513
+ }
514
+
515
+ // Portal search
516
+ const commentInfo = commentScopeRegistry.get(scope)
517
+ const scopeId = commentInfo?.scopeId ?? getScopeId(scope)
518
+ if (scopeId) return findInPortals(scopeId, selector)
519
+
520
+ return null
521
+ }
522
+
523
+ /**
524
+ * Get all possible parent scope IDs for child resolution.
525
+ *
526
+ * Dual-registered elements (comment scope proxy + bf-s attribute) host children
527
+ * from two components: the fragment-root component (comment scope) and the
528
+ * proxied child component (bf-s). Both IDs are returned so $cSingle can try each.
529
+ *
530
+ * Returns deduplicated array of scope IDs, comment scope first (most common case).
531
+ */
532
+ function getDualScopeIds(scope: Element | null): string[] {
533
+ if (!scope) return []
534
+
535
+ const bfScopeId = getScopeId(scope)
536
+
537
+ const commentInfo = commentScopeRegistry.get(scope)
538
+ const commentScopeId = commentInfo?.scopeId ?? null
539
+
540
+ if (commentScopeId && bfScopeId && commentScopeId !== bfScopeId) {
541
+ return [commentScopeId, bfScopeId]
542
+ }
543
+
544
+ const id = commentScopeId ?? bfScopeId
545
+ return id ? [id] : []
546
+ }
547
+
548
+ // --- $t: text node finder via comment markers ---
549
+
550
+ /**
551
+ * Find Text nodes for reactive text expressions marked by comment nodes.
552
+ * Expects marker format: <!--bf:sX-->text<!--/-->
553
+ * Always returns an array — callers use destructuring.
554
+ *
555
+ * Uses a single TreeWalker pass to find all markers at once,
556
+ * with early exit when all are found.
557
+ */
558
+ export function $t(scope: Element | null, ...ids: string[]): (Text | null)[] {
559
+ const results: (Text | null)[] = new Array(ids.length).fill(null)
560
+ if (!scope) return results
561
+
562
+ const commentInfo = commentScopeRegistry.get(scope)
563
+ const searchRoot: Node = commentInfo ? (commentInfo.commentNode.parentNode ?? scope) : scope
564
+
565
+ // When the element is not a component scope (e.g. a loop item element),
566
+ // skip ownership checks — all markers inside it belong to this element.
567
+ const isComponentScope = scope.hasAttribute(BF_SCOPE) || commentInfo != null
568
+
569
+ // Build marker → index map for O(1) lookup during walk
570
+ const markerMap = new Map<string, { index: number; isParentOwned: boolean }>()
571
+ for (let i = 0; i < ids.length; i++) {
572
+ markerMap.set(`bf:${ids[i]}`, {
573
+ index: i,
574
+ isParentOwned: ids[i].startsWith(BF_PARENT_OWNED_PREFIX),
575
+ })
576
+ }
577
+
578
+ let remaining = ids.length
579
+ const walker = document.createTreeWalker(searchRoot, NodeFilter.SHOW_COMMENT)
580
+ while (walker.nextNode() && remaining > 0) {
581
+ const comment = walker.currentNode as Comment
582
+ const entry = markerMap.get(comment.nodeValue ?? '')
583
+ if (!entry || results[entry.index] !== null) continue
584
+
585
+ if (isComponentScope && !entry.isParentOwned && !commentBelongsToScope(comment, scope, commentInfo)) {
586
+ continue
587
+ }
588
+ results[entry.index] = textNodeAfterComment(comment)
589
+ remaining--
590
+ }
591
+ return results
592
+ }
593
+
594
+ /**
595
+ * Get or create the Text node immediately after a comment marker.
596
+ */
597
+ function textNodeAfterComment(comment: Comment): Text {
598
+ const next = comment.nextSibling
599
+ if (next?.nodeType === Node.TEXT_NODE) {
600
+ return next as Text
601
+ }
602
+ // No text node exists (empty initial value) — create one
603
+ const textNode = document.createTextNode('')
604
+ comment.parentNode?.insertBefore(textNode, comment.nextSibling)
605
+ return textNode
606
+ }
607
+
608
+ /**
609
+ * Check if a comment node belongs to the given scope (not inside a nested child scope).
610
+ */
611
+ function commentBelongsToScope(
612
+ comment: Comment,
613
+ scope: Element,
614
+ commentInfo: { commentNode: Comment; scopeId: string } | undefined
615
+ ): boolean {
616
+ // Walk up from the comment to find the nearest scope element
617
+ const parent = comment.parentElement
618
+ if (!parent) return false
619
+
620
+ // If the comment's parent element has a bf-s attribute that is NOT our scope,
621
+ // then the comment is inside a child component's scope
622
+ const parentScope = parent.closest(`[${BF_SCOPE}]`)
623
+ if (parentScope === scope) return true
624
+
625
+ // For comment-based scopes, the scope element is virtual
626
+ if (commentInfo) {
627
+ return isInCommentScopeRange(parent, commentInfo.commentNode)
628
+ }
629
+
630
+ // If the nearest scope is inside our scope, the comment is in a nested scope
631
+ return false
632
+ }