@barefootjs/xslate 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,7 @@ import type {
39
39
  CompilerError,
40
40
  TypeInfo,
41
41
  TemplatePrimitiveRegistry,
42
+ IRMetadata,
42
43
  } from '@barefootjs/jsx'
43
44
  import {
44
45
  BaseAdapter,
@@ -59,8 +60,12 @@ import {
59
60
  emitParsedExpr,
60
61
  emitIRNode,
61
62
  emitAttrValue,
63
+ augmentInheritedPropAccesses,
64
+ parseRecordIndexAccess,
65
+ evalStringArrayJoin,
62
66
  } from '@barefootjs/jsx'
63
67
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result'
68
+ import ts from 'typescript'
64
69
 
65
70
  /**
66
71
  * Xslate adapter's IRNode render context. Like the Mojo adapter, Kolon's
@@ -108,6 +113,25 @@ const XSLATE_PRIMITIVE_EMIT_MAP: Record<string, (args: string[]) => string> =
108
113
  * AttrValue `kind` discriminator so adapter code stays type-safe if the IR
109
114
  * shape evolves.
110
115
  */
116
+ /**
117
+ * Escape a string for a Kolon/Perl single-quoted literal: backslash first
118
+ * (so it doesn't double-escape the quote we add next), then the quote. Used
119
+ * by every `'…'` hashref key/value emitter below.
120
+ */
121
+ function escapeKolonSingleQuoted(s: string): string {
122
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
123
+ }
124
+
125
+ /**
126
+ * Quote a hashref KEY for Kolon when it isn't a bare-identifier-safe name.
127
+ * Kolon parses `data-slot` as `data - slot` (subtraction) and faults on the
128
+ * undefined `data` symbol, so a hyphenated key (`data-slot`, `aria-label`)
129
+ * must be single-quoted: `'data-slot'`. Bare identifiers pass through unquoted.
130
+ */
131
+ function kolonHashKey(name: string): string {
132
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${escapeKolonSingleQuoted(name)}'`
133
+ }
134
+
111
135
  function resolveJsxChildrenProp(props: readonly IRProp[]): IRNode[] {
112
136
  const prop = props.find(p => p.name === 'children')
113
137
  if (!prop) return []
@@ -159,6 +183,33 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
159
183
  */
160
184
  private stringValueNames: Set<string> = new Set()
161
185
 
186
+ /**
187
+ * Module-scope pure-string consts (`const x = 'literal'`), keyed by name →
188
+ * unescaped value. A className template literal that references such a const
189
+ * (`className={`${x} ${className}`}`) must inline the literal: the const is
190
+ * module-scope, so it never reaches the per-render template stash and a bare
191
+ * `$x` reference would render empty. Mirrors the Mojo adapter's
192
+ * `moduleStringConsts` fix.
193
+ */
194
+ private moduleStringConsts: Map<string, string> = new Map()
195
+
196
+ /**
197
+ * Local + module constants from the IR, used by the conditional-spread and
198
+ * `Record<staticKeys, scalar>[propKey]` lowering paths (#textarea / #checkbox).
199
+ * Stashed at `generate()` entry so `emitSpread` can resolve a bare local
200
+ * const (`const sizeAttrs = size ? {…} : {}`) to its initializer text.
201
+ */
202
+ private localConstants: IRMetadata['localConstants'] = []
203
+
204
+ /**
205
+ * Optional, no-default props that are `undef` when the caller omits them.
206
+ * Their bare-reference attribute emission is guarded with Kolon `defined` so
207
+ * the attribute DROPS rather than rendering `attr=""` (Hono-style nullish
208
+ * omission, e.g. textarea's `rows`). The filter excludes destructure-
209
+ * defaulted, rest, and concrete-primitive props.
210
+ */
211
+ private nullableOptionalProps: Set<string> = new Set()
212
+
162
213
  constructor(options: XslateAdapterOptions = {}) {
163
214
  super()
164
215
  this.options = {
@@ -170,7 +221,25 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
170
221
  generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
171
222
  this.componentName = ir.metadata.componentName
172
223
  this.propsObjectName = ir.metadata.propsObjectName ?? null
224
+ // (#checkbox) Enumerate the props-object pattern's inherited attribute
225
+ // accesses (`props.className`/`id`/`disabled`) into propsParams via the
226
+ // shared helper, before deriving `nullableOptionalProps` below.
227
+ augmentInheritedPropAccesses(ir)
173
228
  this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
229
+ this.localConstants = ir.metadata.localConstants ?? []
230
+ // Bare references to optional, no-default, non-primitive props (e.g.
231
+ // textarea's `rows`) are `undef` when omitted → `defined`-guarded in
232
+ // `emitExpression`. See the `nullableOptionalProps` field docstring.
233
+ this.nullableOptionalProps = new Set(
234
+ ir.metadata.propsParams
235
+ .filter(
236
+ p =>
237
+ p.defaultValue === undefined &&
238
+ !p.isRest &&
239
+ p.type?.kind !== 'primitive',
240
+ )
241
+ .map(p => p.name),
242
+ )
174
243
  // Record string-typed signals and props so equality comparisons against
175
244
  // them lower to `eq`/`ne`. A signal is string-typed when its inferred
176
245
  // type is `string` (or, defensively, when its initial value is a bare
@@ -184,6 +253,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
184
253
  for (const p of ir.metadata.propsParams) {
185
254
  if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
186
255
  }
256
+ this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
187
257
  this.errors = []
188
258
  this.childrenCaptureCounter = 0
189
259
 
@@ -653,12 +723,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
653
723
  * below, not threaded through the hashref entry list.
654
724
  */
655
725
  private readonly componentPropEmitter: AttrValueEmitter = {
656
- emitLiteral: (value, name) => `${name} => '${value.value}'`,
726
+ emitLiteral: (value, name) => `${kolonHashKey(name)} => '${value.value}'`,
657
727
  emitExpression: (value, name) => {
658
728
  if (value.parts) {
659
- return `${name} => ${this.convertTemplateLiteralPartsToKolon(value.parts)}`
729
+ return `${kolonHashKey(name)} => ${this.convertTemplateLiteralPartsToKolon(value.parts)}`
660
730
  }
661
- return `${name} => ${this.convertExpressionToKolon(value.expr)}`
731
+ return `${kolonHashKey(name)} => ${this.convertExpressionToKolon(value.expr)}`
662
732
  },
663
733
  emitSpread: (value) => {
664
734
  // Kolon hashrefs can't be splatted into the entry list the way Perl
@@ -670,9 +740,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
670
740
  return this.convertExpressionToKolon(value.expr)
671
741
  },
672
742
  emitTemplate: (value, name) =>
673
- `${name} => ${this.convertTemplateLiteralPartsToKolon(value.parts)}`,
674
- emitBooleanAttr: (_value, name) => `${name} => 1`,
675
- emitBooleanShorthand: (_value, name) => `${name} => 1`,
743
+ `${kolonHashKey(name)} => ${this.convertTemplateLiteralPartsToKolon(value.parts)}`,
744
+ emitBooleanAttr: (_value, name) => `${kolonHashKey(name)} => 1`,
745
+ emitBooleanShorthand: (_value, name) => `${kolonHashKey(name)} => 1`,
676
746
  // JSX children flow through the Kolon macro capture below; they're not
677
747
  // part of the hashref entry list.
678
748
  emitJsxChildren: () => '',
@@ -824,6 +894,34 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
824
894
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
825
895
  return ''
826
896
  }
897
+ // Hono-style nullish omission: a bare reference to an optional,
898
+ // no-default prop (`nullableOptionalProps`) is `defined`-guarded so the
899
+ // attribute drops instead of rendering `attr=""`. Narrowly scoped to bare
900
+ // identifiers — member exprs, calls, and concrete/defaulted props are
901
+ // unaffected.
902
+ const bareId = value.expr.trim()
903
+ // Normalize a props-object access (`props.id`) to its bare prop name
904
+ // (`id`) so the nullable-optional set — keyed by bare name — matches the
905
+ // SolidJS props-object pattern, not just destructured params.
906
+ const normalizedBareId =
907
+ this.propsObjectName && bareId.startsWith(`${this.propsObjectName}.`)
908
+ ? bareId.slice(this.propsObjectName.length + 1)
909
+ : bareId
910
+ if (
911
+ !isBooleanAttr(name) &&
912
+ !value.presenceOrUndefined &&
913
+ /^[A-Za-z_$][\w$]*$/.test(normalizedBareId) &&
914
+ this.nullableOptionalProps.has(normalizedBareId)
915
+ ) {
916
+ const perl = this.convertExpressionToKolon(value.expr)
917
+ const body =
918
+ isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)
919
+ ? `${name}="<: $bf.bool_str(${perl}) :>"`
920
+ : `${name}="<: ${perl} :>"`
921
+ // Kolon `:` line directives must each stand alone on their own line, so
922
+ // wrap in newlines (`normalizeHTML` collapses the surrounding space).
923
+ return `\n: if (defined ${perl}) {\n${body}\n: }\n`
924
+ }
827
925
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
828
926
  // Boolean attributes: render conditionally (present or absent).
829
927
  return `<: ${this.convertExpressionToKolon(value.expr)} ? '${name}' : '' :>`
@@ -856,6 +954,35 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
856
954
  )
857
955
  return `<: $bf.spread_attrs({${entries.join(', ')}}) | mark_raw :>`
858
956
  }
957
+ // Conditional inline-object spread (#textarea):
958
+ // `{...(COND ? { 'aria-describedby': describedBy } : {})}`
959
+ // Emit a Kolon inline ternary of hashrefs — Perl truthiness handles the
960
+ // condition for free, and the falsy `{}` branch OMITS the key
961
+ // (`spread_attrs` does NOT emit empty hashref entries).
962
+ const ternaryHashref = this.conditionalSpreadToKolon(trimmed)
963
+ if (ternaryHashref !== null) {
964
+ return `<: $bf.spread_attrs(${ternaryHashref}) | mark_raw :>`
965
+ }
966
+ // Function-scope local const holding a conditional inline-object
967
+ // `const sizeAttrs = size ? {…} : {}` then `{...sizeAttrs}`
968
+ // (#checkbox / icon). Resolve the bare identifier to its initializer text
969
+ // and route through the same conditional-spread lowering. Only
970
+ // function-scope (`!isModule`) consts whose value is NOT itself a bare
971
+ // identifier (loop guard) are considered.
972
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
973
+ const localConst = (this.localConstants ?? []).find(
974
+ c => c.name === trimmed && !c.isModule,
975
+ )
976
+ if (localConst?.value !== undefined) {
977
+ const initTrimmed = localConst.value.trim()
978
+ if (!/^[A-Za-z_$][\w$]*$/.test(initTrimmed)) {
979
+ const resolved = this.conditionalSpreadToKolon(initTrimmed)
980
+ if (resolved !== null) {
981
+ return `<: $bf.spread_attrs(${resolved}) | mark_raw :>`
982
+ }
983
+ }
984
+ }
985
+ }
859
986
  const perlExpr = this.convertExpressionToKolon(value.expr)
