@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.
- package/LICENSE +21 -0
- package/dist/adapter/index.js +187506 -57
- package/dist/adapter/xslate-adapter.d.ts +60 -0
- package/dist/adapter/xslate-adapter.d.ts.map +1 -1
- package/dist/build.js +187506 -57
- package/dist/index.js +187506 -57
- package/lib/BarefootJS/Backend/Xslate.pm +10 -2
- package/package.json +10 -24
- package/src/__tests__/xslate-adapter.test.ts +131 -0
- package/src/__tests__/xslate-spread-attrs.test.ts +218 -0
- package/src/adapter/xslate-adapter.ts +356 -50
- package/src/test-render.ts +661 -0
|
@@ -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
|
-
|
|
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 '
|
|
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`
|
|
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)}
|
|
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
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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 '
|
|
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
|
-
//
|
|
1553
|
-
|
|
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
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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
|
-
//
|
|
1622
|
-
//
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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)
|