@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.
- package/dist/adapters/interface.d.ts +20 -0
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +36 -19
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/import-map.d.ts +56 -0
- package/dist/import-map.d.ts.map +1 -0
- package/dist/import-map.js +18 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +333 -199
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +0 -14
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +7 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +2 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/scanner/js-scanner.d.ts +10 -0
- package/dist/scanner/js-scanner.d.ts.map +1 -1
- package/dist/scanner/js-scanner.js +5 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +438 -190
- package/src/__tests__/adapter-output.test.ts +49 -0
- package/src/__tests__/child-components-in-map.test.ts +76 -0
- package/src/__tests__/client-js-generation.test.ts +5 -2
- package/src/__tests__/import-map.test.ts +75 -0
- package/src/__tests__/inline-jsx-callback.test.ts +95 -0
- package/src/__tests__/ir-jsx-props.test.ts +5 -2
- package/src/__tests__/ir-sort-comparator.test.ts +212 -9
- package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
- package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
- package/src/__tests__/missing-key-in-list.test.ts +49 -0
- package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
- package/src/__tests__/token-contains-ident.test.ts +27 -0
- package/src/__tests__/unsupported-expression.test.ts +42 -13
- package/src/adapters/interface.ts +20 -0
- package/src/adapters/test-adapter.ts +16 -1
- package/src/expression-parser.ts +265 -50
- package/src/import-map.ts +72 -0
- package/src/index.ts +5 -1
- package/src/ir-to-client-js/collect-elements.ts +3 -0
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
- package/src/ir-to-client-js/emit-reactive.ts +12 -3
- package/src/ir-to-client-js/html-template.ts +29 -3
- package/src/ir-to-client-js/imports.ts +2 -2
- package/src/ir-to-client-js/reactivity.ts +17 -1
- package/src/ir-to-client-js/stringify/static-array-child-init.ts +8 -4
- package/src/ir-to-client-js/types.ts +7 -0
- package/src/ir-to-client-js/utils.ts +31 -116
- package/src/jsx-to-ir.ts +161 -12
- package/src/preprocess-inline-jsx-callbacks.ts +28 -10
- package/src/scanner/js-scanner.ts +16 -1
- package/src/types.ts +12 -0
package/src/expression-parser.ts
CHANGED
|
@@ -69,13 +69,12 @@ export type ParsedExpr =
|
|
|
69
69
|
| { kind: 'unsupported'; raw: string; reason: string }
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* (
|
|
75
|
-
*
|
|
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
|
|
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
|
-
|
|
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).
|
|
383
|
-
//
|
|
384
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
669
|
-
*
|
|
670
|
-
* (
|
|
671
|
-
* (
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
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
|
-
*
|
|
678
|
-
*
|
|
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
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
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
|
|
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
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
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
|
-
//
|
|
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'
|
|
802
|
+
return classifyComparatorOperands(body.left, body.right, paramA, paramB, 'numeric')
|
|
723
803
|
}
|
|
724
804
|
|
|
725
|
-
// localeCompare
|
|
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
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
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
|
-
|
|
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}
|
|
174
|
+
lines.push(`${indent} __anchor_${v} = __bfText(__anchor_${v}, __val)`)
|
|
170
175
|
lines.push(`${indent}}))`)
|
|
171
176
|
}
|
|
172
177
|
|