@barefootjs/mojolicious 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 +147 -47
- package/dist/adapter/mojo-adapter.d.ts +62 -10
- package/dist/adapter/mojo-adapter.d.ts.map +1 -1
- package/dist/build.js +147 -47
- package/dist/index.js +147 -47
- package/dist/test-render.d.ts.map +1 -1
- package/lib/BarefootJS/Backend/Mojo.pm +1 -1
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +1 -1
- package/lib/Mojolicious/Plugin/BarefootJS.pm +11 -1
- package/package.json +3 -3
- package/src/__tests__/mojo-adapter.test.ts +53 -4
- package/src/adapter/boolean-result.ts +5 -0
- package/src/adapter/mojo-adapter.ts +287 -39
- package/src/test-render.ts +98 -8
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
parseStyleObjectEntries,
|
|
45
45
|
isSupported,
|
|
46
46
|
exprToString,
|
|
47
|
+
parseProviderObjectLiteral,
|
|
47
48
|
identifierPath,
|
|
48
49
|
emitParsedExpr,
|
|
49
50
|
emitIRNode,
|
|
@@ -55,7 +56,10 @@ import {
|
|
|
55
56
|
collectContextConsumers,
|
|
56
57
|
isLowerableObjectRestDestructure,
|
|
57
58
|
type ContextConsumer,
|
|
58
|
-
|
|
59
|
+
collectModuleStringConsts,
|
|
60
|
+
lookupStaticRecordLiteral,
|
|
61
|
+
searchParamsLocalNames,
|
|
62
|
+
matchSearchParamsMethodCall
|
|
59
63
|
} from '@barefootjs/jsx'
|
|
60
64
|
import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
|
|
61
65
|
|
|
@@ -68,7 +72,7 @@ import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
|
|
|
68
72
|
*/
|
|
69
73
|
type MojoRenderCtx = Record<string, never>
|
|
70
74
|
import type { ParsedExpr, ParsedStatement, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '@barefootjs/jsx'
|
|
71
|
-
import { BF_SLOT, BF_COND } from '@barefootjs/shared'
|
|
75
|
+
import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
|
|
72
76
|
|
|
73
77
|
interface PrimitiveSpec {
|
|
74
78
|
arity: number
|
|
@@ -249,6 +253,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
249
253
|
*/
|
|
250
254
|
private propsObjectName: string | null = null
|
|
251
255
|
private propsParams: { name: string }[] = []
|
|
256
|
+
private booleanTypedProps: Set<string> = new Set()
|
|
252
257
|
/**
|
|
253
258
|
* Names (signal getters + props) whose value is a string, so `===`/`!==`
|
|
254
259
|
* against them lowers to Perl `eq`/`ne` rather than numeric `==`/`!=`.
|
|
@@ -256,6 +261,15 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
256
261
|
* true — selecting the string operator from the operand's type avoids that.
|
|
257
262
|
*/
|
|
258
263
|
private stringValueNames: Set<string> = new Set()
|
|
264
|
+
/**
|
|
265
|
+
* (#1922) Local binding names the request-scoped `searchParams()` env signal
|
|
266
|
+
* is imported under (handles `import { searchParams as sp }`). When non-empty
|
|
267
|
+
* the emitter lowers a `<binding>().get(k)` call to a real method call on the
|
|
268
|
+
* per-request `$searchParams` reader (`$searchParams->get('sort')`) instead of
|
|
269
|
+
* the generic hash deref. Set at `generate()` entry from `ir.metadata.imports`;
|
|
270
|
+
* read by the top-level ParsedExpr emitter.
|
|
271
|
+
*/
|
|
272
|
+
_searchParamsLocals: Set<string> = new Set()
|
|
259
273
|
/**
|
|
260
274
|
* Module-scope pure string-literal constants (`const X = 'literal'` at
|
|
261
275
|
* file top-level), keyed by name → resolved literal value. Populated at
|
|
@@ -322,6 +336,15 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
322
336
|
// serialization happens before `generate`, so this mutation doesn't reach it).
|
|
323
337
|
augmentInheritedPropAccesses(ir)
|
|
324
338
|
this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
|
|
339
|
+
// Props whose declared TS type is boolean — a bare binding of one
|
|
340
|
+
// (`data-active={props.isActive}`) must stringify as JS
|
|
341
|
+
// `String(boolean)` ("true"/"false"), not Perl's native `1`/`''`
|
|
342
|
+
// (#1897, pagination's data-active).
|
|
343
|
+
this.booleanTypedProps = new Set(
|
|
344
|
+
ir.metadata.propsParams
|
|
345
|
+
.filter(prop => prop.type?.primitive === 'boolean' || prop.type?.raw === 'boolean')
|
|
346
|
+
.map(prop => prop.name),
|
|
347
|
+
)
|
|
325
348
|
// No-destructure-default props → `undef` when the caller omits them
|
|
326
349
|
// → guard their bare-reference attribute emission with Perl `defined`
|
|
327
350
|
// so the attribute drops instead of rendering `attr=""` (Hono-style
|
|
@@ -361,7 +384,8 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
361
384
|
for (const p of ir.metadata.propsParams) {
|
|
362
385
|
if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
|
|
363
386
|
}
|
|
364
|
-
this.moduleStringConsts =
|
|
387
|
+
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
388
|
+
this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
|
|
365
389
|
this.localConstants = ir.metadata.localConstants ?? []
|
|
366
390
|
this.loopBoundNames.clear()
|
|
367
391
|
this.errors = []
|
|
@@ -430,25 +454,6 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
430
454
|
}
|
|
431
455
|
}
|
|
432
456
|
|
|
433
|
-
/**
|
|
434
|
-
* Build the module pure-string-const map from the IR's localConstants.
|
|
435
|
-
* A const qualifies only when it is module-scope (`isModule`) and its
|
|
436
|
-
* initializer parses to a single string literal (`ts.StringLiteral` or
|
|
437
|
-
* `ts.NoSubstitutionTemplateLiteral`). Template literals with `${}`,
|
|
438
|
-
* numeric/object initializers, `Record<T,string>` maps, memos, and
|
|
439
|
-
* signals are all excluded — only a pure compile-time string can be
|
|
440
|
-
* inlined byte-for-byte.
|
|
441
|
-
*/
|
|
442
|
-
private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
|
|
443
|
-
const map = new Map<string, string>()
|
|
444
|
-
for (const c of constants ?? []) {
|
|
445
|
-
if (!c.isModule) continue
|
|
446
|
-
if (c.value === undefined) continue
|
|
447
|
-
const literal = parsePureStringLiteral(c.value)
|
|
448
|
-
if (literal !== null) map.set(c.name, literal)
|
|
449
|
-
}
|
|
450
|
-
return map
|
|
451
|
-
}
|
|
452
457
|
|
|
453
458
|
/**
|
|
454
459
|
* Resolve an identifier to its inlined Perl single-quoted string literal
|
|
@@ -456,6 +461,78 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
456
461
|
* falls back to its normal `$name` stash lowering). Returns the Perl
|
|
457
462
|
* literal form `'<escaped>'` ready to drop into an expression.
|
|
458
463
|
*/
|
|
464
|
+
/**
|
|
465
|
+
* Resolve `IDENT.key` over a module object-literal const to its Perl
|
|
466
|
+
* literal (`variantClasses.ghost` in a class template literal —
|
|
467
|
+
* #1897). Same compile-time inlining family as
|
|
468
|
+
* `resolveModuleStringConst`; returns `null` for any non-static shape.
|
|
469
|
+
*/
|
|
470
|
+
/**
|
|
471
|
+
* Whether `expr` is a bare reference to a boolean-TYPED prop
|
|
472
|
+
* (`props.isActive` / destructured `isActive`) — used to route the
|
|
473
|
+
* binding through `bool_str` even though the expression itself is
|
|
474
|
+
* structurally opaque (#1897).
|
|
475
|
+
*/
|
|
476
|
+
isBooleanTypedPropRef(expr: string): boolean {
|
|
477
|
+
let bare = expr.trim()
|
|
478
|
+
if (this.propsObjectName && bare.startsWith(`${this.propsObjectName}.`)) {
|
|
479
|
+
bare = bare.slice(this.propsObjectName.length + 1)
|
|
480
|
+
}
|
|
481
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(bare)) return false
|
|
482
|
+
return this.booleanTypedProps.has(bare)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Parse `cond ? value : undefined` (or `: null`), returning the
|
|
487
|
+
* condition/consequent source spans, else `null`. Used for the
|
|
488
|
+
* attribute-omission rule (#1897); mirrors the Xslate adapter.
|
|
489
|
+
*/
|
|
490
|
+
parseUndefinedAlternateTernary(
|
|
491
|
+
expr: string,
|
|
492
|
+
): { condition: string; consequent: string } | null {
|
|
493
|
+
const parsed = parseExpression(expr.trim())
|
|
494
|
+
if (parsed?.kind !== 'conditional') return null
|
|
495
|
+
const alt = parsed.alternate
|
|
496
|
+
const isUndef =
|
|
497
|
+
(alt.kind === 'identifier' && (alt.name === 'undefined' || alt.name === 'null')) ||
|
|
498
|
+
(alt.kind === 'literal' && (alt.value === null || alt.value === undefined))
|
|
499
|
+
if (!isUndef) return null
|
|
500
|
+
// Serialise the parsed sub-expressions back to JS source rather than
|
|
501
|
+
// slicing `expr` text — `indexOf('?')` / `lastIndexOf(':')` would
|
|
502
|
+
// mis-split when the consequent itself contains `?` / `:` inside a
|
|
503
|
+
// string or nested ternary (`cond ? 'a:b' : undefined`).
|
|
504
|
+
return {
|
|
505
|
+
condition: exprToString(parsed.test),
|
|
506
|
+
consequent: exprToString(parsed.consequent),
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Inline a const (any scope) whose initializer is a pure numeric or
|
|
512
|
+
* quoted string literal (`const totalPages = 5`, #1897 pagination) —
|
|
513
|
+
* function-scope consts never reach the per-render stash, so a bare
|
|
514
|
+
* `$totalPages` faults under strict mode.
|
|
515
|
+
*/
|
|
516
|
+
resolveLiteralConst(name: string): string | null {
|
|
517
|
+
if (this.loopBoundNames?.has?.(name)) return null
|
|
518
|
+
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
519
|
+
if (c?.value === undefined) return null
|
|
520
|
+
const v = c.value.trim()
|
|
521
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return v
|
|
522
|
+
const strLit = /^'([^'\\]*)'$/.exec(v) ?? /^"([^"\\]*)"$/.exec(v)
|
|
523
|
+
if (strLit) return `'${strLit[1].replace(/[\\']/g, m => `\\${m}`)}'`
|
|
524
|
+
return null
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
528
|
+
if (this.loopBoundNames?.has?.(objectName)) return null
|
|
529
|
+
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
530
|
+
if (!hit) return null
|
|
531
|
+
return hit.kind === 'number'
|
|
532
|
+
? hit.text
|
|
533
|
+
: `'${hit.text.replace(/[\\']/g, m => `\\${m}`)}'`
|
|
534
|
+
}
|
|
535
|
+
|
|
459
536
|
resolveModuleStringConst(name: string): string | null {
|
|
460
537
|
// A loop body introduces `my $<param>` / `my $<index>` bindings that
|
|
461
538
|
// shadow a module const of the same name — never inline inside one.
|
|
@@ -570,13 +647,49 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
570
647
|
? `'${v.value.replace(/[\\']/g, m => `\\${m}`)}'`
|
|
571
648
|
: String(v.value)
|
|
572
649
|
}
|
|
573
|
-
if (v.kind === 'expression')
|
|
650
|
+
if (v.kind === 'expression') {
|
|
651
|
+
const hashref = this.providerObjectLiteralPerl(v.expr)
|
|
652
|
+
if (hashref !== null) return hashref
|
|
653
|
+
return this.convertExpressionToPerl(v.expr)
|
|
654
|
+
}
|
|
574
655
|
if (v.kind === 'template') return this.convertTemplateLiteralPartsToPerl(v.parts)
|
|
575
656
|
// Out-of-shape value (spread / jsx-children) — render as undef rather
|
|
576
657
|
// than emit invalid Perl; the consumer falls back to its default.
|
|
577
658
|
return 'undef'
|
|
578
659
|
}
|
|
579
660
|
|
|
661
|
+
/**
|
|
662
|
+
* Lower an object-literal provider value (`value={{ open: () => props.open
|
|
663
|
+
* ?? false, onOpenChange: … }}`) to a Perl hashref (#1897). The SSR
|
|
664
|
+
* lowering is a per-member snapshot of what a consumer would READ during
|
|
665
|
+
* the same render:
|
|
666
|
+
*
|
|
667
|
+
* - zero-param expression-body arrows are getters — lower the body (the
|
|
668
|
+
* value is fixed for the render, so the call-time indirection drops out)
|
|
669
|
+
* - `on[A-Z]`-named members and function-shaped values are client-only
|
|
670
|
+
* behavior SSR never invokes — lower to `undef`
|
|
671
|
+
* - anything else lowers through the normal expression pipeline (so an
|
|
672
|
+
* unsupported getter body still refuses loudly with BF101)
|
|
673
|
+
*
|
|
674
|
+
* Keys keep their JS names verbatim so a consumer-side `ctx.open` access
|
|
675
|
+
* maps onto the same key. Returns `null` when the expression is not a
|
|
676
|
+
* plain object literal (spread / computed key) — the caller falls back to
|
|
677
|
+
* the whole-expression path, which refuses those shapes with BF101.
|
|
678
|
+
*/
|
|
679
|
+
private providerObjectLiteralPerl(expr: string): string | null {
|
|
680
|
+
const members = parseProviderObjectLiteral(expr.trim())
|
|
681
|
+
if (members === null) return null
|
|
682
|
+
const entries = members.map(m => {
|
|
683
|
+
// String-literal JS keys can carry `'` / `\` — escape for the
|
|
684
|
+
// single-quoted Perl key string.
|
|
685
|
+
const key = `'${m.name.replace(/[\\']/g, c => `\\${c}`)}'`
|
|
686
|
+
if (m.kind === 'function' || /^on[A-Z]/.test(m.name)) return `${key} => undef`
|
|
687
|
+
const src = m.kind === 'getter' ? m.body : m.expr
|
|
688
|
+
return `${key} => ${this.convertExpressionToPerl(src)}`
|
|
689
|
+
})
|
|
690
|
+
return `{ ${entries.join(', ')} }`
|
|
691
|
+
}
|
|
692
|
+
|
|
580
693
|
/** Perl literal for a context-consumer's `createContext` default. */
|
|
581
694
|
private contextDefaultPerl(c: ContextConsumer): string {
|
|
582
695
|
const d = c.defaultValue
|
|
@@ -619,7 +732,6 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
619
732
|
const memos = ir.metadata.memos ?? []
|
|
620
733
|
const signals = ir.metadata.signals ?? []
|
|
621
734
|
if (memos.length === 0 && signals.length === 0) return ''
|
|
622
|
-
const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
|
|
623
735
|
// Props seed first; each signal/memo adds its own name as it lands so a
|
|
624
736
|
// later one can reference an earlier one.
|
|
625
737
|
const available = new Set<string>(ir.metadata.propsParams.map(p => p.name))
|
|
@@ -641,17 +753,20 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
641
753
|
}
|
|
642
754
|
|
|
643
755
|
for (const memo of memos) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
756
|
+
// Seed every memo whose body lowers cleanly — not just the ones whose
|
|
757
|
+
// static SSR default is null. A statically-foldable prop-derived memo
|
|
758
|
+
// (`createMemo(() => props.disabled ?? false)` → default `false`)
|
|
759
|
+
// still depends on the per-call prop: the static stash seed bakes in
|
|
760
|
+
// the absent-prop fold, so a caller passing `disabled => 1` would
|
|
761
|
+
// render the default branch (#1897, select's disabled item). The
|
|
762
|
+
// in-template recomputation reads the prop lexical the stash already
|
|
763
|
+
// seeded, so it's correct per call; block-bodied arrows /
|
|
764
|
+
// out-of-scope references fall back to the static ssr-defaults seed.
|
|
650
765
|
const body = extractArrowBodyExpression(memo.computation)
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
766
|
+
if (body !== null) {
|
|
767
|
+
const perl = this.tryLowerToPerl(body, available)
|
|
768
|
+
if (perl !== null) lines.push(`% my $${memo.name} = ${perl};`)
|
|
769
|
+
}
|
|
655
770
|
available.add(memo.name)
|
|
656
771
|
}
|
|
657
772
|
return lines.length > 0 ? lines.join('\n') + '\n' : ''
|
|
@@ -703,6 +818,12 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
703
818
|
if (element.slotId) {
|
|
704
819
|
hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
|
|
705
820
|
}
|
|
821
|
+
// Page-lifecycle boundary lowered from `<Region>` (spec/router.md). The id
|
|
822
|
+
// is a deterministic static string (`<file scope>:<index>`), so it emits as
|
|
823
|
+
// a plain literal attribute — no Mojolicious template tag.
|
|
824
|
+
if (element.regionId) {
|
|
825
|
+
hydrationAttrs += ` ${BF_REGION}="${element.regionId}"`
|
|
826
|
+
}
|
|
706
827
|
|
|
707
828
|
const voidElements = [
|
|
708
829
|
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
@@ -1098,8 +1219,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
1098
1219
|
renderComponent(comp: IRComponent): string {
|
|
1099
1220
|
const propParts: string[] = []
|
|
1100
1221
|
for (const p of comp.props) {
|
|
1101
|
-
// Skip callback props (onXxx) —
|
|
1102
|
-
|
|
1222
|
+
// Skip callback props (onXxx) and `ref` — both are client-only for
|
|
1223
|
+
// SSR (Hono renders neither; the client JS wires them at hydration).
|
|
1224
|
+
if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
|
|
1103
1225
|
const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
|
|
1104
1226
|
if (lowered) propParts.push(lowered)
|
|
1105
1227
|
}
|
|
@@ -1132,7 +1254,10 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
1132
1254
|
// `render_child` would let the inner `%>` close the outer tag.
|
|
1133
1255
|
// `render_child` materializes the resulting CODE ref into the
|
|
1134
1256
|
// captured Mojo::ByteStream.
|
|
1257
|
+
const prevInLoop = this.inLoop
|
|
1258
|
+
this.inLoop = false
|
|
1135
1259
|
const childrenBody = this.renderChildren(effectiveChildren)
|
|
1260
|
+
this.inLoop = prevInLoop
|
|
1136
1261
|
const varName = `$bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
|
|
1137
1262
|
return `<% my ${varName} = begin %>${childrenBody}<% end %><%== bf->render_child('${tplName}'${propsStr}, children => ${varName}) %>`
|
|
1138
1263
|
}
|
|
@@ -1141,6 +1266,10 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
1141
1266
|
|
|
1142
1267
|
private childrenCaptureCounter = 0
|
|
1143
1268
|
|
|
1269
|
+
/** Uniquifies the `presenceOrUndefined` temp binding (`$bf_puN`) so two
|
|
1270
|
+
* presence-folded attrs in one template don't collide. */
|
|
1271
|
+
private presenceVarCounter = 0
|
|
1272
|
+
|
|
1144
1273
|
private toTemplateName(componentName: string): string {
|
|
1145
1274
|
// Convert PascalCase to snake_case for Mojo template naming
|
|
1146
1275
|
return componentName
|
|
@@ -1267,15 +1396,32 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
1267
1396
|
) {
|
|
1268
1397
|
const perl = this.convertExpressionToPerl(value.expr)
|
|
1269
1398
|
const body =
|
|
1270
|
-
isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)
|
|
1399
|
+
isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)
|
|
1271
1400
|
? `${name}="<%= bf->bool_str(${perl}) %>"`
|
|
1272
1401
|
: `${name}="<%= ${perl} %>"`
|
|
1273
1402
|
return `<% if (defined ${perl}) { %>${body}<% } %>`
|
|
1274
1403
|
}
|
|
1275
|
-
if (isBooleanAttr(name)
|
|
1404
|
+
if (isBooleanAttr(name)) {
|
|
1276
1405
|
// Boolean attributes: render conditionally (present or absent).
|
|
1277
1406
|
return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`
|
|
1278
1407
|
}
|
|
1408
|
+
if (value.presenceOrUndefined) {
|
|
1409
|
+
// `attr={expr || undefined}` on a NON-boolean attribute: Hono
|
|
1410
|
+
// renders the attr with its stringified value when truthy and
|
|
1411
|
+
// omits it otherwise (`aria-disabled={isDisabled() || undefined}`
|
|
1412
|
+
// → `aria-disabled="true"`), so bare presence would diverge.
|
|
1413
|
+
// Route through `bool_str` when the name/shape witnesses a
|
|
1414
|
+
// boolean value, same as the unconditional path below (#1897).
|
|
1415
|
+
// Bind to a temp first so the expression evaluates once, not in
|
|
1416
|
+
// both the guard and the value.
|
|
1417
|
+
const perl = this.convertExpressionToPerl(value.expr)
|
|
1418
|
+
const tmp = `$bf_pu${this.presenceVarCounter++}`
|
|
1419
|
+
const body =
|
|
1420
|
+
isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)
|
|
1421
|
+
? `${name}="<%= bf->bool_str(${tmp}) %>"`
|
|
1422
|
+
: `${name}="<%= ${tmp} %>"`
|
|
1423
|
+
return `<% my ${tmp} = ${perl}; if (${tmp}) { %>${body}<% } %>`
|
|
1424
|
+
}
|
|
1279
1425
|
// Boolean-result handling (#1466 follow-up). Two trigger paths:
|
|
1280
1426
|
//
|
|
1281
1427
|
// - `isBooleanResultExpr(expr)` — the JS source structurally
|
|
@@ -1294,8 +1440,21 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
1294
1440
|
// Hono's / Go's `attr="false"` / `attr="true"`. Routing through
|
|
1295
1441
|
// the `bf->bool_str` Perl helper realigns the wire bytes with
|
|
1296
1442
|
// JS `String(boolean)` semantics.
|
|
1443
|
+
// `attr={cond ? value : undefined}` OMITS the attribute on the
|
|
1444
|
+
// falsy branch (Hono drops undefined-valued attributes) — wrap the
|
|
1445
|
+
// whole attribute in the condition instead of rendering `attr=""`
|
|
1446
|
+
// (#1897, pagination's `aria-current={props.isActive ? 'page' :
|
|
1447
|
+
// undefined}`). Same parity rule as the Go / Xslate adapters.
|
|
1448
|
+
{
|
|
1449
|
+
const m = this.parseUndefinedAlternateTernary(value.expr)
|
|
1450
|
+
if (m) {
|
|
1451
|
+
const cond = this.convertExpressionToPerl(m.condition)
|
|
1452
|
+
const val = this.convertExpressionToPerl(m.consequent)
|
|
1453
|
+
return `<% if (${cond}) { %>${name}="<%= ${val} %>"<% } %>`
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1297
1456
|
const perl = this.convertExpressionToPerl(value.expr)
|
|
1298
|
-
if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)) {
|
|
1457
|
+
if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(value.expr)) {
|
|
1299
1458
|
return `${name}="<%= bf->bool_str(${perl}) %>"`
|
|
1300
1459
|
}
|
|
1301
1460
|
return `${name}="<%= ${perl} %>"`
|
|
@@ -1768,6 +1927,35 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
1768
1927
|
return n
|
|
1769
1928
|
})()
|
|
1770
1929
|
const indexed = this.recordIndexAccessToPerl(initNode)
|
|
1930
|
+
if (
|
|
1931
|
+
indexed === null &&
|
|
1932
|
+
ts.isElementAccessExpression(initNode) &&
|
|
1933
|
+
initNode.argumentExpression &&
|
|
1934
|
+
!ts.isNumericLiteral(initNode.argumentExpression) &&
|
|
1935
|
+
!ts.isStringLiteral(initNode.argumentExpression)
|
|
1936
|
+
) {
|
|
1937
|
+
// Variable-index record access (`sizeMap[size]`) that the
|
|
1938
|
+
// static-inline path couldn't resolve — a non-scalar record
|
|
1939
|
+
// value, or a non-const receiver. Since #1897 made variable
|
|
1940
|
+
// indices parseable (`index-access`), the generic value lowering
|
|
1941
|
+
// would now emit `$sizeMap->{$size}` against an UNBOUND module
|
|
1942
|
+
// const instead of refusing. Record BF101 and bail so the whole
|
|
1943
|
+
// spread surfaces the out-of-shape diagnostic, matching the
|
|
1944
|
+
// pre-#1897 behaviour (the refusal then was a side effect of the
|
|
1945
|
+
// value lowering). (A bound receiver — a signal getter like
|
|
1946
|
+
// `selected()[index]` — is an attribute value, not a spread
|
|
1947
|
+
// member, and never reaches here.)
|
|
1948
|
+
this.errors.push({
|
|
1949
|
+
code: 'BF101',
|
|
1950
|
+
severity: 'error',
|
|
1951
|
+
message: `Spread object value '${initNode.getText(sf)}' indexes a record map whose values aren't scalar literals — it can't lower to an inline Perl hashref.`,
|
|
1952
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1953
|
+
suggestion: {
|
|
1954
|
+
message: 'Index a record whose values are number/string literals, or move the spread into a `\'use client\'` component so hydration computes it.',
|
|
1955
|
+
},
|
|
1956
|
+
})
|
|
1957
|
+
return null
|
|
1958
|
+
}
|
|
1771
1959
|
const valPerl =
|
|
1772
1960
|
indexed !== null
|
|
1773
1961
|
? indexed
|
|
@@ -2010,6 +2198,13 @@ function renderArrayMethod(
|
|
|
2010
2198
|
const recv = emit(object)
|
|
2011
2199
|
return `bf->trim(${recv})`
|
|
2012
2200
|
}
|
|
2201
|
+
case 'toFixed': {
|
|
2202
|
+
// `.toFixed(digits?)` — Number → fixed-decimal string. `bf->to_fixed`
|
|
2203
|
+
// mirrors JS rounding + zero-padding (default 0 digits). #1897.
|
|
2204
|
+
const recv = emit(object)
|
|
2205
|
+
const digits = args.length >= 1 ? emit(args[0]) : '0'
|
|
2206
|
+
return `bf->to_fixed(${recv}, ${digits})`
|
|
2207
|
+
}
|
|
2013
2208
|
case 'split': {
|
|
2014
2209
|
// `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
|
|
2015
2210
|
// ARRAY ref via `bf->split`. With no separator the helper returns
|
|
@@ -2237,6 +2432,25 @@ function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boo
|
|
|
2237
2432
|
return false
|
|
2238
2433
|
}
|
|
2239
2434
|
|
|
2435
|
+
/**
|
|
2436
|
+
* Lower `arr[index]` to a Perl deref. Perl distinguishes array
|
|
2437
|
+
* (`->[$i]`) from hash (`->{$k}`) access, which JS's single `[]` does
|
|
2438
|
+
* not — so we pick by the index expression's type: a string-typed key
|
|
2439
|
+
* derefs the hash, anything else (the common loop-index / arithmetic
|
|
2440
|
+
* case, e.g. `selected()[index]`) derefs the array. #1897.
|
|
2441
|
+
*/
|
|
2442
|
+
function emitIndexAccessPerl(
|
|
2443
|
+
object: ParsedExpr,
|
|
2444
|
+
index: ParsedExpr,
|
|
2445
|
+
emit: (e: ParsedExpr) => string,
|
|
2446
|
+
isStringName: (n: string) => boolean,
|
|
2447
|
+
): string {
|
|
2448
|
+
const i = emit(index)
|
|
2449
|
+
return isStringTypedOperand(index, isStringName)
|
|
2450
|
+
? `${emit(object)}->{${i}}`
|
|
2451
|
+
: `${emit(object)}->[${i}]`
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2240
2454
|
/**
|
|
2241
2455
|
* Lowering for the predicate body of a filter / every / some / find,
|
|
2242
2456
|
* plus the same shape used by `renderBlockBodyCondition` for complex
|
|
@@ -2288,6 +2502,10 @@ class MojoFilterEmitter implements ParsedExprEmitter {
|
|
|
2288
2502
|
return `${emit(object)}->{${property}}`
|
|
2289
2503
|
}
|
|
2290
2504
|
|
|
2505
|
+
indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
2506
|
+
return emitIndexAccessPerl(object, index, emit, this.isStringName)
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2291
2509
|
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
2292
2510
|
// Signal getter calls: filter() → $filter
|
|
2293
2511
|
if (callee.kind === 'identifier' && args.length === 0) {
|
|
@@ -2431,11 +2649,19 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
|
|
|
2431
2649
|
constructor(private readonly adapter: MojoAdapter) {}
|
|
2432
2650
|
|
|
2433
2651
|
identifier(name: string): string {
|
|
2652
|
+
// `undefined` / `null` nested inside a larger expression tree
|
|
2653
|
+
// (#1897, pagination's `props.isActive ? 'page' : undefined`) — the
|
|
2654
|
+
// top-level short-circuits don't see them.
|
|
2655
|
+
if (name === 'undefined' || name === 'null') return 'undef'
|
|
2434
2656
|
// Module pure-string const (e.g. `const baseClasses = '...'` used in a
|
|
2435
2657
|
// className template literal): inline the literal value rather than emit
|
|
2436
2658
|
// `$baseClasses` against a stash variable that is never bound.
|
|
2437
2659
|
const inlined = this.adapter.resolveModuleStringConst(name)
|
|
2438
2660
|
if (inlined !== null) return inlined
|
|
2661
|
+
// Same for a literal const of any scope (`const totalPages = 5`,
|
|
2662
|
+
// #1897 pagination's `Page {currentPage()} of {totalPages}`).
|
|
2663
|
+
const literalConst = this.adapter.resolveLiteralConst(name)
|
|
2664
|
+
if (literalConst !== null) return literalConst
|
|
2439
2665
|
return `$${name}`
|
|
2440
2666
|
}
|
|
2441
2667
|
|
|
@@ -2453,16 +2679,38 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
|
|
|
2453
2679
|
if (object.kind === 'identifier' && object.name === 'props') {
|
|
2454
2680
|
return `$${property}`
|
|
2455
2681
|
}
|
|
2682
|
+
// Static property access on a module object-literal const
|
|
2683
|
+
// (`variantClasses.ghost`, #1897) resolves at compile time — the
|
|
2684
|
+
// generic hash lowering below would dereference a Perl var that
|
|
2685
|
+
// doesn't exist server-side.
|
|
2686
|
+
if (object.kind === 'identifier') {
|
|
2687
|
+
const staticValue = this.adapter.resolveStaticRecordLiteral(object.name, property)
|
|
2688
|
+
if (staticValue !== null) return staticValue
|
|
2689
|
+
}
|
|
2456
2690
|
const obj = emit(object)
|
|
2457
2691
|
if (property === 'length') return `scalar(@{${obj}})`
|
|
2458
2692
|
return `${obj}->{${property}}`
|
|
2459
2693
|
}
|
|
2460
2694
|
|
|
2695
|
+
indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
2696
|
+
return emitIndexAccessPerl(object, index, emit, n => this.adapter._isStringValueName(n))
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2461
2699
|
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
2462
2700
|
// Signal getter: count() → $count
|
|
2463
2701
|
if (callee.kind === 'identifier' && args.length === 0) {
|
|
2464
2702
|
return `$${callee.name}`
|
|
2465
2703
|
}
|
|
2704
|
+
// Env-signal method call (#1922): `searchParams().get('sort')` is a real
|
|
2705
|
+
// method call on the per-request `$searchParams` reader object, not the
|
|
2706
|
+
// generic hash deref `member` would emit (`$searchParams->{get}`, which
|
|
2707
|
+
// drops the arg). Matches the local import binding (incl. an alias).
|
|
2708
|
+
if (this.adapter._searchParamsLocals.size > 0) {
|
|
2709
|
+
const sp = matchSearchParamsMethodCall(callee, args, this.adapter._searchParamsLocals)
|
|
2710
|
+
if (sp) {
|
|
2711
|
+
return `$searchParams->${sp.method}(${sp.args.map(emit).join(', ')})`
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2466
2714
|
// Identifier-path templatePrimitive (#1189): `JSON.stringify(x)` /
|
|
2467
2715
|
// `Math.floor(x)` → `bf->json($x)` / `bf->floor($x)`. Args render
|
|
2468
2716
|
// recursively through this same emitter so prop refs / signal calls
|