@barefootjs/xslate 0.13.0 → 0.15.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.
@@ -55,6 +55,8 @@ import {
55
55
  type AttrValueEmitter,
56
56
  isBooleanAttr,
57
57
  parseExpression,
58
+ exprToString,
59
+ parseProviderObjectLiteral,
58
60
  parseStyleObjectEntries,
59
61
  isSupported,
60
62
  identifierPath,
@@ -64,11 +66,14 @@ import {
64
66
  augmentInheritedPropAccesses,
65
67
  parseRecordIndexAccess,
66
68
  evalStringArrayJoin,
69
+ collectModuleStringConsts,
67
70
  extractArrowBodyExpression,
68
71
  collectContextConsumers,
69
72
  isLowerableObjectRestDestructure,
70
73
  type ContextConsumer,
71
- extractSsrDefaults,
74
+ lookupStaticRecordLiteral,
75
+ searchParamsLocalNames,
76
+ matchSearchParamsMethodCall
72
77
  } from '@barefootjs/jsx'
73
78
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
74
79
  import ts from 'typescript'
@@ -81,7 +86,7 @@ import ts from 'typescript'
81
86
  */
82
87
  type XslateRenderCtx = Record<string, never>
83
88
  import type { ParsedExpr, ParsedStatement, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '@barefootjs/jsx'
84
- import { BF_SLOT, BF_COND } from '@barefootjs/shared'
89
+ import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
85
90
 
86
91
  interface PrimitiveSpec {
87
92
  arity: number
@@ -223,6 +228,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
223
228
  */
224
229
  private propsObjectName: string | null = null
225
230
  private propsParams: { name: string }[] = []
231
+ private booleanTypedProps: Set<string> = new Set()
226
232
  /**
227
233
  * Names (signal getters + props) whose value is a string, so `===`/`!==`
228
234
  * against them lowers to Perl `eq`/`ne` rather than numeric `==`/`!=`.
@@ -241,6 +247,16 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
241
247
  */
242
248
  private moduleStringConsts: Map<string, string> = new Map()
243
249
 
250
+ /**
251
+ * (#1922) Local binding names the request-scoped `searchParams()` env signal
252
+ * is imported under (handles `import { searchParams as sp }`). When non-empty
253
+ * the emitter lowers a `<binding>().get(k)` call to a real method call on the
254
+ * per-request `$searchParams` reader (`$searchParams.get('sort')`) instead of
255
+ * the generic dot deref. Set at `generate()` entry from `ir.metadata.imports`;
256
+ * read by the top-level ParsedExpr emitter.
257
+ */
258
+ _searchParamsLocals: Set<string> = new Set()
259
+
244
260
  /**
245
261
  * Local + module constants from the IR, used by the conditional-spread and
246
262
  * `Record<staticKeys, scalar>[propKey]` lowering paths (#textarea / #checkbox).
@@ -274,6 +290,15 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
274
290
  // shared helper, before deriving `nullableOptionalProps` below.
275
291
  augmentInheritedPropAccesses(ir)
276
292
  this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
293
+ // Props whose declared TS type is boolean — a bare binding of one
294
+ // (`data-active={props.isActive}`) must stringify as JS
295
+ // `String(boolean)` ("true"/"false"), not Perl's native `1`/`''`
296
+ // (#1897, pagination's data-active).
297
+ this.booleanTypedProps = new Set(
298
+ ir.metadata.propsParams
299
+ .filter(prop => prop.type?.primitive === 'boolean' || prop.type?.raw === 'boolean')
300
+ .map(prop => prop.name),
301
+ )
277
302
  this.localConstants = ir.metadata.localConstants ?? []
278
303
  // Bare references to optional, no-default, non-primitive props (e.g.
279
304
  // textarea's `rows`) are `undef` when omitted → `defined`-guarded in
@@ -302,6 +327,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
302
327
  if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
303
328
  }
304
329
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
330
+ this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
305
331
  this.errors = []
306
332
  this.childrenCaptureCounter = 0
307
333
 
@@ -473,12 +499,48 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
473
499
  ? `'${v.value.replace(/[\\']/g, m => `\\${m}`)}'`
474
500
  : String(v.value)
475
501
  }
476
- if (v.kind === 'expression') return this.convertExpressionToKolon(v.expr)
502
+ if (v.kind === 'expression') {
503
+ const hashref = this.providerObjectLiteralKolon(v.expr)
504
+ if (hashref !== null) return hashref
505
+ return this.convertExpressionToKolon(v.expr)
506
+ }
477
507
  if (v.kind === 'template') return this.convertTemplateLiteralPartsToKolon(v.parts)
478
508
  // Out-of-shape value (spread / jsx-children) — nil; consumer defaults.
479
509
  return 'nil'
480
510
  }
481
511
 
512
+ /**
513
+ * Lower an object-literal provider value (`value={{ open: () => props.open
514
+ * ?? false, onOpenChange: … }}`) to a Kolon hashref literal (#1897). The
515
+ * SSR lowering is a per-member snapshot of what a consumer would READ
516
+ * during the same render:
517
+ *
518
+ * - zero-param expression-body arrows are getters — lower the body (the
519
+ * value is fixed for the render, so the call-time indirection drops out)
520
+ * - `on[A-Z]`-named members and function-shaped values are client-only
521
+ * behavior SSR never invokes — lower to `nil`
522
+ * - anything else lowers through the normal expression pipeline (so an
523
+ * unsupported getter body still refuses loudly with BF101)
524
+ *
525
+ * Keys keep their JS names verbatim so a consumer-side `ctx.open` access
526
+ * maps onto the same key. Returns `null` when the expression is not a
527
+ * plain object literal (spread / computed key) — the caller falls back to
528
+ * the whole-expression path, which refuses those shapes with BF101.
529
+ */
530
+ private providerObjectLiteralKolon(expr: string): string | null {
531
+ const members = parseProviderObjectLiteral(expr.trim())
532
+ if (members === null) return null
533
+ const entries = members.map(m => {
534
+ // String-literal JS keys can carry `'` / `\` — escape for the
535
+ // single-quoted Kolon key string.
536
+ const key = `'${m.name.replace(/[\\']/g, c => `\\${c}`)}'`
537
+ if (m.kind === 'function' || /^on[A-Z]/.test(m.name)) return `${key} => nil`
538
+ const src = m.kind === 'getter' ? m.body : m.expr
539
+ return `${key} => ${this.convertExpressionToKolon(src)}`
540
+ })
541
+ return `{ ${entries.join(', ')} }`
542
+ }
543
+
482
544
  /** Kolon literal for a context-consumer's `createContext` default. */
483
545
  private contextDefaultKolon(c: ContextConsumer): string {
484
546
  const d = c.defaultValue
@@ -518,7 +580,6 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
518
580
  const memos = ir.metadata.memos ?? []
519
581
  const signals = ir.metadata.signals ?? []
520
582
  if (memos.length === 0 && signals.length === 0) return ''
521
- const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
522
583
  // Props seed first; each signal/memo adds its own name as it lands.
523
584
  const available = new Set<string>(ir.metadata.propsParams.map(p => p.name))
524
585
  const lines: string[] = []
@@ -542,16 +603,22 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
542
603
  }
543
604
 
544
605
  for (const memo of memos) {
545
- const def = ssrDefaults[memo.name]
546
- const isNull = !def || (typeof def === 'object' && 'value' in def && def.value === null)
547
- if (!isNull) {
548
- available.add(memo.name)
549
- continue
550
- }
606
+ // Seed every memo whose body lowers cleanly — not just the ones whose
607
+ // static SSR default is null. A statically-foldable prop-derived memo
608
+ // (`createMemo(() => props.disabled ?? false)` → default `false`)
609
+ // still depends on the per-call prop: the static stash seed bakes in
610
+ // the absent-prop fold, so a caller passing `disabled => 1` would
611
+ // render the default branch (#1897, select's disabled item). The
612
+ // in-template recomputation reads the prop lexical already in scope;
613
+ // block-bodied arrows / out-of-scope references fall back to the
614
+ // static ssr-defaults seed. Same self-reference guard as the signal
615
+ // loop above — Kolon's `my` shadows the render var on the RHS.
551
616
  const body = extractArrowBodyExpression(memo.computation)
552
- if (body === null) continue
553
- const kolon = this.tryLowerToKolon(body, available)
554
- if (kolon !== null) lines.push(`: my $${memo.name} = ${kolon};`)
617
+ if (body !== null) {
618
+ const kolon = this.tryLowerToKolon(body, available)
619
+ const refsSelf = kolon !== null && new RegExp(`\\$${memo.name}\\b`).test(kolon)
620
+ if (kolon !== null && !refsSelf) lines.push(`: my $${memo.name} = ${kolon};`)
621
+ }
555
622
  available.add(memo.name)
556
623
  }
557
624
  return lines.length > 0 ? lines.join('\n') + '\n' : ''
@@ -600,6 +667,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
600
667
  if (element.slotId) {
601
668
  hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
602
669
  }
670
+ // Page-lifecycle boundary lowered from `<Region>` (spec/router.md). The id
671
+ // is a deterministic static string (`<file scope>:<index>`), so it emits as
672
+ // a plain literal attribute — no Xslate template tag.
673
+ if (element.regionId) {
674
+ hydrationAttrs += ` ${BF_REGION}="${element.regionId}"`
675
+ }
603
676
 
604
677
  const voidElements = [
605
678
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
@@ -959,8 +1032,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
959
1032
  renderComponent(comp: IRComponent): string {
960
1033
  const propParts: string[] = []
961
1034
  for (const p of comp.props) {
962
- // Skip callback props (onXxx) — event handlers are client-only for SSR.
963
- if (p.name.match(/^on[A-Z]/) && p.value.kind === 'expression') continue
1035
+ // Skip callback props (onXxx) and `ref` both are client-only for
1036
+ // SSR (Hono renders neither; the client JS wires them at hydration).
1037
+ if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
964
1038
  // Spread props: enumerate the analyzer's props params into hashref
965
1039
  // entries (the propsObject case) — Kolon can't flatten a hashref into
966
1040
  // the entry list. Other spread shapes are refused with BF101.
@@ -1006,7 +1080,10 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1006
1080
  // children HTML; the macro call result is passed as the `children` entry
1007
1081
  // of the render_child hashref. `render_child` materializes a CODE-ref
1008
1082
  // children value through the backend before handing it to the child.
1083
+ const prevInLoop = this.inLoop
1084
+ this.inLoop = false
1009
1085
  const childrenBody = this.renderChildren(effectiveChildren)
1086
+ this.inLoop = prevInLoop
1010
1087
  const macroName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
1011
1088
  const childrenEntry = `children => ${macroName}()`
1012
1089
  const allParts = [...propParts, childrenEntry]
@@ -1019,6 +1096,10 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1019
1096
 
1020
1097
  private childrenCaptureCounter = 0
1021
1098
 
1099
+ /** Uniquifies the `presenceOrUndefined` temp binding (`$bf_puN`) so two
1100
+ * presence-folded attrs in one template don't collide. */
1101
+ private presenceVarCounter = 0
1102
+
1022
1103
  private toTemplateName(componentName: string): string {
1023
1104
  // Convert PascalCase to snake_case for template naming.
1024
1105
  return componentName
@@ -1129,21 +1210,51 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1129
1210
  ) {
1130
1211
  const perl = this.convertExpressionToKolon(value.expr)
1131
1212
  const body =
1132
- isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)
1213
+ isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)
1133
1214
  ? `${name}="<: $bf.bool_str(${perl}) :>"`
1134
1215
  : `${name}="<: ${perl} :>"`
1135
1216
  // Kolon `:` line directives must each stand alone on their own line, so
1136
1217
  // wrap in newlines (`normalizeHTML` collapses the surrounding space).
1137
1218
  return `\n: if (defined ${perl}) {\n${body}\n: }\n`
1138
1219
  }
1139
- if (isBooleanAttr(name) || value.presenceOrUndefined) {
1220
+ if (isBooleanAttr(name)) {
1140
1221
  // Boolean attributes: render conditionally (present or absent).
1141
1222
  return `<: ${this.convertExpressionToKolon(value.expr)} ? '${name}' : '' :>`
1142
1223
  }
1224
+ if (value.presenceOrUndefined) {
1225
+ // `attr={expr || undefined}` on a NON-boolean attribute: Hono
1226
+ // renders the attr with its stringified value when truthy and
1227
+ // omits it otherwise (`aria-disabled={isDisabled() || undefined}`
1228
+ // → `aria-disabled="true"`), so bare presence would diverge.
1229
+ // Route through `bool_str` when the name/shape witnesses a
1230
+ // boolean value, same as the unconditional path below (#1897).
1231
+ // Bind to a temp first so the expression evaluates once, not in
1232
+ // both the guard and the value.
1233
+ const perl = this.convertExpressionToKolon(value.expr)
1234
+ const tmp = `$bf_pu${this.presenceVarCounter++}`
1235
+ const body =
1236
+ isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)
1237
+ ? `${name}="<: $bf.bool_str(${tmp}) :>"`
1238
+ : `${name}="<: ${tmp} :>"`
1239
+ return `\n: my ${tmp} = ${perl};\n: if (${tmp}) {\n${body}\n: }\n`
1240
+ }
1241
+ // `attr={cond ? value : undefined}` OMITS the attribute on the
1242
+ // falsy branch (Hono drops undefined-valued attributes) — wrap the
1243
+ // whole attribute in the condition instead of rendering `attr=""`
1244
+ // (#1897, pagination's `aria-current={props.isActive ? 'page' :
1245
+ // undefined}`). Same parity rule the Go adapter applies.
1246
+ {
1247
+ const m = this.parseUndefinedAlternateTernary(value.expr)
1248
+ if (m) {
1249
+ const cond = this.convertExpressionToKolon(m.condition)
1250
+ const val = this.convertExpressionToKolon(m.consequent)
1251
+ return `\n: if (${cond}) {\n${name}="<: ${val} :>"\n: }\n`
1252
+ }
1253
+ }
1143
1254
  // Boolean-result handling: route boolean-shaped values through
1144
1255
  // `$bf.bool_str` so the wire bytes match JS `String(boolean)`.
1145
1256
  const perl = this.convertExpressionToKolon(value.expr)
1146
- if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)) {
1257
+ if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)) {
1147
1258
  return `${name}="<: $bf.bool_str(${perl}) :>"`
1148
1259
  }
1149
1260
  return `${name}="<: ${perl} :>"`
@@ -1528,6 +1639,31 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1528
1639
  return n
1529
1640
  })()
