@barefootjs/jsx 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/adapters/interface.d.ts +20 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/test-adapter.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +36 -19
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/import-map.d.ts +56 -0
  7. package/dist/import-map.d.ts.map +1 -0
  8. package/dist/import-map.js +18 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +333 -199
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +0 -14
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/types.d.ts +7 -0
  24. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/utils.d.ts +2 -2
  26. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  27. package/dist/scanner/js-scanner.d.ts +10 -0
  28. package/dist/scanner/js-scanner.d.ts.map +1 -1
  29. package/dist/scanner/js-scanner.js +5 -0
  30. package/dist/types.d.ts +11 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +7 -3
  33. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +438 -190
  34. package/src/__tests__/adapter-output.test.ts +49 -0
  35. package/src/__tests__/child-components-in-map.test.ts +76 -0
  36. package/src/__tests__/client-js-generation.test.ts +5 -2
  37. package/src/__tests__/import-map.test.ts +75 -0
  38. package/src/__tests__/inline-jsx-callback.test.ts +95 -0
  39. package/src/__tests__/ir-jsx-props.test.ts +5 -2
  40. package/src/__tests__/ir-sort-comparator.test.ts +212 -9
  41. package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
  42. package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
  43. package/src/__tests__/missing-key-in-list.test.ts +49 -0
  44. package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
  45. package/src/__tests__/token-contains-ident.test.ts +27 -0
  46. package/src/__tests__/unsupported-expression.test.ts +42 -13
  47. package/src/adapters/interface.ts +20 -0
  48. package/src/adapters/test-adapter.ts +16 -1
  49. package/src/expression-parser.ts +265 -50
  50. package/src/import-map.ts +72 -0
  51. package/src/index.ts +5 -1
  52. package/src/ir-to-client-js/collect-elements.ts +3 -0
  53. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
  54. package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
  55. package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
  56. package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
  57. package/src/ir-to-client-js/emit-reactive.ts +12 -3
  58. package/src/ir-to-client-js/html-template.ts +29 -3
  59. package/src/ir-to-client-js/imports.ts +2 -2
  60. package/src/ir-to-client-js/reactivity.ts +17 -1
  61. package/src/ir-to-client-js/stringify/static-array-child-init.ts +8 -4
  62. package/src/ir-to-client-js/types.ts +7 -0
  63. package/src/ir-to-client-js/utils.ts +31 -116
  64. package/src/jsx-to-ir.ts +161 -12
  65. package/src/preprocess-inline-jsx-callbacks.ts +28 -10
  66. package/src/scanner/js-scanner.ts +16 -1
  67. package/src/types.ts +12 -0
@@ -69,13 +69,12 @@ export type ParsedExpr =
69
69
  | { kind: 'unsupported'; raw: string; reason: string }
70
70
 
71
71
  /**
72
- * Structured form of a JS `(a, b) => …` sort comparator. Built once
73
- * at parse time and consumed by both adapters' arrayMethod emit and
74
- * (when chained directly before `.map()`) the loop-hoist path in
75
- * `jsx-to-ir.ts`. The shape is intentionally finite see
76
- * `extractSortComparatorFromTS` for the accepted catalogue.
72
+ * One comparison key inside a sort comparator. A simple
73
+ * `(a, b) => a.f - b.f` produces a single key; a multi-key
74
+ * `||`-chained comparator (`a.x - b.x || a.y - b.y`) produces one key
75
+ * per `||` operand, applied in priority order as tie-breakers.
77
76
  */
