@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.
@@ -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
- extractSsrDefaults,
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 = this.collectModuleStringConsts(ir.metadata.localConstants)
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') return this.convertExpressionToPerl(v.expr)
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
- const def = ssrDefaults[memo.name]
645
- const isNull = !def || (typeof def === 'object' && 'value' in def && def.value === null)
646
- if (!isNull) {
647
- available.add(memo.name)
648
- continue
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
- // Block-bodied arrows / non-expression shapes stay on the null path.
652
- if (body === null) continue
653
- const perl = this.tryLowerToPerl(body, available)
654
- if (perl !== null) lines.push(`% my $${memo.name} = ${perl};`)
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) — event handlers are client-only for SSR.
1102
- if (p.name.match(/^on[A-Z]/) && p.value.kind === 'expression') continue
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) || value.presenceOrUndefined) {
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