1530
1641
  const indexed = this.recordIndexAccessToKolon(initNode)
1642
+ if (
1643
+ indexed === null &&
1644
+ ts.isElementAccessExpression(initNode) &&
1645
+ initNode.argumentExpression &&
1646
+ !ts.isNumericLiteral(initNode.argumentExpression) &&
1647
+ !ts.isStringLiteral(initNode.argumentExpression)
1648
+ ) {
1649
+ // Variable-index record access (`sizeMap[size]`) the static-inline
1650
+ // path couldn't resolve (non-scalar value / non-const receiver).
1651
+ // Since #1897 made variable indices parseable (`index-access`),
1652
+ // the generic value lowering would emit `$sizeMap[$size]` against
1653
+ // an UNBOUND module const instead of refusing — record BF101 and
1654
+ // bail so the spread surfaces the out-of-shape diagnostic,
1655
+ // matching pre-#1897 behaviour. (Mirrors the Mojo adapter.)
1656
+ this.errors.push({
1657
+ code: 'BF101',
1658
+ severity: 'error',
1659
+ message: `Spread object value '${initNode.getText(sf)}' indexes a record map whose values aren't scalar literals — it can't lower to an inline Kolon hashref.`,
1660
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1661
+ suggestion: {
1662
+ message: 'Index a record whose values are number/string literals, or move the spread into a `\'use client\'` component so hydration computes it.',
1663
+ },
1664
+ })
1665
+ return null
1666
+ }
1531
1667
  const valPerl =