860
987
  return `<: $bf.spread_attrs(${perlExpr}) | mark_raw :>`
861
988
  },
@@ -1090,6 +1217,98 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1090
1217
  return true
1091
1218
  }
1092
1219
 
1220
+ /**
1221
+ * Lower a conditional inline-object spread
1222
+ * `COND ? { 'aria-describedby': describedBy } : {}`
1223
+ * to a Kolon inline ternary of hashrefs
1224
+ * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`.
1225
+ * Both branches must be object literals; the condition + values route through
1226
+ * `convertExpressionToKolon`. Returns `null` for any other shape so the caller
1227
+ * falls back to its normal lowering. Mirror of `conditionalSpreadToPerl`.
1228
+ */
1229
+ private conditionalSpreadToKolon(expr: string): string | null {
1230
+ const sf = ts.createSourceFile('__spread.ts', `(${expr})`, ts.ScriptTarget.Latest, true)
1231
+ if (sf.statements.length !== 1) return null
1232
+ const stmt = sf.statements[0]
1233
+ if (!ts.isExpressionStatement(stmt)) return null
1234
+ let node: ts.Expression = stmt.expression
1235
+ while (ts.isParenthesizedExpression(node)) node = node.expression
1236
+ if (!ts.isConditionalExpression(node)) return null
1237
+ const unwrap = (e: ts.Expression): ts.Expression => {
1238
+ let n = e
1239
+ while (ts.isParenthesizedExpression(n)) n = n.expression
1240
+ return n
1241
+ }
1242
+ const whenTrue = unwrap(node.whenTrue)
1243
+ const whenFalse = unwrap(node.whenFalse)
1244
+ if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
1245
+ return null
1246
+ }
1247
+ const condPerl = this.convertExpressionToKolon(node.condition.getText(sf))
1248
+ const truePerl = this.objectLiteralToKolonHashref(whenTrue, sf)
1249
+ const falsePerl = this.objectLiteralToKolonHashref(whenFalse, sf)
1250
+ if (truePerl === null || falsePerl === null) return null
1251
+ return `${condPerl} ? ${truePerl} : ${falsePerl}`
1252
+ }
1253
+
1254
+ /**
1255
+ * Convert a static object literal into a Kolon hashref string for a
1256
+ * conditional spread. Only static string/identifier keys are allowed; values
1257
+ * resolve via `convertExpressionToKolon` (or the `Record[propKey]` index
1258
+ * lowering). Returns `null` for any computed/spread/dynamic key. Empty object
1259
+ * → `{}`. Mirror of `objectLiteralToPerlHashref`.
1260
+ */
1261
+ private objectLiteralToKolonHashref(
1262
+ obj: ts.ObjectLiteralExpression,
1263
+ sf: ts.SourceFile,
1264
+ ): string | null {
1265
+ const entries: string[] = []
1266
+ for (const prop of obj.properties) {
1267
+ if (!ts.isPropertyAssignment(prop)) return null
1268
+ let key: string
1269
+ if (ts.isIdentifier(prop.name)) {
1270
+ key = prop.name.text
1271
+ } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
1272
+ key = prop.name.text
1273
+ } else {
1274
+ return null
1275
+ }
1276
+ const initNode = (() => {
1277
+ let n: ts.Expression = prop.initializer
1278
+ while (ts.isParenthesizedExpression(n)) n = n.expression
1279
+ return n
1280
+ })()
1281
+ const indexed = this.recordIndexAccessToKolon(initNode)
1282
+ const valPerl =
1283
+ indexed !== null
1284
+ ? indexed
1285
+ : this.convertExpressionToKolon(prop.initializer.getText(sf))
1286
+ entries.push(`'${escapeKolonSingleQuoted(key)}' => ${valPerl}`)
1287
+ }
1288
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
1289
+ }
1290
+
1291
+ /**
1292
+ * Lower a spread-object VALUE of the form `IDENT[KEY]` (CheckIcon's
1293
+ * `sizeMap[size]`) to an inline indexed Kolon hashref
1294
+ * `{ 'sm' => 16, 'md' => 20, ... }[$size]`.
1295
+ * Reuses the shared structural parse (`parseRecordIndexAccess`); this wrapper
1296
+ * only does the single-quote escaping + Kolon index emit. NB: Kolon indexes a
1297
+ * hashref literal with bracket syntax `{…}[$key]`, NOT Perl's arrow-deref
1298
+ * `{…}->{$key}` (which Kolon's parser rejects) — this is the one divergence
1299
+ * from the Mojo `recordIndexAccessToPerl` emit.
1300
+ */
1301
+ private recordIndexAccessToKolon(val: ts.Expression): string | null {
1302
+ const parsed = parseRecordIndexAccess(val, this.localConstants ?? [], this.propsParams)
1303
+ if (!parsed) return null
1304
+ const entries = parsed.entries.map(e => {
1305
+ const mapVal =
1306
+ e.value.kind === 'number' ? e.value.text : `'${escapeKolonSingleQuoted(e.value.text)}'`
1307
+ return `'${escapeKolonSingleQuoted(e.key)}' => ${mapVal}`
1308
+ })
1309
+ return `{ ${entries.join(', ')} }[$${parsed.indexPropName}]`
1310
+ }
1311
+
1093
1312
  private convertExpressionToKolon(expr: string): string {
1094
1313
  // Parse-first lowering — parity with the Mojo adapter's
1095
1314
  // `convertExpressionToPerl`. Parse the JS expression once, gate it on the
@@ -1134,6 +1353,21 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1134
1353
  return this.stringValueNames.has(name)
1135
1354
  }