78
- export type SortComparator = {
77
+ export type SortKey = {
79
78
  // What value to compare on each item:
80
79
  // { kind: 'self' } → primitive array, compare items directly
81
80
  // { kind: 'field', field } → struct-field accessor
@@ -83,11 +82,33 @@ export type SortComparator = {
83
82
  // How to compare:
84
83
  // 'numeric' → `a - b` subtraction semantics
85
84
  // 'string' → `localeCompare` semantics
86
- type: 'numeric' | 'string'
85
+ // 'auto' relational (`>`/`<`) ternary: compare numerically
86
+ // when both keys parse as numbers, else lexically.
87
+ // Both template runtimes apply the same rule so their
88
+ // output stays byte-equal; this diverges from JS
89
+ // `<`/`>` only for numeric *strings* (rare in SSR
90
+ // data — JS would compare those lexically).
91
+ type: 'numeric' | 'string' | 'auto'
87
92
  direction: 'asc' | 'desc'
93
+ }
94
+
95
+ /**
96
+ * Structured form of a JS `(a, b) => …` sort comparator. Built once
97
+ * at parse time and consumed by both adapters' arrayMethod emit and
98
+ * (when chained directly before `.map()`) the loop-hoist path in
99
+ * `jsx-to-ir.ts`. The shape is intentionally finite — see
100
+ * `extractSortComparatorFromTS` for the accepted catalogue.
101
+ */
102
+ export type SortComparator = {
103
+ // Comparison keys in priority order. A simple comparator has one
104
+ // key; a `||`-chained multi-key comparator has one per operand.
105
+ // Always length >= 1.
106
+ keys: SortKey[]
88
107
  // Original JS source of the comparator body; preserved so `@client`
89
108
  // fallback can re-emit the user's exact expression if the call site
90
- // ever gets relocated to the runtime.
109
+ // ever gets relocated to the runtime. For block-body comparators
110
+ // this is the returned expression, not the `{ … }` block — so the
111
+ // client fallback's synthetic `(a, b) => raw` arrow stays valid.
91
112
  raw: string
92
113
  // The two parameter names the user wrote (e.g. `a`/`b`, or
93
114
  // `lhs`/`rhs`). Only consumed by the client-side `@client`
@@ -379,9 +400,11 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
379
400
  // `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B). The comparator
380
401
  // is extracted into a structured `SortComparator` at parse time;
381
402
  // unrecognised shapes fall through to `unsupported` so adapters
382
- // surface BF101 (with `@client` as the escape hatch). Block
383
- // bodies, multi-key comparators, and function-reference
384
- // comparators are out of scope for this PR see #1448 Tier B
403
+ // surface BF101 (with `@client` as the escape hatch). Supported:
404
+ // subtraction / localeCompare / relational-ternary leaves, any
405
+ // of them `||`-chained (multi-key), and single-`return` block
406
+ // bodies. Function-reference comparators and `localeCompare`
407
+ // locale/options args stay out of scope — see #1448 Tier B
385
408
  // follow-up.
386
409
  if ((callee.property === 'sort' || callee.property === 'toSorted') && node.arguments.length === 1) {
387
410
  // Extract from the raw TS AST (not args[0] ParsedExpr) — the
@@ -406,6 +429,8 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
406
429
  ` (a, b) => a.field - b.field\n` +
407
430
  ` (a, b) => a.localeCompare(b)\n` +
408
431
  ` (a, b) => a.field.localeCompare(b.field)\n` +
432
+ ` (a, b) => a.field > b.field ? 1 : -1 (relational ternary)\n` +
433
+ ` any of the above ||-chained for multi-key tie-breaks\n` +
409
434
  `(reverse the operands for descending order). ` +
410
435
  `Wrap the call in /* @client */ to evaluate at hydration.`,
411
436
  }
@@ -663,20 +688,28 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
663
688
  * match exactly, in which case the caller emits an `unsupported` IR
664
689
  * node and adapters surface BF101.
665
690
  *
666
- * Accepted shapes (paired ascending / descending):
691
+ * Body shapes:
692
+ * - expression-bodied arrow (`(a, b) => …`)
693
+ * - single-`return` block body — both arrow (`(a, b) => { return …; }`)
694
+ * and function expression. Multi-statement / local-var bodies stay
695
+ * refused (deferred follow-up).
696
+ *
697
+ * A body is split on top-level `||` into one leaf per operand, giving
698
+ * a multi-key comparator (`a.x - b.x || a.y - b.y` → sort by x, then y).
699
+ * Accepted leaf shapes (each paired ascending / descending by operand
700
+ * order):
667
701
  *
668
- * (a, b) => a.field - b.field → field, numeric, asc
669
- * (a, b) => b.field - a.field field, numeric, desc
670
- * (a, b) => a - b self, numeric, asc
671
- * (a, b) => b - a → self, numeric, desc
672
- * (a, b) => a.field.localeCompare(b.field) → field, string, asc
673
- * (a, b) => b.field.localeCompare(a.field) → field, string, desc
674
- * (a, b) => a.localeCompare(b) → self, string, asc
675
- * (a, b) => b.localeCompare(a) → self, string, desc
702
+ * a.field - b.field → field, numeric
703
+ * a - b self, numeric
704
+ * a.field.localeCompare(b.field) field, string
705
+ * a.localeCompare(b) → self, string
706
+ * a.field > b.field ? 1 : -1 → field, auto (relational ternary)
707
+ * a.field < b.field ? -1 : 1 → field, auto
708
+ * a < b ? -1 : a > b ? 1 : 0 → self/field, auto (3-way)
709
+ * a === b ? 0 : <relational ternary> leading-tie 3-way
676
710
  *
677
- * Anything outside (block bodies, multi-key `a.x-b.x || a.y-b.y`,
678
- * function-reference comparators, ternary comparators) returns null.
679
- * Block-body support is deferred to a follow-up.
711
+ * Function-reference comparators and `localeCompare(b, locale, opts)`
712
+ * (the multi-arg form) return null deferred follow-ups.
680
713
  */
681
714
  export function extractSortComparatorFromTS(
682
715
  node: ts.Node,
@@ -691,38 +724,87 @@ export function extractSortComparatorFromTS(
691
724
  const paramA = pA.name.text
692
725
  const paramB = pB.name.text
693
726
 
694
- // Body must be an expression. Arrow-fn carries `.body` directly;
695
- // function-expression wraps a single `return <expr>;`. Block bodies
696
- // deferred to a follow-up PR.
727
+ // Resolve the comparator body. Expression-bodied arrows carry it
728
+ // directly; block bodies (both arrow `=> { … }` and function
729
+ // expressions) must reduce to exactly one `return <expr>;`. Anything
730
+ // with locals or multiple statements stays refused — a deferred
731
+ // follow-up.
697
732
  let body: ts.Expression
698
- if (ts.isArrowFunction(node)) {
699
- if (ts.isBlock(node.body)) return null
733
+ if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
700
734
  body = node.body
701
735
  } else {
702
- const stmts = node.body.statements
736
+ const block = node.body as ts.Block
737
+ const stmts = block.statements
703
738
  if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
704
739
  body = stmts[0].expression
705
740
  }
706
741
 
707
742
  // Normalise the comparator body source so consumers of
708
743
  // `SortComparator.raw` get the same string regardless of whether
709
- // the user wrote an arrow expression (`(a, b) => a.x - b.x`) or
710
- // a function expression (`function (a, b) { return a.x - b.x }`).
711
- // Pre-normalisation the function-expression form leaked the whole
712
- // function declaration into `raw`, breaking `@client` fallback
713
- // emit that wraps `raw` in a synthetic arrow.
744
+ // the user wrote an arrow expression (`(a, b) => a.x - b.x`) or a
745
+ // block body (`(a, b) => { return a.x - b.x }`). For block bodies
746
+ // this is the returned expression, not the `{ … }` block — so the
747
+ // `@client` fallback's synthetic `(a, b) => raw` arrow stays valid.
714
748
  //
715
749
  // `body.getText()` resolves against the node's source file via the
716
750
  // parent chain — `ts.createSourceFile`-parsed nodes (the only
717
751
  // shape this helper accepts) carry that wiring.
718
752
  const raw = body.getText()
719
753
 
720
- // Subtraction: `a.field - b.field` / `a - b` etc.
754
+ // A `||`-chain is a multi-key comparator: each operand is an
755
+ // independent leaf applied as the next tie-breaker. A non-`||` body
756
+ // is a single-key comparator (one-element chain).
757
+ const keys: SortKey[] = []
758
+ for (const operand of flattenLogicalOr(body)) {
759
+ const key = classifyLeafComparator(operand, paramA, paramB)
760
+ if (!key) return null
761
+ keys.push(key)
762
+ }
763
+ if (keys.length === 0) return null
764
+
765
+ return { keys, raw, paramA, paramB, method }
766
+ }
767
+
768
+ /** Strip redundant parentheses so the classifiers see the real node. */
769
+ function unwrapParens(expr: ts.Expression): ts.Expression {
770
+ let e = expr
771
+ while (ts.isParenthesizedExpression(e)) e = e.expression
772
+ return e
773
+ }
774
+
775
+ /**
776
+ * Flatten a left-associative top-level `||` chain into its operands.
777
+ * `a || b || c` parses as `((a || b) || c)`; this returns `[a, b, c]`.
778
+ * A non-`||` expression returns a single-element list.
779
+ */
780
+ function flattenLogicalOr(expr: ts.Expression): ts.Expression[] {
781
+ const inner = unwrapParens(expr)
782
+ if (ts.isBinaryExpression(inner) && inner.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
783
+ return [...flattenLogicalOr(inner.left), ...flattenLogicalOr(inner.right)]
784
+ }
785
+ return [inner]
786
+ }
787
+
788
+ /**
789
+ * Classify a single comparator leaf (one `||` operand) into a SortKey.
790
+ * Accepts subtraction (numeric), `localeCompare` (string), and
791
+ * relational-ternary (auto) shapes; returns null otherwise.
792
+ */
793
+ function classifyLeafComparator(
794
+ expr: ts.Expression,
795
+ paramA: string,
796
+ paramB: string,
797
+ ): SortKey | null {
798
+ const body = unwrapParens(expr)
799
+
800
+ // Subtraction: `a.field - b.field` / `a - b` → numeric.
721
801
  if (ts.isBinaryExpression(body) && body.operatorToken.kind === ts.SyntaxKind.MinusToken) {
722
- return classifyComparatorOperands(body.left, body.right, paramA, paramB, 'numeric', method, raw)
802
+ return classifyComparatorOperands(body.left, body.right, paramA, paramB, 'numeric')
723
803
  }
724
804
 
725
- // localeCompare call: `<lhs>.localeCompare(<rhs>)`.
805
+ // localeCompare (zero-arg form): `<lhs>.localeCompare(<rhs>)` →
806
+ // string. The locale/options form (2–3 args) stays refused — it
807
+ // needs per-adapter collation plumbing (deferred follow-up).
726
808
  if (
727
809
  ts.isCallExpression(body) &&
728
810
  ts.isPropertyAccessExpression(body.expression) &&
@@ -735,11 +817,14 @@ export function extractSortComparatorFromTS(
735
817
  paramA,
736
818
  paramB,
737
819
  'string',
738
- method,
739
- raw,
740
820
  )
741
821
  }
742
822
 
823
+ // Relational-ternary sign comparator → auto.
824
+ if (ts.isConditionalExpression(body)) {
825
+ return classifyTernaryComparator(body, paramA, paramB)
826
+ }
827
+
743
828
  return null
744
829
  }
745
830
 
@@ -762,9 +847,7 @@ function classifyComparatorOperands(
762
847
  paramA: string,
763
848
  paramB: string,
764
849
  type: 'numeric' | 'string',
765
- method: 'sort' | 'toSorted',
766
- raw: string,
767
- ): SortComparator | null {
850
+ ): SortKey | null {
768
851
  const leftRef = classifySortOperand(left, paramA, paramB)
769
852
  const rightRef = classifySortOperand(right, paramA, paramB)
770
853
  if (!leftRef || !rightRef) return null
@@ -774,15 +857,147 @@ function classifyComparatorOperands(
774
857
  return null
775
858
  }
776
859
  const direction = leftRef.param === 'A' ? 'asc' : 'desc'
777
- return {
778
- key: leftRef.key,
779
- type,
780
- direction,
781
- raw,
782
- paramA,
783
- paramB,
784
- method,
860
+ return { key: leftRef.key, type, direction }
861
+ }
862
+
863
+ /**
864
+ * Classify a relational-ternary comparator leaf into an `auto` SortKey.
865
+ * Handles the 2-way sign form (`a.f > b.f ? 1 : -1`), the canonical
866
+ * 3-way (`a.f < b.f ? -1 : a.f > b.f ? 1 : 0`), and a leading
867
+ * equality tie (`a.f === b.f ? 0 : <relational ternary>`).
868
+ *
869
+ * Direction is derived from (relational op, operand order, sign of the
870
+ * `whenTrue` branch); the `whenFalse` branch only needs to be a bounded
871
+ * shape (sign literal or a nested ternary on the same key) so we don't
872
+ * silently accept arbitrary expressions.
873
+ */
874
+ function classifyTernaryComparator(
875
+ node: ts.ConditionalExpression,
876
+ paramA: string,
877
+ paramB: string,
878
+ ): SortKey | null {
879
+ const cond = unwrapParens(node.condition)
880
+
881
+ // Leading equality tie: `a.f === b.f ? 0 : <ternary>`. The equality
882
+ // arm returns 0 (tie); the real ordering lives in the else branch.
883
+ if (
884
+ ts.isBinaryExpression(cond) &&
885
+ (cond.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
886
+ cond.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken) &&
887
+ sameKeyOperands(cond.left, cond.right, paramA, paramB) &&
888
+ numericSign(node.whenTrue) === 0
889
+ ) {
890
+ const elseBranch = unwrapParens(node.whenFalse)
891
+ if (ts.isConditionalExpression(elseBranch)) {
892
+ return classifyTernaryComparator(elseBranch, paramA, paramB)
893
+ }
894
+ return null
895
+ }
896
+
897
+ // Relational condition: `<left> <op> <right>` with op ∈ {<,>,<=,>=}.
898
+ if (!ts.isBinaryExpression(cond)) return null
899
+ const op = cond.operatorToken.kind
900
+ const isGreater =
901
+ op === ts.SyntaxKind.GreaterThanToken || op === ts.SyntaxKind.GreaterThanEqualsToken
902
+ const isLess = op === ts.SyntaxKind.LessThanToken || op === ts.SyntaxKind.LessThanEqualsToken
903
+ if (!isGreater && !isLess) return null
904
+
905
+ const leftRef = classifySortOperand(cond.left, paramA, paramB)
906
+ const rightRef = classifySortOperand(cond.right, paramA, paramB)
907
+ if (!leftRef || !rightRef) return null
908
+ if (leftRef.param === rightRef.param) return null
909
+ if (leftRef.key.kind !== rightRef.key.kind) return null
910
+ if (leftRef.key.kind === 'field' && rightRef.key.kind === 'field' && leftRef.key.field !== rightRef.key.field) {
911
+ return null
785
912
  }
913
+
914
+ // whenTrue must be a non-zero sign literal (±1); whenFalse a bounded
915
+ // shape (sign literal or a nested ternary on the same key).
916
+ //
917
+ // Direction is derived solely from this outer comparison — the nested
918
+ // whenFalse branch is only validated for key agreement, not direction
919
+ // consistency. A contradictory hand-written 3-way (e.g.
920
+ // `a.f < b.f ? -1 : a.f < b.f ? 1 : 0`) is therefore lowered per the
921
+ // outer comparison; the JS-runtime (Hono/CSR) path runs the literal
922
+ // body, so such a degenerate comparator could order differently
923
+ // there. The canonical asc/desc 3-way forms agree on both paths.
924
+ const trueSign = numericSign(node.whenTrue)
925
+ if (trueSign === null || trueSign === 0) return null
926
+ if (!isBoundedTernaryElse(node.whenFalse, leftRef.key, paramA, paramB)) return null
927
+
928
+ // Rewrite so the condition reads as `aKey <op> bKey` (paramA left).
929
+ // `b.f > a.f` ⇔ `a.f < b.f`, so a paramB-on-left operand flips it.
930
+ const greaterForA = leftRef.param === 'A' ? isGreater : !isGreater
931
+
932
+ // `a.f > b.f ? +n` → bigger sorts later → ascending
933
+ // `a.f < b.f ? +n` → bigger sorts earlier → descending
934
+ const asc = greaterForA ? trueSign > 0 : trueSign < 0
935
+ return { key: leftRef.key, type: 'auto', direction: asc ? 'asc' : 'desc' }
936
+ }
937
+
938
+ /** True when both operands resolve to the same key on opposite params. */
939
+ function sameKeyOperands(
940
+ left: ts.Expression,
941
+ right: ts.Expression,
942
+ paramA: string,
943
+ paramB: string,
944
+ ): boolean {
945
+ const l = classifySortOperand(left, paramA, paramB)
946
+ const r = classifySortOperand(right, paramA, paramB)
947
+ if (!l || !r) return false
948
+ if (l.param === r.param) return false
949
+ if (l.key.kind !== r.key.kind) return false
950
+ if (l.key.kind === 'field' && r.key.kind === 'field' && l.key.field !== r.key.field) return false
951
+ return true
952
+ }
953
+
954
+ /**
955
+ * Sign of a numeric literal with optional unary minus: 1, -1, or 0.
956
+ * Returns null for anything that isn't a (signed) numeric literal.
957
+ */
958
+ function numericSign(expr: ts.Expression): number | null {
959
+ const e = unwrapParens(expr)
960
+ if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) {
961
+ const inner = numericSign(e.operand)
962
+ return inner === null ? null : -inner
963
+ }
964
+ if (ts.isNumericLiteral(e)) {
965
+ const n = Number(e.text)
966
+ if (Number.isNaN(n)) return null
967
+ if (n === 0) return 0
968
+ return n > 0 ? 1 : -1
969
+ }
970
+ return null
971
+ }
972
+
973
+ /**
974
+ * The `whenFalse` arm of a relational ternary is bounded if it's a
975
+ * sign literal (±1 / 0) or a nested ternary on the same key (the
976
+ * canonical 3-way form). The outer comparison already fixes direction,
977
+ * so the nested branch only needs to agree on which key it compares.
978
+ */
979
+ function isBoundedTernaryElse(
980
+ expr: ts.Expression,
981
+ key: { kind: 'self' } | { kind: 'field'; field: string },
982
+ paramA: string,
983
+ paramB: string,
984
+ ): boolean {
985
+ const e = unwrapParens(expr)
986
+ if (numericSign(e) !== null) return true
987
+ if (ts.isConditionalExpression(e)) {
988
+ const nested = classifyTernaryComparator(e, paramA, paramB)
989
+ return nested !== null && sortKeyEquals(nested.key, key)
990
+ }
991
+ return false
992
+ }
993
+
994
+ function sortKeyEquals(
995
+ a: { kind: 'self' } | { kind: 'field'; field: string },
996
+ b: { kind: 'self' } | { kind: 'field'; field: string },
997
+ ): boolean {
998
+ if (a.kind !== b.kind) return false
999
+ if (a.kind === 'field' && b.kind === 'field') return a.field === b.field
1000
+ return true
786
1001
  }
787
1002
 
788
1003
  function classifySortOperand(
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared importmap-snippet renderer.
3
+ *
4
+ * `bf build` emits `barefoot-externals.json` (the `ExternalsManifest`) whenever
5
+ * `externals` / `bundleEntries` are configured. Component adapters (Hono) read
6
+ * that manifest at render time via a JSX component (`BfImportMap`). Template-
7
+ * string adapters (Go html/template, Mojolicious EP) have no component layer, so
8
+ * `bf build` instead emits a static `barefoot-importmap.html` for them to
9
+ * `{{ template }}` / `%= include` into the page `<head>`.
10
+ *
11
+ * This module is the single source of truth for that snippet's HTML, so every
12
+ * adapter's importmap injection point stays in sync. See issue #1644.
13
+ */
14
+
15
+ /**
16
+ * The subset of `barefoot-externals.json` needed to render the importmap
17
+ * snippet. All fields are optional so a partial or hand-written manifest
18
+ * (e.g. a Hono `BfImportMap` `externals` prop) still type-checks. The strict
19
+ * build-output `ExternalsManifest` is structurally assignable to this, so both
20
+ * the CLI's emitted manifest and a hand-written one feed `renderImportMapHtml`.
21
+ * This is the one shared manifest type for every importmap injection path.
22
+ */
23
+ export interface ImportMapManifest {
24
+ /** Entries for `<script type="importmap">`. */
25
+ importmap?: { imports?: Record<string, string> }
26
+ /** URLs to emit as `<link rel="modulepreload">`. */
27
+ preloads?: string[]
28
+ }
29
+
30
+ /**
31
+ * Shape of `barefoot-externals.json`, written by `bf build`. This is the build
32
+ * output contract shared by the CLI (which writes it) and the adapters (which
33
+ * consume it) — the all-required superset of {@link ImportMapManifest}.
34
+ */
35
+ export interface ExternalsManifest {
36
+ /** Entries for `<script type="importmap">`. */
37
+ importmap: { imports: Record<string, string> }
38
+ /** URLs to emit as `<link rel="modulepreload">`. */
39
+ preloads: string[]
40
+ /** Package names to pass as `--external` to the user's bundler. */
41
+ externals: string[]
42
+ }
43
+
44
+ /** Escape a value for use inside a double-quoted HTML attribute. */
45
+ function escapeHtmlAttr(value: string): string {
46
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
47
+ }
48
+
49
+ /**
50
+ * Render the `<script type="importmap">` (plus `<link rel="modulepreload">`)
51
+ * snippet from a parsed externals manifest. Fields are read defensively so a
52
+ * partial or hand-written manifest still produces valid output.
53
+ *
54
+ * Inside the importmap JSON, each `<` is replaced with its JSON unicode escape
55
+ * for code point U+003C so a URL containing `</script>` cannot break out of the
56
+ * script element — the JSON parser decodes that escape back to `<`, keeping the
57
+ * mapping value-identical.
58
+ */
59
+ export function renderImportMapHtml(manifest: ImportMapManifest): string {
60
+ const imports = manifest.importmap?.imports ?? {}
61
+ const json = JSON.stringify({ imports }).replace(/</g, '\\u003c')
62
+ const lines = [`<script type="importmap">${json}</script>`]
63
+ for (const href of manifest.preloads ?? []) {
64
+ // `crossorigin` is required so a cross-origin (CDN) preload's request
65
+ // matches the actual module `import` (always a CORS fetch); without it
66
+ // the browser discards the preload and re-fetches. Harmless for
67
+ // same-origin preloads, which use the same credentials mode either way.
68
+ // See issue #1648.
69
+ lines.push(`<link rel="modulepreload" href="${escapeHtmlAttr(href)}" crossorigin>`)
70
+ }
71
+ return lines.join('\n') + '\n'
72
+ }
package/src/index.ts CHANGED
@@ -87,6 +87,10 @@ export type { SourceMapV3 } from './ir-to-client-js/source-map'
87
87
  // Client JS Combiner (for build scripts)
88
88
  export { combineParentChildClientJs } from './combine-client-js'
89
89
 
90
+ // Externals manifest + importmap snippet renderer (shared by adapters and CLI)
91
+ export { renderImportMapHtml } from './import-map'
92
+ export type { ExternalsManifest, ImportMapManifest } from './import-map'
93
+
90
94
  // Build options (shared by adapters and CLI)
91
95
  export interface OutputLayout {
92
96
  /** Subdirectory for marked templates (default: 'components') */
@@ -239,7 +243,7 @@ export { ErrorCodes, createError, formatError, generateCodeFrame } from './error
239
243
 
240
244
  // Expression Parser
241
245
  export { parseExpression, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, containsHigherOrder } from './expression-parser'
242
- export type { ParsedExpr, ParsedStatement, SortComparator, SupportLevel, SupportResult, TemplatePart } from './expression-parser'
246
+ export type { ParsedExpr, ParsedStatement, SortComparator, SortKey, SupportLevel, SupportResult, TemplatePart } from './expression-parser'
243
247
  export { buildLoopChainExpr } from './loop-chain'
244
248
  export type { LoopChainInputs } from './loop-chain'
245
249
 
@@ -231,6 +231,7 @@ export function collectInnerLoops(
231
231
  key: n.key,
232
232
  markerId: n.markerId,
233
233
  bodyIsMultiRoot: n.bodyIsMultiRoot,
234
+ bodyIsItemConditional: n.bodyIsItemConditional,
234
235
  iterationShape: n.iterationShape,
235
236
  containerSlotId: scope.parentSlotId,
236
237
  template,
@@ -564,6 +565,7 @@ export function collectElements(
564
565
  key: l.key,
565
566
  markerId: l.markerId,
566
567
  bodyIsMultiRoot: l.bodyIsMultiRoot,
568
+ bodyIsItemConditional: l.bodyIsItemConditional,
567
569
  iterationShape: l.iterationShape,
568
570
  template,
569
571
  staticItemTemplate,
@@ -898,6 +900,7 @@ function collectBranchLoops(
898
900
  key: n.key,
899
901
  markerId: n.markerId,
900
902
  bodyIsMultiRoot: n.bodyIsMultiRoot,
903
+ bodyIsItemConditional: n.bodyIsItemConditional,
901
904
  iterationShape: n.iterationShape,
902
905
  template: childTemplate,
903
906
  containerSlotId: containerSlot,
@@ -56,6 +56,15 @@ export interface BuildLoopPlanOptions {
56
56
  * described above and returns the discriminated `LoopPlan`.
57
57
  */
58
58
  export function buildLoopPlan(elem: TopLevelLoop, opts: BuildLoopPlanOptions): LoopPlan {
59
+ // Whole-item conditional bodies (#1665) render 0-or-1 element per item, so
60
+ // they need anchored `mapArrayAnchored` emission regardless of whether the
61
+ // array is static or dynamic. Routing both through the plain (anchored)
62
+ // path keeps `const arr` and `signal()` behaviour identical — a static
63
+ // array's per-item conditional still toggles reactively instead of freezing
64
+ // in the SSR-time `forEach` (which has no conditional handling at all).
65
+ if (elem.bodyIsItemConditional) {
66
+ return buildPlainLoopPlan(elem)
67
+ }
59
68
  if (elem.isStaticArray) {
60
69
  return buildStaticLoopPlan(elem, opts.unsafeLocalNames)
61
70
  }
@@ -92,6 +101,14 @@ export function buildPlainLoopPlan(elem: TopLevelLoop): PlainLoopPlan {
92
101
  reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem) : null,
93
102
  childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
94
103
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
104
+ anchored: elem.bodyIsItemConditional ?? false,
105
+ // Fall back to the iteration index when the loop has no key. A whole-item
106
+ // conditional without a key is a BF023 error, but the emitted client JS
107
+ // must still parse — an empty `anchorKeyExpr` would produce
108
+ // `createComment(`bf-loop-i:${}`)` (a SyntaxError that breaks the whole
109
+ // bundle). `elem.index || '__idx'` matches `indexParam` above, so the
110
+ // anchor value stays consistent with the renderItem's own index param.
111
+ anchorKeyExpr: elem.key ? wrap(elem.key) : (elem.index || '__idx'),
95
112
  }
96
113
  }
97
114
 
@@ -95,6 +95,20 @@ interface PlainLoopVariant extends DynamicLoopCommon {
95
95
  * `<!--bf-loop-i-->` marker emission, and `qsaItem` slot lookups (#1212).
96
96
  */
97
97
  bodyIsMultiRoot: boolean
98
+ /**
99
+ * True when the loop body is a whole-item conditional (#1665). Switches
100
+ * emission to `mapArrayAnchored`: the renderItem returns a fragment headed
101
+ * by a `<!--bf-loop-i:KEY-->` anchor and seeded with the conditional's
102
+ * markers, and `insert(anchor, …)` (not `insert(__el, …)`) owns the
103
+ * possibly-empty content.
104
+ */
105
+ anchored: boolean
106
+ /**
107
+ * Key expression wrapped as a loop-param accessor (`t().id`), used to bake
108
+ * the per-item `bf-loop-i:KEY` anchor value inside the anchored renderItem.
109
+ * Empty when the loop has no key (only meaningful when `anchored`).
110
+ */
111
+ anchorKeyExpr: string
98
112
  }
99
113
 
100
114
  /**
@@ -163,10 +163,15 @@ function emitArmBody(
163
163
 
164
164
  for (const te of body.textEffects) {
165
165
  const v = varSlotId(te.slotId)
166
- lines.push(`${indent}const [__el_${v}] = $t(__branchScope, '${te.slotId}')`)
166
+ // Route through `__bfText` so a JSX-valued expression (`{cond && logo(id)}`)
167
+ // re-splices the live element by identity instead of stringifying it to
168
+ // "[object HTMLElement]" — the branch template already spliced it via
169
+ // `__bfSlot`, and this effect re-renders it when its deps change (#1663).
170
+ // The `let` tracker carries the replaced node across reactive re-runs.
171
+ lines.push(`${indent}let __anchor_${v} = $t(__branchScope, '${te.slotId}')[0]`)
167
172
  lines.push(`${indent}__disposers.push(createDisposableEffect(() => {`)
168
173
  lines.push(`${indent} const __val = ${te.expression}`)
169
- lines.push(`${indent} if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`)
174
+ lines.push(`${indent} __anchor_${v} = __bfText(__anchor_${v}, __val)`)
170
175
  lines.push(`${indent}}))`)
171
176
  }
172
177