1532
1668
  indexed !== null
1533
1669
  ? indexed
@@ -1608,6 +1744,76 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1608
1744
  * normal `$name` stash lowering). Loop-bound names shadow module consts, so
1609
1745
  * never inline inside a loop body. Returns `'<escaped>'`.
1610
1746
  */
1747
+ /**
1748
+ * Resolve `IDENT.key` over a module object-literal const to its Kolon
1749
+ * literal (`variantClasses.ghost` in a class template literal —
1750
+ * #1897). Same compile-time inlining family as
1751
+ * `_resolveModuleStringConst`; returns `null` for any non-static shape.
1752
+ */
1753
+ /**
1754
+ * Whether `expr` is a bare reference to a boolean-TYPED prop
1755
+ * (`props.isActive` / destructured `isActive`) — used to route the
1756
+ * binding through `bool_str` even though the expression itself is
1757
+ * structurally opaque (#1897).
1758
+ */
1759
+ /**
1760
+ * Parse `cond ? value : undefined` (or `: null`), returning the
1761
+ * condition/consequent source spans, else `null`. Used for the
1762
+ * attribute-omission rule (#1897).
1763
+ */
1764
+ parseUndefinedAlternateTernary(
1765
+ expr: string,
1766
+ ): { condition: string; consequent: string } | null {
1767
+ const parsed = parseExpression(expr.trim())
1768
+ if (parsed?.kind !== 'conditional') return null
1769
+ const alt = parsed.alternate
1770
+ const isUndef =
1771
+ (alt.kind === 'identifier' && (alt.name === 'undefined' || alt.name === 'null')) ||
1772
+ (alt.kind === 'literal' && (alt.value === null || alt.value === undefined))
1773
+ if (!isUndef) return null
1774
+ // Serialise the parsed sub-expressions back to JS source rather than
1775
+ // slicing `expr` text — `indexOf('?')` / `lastIndexOf(':')` would
1776
+ // mis-split when the consequent itself contains `?` / `:` inside a
1777
+ // string or nested ternary (`cond ? 'a:b' : undefined`).
1778
+ return {
1779
+ condition: exprToString(parsed.test),
1780
+ consequent: exprToString(parsed.consequent),
1781
+ }
1782
+ }
1783
+
1784
+ isBooleanTypedPropRef(expr: string): boolean {
1785
+ let bare = expr.trim()
1786
+ if (this.propsObjectName && bare.startsWith(`${this.propsObjectName}.`)) {
1787
+ bare = bare.slice(this.propsObjectName.length + 1)
1788
+ }
1789
+ if (!/^[A-Za-z_$][\w$]*$/.test(bare)) return false
1790
+ return this.booleanTypedProps.has(bare)
1791
+ }
1792
+
1793
+ /**
1794
+ * Inline a const (any scope) whose initializer is a pure numeric or
1795
+ * single-quoted string literal (`const totalPages = 5`, #1897
1796
+ * pagination) — function-scope consts never reach the per-render
1797
+ * stash, so a bare `$totalPages` renders empty.
1798
+ */
1799
+ _resolveLiteralConst(name: string): string | null {
1800
+ const c = (this.localConstants ?? []).find(lc => lc.name === name)
1801
+ if (c?.value === undefined) return null
1802
+ const v = c.value.trim()
1803
+ if (/^-?\d+(\.\d+)?$/.test(v)) return v
1804
+ const strLit = /^'([^'\\]*)'$/.exec(v) ?? /^"([^"\\]*)"$/.exec(v)
1805
+ if (strLit) return `'${strLit[1].replace(/[\\']/g, m => `\\${m}`)}'`
1806
+ return null
1807
+ }
1808
+
1809
+ _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1810
+ const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1811
+ if (!hit) return null
1812
+ return hit.kind === 'number'
1813
+ ? hit.text
1814
+ : `'${hit.text.replace(/[\\']/g, m => `\\${m}`)}'`
1815
+ }
1816
+
1611
1817
  _resolveModuleStringConst(name: string): string | null {
1612
1818
  // A loop body may bind `my $<param>` that shadows a module const of the
1613
1819
  // same name; never inline inside one (conservative — drop to `$name`).
@@ -1721,6 +1927,13 @@ function renderArrayMethod(
1721
1927
  const recv = emit(object)
1722
1928
  return `$bf.trim(${recv})`
1723
1929
  }
1930
+ case 'toFixed': {
1931
+ // `.toFixed(digits?)` — `$bf.to_fixed` mirrors JS rounding +
1932
+ // zero-padding (default 0 digits). #1897.
1933
+ const recv = emit(object)
1934
+ const digits = args.length >= 1 ? emit(args[0]) : '0'
1935
+ return `$bf.to_fixed(${recv}, ${digits})`
1936
+ }
1724
1937
  case 'split': {
1725
1938
  const recv = emit(object)
1726
1939
  if (args.length === 0) {
@@ -1881,21 +2094,6 @@ function unescapeStringLiteralBody(s: string): string {
1881
2094
  })
1882
2095
  }
1883
2096
 
1884
- /**
1885
- * Build the module pure-string-const map from the IR's localConstants. A const
1886
- * qualifies only when module-scope (`isModule`) and its initializer parses to a
1887
- * single pure string literal.
1888
- */
1889
- function collectModuleStringConsts(constants: IRMetadata['localConstants'] | undefined): Map<string, string> {
1890
- const map = new Map<string, string>()
1891
- for (const c of constants ?? []) {
1892
- if (!c.isModule) continue
1893
- if (c.value === undefined) continue
1894
- const literal = parsePureStringLiteral(c.value)
1895
- if (literal !== null) map.set(c.name, literal)
1896
- }
1897
- return map
1898
- }
1899
2097
 
1900
2098
  /** True when `type` is the `string` primitive. */
1901
2099
  function isStringTypeInfo(type: TypeInfo | undefined): boolean {
@@ -1969,6 +2167,13 @@ class XslateFilterEmitter implements ParsedExprEmitter {
1969
2167
  return `${emit(object)}.${property}`
1970
2168
  }
1971
2169
 
2170
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2171
+ // Kolon's `[]` postfix is polymorphic (array index or hash key),
2172
+ // mirroring JS — no array/hash split is needed (unlike Perl's
2173
+ // `->[]` vs `->{}`). #1897 (data-table's `selected()[index]`).
2174
+ return `${emit(object)}[${emit(index)}]`
2175
+ }
2176
+
1972
2177
  call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
1973
2178
  // Signal getter calls: filter() → $filter
1974
2179
  if (callee.kind === 'identifier' && args.length === 0) {
@@ -2088,10 +2293,17 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
2088
2293
  constructor(private readonly adapter: XslateAdapter) {}
2089
2294
 
2090
2295
  identifier(name: string): string {
2296
+ // `undefined` / `null` nested inside a larger expression tree —
2297
+ // Kolon `nil` (#1897).
2298
+ if (name === 'undefined' || name === 'null') return 'nil'
2091
2299
  // Inline a module-scope pure-string const (`const x = 'literal'`) — it
2092
2300
  // never reaches the per-render stash, so a bare `$x` would render empty.
2093
2301
  const inlined = this.adapter._resolveModuleStringConst(name)
2094
2302
  if (inlined !== null) return inlined
2303
+ // Same for a literal const of any scope (`const totalPages = 5`,
2304
+ // #1897 pagination's `Page {currentPage()} of {totalPages}`).
2305
+ const literalConst = this.adapter._resolveLiteralConst(name)
2306
+ if (literalConst !== null) return literalConst
2095
2307
  return `$${name}`
2096
2308
  }
2097
2309
 
@@ -2108,6 +2320,14 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
2108
2320
  if (object.kind === 'identifier' && object.name === 'props') {
2109
2321
  return `$${property}`
2110
2322
  }
2323
+ // Static property access on a module object-literal const
2324
+ // (`variantClasses.ghost`, #1897) resolves at compile time — the
2325
+ // generic dot lowering below would reference a Kolon var that
2326
+ // doesn't exist server-side and silently render ''.
2327
+ if (object.kind === 'identifier') {
2328
+ const staticValue = this.adapter._resolveStaticRecordLiteral(object.name, property)
2329
+ if (staticValue !== null) return staticValue
2330
+ }
2111
2331
  const obj = emit(object)
2112
2332
  // `.length` → `$bf.length` (array count or string char count, JS-compat);
2113
2333
  // Kolon's builtin `.size()` is array-only and faults on a string.
@@ -2116,11 +2336,27 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
2116
2336
  return `${obj}.${property}`
2117
2337
  }
2118
2338
 
2339
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2340
+ // Kolon's `[]` postfix is polymorphic (array index or hash key),
2341
+ // mirroring JS. #1897 (data-table's `selected()[index]`).
2342
+ return `${emit(object)}[${emit(index)}]`
2343
+ }
2344
+
2119
2345
  call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2120