1136
1355
 
1356
+ /**
1357
+ * Resolve an identifier to its inlined Kolon single-quoted literal when it
1358
+ * names a module pure-string const, else `null` (caller falls back to the
1359
+ * normal `$name` stash lowering). Loop-bound names shadow module consts, so
1360
+ * never inline inside a loop body. Returns `'<escaped>'`.
1361
+ */
1362
+ _resolveModuleStringConst(name: string): string | null {
1363
+ // A loop body may bind `my $<param>` that shadows a module const of the
1364
+ // same name; never inline inside one (conservative — drop to `$name`).
1365
+ if (this.inLoop) return null
1366
+ const value = this.moduleStringConsts.get(name)
1367
+ if (value === undefined) return null
1368
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
1369
+ }
1370
+
1137
1371
  _recordExprBF101(message: string, reason?: string): void {
1138
1372
  this.errors.push({
1139
1373
  code: 'BF101',
@@ -1179,6 +1413,9 @@ function renderArrayMethod(
1179
1413
  ): string {
1180
1414
  switch (method) {
1181
1415
  case 'join': {
1416
+ // Route through the runtime (`$bf.join`) rather than Kolon's builtin
1417
+ // `.join`, so the JS-compat element handling (undef → empty, default
1418
+ // separator) is applied consistently — same reasoning as $bf.lc / etc.
1182
1419
  const obj = emit(object)
1183
1420
  const sep = args.length >= 1 ? emit(args[0]) : `','`
1184
1421
  return `$bf.join(${obj}, ${sep})`
@@ -1211,7 +1448,9 @@ function renderArrayMethod(
1211
1448
  case 'slice': {
1212
1449
  const recv = emit(object)
1213
1450
  const start = args.length >= 1 ? emit(args[0]) : '0'
1214
- const end = args.length >= 2 ? emit(args[1]) : 'undef'
1451
+ // Kolon's undefined literal is `nil`, not Perl's `undef` — the
1452
+ // runtime `slice` treats it as "to end".
1453
+ const end = args.length >= 2 ? emit(args[1]) : 'nil'
1215
1454
  return `$bf.slice(${recv}, ${start}, ${end})`
1216
1455
  }
1217
1456
  case 'reverse':
@@ -1220,6 +1459,8 @@ function renderArrayMethod(
1220
1459
  return `$bf.reverse(${recv})`
1221
1460
  }
1222
1461
  case 'toLowerCase': {
1462
+ // Kolon has no builtin string `lc` / `uc`, so these go through the
1463
+ // runtime object (consistent with $bf.includes / $bf.slice / etc.).
1223
1464
  const recv = emit(object)
1224
1465
  return `$bf.lc(${recv})`
1225
1466
  }
@@ -1339,6 +1580,74 @@ function renderFlatMapMethod(recv: string, op: FlatMapOp): string {
1339
1580
  return `$bf.flat_map(${recv}, 'field', '${proj.field}')`
1340
1581
  }
1341
1582
 
1583
+ /**
1584
+ * Parse a const initializer's source text. Returns the unescaped string value
1585
+ * when the whole initializer is a single pure string literal — single/double
1586
+ * quoted, or a no-substitution backtick template (no `${}`) — else `null`.
1587
+ * Only such a value can be inlined byte-for-byte; template literals with
1588
+ * interpolation, numbers, objects, and `Record<T,string>` maps are excluded.
1589
+ */
1590
+ function parsePureStringLiteral(source: string): string | null {
1591
+ let s = source.trim()
1592
+ // Peel a single layer of wrapping parens.
1593
+ while (s.startsWith('(') && s.endsWith(')')) s = s.slice(1, -1).trim()
1594
+ const quote = s[0]
1595
+ if ((quote === "'" || quote === '"') && s[s.length - 1] === quote) {
1596
+ const body = s.slice(1, -1)
1597
+ // Reject if an unescaped matching quote appears inside (not a single
1598
+ // literal then).
1599
+ if (containsUnescaped(body, quote)) return null
1600
+ return unescapeStringLiteralBody(body)
1601
+ }
1602
+ if (quote === '`' && s[s.length - 1] === '`') {
1603
+ const body = s.slice(1, -1)
1604
+ if (body.includes('${')) return null
1605
+ if (containsUnescaped(body, '`')) return null
1606
+ return unescapeStringLiteralBody(body)
1607
+ }
1608
+ // `[<literals>].join(' ')` module consts (e.g. Switch's `trackStateClasses`)
1609
+ // → inline the flattened string byte-for-byte. See `evalStringArrayJoin`.
1610
+ return evalStringArrayJoin(source)
1611
+ }
1612
+
1613
+ /** Whether `s` contains an unescaped occurrence of `ch`. */
1614
+ function containsUnescaped(s: string, ch: string): boolean {
1615
+ for (let i = 0; i < s.length; i++) {
1616
+ if (s[i] === '\\') { i++; continue }
1617
+ if (s[i] === ch) return true
1618
+ }
1619
+ return false
1620
+ }
1621
+
1622
+ /** Unescape a JS string-literal body's common escape sequences. */
1623
+ function unescapeStringLiteralBody(s: string): string {
1624
+ return s.replace(/\\(.)/g, (_, c) => {
1625
+ switch (c) {
1626
+ case 'n': return '\n'
1627
+ case 'r': return '\r'
1628
+ case 't': return '\t'
1629
+ case '0': return '\0'
1630
+ default: return c
1631
+ }
1632
+ })
1633
+ }
1634
+
1635
+ /**
1636
+ * Build the module pure-string-const map from the IR's localConstants. A const
1637
+ * qualifies only when module-scope (`isModule`) and its initializer parses to a
1638
+ * single pure string literal.
1639
+ */
1640
+ function collectModuleStringConsts(constants: IRMetadata['localConstants'] | undefined): Map<string, string> {
1641
+ const map = new Map<string, string>()
1642
+ for (const c of constants ?? []) {
1643
+ if (!c.isModule) continue
1644
+ if (c.value === undefined) continue
1645
+ const literal = parsePureStringLiteral(c.value)
1646
+ if (literal !== null) map.set(c.name, literal)
1647
+ }
1648
+ return map
1649
+ }
1650
+
1342
1651
  /** True when `type` is the `string` primitive. */
1343
1652
  function isStringTypeInfo(type: TypeInfo | undefined): boolean {
1344
1653
  return type?.kind === 'primitive' && type.primitive === 'string'
@@ -1396,14 +1705,16 @@ class XslateFilterEmitter implements ParsedExprEmitter {
1396
1705
  literal(value: string | number | boolean | null, literalType: LiteralType): string {
1397
1706
  if (literalType === 'string') return `'${value}'`
1398
1707
  if (literalType === 'boolean') return value ? '1' : '0'
1399
- if (literalType === 'null') return 'undef'
1708
+ if (literalType === 'null') return 'nil'
1400
1709
  return String(value)
1401
1710
  }
1402
1711
 
1403
1712
  member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
1404
- // `.length` on an array Kolon's array length is `$arr.size()`.
1713
+ // `.length` route through `$bf.length` (handles both array element
1714
+ // count and string char count, JS-compatibly). Kolon's builtin `.size()`
1715
+ // is array-only and faults on a string.
1405
1716
  if (property === 'length') {
1406
- return `${emit(object)}.size()`
1717
+ return `$bf.length(${emit(object)})`
1407
1718
  }
1408
1719
  // Hash field access — Kolon dot works on hash refs.
1409
1720
  return `${emit(object)}.${property}`
@@ -1430,14 +1741,10 @@ class XslateFilterEmitter implements ParsedExprEmitter {
1430
1741
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1431
1742
  const l = emit(left)
1432
1743
  const r = emit(right)
1433
- const isStr = (e: ParsedExpr) => isStringTypedOperand(e, this.isStringName)
1434
- const stringCmp = isStr(left) || isStr(right)
1435
- if ((op === '===' || op === '==') && stringCmp) {
1436
- return `${l} eq ${r}`
1437
- }
1438
- if ((op === '!==' || op === '!=') && stringCmp) {
1439
- return `${l} ne ${r}`
1440
- }
1744
+ // Kolon's `==` / `!=` are value-equality operators that compare strings
1745
+ // and numbers correctly — unlike Perl's numeric `==` (which the Mojo
1746
+ // adapter must steer around with `eq`/`ne`). Kolon has no `eq`/`ne`
1747
+ // operator at all, so string comparisons stay on `==` / `!=` here.
1441
1748
  const opMap: Record<string, string> = {
1442
1749
  '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
1443
1750
  '+': '+', '-': '-', '*': '*', '/': '/',
@@ -1532,13 +1839,17 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
1532
1839
  constructor(private readonly adapter: XslateAdapter) {}
1533
1840
 
1534
1841
  identifier(name: string): string {
1842
+ // Inline a module-scope pure-string const (`const x = 'literal'`) — it
1843
+ // never reaches the per-render stash, so a bare `$x` would render empty.
1844
+ const inlined = this.adapter._resolveModuleStringConst(name)
1845
+ if (inlined !== null) return inlined
1535
1846
  return `$${name}`
1536
1847
  }
1537
1848
 
1538
1849
  literal(value: string | number | boolean | null, literalType: LiteralType): string {
1539
1850
  if (literalType === 'string') return `'${value}'`
1540
1851
  if (literalType === 'boolean') return value ? '1' : '0'
1541
- if (literalType === 'null') return 'undef'
1852
+ if (literalType === 'null') return 'nil'
1542
1853
  return String(value)
1543
1854
  }
1544
1855
 
@@ -1549,8 +1860,9 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
1549
1860
  return `$${property}`
1550
1861
  }
1551
1862
  const obj = emit(object)
1552
- // Kolon array length is `$arr.size()`.
1553
- if (property === 'length') return `${obj}.size()`
1863
+ // `.length` `$bf.length` (array count or string char count, JS-compat);
1864
+ // Kolon's builtin `.size()` is array-only and faults on a string.
1865
+ if (property === 'length') return `$bf.length(${obj})`
1554
1866
  // Kolon dot access works for hash refs.
1555
1867
  return `${obj}.${property}`
1556
1868
  }
@@ -1588,14 +1900,10 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
1588
1900
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1589
1901
  const l = emit(left)
1590
1902
  const r = emit(right)
1591
- const isStr = (e: ParsedExpr) => isStringTypedOperand(e, n => this.adapter._isStringValueName(n))
1592
- const stringCmp = isStr(left) || isStr(right)
1593
- if ((op === '===' || op === '==') && stringCmp) {
1594
- return `${l} eq ${r}`
1595
- }
1596
- if ((op === '!==' || op === '!=') && stringCmp) {
1597
- return `${l} ne ${r}`
1598
- }
1903
+ // Kolon's `==` / `!=` are value-equality operators handling both strings
1904
+ // and numbers (unlike Perl's numeric `==`, which the Mojo adapter must
1905
+ // route around with `eq`/`ne`). Kolon has no `eq`/`ne` operator, so all
1906
+ // equality comparisons — string or numeric — stay on `==` / `!=`.
1599
1907
  const opMap: Record<string, string> = {
1600
1908
  '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
1601
1909
  '+': '+', '-': '-', '*': '*',
@@ -1618,27 +1926,25 @@ class XslateTopLevelEmitter implements ParsedExprEmitter {
1618
1926
  predicate: ParsedExpr,
1619
1927
  emit: (e: ParsedExpr) => string,
1620
1928
  ): string {
1621
- // `.filter` / `.every` / `.some` route through `$bf` array helpers that
1622
- // accept a Kolon code-ref predicate. `.find*` have no lowering yet.
1623
- if (method === 'find' || method === 'findIndex' || method === 'findLast' || method === 'findLastIndex') {
1624
- this.adapter._recordExprBF101(
1625
- `Xslate adapter has not lowered Array.prototype.${method} yet`,
1626
- )
1627
- return "''"
1628
- }
1629
- // Standalone `.filter` / `.every` / `.some` would need v1 runtime array
1630
- // helpers that accept a Kolon code-ref predicate, which the Xslate runtime
1631
- // doesn't expose. Refuse with a clear diagnostic rather than emit a call to
1632
- // a non-existent helper. The common `.filter(...).map(...)` *loop* form is
1633
- // handled separately by renderLoop's inline predicate, so it still works.
1634
- if (method === 'filter' || method === 'every' || method === 'some') {
1635
- this.adapter._recordExprBF101(
1636
- `Xslate adapter does not lower a standalone Array.prototype.${method} yet ` +
1637
- `(the .filter(...).map(...) loop form is supported). ` +
1638
- `Use /* @client */ or precompute the value.`,
1639
- )
1640
- return "''"
1641
- }
1929
+ // Higher-order array methods all take a JS arrow predicate, lowered to a
1930
+ // Kolon lambda `-> $param { PRED }` (callable from Perl as a code ref), and
1931
+ // go through the runtime object consistent with the other array helpers
1932
+ // ($bf.includes / $bf.slice / ...). `.find*` map to snake_case runtime
1933
+ // methods (like index_of / last_index_of). The `.filter(...).map(...)`
1934
+ // *loop* form is handled separately by renderLoop's inline predicate.
1935
+ const arrayExpr = emit(object)
1936
+ const predBody = this.adapter._renderKolonFilterExprPublic(predicate, param)
1937
+ const lambda = `-> $${param} { ${predBody} }`
1938
+ const fn: Record<string, string> = {
1939
+ filter: 'filter',
1940
+ every: 'every',
1941
+ some: 'some',
1942
+ find: 'find',
1943
+ findIndex: 'find_index',
1944
+ findLast: 'find_last',
1945
+ findLastIndex: 'find_last_index',
1946
+ }
1947
+ if (fn[method]) return `$bf.${fn[method]}(${arrayExpr}, ${lambda})`
1642
1948
  void predicate
1643
1949
  void param
1644
1950
  return emit(object)