@barefootjs/xslate 0.14.0 → 0.15.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.
- package/dist/adapter/boolean-result.d.ts.map +1 -1
- package/dist/adapter/index.js +150 -81
- package/dist/adapter/xslate-adapter.d.ts +62 -0
- package/dist/adapter/xslate-adapter.d.ts.map +1 -1
- package/dist/build.js +150 -81
- package/dist/index.js +150 -81
- package/lib/BarefootJS/Backend/Xslate.pm +1 -1
- package/package.json +3 -3
- package/src/__tests__/xslate-adapter.test.ts +23 -4
- package/src/adapter/boolean-result.ts +5 -0
- package/src/adapter/xslate-adapter.ts +269 -33
- package/src/test-render.ts +43 -19
|
@@ -55,6 +55,8 @@ import {
|
|
|
55
55
|
type AttrValueEmitter,
|
|
56
56
|
isBooleanAttr,
|
|
57
57
|
parseExpression,
|
|
58
|
+
exprToString,
|
|
59
|
+
parseProviderObjectLiteral,
|
|
58
60
|
parseStyleObjectEntries,
|
|
59
61
|
isSupported,
|
|
60
62
|
identifierPath,
|
|
@@ -64,11 +66,14 @@ import {
|
|
|
64
66
|
augmentInheritedPropAccesses,
|
|
65
67
|
parseRecordIndexAccess,
|
|
66
68
|
evalStringArrayJoin,
|
|
69
|
+
collectModuleStringConsts,
|
|
67
70
|
extractArrowBodyExpression,
|
|
68
71
|
collectContextConsumers,
|
|
69
72
|
isLowerableObjectRestDestructure,
|
|
70
73
|
type ContextConsumer,
|
|
71
|
-
|
|
74
|
+
lookupStaticRecordLiteral,
|
|
75
|
+
searchParamsLocalNames,
|
|
76
|
+
matchSearchParamsMethodCall
|
|
72
77
|
} from '@barefootjs/jsx'
|
|
73
78
|
import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
|
|
74
79
|
import ts from 'typescript'
|
|
@@ -81,7 +86,7 @@ import ts from 'typescript'
|
|
|
81
86
|
*/
|
|
82
87
|
type XslateRenderCtx = Record<string, never>
|
|
83
88
|
import type { ParsedExpr, ParsedStatement, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '@barefootjs/jsx'
|
|
84
|
-
import { BF_SLOT, BF_COND } from '@barefootjs/shared'
|
|
89
|
+
import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
|
|
85
90
|
|
|
86
91
|
interface PrimitiveSpec {
|
|
87
92
|
arity: number
|
|
@@ -223,6 +228,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
223
228
|
*/
|
|
224
229
|
private propsObjectName: string | null = null
|
|
225
230
|
private propsParams: { name: string }[] = []
|
|
231
|
+
private booleanTypedProps: Set<string> = new Set()
|
|
226
232
|
/**
|
|
227
233
|
* Names (signal getters + props) whose value is a string, so `===`/`!==`
|
|
228
234
|
* against them lowers to Perl `eq`/`ne` rather than numeric `==`/`!=`.
|
|
@@ -241,6 +247,16 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
241
247
|
*/
|
|
242
248
|
private moduleStringConsts: Map<string, string> = new Map()
|
|
243
249
|
|
|
250
|
+
/**
|
|
251
|
+
* (#1922) Local binding names the request-scoped `searchParams()` env signal
|
|
252
|
+
* is imported under (handles `import { searchParams as sp }`). When non-empty
|
|
253
|
+
* the emitter lowers a `<binding>().get(k)` call to a real method call on the
|
|
254
|
+
* per-request `$searchParams` reader (`$searchParams.get('sort')`) instead of
|
|
255
|
+
* the generic dot deref. Set at `generate()` entry from `ir.metadata.imports`;
|
|
256
|
+
* read by the top-level ParsedExpr emitter.
|
|
257
|
+
*/
|
|
258
|
+
_searchParamsLocals: Set<string> = new Set()
|
|
259
|
+
|
|
244
260
|
/**
|
|
245
261
|
* Local + module constants from the IR, used by the conditional-spread and
|
|
246
262
|
* `Record<staticKeys, scalar>[propKey]` lowering paths (#textarea / #checkbox).
|
|
@@ -274,6 +290,15 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
274
290
|
// shared helper, before deriving `nullableOptionalProps` below.
|
|
275
291
|
augmentInheritedPropAccesses(ir)
|
|
276
292
|
this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
|
|
293
|
+
// Props whose declared TS type is boolean — a bare binding of one
|
|
294
|
+
// (`data-active={props.isActive}`) must stringify as JS
|
|
295
|
+
// `String(boolean)` ("true"/"false"), not Perl's native `1`/`''`
|
|
296
|
+
// (#1897, pagination's data-active).
|
|
297
|
+
this.booleanTypedProps = new Set(
|
|
298
|
+
ir.metadata.propsParams
|
|
299
|
+
.filter(prop => prop.type?.primitive === 'boolean' || prop.type?.raw === 'boolean')
|
|
300
|
+
.map(prop => prop.name),
|
|
301
|
+
)
|
|
277
302
|
this.localConstants = ir.metadata.localConstants ?? []
|
|
278
303
|
// Bare references to optional, no-default, non-primitive props (e.g.
|
|
279
304
|
// textarea's `rows`) are `undef` when omitted → `defined`-guarded in
|
|
@@ -302,6 +327,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
302
327
|
if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
|
|
303
328
|
}
|
|
304
329
|
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
330
|
+
this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
|
|
305
331
|
this.errors = []
|
|
306
332
|
this.childrenCaptureCounter = 0
|
|
307
333
|
|
|
@@ -473,12 +499,48 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
473
499
|
? `'${v.value.replace(/[\\']/g, m => `\\${m}`)}'`
|
|
474
500
|
: String(v.value)
|
|
475
501
|
}
|
|
476
|
-
if (v.kind === 'expression')
|
|
502
|
+
if (v.kind === 'expression') {
|
|
503
|
+
const hashref = this.providerObjectLiteralKolon(v.expr)
|
|
504
|
+
if (hashref !== null) return hashref
|
|
505
|
+
return this.convertExpressionToKolon(v.expr)
|
|
506
|
+
}
|
|
477
507
|
if (v.kind === 'template') return this.convertTemplateLiteralPartsToKolon(v.parts)
|
|
478
508
|
// Out-of-shape value (spread / jsx-children) — nil; consumer defaults.
|
|
479
509
|
return 'nil'
|
|
480
510
|
}
|
|
481
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Lower an object-literal provider value (`value={{ open: () => props.open
|
|
514
|
+
* ?? false, onOpenChange: … }}`) to a Kolon hashref literal (#1897). The
|
|
515
|
+
* SSR lowering is a per-member snapshot of what a consumer would READ
|
|
516
|
+
* during the same render:
|
|
517
|
+
*
|
|
518
|
+
* - zero-param expression-body arrows are getters — lower the body (the
|
|
519
|
+
* value is fixed for the render, so the call-time indirection drops out)
|
|
520
|
+
* - `on[A-Z]`-named members and function-shaped values are client-only
|
|
521
|
+
* behavior SSR never invokes — lower to `nil`
|
|
522
|
+
* - anything else lowers through the normal expression pipeline (so an
|
|
523
|
+
* unsupported getter body still refuses loudly with BF101)
|
|
524
|
+
*
|
|
525
|
+
* Keys keep their JS names verbatim so a consumer-side `ctx.open` access
|
|
526
|
+
* maps onto the same key. Returns `null` when the expression is not a
|
|
527
|
+
* plain object literal (spread / computed key) — the caller falls back to
|
|
528
|
+
* the whole-expression path, which refuses those shapes with BF101.
|
|
529
|
+
*/
|
|
530
|
+
private providerObjectLiteralKolon(expr: string): string | null {
|
|
531
|
+
const members = parseProviderObjectLiteral(expr.trim())
|
|
532
|
+
if (members === null) return null
|
|
533
|
+
const entries = members.map(m => {
|
|
534
|
+
// String-literal JS keys can carry `'` / `\` — escape for the
|
|
535
|
+
// single-quoted Kolon key string.
|
|
536
|
+
const key = `'${m.name.replace(/[\\']/g, c => `\\${c}`)}'`
|
|
537
|
+
if (m.kind === 'function' || /^on[A-Z]/.test(m.name)) return `${key} => nil`
|
|
538
|
+
const src = m.kind === 'getter' ? m.body : m.expr
|
|
539
|
+
return `${key} => ${this.convertExpressionToKolon(src)}`
|
|
540
|
+
})
|
|
541
|
+
return `{ ${entries.join(', ')} }`
|
|
542
|
+
}
|
|
543
|
+
|
|
482
544
|
/** Kolon literal for a context-consumer's `createContext` default. */
|
|
483
545
|
private contextDefaultKolon(c: ContextConsumer): string {
|
|
484
546
|
const d = c.defaultValue
|
|
@@ -518,7 +580,6 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
518
580
|
const memos = ir.metadata.memos ?? []
|
|
519
581
|
const signals = ir.metadata.signals ?? []
|
|
520
582
|
if (memos.length === 0 && signals.length === 0) return ''
|
|
521
|
-
const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
|
|
522
583
|
// Props seed first; each signal/memo adds its own name as it lands.
|
|
523
584
|
const available = new Set<string>(ir.metadata.propsParams.map(p => p.name))
|
|
524
585
|
const lines: string[] = []
|
|
@@ -542,16 +603,22 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
542
603
|
}
|
|
543
604
|
|
|
544
605
|
for (const memo of memos) {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
606
|
+
// Seed every memo whose body lowers cleanly — not just the ones whose
|
|
607
|
+
// static SSR default is null. A statically-foldable prop-derived memo
|
|
608
|
+
// (`createMemo(() => props.disabled ?? false)` → default `false`)
|
|
609
|
+
// still depends on the per-call prop: the static stash seed bakes in
|
|
610
|
+
// the absent-prop fold, so a caller passing `disabled => 1` would
|
|
611
|
+
// render the default branch (#1897, select's disabled item). The
|
|
612
|
+
// in-template recomputation reads the prop lexical already in scope;
|
|
613
|
+
// block-bodied arrows / out-of-scope references fall back to the
|
|
614
|
+
// static ssr-defaults seed. Same self-reference guard as the signal
|
|
615
|
+
// loop above — Kolon's `my` shadows the render var on the RHS.
|
|
551
616
|
const body = extractArrowBodyExpression(memo.computation)
|
|
552
|
-
if (body
|
|
553
|
-
|
|
554
|
-
|
|
617
|
+
if (body !== null) {
|
|
618
|
+
const kolon = this.tryLowerToKolon(body, available)
|
|
619
|
+
const refsSelf = kolon !== null && new RegExp(`\\$${memo.name}\\b`).test(kolon)
|
|
620
|
+
if (kolon !== null && !refsSelf) lines.push(`: my $${memo.name} = ${kolon};`)
|
|
621
|
+
}
|
|
555
622
|
available.add(memo.name)
|
|
556
623
|
}
|
|
557
624
|
return lines.length > 0 ? lines.join('\n') + '\n' : ''
|
|
@@ -600,6 +667,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
600
667
|
if (element.slotId) {
|
|
601
668
|
hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
|
|
602
669
|
}
|
|
670
|
+
// Page-lifecycle boundary lowered from `<Region>` (spec/router.md). The id
|
|
671
|
+
// is a deterministic static string (`<file scope>:<index>`), so it emits as
|
|
672
|
+
// a plain literal attribute — no Xslate template tag.
|
|
673
|
+
if (element.regionId) {
|
|
674
|
+
hydrationAttrs += ` ${BF_REGION}="${element.regionId}"`
|
|
675
|
+
}
|
|
603
676
|
|
|
604
677
|
const voidElements = [
|
|
605
678
|
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
@@ -959,8 +1032,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
959
1032
|
renderComponent(comp: IRComponent): string {
|
|
960
1033
|
const propParts: string[] = []
|
|
961
1034
|
for (const p of comp.props) {
|
|
962
|
-
// Skip callback props (onXxx) —
|
|
963
|
-
|
|
1035
|
+
// Skip callback props (onXxx) and `ref` — both are client-only for
|
|
1036
|
+
// SSR (Hono renders neither; the client JS wires them at hydration).
|
|
1037
|
+
if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
|
|
964
1038
|
// Spread props: enumerate the analyzer's props params into hashref
|
|
965
1039
|
// entries (the propsObject case) — Kolon can't flatten a hashref into
|
|
966
1040
|
// the entry list. Other spread shapes are refused with BF101.
|
|
@@ -1006,7 +1080,10 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
1006
1080
|
// children HTML; the macro call result is passed as the `children` entry
|
|
1007
1081
|
// of the render_child hashref. `render_child` materializes a CODE-ref
|
|
1008
1082
|
// children value through the backend before handing it to the child.
|
|
1083
|
+
const prevInLoop = this.inLoop
|
|
1084
|
+
this.inLoop = false
|
|
1009
1085
|
const childrenBody = this.renderChildren(effectiveChildren)
|
|
1086
|
+
this.inLoop = prevInLoop
|
|
1010
1087
|
const macroName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
|
|
1011
1088
|
const childrenEntry = `children => ${macroName}()`
|
|
1012
1089
|
const allParts = [...propParts, childrenEntry]
|
|
@@ -1019,6 +1096,10 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
1019
1096
|
|
|
1020
1097
|
private childrenCaptureCounter = 0
|
|
1021
1098
|
|
|
1099
|
+
/** Uniquifies the `presenceOrUndefined` temp binding (`$bf_puN`) so two
|
|
1100
|
+
* presence-folded attrs in one template don't collide. */
|
|
1101
|
+
private presenceVarCounter = 0
|
|
1102
|
+
|
|
1022
1103
|
private toTemplateName(componentName: string): string {
|
|
1023
1104
|
// Convert PascalCase to snake_case for template naming.
|
|
1024
1105
|
return componentName
|
|
@@ -1129,21 +1210,51 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
1129
1210
|
) {
|
|
1130
1211
|
const perl = this.convertExpressionToKolon(value.expr)
|
|
1131
1212
|
const body =
|
|
1132
|
-
isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)
|
|
1213
|
+
isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)
|
|
1133
1214
|
? `${name}="<: $bf.bool_str(${perl}) :>"`
|
|
1134
1215
|
: `${name}="<: ${perl} :>"`
|
|
1135
1216
|
// Kolon `:` line directives must each stand alone on their own line, so
|
|
1136
1217
|
// wrap in newlines (`normalizeHTML` collapses the surrounding space).
|
|
1137
1218
|
return `\n: if (defined ${perl}) {\n${body}\n: }\n`
|
|
1138
1219
|
}
|
|
1139
|
-
if (isBooleanAttr(name)
|
|
1220
|
+
if (isBooleanAttr(name)) {
|
|
1140
1221
|
// Boolean attributes: render conditionally (present or absent).
|
|
1141
1222
|
return `<: ${this.convertExpressionToKolon(value.expr)} ? '${name}' : '' :>`
|
|
1142
1223
|
}
|
|
1224
|
+
if (value.presenceOrUndefined) {
|
|
1225
|
+
// `attr={expr || undefined}` on a NON-boolean attribute: Hono
|
|
1226
|
+
// renders the attr with its stringified value when truthy and
|
|
1227
|
+
// omits it otherwise (`aria-disabled={isDisabled() || undefined}`
|
|
1228
|
+
// → `aria-disabled="true"`), so bare presence would diverge.
|
|
1229
|
+
// Route through `bool_str` when the name/shape witnesses a
|
|
1230
|
+
// boolean value, same as the unconditional path below (#1897).
|
|
1231
|
+
// Bind to a temp first so the expression evaluates once, not in
|
|
1232
|
+
// both the guard and the value.
|
|
1233
|
+
const perl = this.convertExpressionToKolon(value.expr)
|
|
1234
|
+
const tmp = `$bf_pu${this.presenceVarCounter++}`
|
|
1235
|
+
const body =
|
|
1236
|
+
isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)
|
|
1237
|
+
? `${name}="<: $bf.bool_str(${tmp}) :>"`
|
|
1238
|
+
: `${name}="<: ${tmp} :>"`
|
|
1239
|
+
return `\n: my ${tmp} = ${perl};\n: if (${tmp}) {\n${body}\n: }\n`
|
|
1240
|
+
}
|
|
1241
|
+
// `attr={cond ? value : undefined}` OMITS the attribute on the
|
|
1242
|
+
// falsy branch (Hono drops undefined-valued attributes) — wrap the
|
|
1243
|
+
// whole attribute in the condition instead of rendering `attr=""`
|
|
1244
|
+
// (#1897, pagination's `aria-current={props.isActive ? 'page' :
|
|
1245
|
+
// undefined}`). Same parity rule the Go adapter applies.
|
|
1246
|
+
{
|
|
1247
|
+
const m = this.parseUndefinedAlternateTernary(value.expr)
|
|
1248
|
+
if (m) {
|
|
1249
|
+
const cond = this.convertExpressionToKolon(m.condition)
|
|
1250
|
+
const val = this.convertExpressionToKolon(m.consequent)
|
|
1251
|
+
return `\n: if (${cond}) {\n${name}="<: ${val} :>"\n: }\n`
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1143
1254
|
// Boolean-result handling: route boolean-shaped values through
|
|
1144
1255
|
// `$bf.bool_str` so the wire bytes match JS `String(boolean)`.
|
|
1145
1256
|
const perl = this.convertExpressionToKolon(value.expr)
|
|
1146
|
-
if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)) {
|
|
1257
|
+
if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)) {
|
|
1147
1258
|
return `${name}="<: $bf.bool_str(${perl}) :>"`
|
|
1148
1259
|
}
|
|
1149
1260
|
return `${name}="<: ${perl} :>"`
|
|
@@ -1528,6 +1639,31 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
1528
1639
|
return n
|
|
1529
1640
|
})()
|
|
1530
1641
|
const indexed = this.recordIndexAccessToKolon(initNode)
|
|
1642
|
+
if (
|
|
1643
|
+
indexed === null &&
|
|
1644
|
+
ts.isElementAccessExpression(initNode) &&
|
|
1645
|
+
initNode.argumentExpression &&
|
|
1646
|
+
!ts.isNumericLiteral(initNode.argumentExpression) &&
|
|
1647
|
+
!ts.isStringLiteral(initNode.argumentExpression)
|
|
1648
|
+
) {
|
|
1649
|
+
// Variable-index record access (`sizeMap[size]`) the static-inline
|
|
1650
|
+
// path couldn't resolve (non-scalar value / non-const receiver).
|
|
1651
|
+
// Since #1897 made variable indices parseable (`index-access`),
|
|
1652
|
+
// the generic value lowering would emit `$sizeMap[$size]` against
|
|
1653
|
+
// an UNBOUND module const instead of refusing — record BF101 and
|
|
1654
|
+
// bail so the spread surfaces the out-of-shape diagnostic,
|
|
1655
|
+
// matching pre-#1897 behaviour. (Mirrors the Mojo adapter.)
|
|
1656
|
+
this.errors.push({
|
|
1657
|
+
code: 'BF101',
|
|
1658
|
+
severity: 'error',
|
|
1659
|
+
message: `Spread object value '${initNode.getText(sf)}' indexes a record map whose values aren't scalar literals — it can't lower to an inline Kolon hashref.`,
|
|
1660
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1661
|
+
suggestion: {
|
|
1662
|
+
message: 'Index a record whose values are number/string literals, or move the spread into a `\'use client\'` component so hydration computes it.',
|
|
1663
|
+
},
|
|
1664
|
+
})
|
|
1665
|
+
return null
|
|
1666
|
+
}
|
|
1531
1667
|
const valPerl =
|
|
1532
1668
|
indexed !== null
|
|
1533
1669
|
? indexed
|
|
@@ -1608,6 +1744,76 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
1608
1744
|
* normal `$name` stash lowering). Loop-bound names shadow module consts, so
|
|
1609
1745
|
* never inline inside a loop body. Returns `'<escaped>'`.
|
|
1610
1746
|
*/
|
|
1747
|
+
/**
|
|
1748
|
+
* Resolve `IDENT.key` over a module object-literal const to its Kolon
|
|
1749
|
+
* literal (`variantClasses.ghost` in a class template literal —
|
|
1750
|
+
* #1897). Same compile-time inlining family as
|
|
1751
|
+
* `_resolveModuleStringConst`; returns `null` for any non-static shape.
|
|
1752
|
+
*/
|
|
1753
|
+
/**
|
|
1754
|
+
* Whether `expr` is a bare reference to a boolean-TYPED prop
|
|
1755
|
+
* (`props.isActive` / destructured `isActive`) — used to route the
|
|
1756
|
+
* binding through `bool_str` even though the expression itself is
|
|
1757
|
+
* structurally opaque (#1897).
|
|
1758
|
+
*/
|
|
1759
|
+
/**
|
|
1760
|
+
* Parse `cond ? value : undefined` (or `: null`), returning the
|
|
1761
|
+
* condition/consequent source spans, else `null`. Used for the
|
|
1762
|
+
* attribute-omission rule (#1897).
|
|
1763
|
+
*/
|
|
1764
|
+
parseUndefinedAlternateTernary(
|
|
1765
|
+
expr: string,
|
|
1766
|
+
): { condition: string; consequent: string } | null {
|
|
1767
|
+
const parsed = parseExpression(expr.trim())
|
|
1768
|
+
if (parsed?.kind !== 'conditional') return null
|
|
1769
|
+
const alt = parsed.alternate
|
|
1770
|
+
const isUndef =
|
|
1771
|
+
(alt.kind === 'identifier' && (alt.name === 'undefined' || alt.name === 'null')) ||
|
|
1772
|
+
(alt.kind === 'literal' && (alt.value === null || alt.value === undefined))
|
|
1773
|
+
if (!isUndef) return null
|
|
1774
|
+
// Serialise the parsed sub-expressions back to JS source rather than
|
|
1775
|
+
// slicing `expr` text — `indexOf('?')` / `lastIndexOf(':')` would
|
|
1776
|
+
// mis-split when the consequent itself contains `?` / `:` inside a
|
|
1777
|
+
// string or nested ternary (`cond ? 'a:b' : undefined`).
|
|
1778
|
+
return {
|
|
1779
|
+
condition: exprToString(parsed.test),
|
|
1780
|
+
consequent: exprToString(parsed.consequent),
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
isBooleanTypedPropRef(expr: string): boolean {
|
|
1785
|
+
let bare = expr.trim()
|
|
1786
|
+
if (this.propsObjectName && bare.startsWith(`${this.propsObjectName}.`)) {
|
|
1787
|
+
bare = bare.slice(this.propsObjectName.length + 1)
|
|
1788
|
+
}
|
|
1789
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(bare)) return false
|
|
1790
|
+
return this.booleanTypedProps.has(bare)
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Inline a const (any scope) whose initializer is a pure numeric or
|
|
1795
|
+
* single-quoted string literal (`const totalPages = 5`, #1897
|
|
1796
|
+
* pagination) — function-scope consts never reach the per-render
|
|
1797
|
+
* stash, so a bare `$totalPages` renders empty.
|
|
1798
|
+
*/
|
|
1799
|
+
_resolveLiteralConst(name: string): string | null {
|
|
1800
|
+
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
1801
|
+
if (c?.value === undefined) return null
|
|
1802
|
+
const v = c.value.trim()
|
|
1803
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return v
|
|
1804
|
+
const strLit = /^'([^'\\]*)'$/.exec(v) ?? /^"([^"\\]*)"$/.exec(v)
|
|
1805
|
+
if (strLit) return `'${strLit[1].replace(/[\\']/g, m => `\\${m}`)}'`
|
|
1806
|
+
return null
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
_resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
1810
|
+
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
1811
|
+
if (!hit) return null
|
|
1812
|
+
return hit.kind === 'number'
|
|
1813
|
+
? hit.text
|
|
1814
|
+
: `'${hit.text.replace(/[\\']/g, m => `\\${m}`)}'`
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1611
1817
|
_resolveModuleStringConst(name: string): string | null {
|
|
1612
1818
|
// A loop body may bind `my $<param>` that shadows a module const of the
|
|
1613
1819
|
// same name; never inline inside one (conservative — drop to `$name`).
|
|
@@ -1721,6 +1927,13 @@ function renderArrayMethod(
|
|
|
1721
1927
|
const recv = emit(object)
|
|
1722
1928
|
return `$bf.trim(${recv})`
|
|
1723
1929
|
}
|
|
1930
|
+
case 'toFixed': {
|
|
1931
|
+
// `.toFixed(digits?)` — `$bf.to_fixed` mirrors JS rounding +
|
|
1932
|
+
// zero-padding (default 0 digits). #1897.
|
|
1933
|
+
const recv = emit(object)
|
|
1934
|
+
const digits = args.length >= 1 ? emit(args[0]) : '0'
|
|
1935
|
+
return `$bf.to_fixed(${recv}, ${digits})`
|
|
1936
|
+
}
|
|
1724
1937
|
case 'split': {
|
|
1725
1938
|
const recv = emit(object)
|
|
1726
1939
|
if (args.length === 0) {
|
|
@@ -1881,21 +2094,6 @@ function unescapeStringLiteralBody(s: string): string {
|
|
|
1881
2094
|
})
|
|
1882
2095
|
}
|
|
1883
2096
|
|
|
1884
|
-
/**
|
|
1885
|
-
* Build the module pure-string-const map from the IR's localConstants. A const
|
|
1886
|
-
* qualifies only when module-scope (`isModule`) and its initializer parses to a
|
|
1887
|
-
* single pure string literal.
|
|
1888
|
-
*/
|
|
1889
|
-
function collectModuleStringConsts(constants: IRMetadata['localConstants'] | undefined): Map<string, string> {
|
|
1890
|
-
const map = new Map<string, string>()
|
|
1891
|
-
for (const c of constants ?? []) {
|
|
1892
|
-
if (!c.isModule) continue
|
|
1893
|
-
if (c.value === undefined) continue
|
|
1894
|
-
const literal = parsePureStringLiteral(c.value)
|
|
1895
|
-
if (literal !== null) map.set(c.name, literal)
|
|
1896
|
-
}
|
|
1897
|
-
return map
|
|
1898
|
-
}
|
|
1899
2097
|
|
|
1900
2098
|
/** True when `type` is the `string` primitive. */
|
|
1901
2099
|
function isStringTypeInfo(type: TypeInfo | undefined): boolean {
|
|
@@ -1969,6 +2167,13 @@ class XslateFilterEmitter implements ParsedExprEmitter {
|
|
|
1969
2167
|
return `${emit(object)}.${property}`
|
|
1970
2168
|
}
|
|
1971
2169
|
|
|
2170
|
+
indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
2171
|
+
// Kolon's `[]` postfix is polymorphic (array index or hash key),
|
|
2172
|
+
// mirroring JS — no array/hash split is needed (unlike Perl's
|
|
2173
|
+
// `->[]` vs `->{}`). #1897 (data-table's `selected()[index]`).
|
|
2174
|
+
return `${emit(object)}[${emit(index)}]`
|
|
2175
|
+
}
|
|
2176
|
+
|
|
1972
2177
|
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1973
2178
|
// Signal getter calls: filter() → $filter
|
|
1974
2179
|
if (callee.kind === 'identifier' && args.length === 0) {
|
|
@@ -2088,10 +2293,17 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
|
|
|
2088
2293
|
constructor(private readonly adapter: XslateAdapter) {}
|
|
2089
2294
|
|
|
2090
2295
|
identifier(name: string): string {
|
|
2296
|
+
// `undefined` / `null` nested inside a larger expression tree —
|
|
2297
|
+
// Kolon `nil` (#1897).
|
|
2298
|
+
if (name === 'undefined' || name === 'null') return 'nil'
|
|
2091
2299
|
// Inline a module-scope pure-string const (`const x = 'literal'`) — it
|
|
2092
2300
|
// never reaches the per-render stash, so a bare `$x` would render empty.
|
|
2093
2301
|
const inlined = this.adapter._resolveModuleStringConst(name)
|
|
2094
2302
|
if (inlined !== null) return inlined
|
|
2303
|
+
// Same for a literal const of any scope (`const totalPages = 5`,
|
|
2304
|
+
// #1897 pagination's `Page {currentPage()} of {totalPages}`).
|
|
2305
|
+
const literalConst = this.adapter._resolveLiteralConst(name)
|
|
2306
|
+
if (literalConst !== null) return literalConst
|
|
2095
2307
|
return `$${name}`
|
|
2096
2308
|
}
|
|
2097
2309
|
|
|
@@ -2108,6 +2320,14 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
|
|
|
2108
2320
|
if (object.kind === 'identifier' && object.name === 'props') {
|
|
2109
2321
|
return `$${property}`
|
|
2110
2322
|
}
|
|
2323
|
+
// Static property access on a module object-literal const
|
|
2324
|
+
// (`variantClasses.ghost`, #1897) resolves at compile time — the
|
|
2325
|
+
// generic dot lowering below would reference a Kolon var that
|
|
2326
|
+
// doesn't exist server-side and silently render ''.
|
|
2327
|
+
if (object.kind === 'identifier') {
|
|
2328
|
+
const staticValue = this.adapter._resolveStaticRecordLiteral(object.name, property)
|
|
2329
|
+
if (staticValue !== null) return staticValue
|
|
2330
|
+
}
|
|
2111
2331
|
const obj = emit(object)
|
|
2112
2332
|
// `.length` → `$bf.length` (array count or string char count, JS-compat);
|
|
2113
2333
|
// Kolon's builtin `.size()` is array-only and faults on a string.
|
|
@@ -2116,11 +2336,27 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
|
|
|
2116
2336
|
return `${obj}.${property}`
|
|
2117
2337
|
}
|
|
2118
2338
|
|
|
2339
|
+
indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
2340
|
+
// Kolon's `[]` postfix is polymorphic (array index or hash key),
|
|
2341
|
+
// mirroring JS. #1897 (data-table's `selected()[index]`).
|
|
2342
|
+
return `${emit(object)}[${emit(index)}]`
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2119
2345
|
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
2120
2346
|
// Signal getter: count() → $count
|
|
2121
2347
|
if (callee.kind === 'identifier' && args.length === 0) {
|
|
2122
2348
|
return `$${callee.name}`
|
|
2123
2349
|
}
|
|
2350
|
+
// Env-signal method call (#1922): `searchParams().get('sort')` is a real
|
|
2351
|
+
// method call on the per-request `$searchParams` reader object, not the
|
|
2352
|
+
// generic dot deref `member` would emit (`$searchParams.get`, which drops
|
|
2353
|
+
// the arg). Matches the local import binding (incl. an alias).
|
|
2354
|
+
if (this.adapter._searchParamsLocals.size > 0) {
|
|
2355
|
+
const sp = matchSearchParamsMethodCall(callee, args, this.adapter._searchParamsLocals)
|
|
2356
|
+
if (sp) {
|
|
2357
|
+
return `$searchParams.${sp.method}(${sp.args.map(emit).join(', ')})`
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2124
2360
|
// Identifier-path templatePrimitive: `JSON.stringify(x)` / `Math.floor(x)`
|
|
2125
2361
|
// → `$bf.json($x)` / `$bf.floor($x)`. Args render recursively through this
|
|
2126
2362
|
// same emitter. A wrong-arity call records BF101 and returns `''`.
|
package/src/test-render.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* than the Mojo harness's literal `test_<sN>`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { compileJSX, extractSsrDefaults } from '@barefootjs/jsx'
|
|
20
|
+
import { compileJSX, extractSsrDefaults, importsSearchParams } from '@barefootjs/jsx'
|
|
21
21
|
import type { ComponentIR } from '@barefootjs/jsx'
|
|
22
22
|
import { mkdir, rm } from 'node:fs/promises'
|
|
23
23
|
import { resolve } from 'node:path'
|
|
@@ -358,12 +358,29 @@ function buildChildRenderers(
|
|
|
358
358
|
lines.push(`{`)
|
|
359
359
|
lines.push(` my $defaults = ${defaultsPerl};`)
|
|
360
360
|
lines.push(` $bf->register_child_renderer('${snakeName}', sub {`)
|
|
361
|
-
|
|
361
|
+
// `$caller_bf` is the instance whose template invoked render_child
|
|
362
|
+
// (#1897) — nested children chain their scope/slot identity off it.
|
|
363
|
+
lines.push(` my ($child_props, $caller_bf) = @_;`)
|
|
364
|
+
lines.push(` my $host_scope = (defined $caller_bf ? $caller_bf->_scope_id : $bf->_scope_id);`)
|
|
362
365
|
// A child that destructures a rest bag references `$<rest>` in its
|
|
363
366
|
// template; seed it with an empty hashref when the caller didn't pass
|
|
364
367
|
// one so Kolon's var lookup doesn't fault.
|
|
365
368
|
if (restPropsName) {
|
|
366
369
|
lines.push(` $child_props->{${restPropsName}} = {} unless defined $child_props->{${restPropsName}};`)
|
|
370
|
+
// (#1897) Route non-param props into the rest bag, mirroring the
|
|
371
|
+
// production runtime's `_derive_stash_from_defaults` isRestProps
|
|
372
|
+
// branch and JSX rest semantics: a caller prop the child didn't
|
|
373
|
+
// destructure (`href` on PaginationPrevious's `{...props}` anchor)
|
|
374
|
+
// belongs in the bag, not as a top-level stash var the template
|
|
375
|
+
// never reads.
|
|
376
|
+
const paramNames = (childIR.metadata.propsParams ?? []).map(p => p.name)
|
|
377
|
+
const keep = JSON.stringify([...new Set([...paramNames, restPropsName, 'children', 'key', '_bf_slot'])])
|
|
378
|
+
lines.push(` {`)
|
|
379
|
+
lines.push(` my %keep = map { $_ => 1 } @{ ${perlArrayLiteral(keep)} };`)
|
|
380
|
+
lines.push(` for my $k (grep { !$keep{$_} } keys %$child_props) {`)
|
|
381
|
+
lines.push(` $child_props->{${restPropsName}}{$k} = delete $child_props->{$k};`)
|
|
382
|
+
lines.push(` }`)
|
|
383
|
+
lines.push(` }`)
|
|
367
384
|
}
|
|
368
385
|
lines.push(` my $slot_id = delete $child_props->{_bf_slot};`)
|
|
369
386
|
lines.push(` my $child_bf = BarefootJS->new(undef, { backend => $backend });`)
|
|
@@ -374,9 +391,15 @@ function buildChildRenderers(
|
|
|
374
391
|
// A loop child (no slot) gets a fresh `<ComponentName>_<rand>` id per
|
|
375
392
|
// iteration — the PascalCase name is what `normalizeHTML` canonicalises to
|
|
376
393
|
// `<ComponentName>_*`; a slotted child derives from the parent scope.
|
|
377
|
-
lines.push(` $child_bf->_scope_id($slot_id ?
|
|
394
|
+
lines.push(` $child_bf->_scope_id($slot_id ? $host_scope . '_' . $slot_id : '${componentName}_' . substr(rand() =~ s/^0\\.//r, 0, 6));`)
|
|
378
395
|
lines.push(` $child_bf->_is_child(1);`)
|
|
379
|
-
lines.push(` if ($slot_id) { $child_bf->_bf_parent(
|
|
396
|
+
lines.push(` if ($slot_id) { $child_bf->_bf_parent($host_scope); $child_bf->_bf_mount($slot_id); }`)
|
|
397
|
+
// (#1897) A child template may itself call `$bf.render_child(...)`
|
|
398
|
+
// (AccordionTrigger renders ChevronDownIcon) — inside that template
|
|
399
|
+
// `$bf` is THIS fresh child instance, whose renderer registry starts
|
|
400
|
+
// empty, so the nested call silently rendered ''. Share the parent's
|
|
401
|
+
// registry so nested child renders resolve.
|
|
402
|
+
lines.push(` $child_bf->_child_renderers($bf->_child_renderers);`)
|
|
380
403
|
lines.push(` $child_bf->_scripts($bf->_scripts);`)
|
|
381
404
|
lines.push(` $child_bf->_script_seen($bf->_script_seen);`)
|
|
382
405
|
// Seed template vars: static ssrDefaults first, caller's props win.
|
|
@@ -392,21 +415,12 @@ function buildChildRenderers(
|
|
|
392
415
|
return lines.join('\n')
|
|
393
416
|
}
|
|
394
417
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
* `$bf->_scope_id` lookup so an explicit `__instanceId` still flows
|
|
402
|
-
* through.
|
|
403
|
-
*/
|
|
404
|
-
function rootChildScopePrefix(_snakeName: string): string {
|
|
405
|
-
// Resolve the parent scope dynamically in Perl rather than baking the
|
|
406
|
-
// literal here — keeps the child id correct under an explicit
|
|
407
|
-
// __instanceId. The single quotes in the caller string-concat are
|
|
408
|
-
// closed around this Perl fragment.
|
|
409
|
-
return `' . $bf->_scope_id . '`
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
/** Render a JSON string-array as a Perl arrayref literal (['a','b']). */
|
|
421
|
+
function perlArrayLiteral(jsonArray: string): string {
|
|
422
|
+
const names = JSON.parse(jsonArray) as string[]
|
|
423
|
+
return `[${names.map(n => `'${n.replace(/[\\']/g, m => `\\${m}`)}'`).join(', ')}]`
|
|
410
424
|
}
|
|
411
425
|
|
|
412
426
|
/** Serialise an ssrDefaults map to a Perl hashref literal. */
|
|
@@ -517,6 +531,16 @@ function buildPerlProps(
|
|
|
517
531
|
entries.push(`${memo.name} => ${toPerlLiteral(value ?? 0)}`)
|
|
518
532
|
}
|
|
519
533
|
|
|
534
|
+
// (#1922) Request-scoped `searchParams()`: bind `$searchParams` to an
|
|
535
|
+
// empty-query reader via the lazy-loading factory (so the render script
|
|
536
|
+
// needn't `use BarefootJS::SearchParams`). The conformance harness issues no
|
|
537
|
+
// query string (the production Xslate integration builds this from the
|
|
538
|
+
// request's query), so `.get(k)` resolves to nil and the author's
|
|
539
|
+
// `?? default` renders. Only when the component imports `searchParams`.
|
|
540
|
+
if (importsSearchParams(ir.metadata)) {
|
|
541
|
+
entries.push(`searchParams => BarefootJS->search_params('')`)
|
|
542
|
+
}
|
|
543
|
+
|
|
520
544
|
return `{${entries.join(', ')}}`
|
|
521
545
|
}
|
|
522
546
|
|