2346
  // Signal getter: count() → $count
2121
2347
  if (callee.kind === 'identifier' && args.length === 0) {
2122
2348
  return `$${callee.name}`
2123
2349
  }
2350
+ // Env-signal method call (#1922): `searchParams().get('sort')` is a real
2351
+ // method call on the per-request `$searchParams` reader object, not the
2352
+ // generic dot deref `member` would emit (`$searchParams.get`, which drops
2353
+ // the arg). Matches the local import binding (incl. an alias).
2354
+ if (this.adapter._searchParamsLocals.size > 0) {
2355
+ const sp = matchSearchParamsMethodCall(callee, args, this.adapter._searchParamsLocals)
2356
+ if (sp) {
2357
+ return `$searchParams.${sp.method}(${sp.args.map(emit).join(', ')})`
2358
+ }
2359
+ }
2124
2360
  // Identifier-path templatePrimitive: `JSON.stringify(x)` / `Math.floor(x)`
2125
2361
  // → `$bf.json($x)` / `$bf.floor($x)`. Args render recursively through this
2126
2362
  // same emitter. A wrong-arity call records BF101 and returns `''`.
@@ -17,7 +17,7 @@
17
17
  * than the Mojo harness's literal `test_<sN>`.
18
18
  */
19
19
 
20
- import { compileJSX, extractSsrDefaults } from '@barefootjs/jsx'
20
+ import { compileJSX, extractSsrDefaults, importsSearchParams } from '@barefootjs/jsx'
21
21
  import type { ComponentIR } from '@barefootjs/jsx'
