@barefootjs/xslate 0.17.1 → 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'
@@ -578,33 +621,79 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
578
621
  return `<: $bf.comment("loop:${loop.markerId}") | mark_raw :><: $bf.comment("/loop:${loop.markerId}") | mark_raw :>`
579
622
  }
580
623
 
581
- // An array/object-destructure loop param (`([emoji, users]) => ...` or
582
- // `({ name, age }) => ...`) lowers to invalid Kolon Kolon's `for LIST
583
- // -> $item` binds a single scalar and can't unpack a tuple. Surface this
584
- // at build time instead of shipping a broken template line.
585
- // A destructure loop param is lowerable for the object-rest / simple-field
586
- // shape (`.map(({ id, title, ...rest }) => …)`, `rest` read via member
587
- // access): each binding becomes a Kolon `: my` local off the per-item var,
588
- // so the body's `$id` / `$rest.flag` resolve. Array-index / nested /
589
- // rest-spread shapes still can't unpack a tuple → BF104. (#1310)
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).
590
644
  const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
591
- const supportableDestructure = destructure && isLowerableObjectRestDestructure(loop)
645
+ const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
592
646
  if (destructure && !supportableDestructure) {
593
647
  this.errors.push({
594
648
  code: 'BF104',
595
649
  severity: 'error',
596
- 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.`,
597
651
  loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
598
652
  suggestion: {
599
653
  message:
600
654
  `Options:\n` +
601
- ` 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` +
602
- ` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
603
- ` 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.`,
604
659
  },
605
660
  })
606
661
  }
607
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
+
608
697
  const rawArray = this.convertExpressionToKolon(loop.array)
609
698
  // Apply sort if present: wrap the loop array in the shared `$bf.sort`
610
699
  // helper, binding the sorted result to a per-iteration local so the
@@ -642,8 +731,20 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
642
731
  // Index alias: when an explicit `index` param is present (`.map((x, i) =>
643
732
  // ...)`) or the iteration is `keys`-shaped, expose it via a `: my` Kolon
644
733
  // local bound to the loop variable's `.index` accessor. A supported
645
- // destructure param adds one `: my` local per binding (`rest` aliases the
646
- // 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.
647
748
  const indexLocalLines: string[] = []
648
749
  if (loop.iterationShape === 'keys') {
649
750
  indexLocalLines.push(`: my $${param} = $~${loopVar}.index;`)
@@ -652,11 +753,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
652
753
  }
653
754
  if (supportableDestructure) {
654
755
  for (const b of loop.paramBindings ?? []) {
655
- indexLocalLines.push(
656
- b.rest
657
- ? `: my $${b.name} = $${loopVar};`
658
- : `: my $${b.name} = $${loopVar}${b.path};`,
659
- )
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
+ }
660
767
  }
661
768
  }
662
769
 
@@ -750,11 +857,11 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
750
857
  },
751
858
  emitSpread: (value) => {
752
859
  // Kolon hashrefs can't be splatted into the entry list the way Perl
753
- // `%{...}` flattens into a list. The propsObject case is handled in
754
- // `renderComponent` (it enumerates the analyzer's props params); any
755
- // other spread shape is refused there via the unsupported gate. Emit
756
- // the lowered expression so a downstream consumer sees something
757
- // 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.
758
865
  return this.convertExpressionToKolon(value.expr)
759
866
  },
760
867
  emitTemplate: (value, name) =>
@@ -766,41 +873,106 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
766
873
  emitJsxChildren: () => '',
767
874
  }
768
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
+
769
931
  renderComponent(comp: IRComponent): string {
770
- 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
+
771
936
  for (const p of comp.props) {
772
937
  // Skip callback props (onXxx) and `ref` — both are client-only for
773
938
  // SSR (Hono renders neither; the client JS wires them at hydration).
774
939
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
775
- // Spread props: enumerate the analyzer's props params into hashref
776
- // entries (the propsObject case) — Kolon can't flatten a hashref into
777
- // the entry list. Other spread shapes are refused with BF101.
778
940
  if (p.value.kind === 'spread') {
779
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.
780
947
  if (this.propsObjectName && this.propsObjectName === trimmed) {
781
948
  for (const pp of this.propsParams) {
782
- propParts.push(`${pp.name} => $${pp.name}`)
949
+ currentEntries().push(`${pp.name} => $${pp.name}`)
783
950
  }
784
951
  continue
785
952
  }
786
- this.errors.push({
787
- code: 'BF101',
788
- severity: 'error',
789
- 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.`,
790
- loc: comp.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
791
- suggestion: {
792
- message: 'Pass the child component its props explicitly rather than spreading a runtime object.',
793
- },
794
- })
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) })
795
964
  continue
796
965
  }
797
966
  const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
798
- if (lowered) propParts.push(lowered)
967
+ if (lowered) currentEntries().push(lowered)
799
968
  }
800
969
  // Pass slot ID so the child renderer can set correct scope ID for
801
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.
802
974
  if (comp.slotId && !this.inLoop) {
803
- propParts.push(`_bf_slot => '${comp.slotId}'`)
975
+ currentEntries().push(`_bf_slot => '${comp.slotId}'`)
804
976
  }
805
977
  const tplName = this.toTemplateName(comp.name)
806
978
 
@@ -822,12 +994,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
822
994
  const childrenBody = this.renderChildren(effectiveChildren)
823
995
  this.inLoop = prevInLoop
824
996
  const macroName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
825
- const childrenEntry = `children => ${macroName}()`
826
- const allParts = [...propParts, childrenEntry]
827
- 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 :>`
828
1000
  }
829
1001
 
830
- 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)}`
831
1004
  return `<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`
832
1005
  }
833
1006
 
@@ -1327,6 +1500,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1327
1500
  const qArgs = queryHrefArgs(node, n => this.renderParsedExprToKolon(n))
1328
1501
  return `$bf.query(${qArgs.join(', ')})`
1329
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
+ }
1330
1514
  }
1331
1515
  }
1332
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'