@barefootjs/xslate 0.17.0 → 0.18.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.
@@ -40,6 +40,7 @@ import type {
40
40
  TypeInfo,
41
41
  TemplatePrimitiveRegistry,
42
42
  IRMetadata,
43
+ LoopBindingPathSegment,
43
44
  } from '@barefootjs/jsx'
44
45
  import {
45
46
  BaseAdapter,
@@ -64,13 +65,14 @@ import {
64
65
  collectModuleStringConsts,
65
66
  extractArrowBodyExpression,
66
67
  collectContextConsumers,
67
- isLowerableObjectRestDestructure,
68
68
  type ContextConsumer,
69
69
  lookupStaticRecordLiteral,
70
70
  searchParamsLocalNames,
71
71
  prepareLoweringMatchers,
72
72
  queryHrefArgs,
73
+ isValidHelperId,
73
74
  sortComparatorFromArrow,
75
+ isLowerableLoopDestructure,
74
76
  } from '@barefootjs/jsx'
75
77
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
76
78
  import ts from 'typescript'
@@ -79,7 +81,7 @@ import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
79
81
 
80
82
  import type { XslateRenderCtx } from './lib/types.ts'
81
83
  import { XSLATE_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
82
- import { kolonHashKey } from './lib/kolon-naming.ts'
84
+ import { kolonHashKey, escapeKolonSingleQuoted } from './lib/kolon-naming.ts'
83
85
  import {
84
86
  resolveJsxChildrenProp,
85
87
  collectRootScopeNodes,
@@ -108,6 +110,47 @@ import {
108
110
  export type { XslateAdapterOptions } from './lib/types.ts'
109
111
  import type { XslateAdapterOptions } from './lib/types.ts'
110
112
 
113
+ /**
114
+ * Build a Kolon accessor expression for a `.map()` destructure binding's
115
+ * structured `segments` path (#2087 Phase B), walking `field` (`.key` for an
116
+ * identifier-safe name, `["key"]` — quoted with `kolonHashKey`'s convention —
117
+ * otherwise, since Kolon parses `$x.data-priority` as a subtraction) /
118
+ * `index` (`[N]`) steps onto `base`. Verified against real Text::Xslate that
119
+ * dot- and bracket-access chain freely (`$x.cells[0]`, `$x["cells"][0]`).
120
+ * Never string-parses `LoopParamBinding.path` — see the repo-wide rule
121
+ * against regex-parsing JS/TS-derived syntax.
122
+ *
123
+ * Used both for a fixed binding's FULL accessor (`base` = `$__bf_item`,
124
+ * `segments` = the whole path) and a rest binding's PARENT-prefix accessor
125
+ * (`segments` may be empty, at the loop root, in which case this returns
126
+ * `base` unchanged) — see `LoopParamBinding.segments` jsdoc for which case
127
+ * a binding is in.
128
+ */
129
+ function kolonSegmentAccessor(base: string, segments: readonly LoopBindingPathSegment[]): string {
130
+ let expr = base
131
+ for (const seg of segments) {
132
+ expr +=
133
+ seg.kind === 'field'
134
+ ? seg.isIdent
135
+ ? `.${seg.key}`
136
+ : `[${kolonHashKey(seg.key)}]`
137
+ : `[${seg.index}]`
138
+ }
139
+ return expr
140
+ }
141
+
142
+ /**
143
+ * Quote a string as a Kolon single-quoted literal, unconditionally — unlike
144
+ * `kolonHashKey` (which leaves an identifier-safe name unquoted for `key =>
145
+ * val` hashref-literal position), a plain array-literal element
146
+ * (`$bf.omit($x, [id, title])`) is NOT hash-key position: Kolon has no
147
+ * bareword-as-string convention there, so every object-rest exclude key
148
+ * needs an explicit string literal.
149
+ */
150
+ function kolonStringLiteral(s: string): string {
151
+ return `'${escapeKolonSingleQuoted(s)}'`
152
+ }
153
+
111
154
  export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRenderCtx> {
112
155
  name = 'xslate'
113
156
  extension = '.tx'
@@ -568,36 +611,89 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
568
611
  // ===========================================================================
569
612
 
570
613
  renderLoop(loop: IRLoop): string {
571
- // Client-only loops: skip SSR rendering entirely
572
- if (loop.clientOnly) return ''
573
-
574
- // An array/object-destructure loop param (`([emoji, users]) => ...` or
575
- // `({ name, age }) => ...`) lowers to invalid Kolon Kolon's `for LIST
576
- // -> $item` binds a single scalar and can't unpack a tuple. Surface this
577
- // at build time instead of shipping a broken template line.
578
- // A destructure loop param is lowerable for the object-rest / simple-field
579
- // shape (`.map(({ id, title, ...rest }) => …)`, `rest` read via member
580
- // access): each binding becomes a Kolon `: my` local off the per-item var,
581
- // so the body's `$id` / `$rest.flag` resolve. Array-index / nested /
582
- // rest-spread shapes still can't unpack a tuple BF104. (#1310)
614
+ // clientOnly loops must not render items at SSR time, but must still emit
615
+ // the `loop:`/`/loop:` boundary marker pair (Hono and Go parity) so the
616
+ // client runtime's mapArray() can locate the insertion anchor when
617
+ // hydrating the array. Without the markers, mapArray() resolves
618
+ // anchor = null and appends after sibling markers (#872). The marker id
619
+ // disambiguates sibling `.map()` calls under the same parent (#1087).
620
+ if (loop.clientOnly) {
621
+ return `<: $bf.comment("loop:${loop.markerId}") | mark_raw :><: $bf.comment("/loop:${loop.markerId}") | mark_raw :>`
622
+ }
623
+
624
+ // A `.map()` destructure loop param (`([k, v]) => ...` / `({ id, user: {
625
+ // name } }) => ...` / `({ id, ...rest }) => ...`) lowers to a Kolon `: my`
626
+ // local per binding, walking each binding's structured `segments` path
627
+ // (#2087 Phase B) into a native `.key` / `["key"]` / `[N]` accessor off
628
+ // the per-item var — so the body's `$id` / `$name` / `$rest.flag` /
629
+ // `{...rest}` all resolve natively, at any nesting depth.
630
+ //
631
+ // Check the IR's structured `paramBindings` field rather than
632
+ // string-matching `loop.param`: Phase 1 populates `paramBindings`
633
+ // iff the param is a destructure pattern (array or object); a
634
+ // simple identifier leaves it `undefined`. The structured check is
635
+ // robust to whitespace / formatting variants in the source.
636
+ //
637
+ // `isLowerableLoopDestructure` (#2087) still refuses: an object-rest
638
+ // binding used any way other than member access (`rest.flag`) or a
639
+ // `{...rest}` spread onto an intrinsic element (that needs the actual
640
+ // residual *object*, which isn't always safe to materialize — see the
641
+ // gate's own jsdoc for the full list); a `.filter().map(destructure)`
642
+ // chain (the filter-param rewrite is out of scope here); and a
643
+ // computed property key (`{ [k]: v }`, refused earlier as BF025).
583
644
  const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
584
- const supportableDestructure = destructure && isLowerableObjectRestDestructure(loop)
645
+ const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
585
646
  if (destructure && !supportableDestructure) {
586
647
  this.errors.push({
587
648
  code: 'BF104',
588
649
  severity: 'error',
589
- message: `Loop callback uses an array/object destructure pattern (\`${loop.param}\`) that the Xslate adapter cannot lower — Kolon \`for LIST -> $item\` binds a single scalar and can't unpack a tuple.`,
650
+ message: `Loop callback uses a destructure pattern (\`${loop.param}\`) that the Xslate adapter cannot lower — see the diagnostic detail for the specific shape.`,
590
651
  loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
591
652
  suggestion: {
592
653
  message:
593
654
  `Options:\n` +
594
- ` 1. Rename the parameter to a single name and access tuple elements with index syntax in the body (e.g. \`entry => entry[0]\` instead of \`([k, v]) => ...\`).\n` +
595
- ` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
596
- ` 3. Move the loop into a primitive that the adapter registers explicitly.`,
655
+ ` 1. If this is an object-rest binding (\`{ ...rest }\`), only reading \`rest.field\` or spreading \`{...rest}\` onto an intrinsic element lowers — other uses (passing \`rest\` to a function, rendering it as text) need the client runtime.\n` +
656
+ ` 2. If this is chained \`.filter().map(({ ... }) => ...)\`, hoist the destructure into a variable inside the callback body instead.\n` +
657
+ ` 3. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
658
+ ` 4. Move the loop into a primitive that the adapter registers explicitly.`,
597
659
  },
598
660
  })
599
661
  }
600
662
 
663
+ // A `.map()` loop whose array is a bare identifier bound to a
664
+ // FUNCTION-scope local const with a non-statically-evaluable initializer
665
+ // that reads props/signals (e.g. `const entries =
666
+ // Object.entries(props.x ?? {}).filter(...)`) can't render correctly.
667
+ // Module-scope consts (`isModule`, e.g. `const payments = [...]` at the
668
+ // top of the file) are a DIFFERENT, already-working case handled
669
+ // elsewhere. Function-scope locals get no per-render stash slot — this
670
+ // adapter's only "elsewhere" for a local const is inlining its value at
671
+ // the use site (`_resolveLiteralConst`'s numeric/single-quoted-string
672
+ // fast path, or a static-record-literal lookup), never binding one as a
673
+ // `: my` template local. Left unchecked, `: for $entries -> $__bf_item {`
674
+ // over an undeclared `$entries` faults at request time instead of
675
+ // failing loudly at build time. Pre-existing, general limitation,
676
+ // orthogonal to #2087's destructure-binding work — newly reachable in
677
+ // this adapter's test corpus only because the widened destructure gate
678
+ // (#2087 Phase A/B) no longer refuses this fixture's `([emoji, users])
679
+ // => ...` param first.
680
+ const arrayName = loop.array.trim()
681
+ if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
682
+ const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
683
+ if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
684
+ this.errors.push({
685
+ code: 'BF101',
686
+ severity: 'error',
687
+ message: `Loop array \`${arrayName}\` is a local computed value (\`${arrayConst.value}\`) that the Xslate adapter cannot bind as a template variable — only numeric/string-literal locals inline at their use site.`,
688
+ loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
689
+ suggestion: {
690
+ message:
691
+ 'Pre-compute the array server-side and pass it as a prop, or mark the loop position as @client-only so it runs in JS on the client.',
692
+ },
693
+ })
694
+ }
695
+ }
696
+
601
697
  const rawArray = this.convertExpressionToKolon(loop.array)
602
698
  // Apply sort if present: wrap the loop array in the shared `$bf.sort`
603
699
  // helper, binding the sorted result to a per-iteration local so the
@@ -635,8 +731,20 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
635
731
  // Index alias: when an explicit `index` param is present (`.map((x, i) =>
636
732
  // ...)`) or the iteration is `keys`-shaped, expose it via a `: my` Kolon
637
733
  // local bound to the loop variable's `.index` accessor. A supported
638
- // destructure param adds one `: my` local per binding (`rest` aliases the
639
- // item so `$rest.flag` resolves).
734
+ // destructure param adds one `: my` local per binding, walking each
735
+ // binding's `segments` path (#2087 Phase B):
736
+ // - fixed (`b.rest` unset): the FULL accessor from `$__bf_item`.
737
+ // - array-rest: `$bf.slice(parent, from, nil)` — the same runtime
738
+ // helper `.slice()` JS-method calls lower to (see `array-method.ts`),
739
+ // so the "no end → to length" arithmetic stays in one place. Kolon's
740
+ // undefined literal is `nil`, not Perl's `undef`.
741
+ // - object-rest: `$bf.omit(parent, [...excluded keys...])` — a TRUE
742
+ // residual hashref (not the whole item aliased), so both
743
+ // `$rest.flag` (member-access use) and `$bf.spread_attrs($rest)`
744
+ // (spread-onto-element use) see only the non-destructured keys.
745
+ // `parent` is `$__bf_item` walked through the binding's PARENT-prefix
746
+ // `segments` (empty at the loop root, per the `LoopParamBinding` jsdoc) —
747
+ // NOT the same as a fixed binding's full-accessor segments.
640
748
  const indexLocalLines: string[] = []
641
749
  if (loop.iterationShape === 'keys') {
642
750
  indexLocalLines.push(`: my $${param} = $~${loopVar}.index;`)
@@ -645,11 +753,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
645
753
  }
646
754
  if (supportableDestructure) {
647
755
  for (const b of loop.paramBindings ?? []) {
648
- indexLocalLines.push(
649
- b.rest
650
- ? `: my $${b.name} = $${loopVar};`
651
- : `: my $${b.name} = $${loopVar}${b.path};`,
652
- )
756
+ const parent = kolonSegmentAccessor(`$${loopVar}`, b.segments ?? [])
757
+ if (b.rest?.kind === 'object') {
758
+ const exclude = b.rest.exclude.map(k => kolonStringLiteral(k.key)).join(', ')
759
+ indexLocalLines.push(`: my $${b.name} = $bf.omit(${parent}, [${exclude}]);`)
760
+ } else if (b.rest?.kind === 'array') {
761
+ indexLocalLines.push(`: my $${b.name} = $bf.slice(${parent}, ${b.rest.from}, nil);`)
762
+ } else {
763
+ indexLocalLines.push(
764
+ `: my $${b.name} = ${kolonSegmentAccessor(`$${loopVar}`, b.segments ?? [])};`,
765
+ )
766
+ }
653
767
  }
654
768
  }
655
769
 
@@ -743,11 +857,11 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
743
857
  },
744
858
  emitSpread: (value) => {
745
859
  // Kolon hashrefs can't be splatted into the entry list the way Perl
746
- // `%{...}` flattens into a list. The propsObject case is handled in
747
- // `renderComponent` (it enumerates the analyzer's props params); any
748
- // other spread shape is refused there via the unsupported gate. Emit
749
- // the lowered expression so a downstream consumer sees something
750
- // coherent, but renderComponent only routes the enumerated case here.
860
+ // `%{...}` flattens into a list. `renderComponent` handles EVERY
861
+ // spread shape itself (both the enumerated propsObject case and the
862
+ // general chained `.merge(...)` fold see its own docstring), so
863
+ // this callback is never reached for `kind: 'spread'` props; it
864
+ // only exists to satisfy the `AttrValueEmitter` interface.
751
865
  return this.convertExpressionToKolon(value.expr)
752
866
  },
753
867
  emitTemplate: (value, name) =>
@@ -759,41 +873,106 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
759
873
  emitJsxChildren: () => '',
760
874
  }
761
875
 
876
+ /**
877
+ * A `renderComponent` props hashref, built as an ORDERED sequence of
878
+ * segments so `{...before, ...spread, after: 1}` JSX spread semantics
879
+ * (later entries win) survive the trip through Kolon, which has no
880
+ * hash-splat syntax (no `%$h`-into-hashref-literal form — verified: parse
881
+ * error). Each `'entries'` segment is a literal Kolon hashref
882
+ * `{ k => v, ... }`; each `'spread'` segment is an arbitrary expression
883
+ * lowered from a `{...expr}` prop. `combineComponentPropSegments` folds
884
+ * the sequence into ONE expression via chained `.merge(...)` calls
885
+ * (Kolon's builtin hash method, later argument wins on key conflict,
886
+ * matching `Object.assign`/JSX order).
887
+ */
888
+ private componentPropSegmentEntries(
889
+ segments: Array<{ kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }>,
890
+ ): string[] {
891
+ const last = segments[segments.length - 1]
892
+ if (last && last.kind === 'entries') return last.parts
893
+ const seg = { kind: 'entries' as const, parts: [] as string[] }
894
+ segments.push(seg)
895
+ return seg.parts
896
+ }
897
+
898
+ /**
899
+ * Fold ordered prop segments into a single Kolon expression via chained
900
+ * `.merge(...)` calls — Kolon's builtin hash method, later argument wins
901
+ * on key conflict, exactly like `{...a, ...b}`. A spread segment's
902
+ * expression is wrapped `(EXPR // {})` before merging: `.merge(undef)`
903
+ * warns "Merging value is not a HASH reference" (verified against real
904
+ * Text::Xslate 3.5.9), so the defined-or guard normalises a missing bag
905
+ * (e.g. `$children.props` when `children` was never passed — Kolon
906
+ * tolerates the chained dot-access on an undefined value itself,
907
+ * verified empirically) to an empty hashref first. If the fold starts
908
+ * with a spread segment, the base is just `($SPREAD // {})`; a
909
+ * following segment chains `.merge({...})` onto it. Empty `'entries'`
910
+ * segments are dropped so a leading/trailing spread doesn't drag in a
911
+ * needless `{}.merge(...)`. Returns `'{}'` when every segment is empty
912
+ * (no props at all).
913
+ */
914
+ private combineComponentPropSegments(
915
+ segments: ReadonlyArray<{ kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }>,
916
+ ): string {
917
+ let acc: string | null = null
918
+ for (const seg of segments) {
919
+ if (seg.kind === 'entries') {
920
+ if (seg.parts.length === 0) continue
921
+ const text = `{ ${seg.parts.join(', ')} }`
922
+ acc = acc === null ? text : `${acc}.merge(${text})`
923
+ } else {
924
+ const text = `(${seg.expr} // {})`
925
+ acc = acc === null ? text : `${acc}.merge(${text})`
926
+ }
927
+ }
928
+ return acc ?? '{}'
929
+ }
930
+
762
931
  renderComponent(comp: IRComponent): string {
763
- const propParts: string[] = []
932
+ type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
933
+ const segments: Segment[] = [{ kind: 'entries', parts: [] }]
934
+ const currentEntries = () => this.componentPropSegmentEntries(segments)
935
+
764
936
  for (const p of comp.props) {
765
937
  // Skip callback props (onXxx) and `ref` — both are client-only for
766
938
  // SSR (Hono renders neither; the client JS wires them at hydration).
767
939
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
768
- // Spread props: enumerate the analyzer's props params into hashref
769
- // entries (the propsObject case) — Kolon can't flatten a hashref into
770
- // the entry list. Other spread shapes are refused with BF101.
771
940
  if (p.value.kind === 'spread') {
772
941
  const trimmed = p.value.expr.trim()
942
+ // SolidJS-style props identifier (`function(props: P)`) has no
943
+ // matching runtime hash in Kolon scope — props arrive as a flat
944
+ // set of top-level template vars, so enumerate the
945
+ // analyzer-extracted props params into hashref entries instead of
946
+ // treating it as a runtime spread expression.
773
947
  if (this.propsObjectName && this.propsObjectName === trimmed) {
774
948
  for (const pp of this.propsParams) {
775
- propParts.push(`${pp.name} => $${pp.name}`)
949
+ currentEntries().push(`${pp.name} => $${pp.name}`)
776
950
  }
777
951
  continue
778
952
  }
779
- this.errors.push({
780
- code: 'BF101',
781
- severity: 'error',
782
- message: `Spread props (\`{...${trimmed}}\`) on a child component cannot be lowered to Kolon — Kolon hashref method args can't splat a runtime hash into named entries.`,
783
- loc: comp.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
784
- suggestion: {
785
- message: 'Pass the child component its props explicitly rather than spreading a runtime object.',
786
- },
787
- })
953
+ // Every other spread shape (a destructure rest-bag `props`, a
954
+ // member-access bag like `children.props`, an intrinsic-element
955
+ // spread helper's own operand, …) — Kolon hashref literals can't
956
+ // splat a runtime hash into named entries at a call site, but the
957
+ // builtin `.merge` method can fold it into the accumulated
958
+ // hashref at the right ordinal position, mirroring Twig's
959
+ // `|merge` / Jinja's `dict(base, **top)`: no compile-time
960
+ // filtering of onXxx/ref keys out of the runtime bag (the render
961
+ // contract tolerates them, same as the other spread-lowering
962
+ // adapters).
963
+ segments.push({ kind: 'spread', expr: this.convertExpressionToKolon(p.value.expr) })
788
964
  continue
789
965
  }
790
966
  const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
791
- if (lowered) propParts.push(lowered)
967
+ if (lowered) currentEntries().push(lowered)
792
968
  }
793
969
  // Pass slot ID so the child renderer can set correct scope ID for
794
970
  // hydration. Skip for loop children — they use ComponentName_random.
971
+ // Appended to whatever the trailing entries segment is so a spread's
972
+ // own `_bf_slot`/`children` keys (if any) never win over these
973
+ // compiler-controlled entries.
795
974
  if (comp.slotId && !this.inLoop) {
796
- propParts.push(`_bf_slot => '${comp.slotId}'`)
975
+ currentEntries().push(`_bf_slot => '${comp.slotId}'`)
797
976
  }
798
977
  const tplName = this.toTemplateName(comp.name)
799
978
 
@@ -815,12 +994,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
815
994
  const childrenBody = this.renderChildren(effectiveChildren)
816
995
  this.inLoop = prevInLoop
817
996
  const macroName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
818
- const childrenEntry = `children => ${macroName}()`
819
- const allParts = [...propParts, childrenEntry]
820
- return `<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', { ${allParts.join(', ')} }) | mark_raw :>`
997
+ currentEntries().push(`children => ${macroName}()`)
998
+ const dict = this.combineComponentPropSegments(segments)
999
+ return `<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', ${dict}) | mark_raw :>`
821
1000
  }
822
1001
 
823
- const hashEntries = propParts.length > 0 ? `, { ${propParts.join(', ')} }` : ''
1002
+ const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
1003
+ const hashEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
824
1004
  return `<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`
825
1005
  }
826
1006
 
@@ -1147,7 +1327,18 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1147
1327
  param: string,
1148
1328
  localVarMap: Map<string, string> = new Map(),
1149
1329
  ): string {
1150
- return emitParsedExpr(expr, new XslateFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
1330
+ return emitParsedExpr(
1331
+ expr,
1332
+ new XslateFilterEmitter(
1333
+ param,
1334
+ localVarMap,
1335
+ n => this._isStringValueName(n),
1336
+ // A nested callback method inside the predicate has no Kolon scalar
1337
+ // form — surface BF101 (#2038) instead of silently degrading it to
1338
+ // its receiver.
1339
+ (message, reason) => this._recordExprBF101(message, reason),
1340
+ ),
1341
+ )
1151
1342
  }
1152
1343
 
1153
1344
  // ===========================================================================
@@ -1309,6 +1500,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1309
1500
  const qArgs = queryHrefArgs(node, n => this.renderParsedExprToKolon(n))
1310
1501
  return `$bf.query(${qArgs.join(', ')})`
1311
1502
  }
1503
+ // Generic `helper-call` (#2069) — the neutral vocabulary's escape
1504
+ // hatch for a userland `LoweringPlugin` that lowers to a single
1505
+ // runtime-helper invocation. `$bf.<helper>(args…)` mirrors the
1506
+ // `query` helper's own naming convention exactly: the framework
1507
+ // renders the call, the plugin author registers `<helper>` as a
1508
+ // Kolon-callable method on the `$bf` vars entry in their own
1509
+ // runtime — same contract as `$bf.query` itself, just not built in.
1510
+ if (node?.kind === 'helper-call' && isValidHelperId(node.helper)) {
1511
+ const argsX = node.args.map(a => this.renderParsedExprToKolon(a))
1512
+ return `$bf.${node.helper}(${argsX.join(', ')})`
1513
+ }
1312
1514
  }
1313
1515
  }
1314
1516
 
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Per-fixture build-time contracts for shapes the Xslate adapter
3
+ * intentionally refuses to lower. Mirrors mojo's set — the lowering
4
+ * gates are shared code paths in the ported adapter. Consumed by this
5
+ * package's own conformance test (as `expectedDiagnostics`) and by
6
+ * `bf compat` (issue-URL attribution).
7
+ */
8
+
9
+ import type { ConformancePins } from '@barefootjs/jsx'
10
+
11
+ export const conformancePins: ConformancePins = {
12
+ // Sibling-imported child component in a loop body: emits a
13
+ // cross-template call needing separate registration. BF103 makes
14
+ // the requirement loud (same as mojo).
15
+ 'static-array-children': [{ code: 'BF103', severity: 'error' }],
16
+ // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
17
+ // call it inside a keyed `.map`. With the standalone-filter fix in
18
+ // place these reach the SAME BF103 (imported child in `.map`) as
19
+ // mojo — NOT BF101 — confirming the `.filter(...)` chain itself now
20
+ // lowers and the only remaining gate is the imported-child one.
21
+ 'todo-app': [{ code: 'BF103', severity: 'error' }],
22
+ 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
23
+ // `([emoji, users]) => ...` / `([id, t]) => ...` are plain array-index
24
+ // (tuple) destructures, no rest — #2087 Phase B's `segments`-walking
25
+ // accessor lowers both to `$__bf_item[0]` / `$__bf_item[1]` `: my` locals
26
+ // like any other fixed binding, so BF104 no longer fires for either
27
+ // fixture. Each now hits a DIFFERENT, pre-existing, orthogonal gap
28
+ // instead: the loop array (`entries`) is a function-scope local const
29
+ // with a computed initializer (`Object.entries(props.x ?? {}).filter(...)`)
30
+ // that the adapter can't bind as a template variable — see the dedicated
31
+ // `arrayConst` BF101 check in `renderLoop`. This was always true; it was
32
+ // simply unreachable before because BF104 refused the destructure shape
33
+ // first.
34
+ 'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
35
+ // Both BF103 (sibling-imported `<Tag>` child component) and the BF101
36
+ // above fire; BF104 no longer does (see above).
37
+ 'static-array-from-props-with-component': [
38
+ { code: 'BF103', severity: 'error' },
39
+ { code: 'BF101', severity: 'error' },
40
+ ],
41
+ // #1310 / #2087: rest destructure in .map() callback. All four shapes now
42
+ // lower via #2087 Phase B's `segments`-walking accessor:
43
+ // - object-rest read via member access (`rest-destructure-object-in-map`):
44
+ // `$bf.omit($__bf_item, [...exclude keys...])`, `$rest.flag` reads the
45
+ // residual hashref.
46
+ // - object-rest spread onto the root element
47
+ // (`rest-destructure-object-spread-in-map`): same `$bf.omit(...)`
48
+ // residual, forwarded via the existing `$bf.spread_attrs($rest)` path.
49
+ // - array-rest (`rest-destructure-array-in-map`): `$bf.slice($__bf_item,
50
+ // N, nil)` — the same runtime helper `.slice()` JS-method calls use.
51
+ // - nested rest inside an object pattern (`rest-destructure-nested-in-map`):
52
+ // the parent-prefix accessor (`$__bf_item.cells`) feeds the same
53
+ // `$bf.slice(...)` call.
54
+ // None of these are pinned here anymore.
55
+ // (button/kbd graduated: the site/ui Button/Kbd `<Slot>` `{...props}` /
56
+ // `{...children.props}` component-spread now lowers via Kolon's builtin
57
+ // `.merge(...)` method chain — see `xslate-adapter.ts`'s
58
+ // `renderComponent` — instead of refusing with BF101, so these two no
59
+ // longer need a pin here.)
60
+ // #1467 demo-corpus context providers (`radio-group`, `select`,
61
+ // `dropdown-menu`, `combobox`, `command`) are no longer pinned — an
62
+ // object-literal provider value (`{ value: currentValue,
63
+ // onValueChange: (v) => {…} }`) lowers to a Kolon hashref via
64
+ // `parseProviderObjectLiteral` (#1897): getter members snapshot
65
+ // their body's SSR value, handler / function-shaped members lower
66
+ // to `nil`. The command demo's `ref={(el) => {…}}` function prop on
67
+ // an imported component is skipped at SSR like `on*` handlers.
68
+ //
69
+ // #1467 Phase 2e: `data-table` is no longer pinned here — it
70
+ // compiles clean now (`selected()[index]` → `index-access`,
71
+ // `.toFixed(2)` → `$bf.to_fixed`, `/* @client */` memo SSR-folded)
72
+ // and renders to Hono parity on real Text::Xslate. The keyed-loop
73
+ // scope-ID divergence (#1896) was fixed by the body-children
74
+ // `inLoop` reset (loop-item children get `_bf_slot`); data-table is
75
+ // off `skipJsx` entirely and only kept in `skipMarkerConformance`
76
+ // below for the shared `/* @client */` keyed-map slot-id elision
77
+ // contract (same as `todo-app`), not a render or BF101 gap.
78
+ // `style-3-signals` / `style-object-dynamic` no longer pinned — a
79
+ // `style={{ … }}` object literal now lowers to a CSS string with dynamic
80
+ // values interpolated (`background-color:<: $color :>;padding:8px`) via
81
+ // `tryLowerStyleObject` (#1322).
82
+ // (`tagged-template-classname` graduated by #2092 — the tag resolves
83
+ // through the interleave-tag catalogue and desugars to an untagged
84
+ // template literal, so it lowers like any other className template.)
85
+ // #2038: a filter predicate whose body contains a NESTED callback call
86
+ // (`t => !picked().some(p => …)` / `t => picked().find(p => …)`). Kolon
87
+ // has no inline `grep` form, so `XslateFilterEmitter.callbackMethod` used
88
+ // to degrade the inner call to its receiver, silently changing predicate
89
+ // semantics — the compiler is loud instead of lossy. (Mojo is pinned only
90
+ // for the `.find` variant: it lowers a nested `.some` to a real inline
91
+ // Perl `grep`.) The `/* @client */` twin
92
+ // (`filter-nested-callback-predicate-client`) has no pin here: it must
93
+ // render clean on every adapter, which asserts the suppression contract.
94
+ // https://github.com/piconic-ai/barefootjs/issues/2038
95
+ 'filter-nested-callback-predicate': [
96
+ { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2038' },
97
+ ],
98
+ 'filter-nested-find-predicate': [
99
+ { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2038' },
100
+ ],
101
+ // NB: TOP-LEVEL `.find` / `.findIndex` / `.findLast` / `.findLastIndex`
102
+ // (text position) are NOT pinned here — unlike mojo (which refuses them),
103
+ // Xslate lowers them to `$bf.find` / `find_index` / `find_last` /
104
+ // `find_last_index` via the same Kolon-lambda mechanism as `.filter` /
105
+ // `.every` / `.some`, so they render. Only the NESTED-in-a-predicate form
106
+ // above is refused (#2038).
107
+ // #2073 follow-up: a function-reference `.map(format)` callback has no
108
+ // arrow body to serialize — not a CALLBACK_METHODS shape — so the
109
+ // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
110
+ // a broken template.
111
+ 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
112
+ }
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@
6
6
 
7
7
  export { XslateAdapter, xslateAdapter } from './adapter/index.ts'
8
8
  export type { XslateAdapterOptions } from './adapter/index.ts'
9
+ export { conformancePins } from './conformance-pins.ts'