22
22
  import { mkdir, rm } from 'node:fs/promises'
23
23
  import { resolve } from 'node:path'
@@ -358,12 +358,29 @@ function buildChildRenderers(
358
358
  lines.push(`{`)
359
359
  lines.push(` my $defaults = ${defaultsPerl};`)
360
360
  lines.push(` $bf->register_child_renderer('${snakeName}', sub {`)
361
- lines.push(` my ($child_props) = @_;`)
361
+ // `$caller_bf` is the instance whose template invoked render_child
362
+ // (#1897) — nested children chain their scope/slot identity off it.
363
+ lines.push(` my ($child_props, $caller_bf) = @_;`)
364
+ lines.push(` my $host_scope = (defined $caller_bf ? $caller_bf->_scope_id : $bf->_scope_id);`)
362
365
  // A child that destructures a rest bag references `$<rest>` in its
363
366
  // template; seed it with an empty hashref when the caller didn't pass
364
367
  // one so Kolon's var lookup doesn't fault.
365
368
  if (restPropsName) {
366
369
  lines.push(` $child_props->{${restPropsName}} = {} unless defined $child_props->{${restPropsName}};`)
370
+ // (#1897) Route non-param props into the rest bag, mirroring the
371
+ // production runtime's `_derive_stash_from_defaults` isRestProps
372
+ // branch and JSX rest semantics: a caller prop the child didn't
373
+ // destructure (`href` on PaginationPrevious's `{...props}` anchor)
374
+ // belongs in the bag, not as a top-level stash var the template
375
+ // never reads.
376
+ const paramNames = (childIR.metadata.propsParams ?? []).map(p => p.name)
377
+ const keep = JSON.stringify([...new Set([...paramNames, restPropsName, 'children', 'key', '_bf_slot'])])
378
+ lines.push(` {`)
379
+ lines.push(` my %keep = map { $_ => 1 } @{ ${perlArrayLiteral(keep)} };`)
380
+ lines.push(` for my $k (grep { !$keep{$_} } keys %$child_props) {`)
381
+ lines.push(` $child_props->{${restPropsName}}{$k} = delete $child_props->{$k};`)
382
+ lines.push(` }`)
383
+ lines.push(` }`)
367
384
  }
368
385
  lines.push(` my $slot_id = delete $child_props->{_bf_slot};`)
369
386
  lines.push(` my $child_bf = BarefootJS->new(undef, { backend => $backend });`)
@@ -374,9 +391,15 @@ function buildChildRenderers(
374
391
  // A loop child (no slot) gets a fresh `<ComponentName>_<rand>` id per
375
392
  // iteration — the PascalCase name is what `normalizeHTML` canonicalises to
376
393
  // `<ComponentName>_*`; a slotted child derives from the parent scope.
377
- lines.push(` $child_bf->_scope_id($slot_id ? '${rootChildScopePrefix(snakeName)}' . '_' . $slot_id : '${componentName}_' . substr(rand() =~ s/^0\\.//r, 0, 6));`)
394
+ lines.push(` $child_bf->_scope_id($slot_id ? $host_scope . '_' . $slot_id : '${componentName}_' . substr(rand() =~ s/^0\\.//r, 0, 6));`)
378
395
  lines.push(` $child_bf->_is_child(1);`)
379
- lines.push(` if ($slot_id) { $child_bf->_bf_parent('${rootChildScopePrefix(snakeName)}'); $child_bf->_bf_mount($slot_id); }`)
396
+ lines.push(` if ($slot_id) { $child_bf->_bf_parent($host_scope); $child_bf->_bf_mount($slot_id); }`)
397
+ // (#1897) A child template may itself call `$bf.render_child(...)`
398
+ // (AccordionTrigger renders ChevronDownIcon) — inside that template
399
+ // `$bf` is THIS fresh child instance, whose renderer registry starts
400
+ // empty, so the nested call silently rendered ''. Share the parent's
401
+ // registry so nested child renders resolve.
402
+ lines.push(` $child_bf->_child_renderers($bf->_child_renderers);`)
380
403
  lines.push(` $child_bf->_scripts($bf->_scripts);`)
381
404
  lines.push(` $child_bf->_script_seen($bf->_script_seen);`)
382
405
  // Seed template vars: static ssrDefaults first, caller's props win.
@@ -392,21 +415,12 @@ function buildChildRenderers(
392
415
  return lines.join('\n')
393
416
  }
394
417
 
395
- /**
396
- * The parent scope prefix child scope ids derive from. The harness pins
397
- * the root scope id to `test` (or `__instanceId`); children read the live
398
- * parent scope at render time, but the test renderer doesn't have the
399
- * parent `$bf` in lexical scope inside `buildChildRenderers`, so we use
400
- * the same `$bf->_scope_id` value the script set. Emitted as the literal
401
- * `$bf->_scope_id` lookup so an explicit `__instanceId` still flows
402
- * through.
403
- */
404
- function rootChildScopePrefix(_snakeName: string): string {
405
- // Resolve the parent scope dynamically in Perl rather than baking the
406
- // literal here — keeps the child id correct under an explicit
407
- // __instanceId. The single quotes in the caller string-concat are
408
- // closed around this Perl fragment.
409
- return `' . $bf->_scope_id . '`
418
+
419
+
420
+ /** Render a JSON string-array as a Perl arrayref literal (['a','b']). */
421
+ function perlArrayLiteral(jsonArray: string): string {
422
+ const names = JSON.parse(jsonArray) as string[]
423
+ return `[${names.map(n => `'${n.replace(/[\\']/g, m => `\\${m}`)}'`).join(', ')}]`
410
424
  }
411
425
 
412
426
  /** Serialise an ssrDefaults map to a Perl hashref literal. */
@@ -517,6 +531,16 @@ function buildPerlProps(
517
531
  entries.push(`${memo.name} => ${toPerlLiteral(value ?? 0)}`)
518
532
  }
519
533
 
534
+ // (#1922) Request-scoped `searchParams()`: bind `$searchParams` to an
535
+ // empty-query reader via the lazy-loading factory (so the render script
536
+ // needn't `use BarefootJS::SearchParams`). The conformance harness issues no
537
+ // query string (the production Xslate integration builds this from the
538
+ // request's query), so `.get(k)` resolves to nil and the author's
539
+ // `?? default` renders. Only when the component imports `searchParams`.
540
+ if (importsSearchParams(ir.metadata)) {
541
+ entries.push(`searchParams => BarefootJS->search_params('')`)
542
+ }
543
+
520
544
  return `{${entries.join(', ')}}`
521
545
  }
522
546