@barefootjs/jsx 0.3.0 → 0.5.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.
@@ -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
 
@@ -1040,6 +1040,14 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1040
1040
  return `\${${transformExpr(node.expr, node.templateExpr)}}`
1041
1041
 
1042
1042
  case 'conditional': {
1043
+ // A client-only conditional (auto-deferred brand read or manual
1044
+ // `/* @client */`) is owned by init's `insert()`, not the module-scope
1045
+ // template lambda. Match the SSR adapter: emit empty cond markers so
1046
+ // the client-render path (`createComponent`) produces the same DOM SSR
1047
+ // does, instead of evaluating an init-scope condition here (#1645).
1048
+ if (node.clientOnly && node.slotId) {
1049
+ return `<!--bf-cond-start:${node.slotId}--><!--bf-cond-end:${node.slotId}-->`
1050
+ }
1043
1051
  const trueBranch = recurse(node.whenTrue)
1044
1052
  const falseBranch = recurse(node.whenFalse)
1045
1053
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
@@ -1418,6 +1426,15 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1418
1426
  }
1419
1427
 
1420
1428
  case 'conditional': {
1429
+ // An auto-deferred conditional (e.g. `{form.field('x').error() && …}`)
1430
+ // reads per-instance init-scope state the module-scope template lambda
1431
+ // can't evaluate — re-deriving it here yields `undefined.field(...)` or
1432
+ // a throwaway re-inlined `createForm({...})`. Match the SSR adapter:
1433
+ // emit empty cond markers and let init's `insert()` populate the branch
1434
+ // at hydrate time via the reactive binding (#1645).
1435
+ if (node.clientOnly && node.slotId) {
1436
+ return `<!--bf-cond-start:${node.slotId}--><!--bf-cond-end:${node.slotId}-->`
1437
+ }
1421
1438
  const trueBranch = recurse(node.whenTrue)
1422
1439
  const falseBranch = recurse(node.whenFalse)
1423
1440
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
@@ -161,10 +161,14 @@ function emitInnerLoopNested(lines: string[], plan: InnerLoopNestedInitPlan): vo
161
161
  for (const stmt of innerPreludeStatements) {
162
162
  lines.push(` ${stmt}`)
163
163
  }
164
- for (const comp of comps) {
165
- lines.push(` const __compEl = qsaChildScope(__innerEl, ${comp.selector})`)
166
- lines.push(` if (__compEl) initChild('${nameForRegistryRef(comp.componentName)}', __compEl, ${comp.propsExpr})`)
167
- }
164
+ // Each inner-loop component gets a uniquely-suffixed `__compEl` binding.
165
+ // Multiple comps share one inner `forEach` body, so a fixed name would
166
+ // re-declare `const __compEl` in the same scope (#1664).
167
+ comps.forEach((comp, i) => {
168
+ const compElVar = comps.length > 1 ? `__compEl${i}` : '__compEl'
169
+ lines.push(` const ${compElVar} = qsaChildScope(__innerEl, ${comp.selector})`)
170
+ lines.push(` if (${compElVar}) initChild('${nameForRegistryRef(comp.componentName)}', ${compElVar}, ${comp.propsExpr})`)
171
+ })
168
172
  lines.push(` })`)
169
173
  lines.push(` })`)
170
174
  lines.push(` }`)