@barefootjs/xslate 0.18.5 → 0.18.7
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/index.js +82 -5
- package/dist/adapter/lib/static-value.d.ts +18 -0
- package/dist/adapter/lib/static-value.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +25 -7
- package/dist/adapter/props/prop-classes.d.ts.map +1 -1
- package/dist/adapter/xslate-adapter.d.ts +41 -0
- package/dist/adapter/xslate-adapter.d.ts.map +1 -1
- package/dist/build.js +82 -5
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +84 -14
- package/dist/render-divergences.d.ts.map +1 -1
- package/lib/BarefootJS/Backend/Xslate.pm +1 -1
- package/package.json +3 -3
- package/src/__tests__/xslate-adapter.test.ts +119 -0
- package/src/adapter/lib/static-value.ts +44 -0
- package/src/adapter/props/prop-classes.ts +29 -7
- package/src/adapter/xslate-adapter.ts +102 -4
- package/src/conformance-pins.ts +26 -28
- package/src/render-divergences.ts +6 -1
- package/src/test-render.ts +13 -139
package/dist/index.js
CHANGED
|
@@ -187308,7 +187308,13 @@ import {
|
|
|
187308
187308
|
queryHrefArgs,
|
|
187309
187309
|
isValidHelperId,
|
|
187310
187310
|
sortComparatorFromArrow as sortComparatorFromArrow2,
|
|
187311
|
-
isLowerableLoopDestructure
|
|
187311
|
+
isLowerableLoopDestructure,
|
|
187312
|
+
isDangerousInnerHtmlAttr,
|
|
187313
|
+
resolveDangerousInnerHtml,
|
|
187314
|
+
dangerousInnerHtmlMetacharViolation,
|
|
187315
|
+
dangerousInnerHtmlDiagnostic,
|
|
187316
|
+
resolveStaticLoopSource,
|
|
187317
|
+
collectLoopBoundNames as collectLoopBoundNames2
|
|
187312
187318
|
} from "@barefootjs/jsx";
|
|
187313
187319
|
|
|
187314
187320
|
// src/adapter/boolean-result.ts
|
|
@@ -187624,6 +187630,39 @@ function renderFlatMethod(recv, depth, emit) {
|
|
|
187624
187630
|
return `$bf.flat(${recv}, ${d})`;
|
|
187625
187631
|
}
|
|
187626
187632
|
|
|
187633
|
+
// src/adapter/lib/static-value.ts
|
|
187634
|
+
function staticValueToKolon(value) {
|
|
187635
|
+
if (value === null || value === undefined)
|
|
187636
|
+
return "nil";
|
|
187637
|
+
if (typeof value === "boolean")
|
|
187638
|
+
return null;
|
|
187639
|
+
if (typeof value === "number")
|
|
187640
|
+
return String(value);
|
|
187641
|
+
if (typeof value === "string")
|
|
187642
|
+
return `'${escapeKolonSingleQuoted(value)}'`;
|
|
187643
|
+
if (Array.isArray(value)) {
|
|
187644
|
+
const items = [];
|
|
187645
|
+
for (const el of value) {
|
|
187646
|
+
const serialized = staticValueToKolon(el);
|
|
187647
|
+
if (serialized === null)
|
|
187648
|
+
return null;
|
|
187649
|
+
items.push(serialized);
|
|
187650
|
+
}
|
|
187651
|
+
return `[${items.join(", ")}]`;
|
|
187652
|
+
}
|
|
187653
|
+
if (typeof value === "object") {
|
|
187654
|
+
const entries = [];
|
|
187655
|
+
for (const [key, val] of Object.entries(value)) {
|
|
187656
|
+
const serialized = staticValueToKolon(val);
|
|
187657
|
+
if (serialized === null)
|
|
187658
|
+
return null;
|
|
187659
|
+
entries.push(`${kolonHashKey(key)} => ${serialized}`);
|
|
187660
|
+
}
|
|
187661
|
+
return `{ ${entries.join(", ")} }`;
|
|
187662
|
+
}
|
|
187663
|
+
return null;
|
|
187664
|
+
}
|
|
187665
|
+
|
|
187627
187666
|
// src/adapter/expr/emitters.ts
|
|
187628
187667
|
import {
|
|
187629
187668
|
groupBinaryOperand,
|
|
@@ -188167,6 +188206,9 @@ function generateDerivedMemoSeed(ctx, ir) {
|
|
|
188167
188206
|
` : "";
|
|
188168
188207
|
}
|
|
188169
188208
|
|
|
188209
|
+
// src/adapter/props/prop-classes.ts
|
|
188210
|
+
import { collectLoopBoundNames } from "@barefootjs/jsx";
|
|
188211
|
+
|
|
188170
188212
|
// src/adapter/value/parsed-literal.ts
|
|
188171
188213
|
import { evalStringArrayJoin } from "@barefootjs/jsx";
|
|
188172
188214
|
function isStringTypeInfo(type2) {
|
|
@@ -188197,6 +188239,12 @@ function collectStringValueNames(ir) {
|
|
|
188197
188239
|
if (isStringTypeInfo(p.type))
|
|
188198
188240
|
names.add(p.name);
|
|
188199
188241
|
}
|
|
188242
|
+
for (const c of ir.metadata.localConstants) {
|
|
188243
|
+
if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value))
|
|
188244
|
+
names.add(c.name);
|
|
188245
|
+
}
|
|
188246
|
+
for (const bound of collectLoopBoundNames(ir))
|
|
188247
|
+
names.delete(bound);
|
|
188200
188248
|
return names;
|
|
188201
188249
|
}
|
|
188202
188250
|
|
|
@@ -188232,6 +188280,7 @@ class XslateAdapter extends BaseAdapter {
|
|
|
188232
188280
|
_searchParamsLocals = new Set;
|
|
188233
188281
|
_loweringMatchers = [];
|
|
188234
188282
|
localConstants = [];
|
|
188283
|
+
staticLoopSourceBoundNames = new Set;
|
|
188235
188284
|
nullableOptionalProps = new Set;
|
|
188236
188285
|
constructor(options = {}) {
|
|
188237
188286
|
super();
|
|
@@ -188247,6 +188296,7 @@ class XslateAdapter extends BaseAdapter {
|
|
|
188247
188296
|
this.propsParams = ir.metadata.propsParams.map((p) => ({ name: p.name }));
|
|
188248
188297
|
this.booleanTypedProps = collectBooleanTypedProps(ir);
|
|
188249
188298
|
this.localConstants = ir.metadata.localConstants ?? [];
|
|
188299
|
+
this.staticLoopSourceBoundNames = collectLoopBoundNames2(ir);
|
|
188250
188300
|
this.nullableOptionalProps = collectNullableOptionalProps(ir);
|
|
188251
188301
|
this.stringValueNames = collectStringValueNames(ir);
|
|
188252
188302
|
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants);
|
|
@@ -188363,7 +188413,8 @@ class XslateAdapter extends BaseAdapter {
|
|
|
188363
188413
|
renderElement(element) {
|
|
188364
188414
|
const tag = element.tag;
|
|
188365
188415
|
const attrs = this.renderAttributes(element);
|
|
188366
|
-
const
|
|
188416
|
+
const dangerousHtml = this.renderDangerousInnerHtml(element);
|
|
188417
|
+
const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children);
|
|
188367
188418
|
let hydrationAttrs = "";
|
|
188368
188419
|
if (element.needsScope) {
|
|
188369
188420
|
hydrationAttrs += ` ${this.renderScopeMarker("")}`;
|
|
@@ -188398,6 +188449,22 @@ class XslateAdapter extends BaseAdapter {
|
|
|
188398
188449
|
}
|
|
188399
188450
|
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`;
|
|
188400
188451
|
}
|
|
188452
|
+
renderDangerousInnerHtml(element) {
|
|
188453
|
+
const resolution = resolveDangerousInnerHtml(element);
|
|
188454
|
+
if (!resolution)
|
|
188455
|
+
return null;
|
|
188456
|
+
if (resolution.kind === "dynamic") {
|
|
188457
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc));
|
|
188458
|
+
return "";
|
|
188459
|
+
}
|
|
188460
|
+
const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name);
|
|
188461
|
+
if (violation) {
|
|
188462
|
+
const attr = element.attrs.find(isDangerousInnerHtmlAttr);
|
|
188463
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation));
|
|
188464
|
+
return "";
|
|
188465
|
+
}
|
|
188466
|
+
return resolution.html;
|
|
188467
|
+
}
|
|
188401
188468
|
renderExpression(expr) {
|
|
188402
188469
|
if (expr.clientOnly) {
|
|
188403
188470
|
if (expr.slotId) {
|
|
@@ -188405,7 +188472,7 @@ class XslateAdapter extends BaseAdapter {
|
|
|
188405
188472
|
}
|
|
188406
188473
|
return "";
|
|
188407
188474
|
}
|
|
188408
|
-
const perlExpr = this.convertExpressionToKolon(expr.expr);
|
|
188475
|
+
const perlExpr = this.convertExpressionToKolon(expr.expr, expr.parsed);
|
|
188409
188476
|
if (expr.slotId) {
|
|
188410
188477
|
return `<: $bf.text_start("${expr.slotId}") | mark_raw :><: ${perlExpr} :><: $bf.text_end() | mark_raw :>`;
|
|
188411
188478
|
}
|
|
@@ -188497,8 +188564,12 @@ ${whenTrue}
|
|
|
188497
188564
|
}
|
|
188498
188565
|
});
|
|
188499
188566
|
}
|
|
188567
|
+
const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
|
|
188568
|
+
isNameShadowed: (name) => this.staticLoopSourceBoundNames.has(name)
|
|
188569
|
+
});
|
|
188570
|
+
const staticArray = staticItems !== null ? staticValueToKolon(staticItems) : null;
|
|
188500
188571
|
const arrayName = loop.array.trim();
|
|
188501
|
-
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
188572
|
+
if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
188502
188573
|
const arrayConst = (this.localConstants ?? []).find((c) => c.name === arrayName);
|
|
188503
188574
|
if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
|
|
188504
188575
|
this.errors.push({
|
|
@@ -188512,7 +188583,7 @@ ${whenTrue}
|
|
|
188512
188583
|
});
|
|
188513
188584
|
}
|
|
188514
188585
|
}
|
|
188515
|
-
const rawArray = this.convertExpressionToKolon(loop.array);
|
|
188586
|
+
const rawArray = staticArray ?? this.convertExpressionToKolon(loop.array);
|
|
188516
188587
|
let array = rawArray;
|
|
188517
188588
|
if (loop.sortComparator) {
|
|
188518
188589
|
const sort = loop.sortComparator;
|
|
@@ -188825,6 +188896,8 @@ ${name}="<: ${val} :>"
|
|
|
188825
188896
|
for (const attr of element.attrs) {
|
|
188826
188897
|
if (attr.clientOnly)
|
|
188827
188898
|
continue;
|
|
188899
|
+
if (isDangerousInnerHtmlAttr(attr))
|
|
188900
|
+
continue;
|
|
188828
188901
|
let attrName;
|
|
188829
188902
|
if (attr.name === "className")
|
|
188830
188903
|
attrName = "class";
|
|
@@ -189008,6 +189081,8 @@ Options:
|
|
|
189008
189081
|
return this.booleanTypedProps.has(bare);
|
|
189009
189082
|
}
|
|
189010
189083
|
_resolveLiteralConst(name) {
|
|
189084
|
+
if (this.staticLoopSourceBoundNames.has(name))
|
|
189085
|
+
return null;
|
|
189011
189086
|
const c = (this.localConstants ?? []).find((lc) => lc.name === name);
|
|
189012
189087
|
if (c?.value === undefined)
|
|
189013
189088
|
return null;
|
|
@@ -189020,6 +189095,8 @@ Options:
|
|
|
189020
189095
|
return null;
|
|
189021
189096
|
}
|
|
189022
189097
|
_resolveStaticRecordLiteral(objectName, key) {
|
|
189098
|
+
if (this.staticLoopSourceBoundNames.has(objectName))
|
|
189099
|
+
return null;
|
|
189023
189100
|
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants);
|
|
189024
189101
|
if (!hit)
|
|
189025
189102
|
return null;
|
|
@@ -189057,22 +189134,15 @@ Options:
|
|
|
189057
189134
|
var xslateAdapter = new XslateAdapter;
|
|
189058
189135
|
// src/conformance-pins.ts
|
|
189059
189136
|
var conformancePins = {
|
|
189060
|
-
"static-array-children": [{ code: "BF103", severity: "error" }],
|
|
189061
|
-
"todo-app": [{ code: "BF103", severity: "error" }],
|
|
189062
|
-
"todo-app-ssr": [{ code: "BF103", severity: "error" }],
|
|
189063
189137
|
"static-array-from-props": [{ code: "BF101", severity: "error" }],
|
|
189064
|
-
"static-array-from-props-with-component": [
|
|
189065
|
-
{ code: "BF103", severity: "error" },
|
|
189066
|
-
{ code: "BF101", severity: "error" }
|
|
189067
|
-
],
|
|
189138
|
+
"static-array-from-props-with-component": [{ code: "BF101", severity: "error" }],
|
|
189068
189139
|
"filter-nested-callback-predicate": [
|
|
189069
189140
|
{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
|
|
189070
189141
|
],
|
|
189071
189142
|
"filter-nested-find-predicate": [
|
|
189072
189143
|
{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
|
|
189073
189144
|
],
|
|
189074
|
-
"
|
|
189075
|
-
"dangerous-inner-html": [{ code: "BF101", severity: "error" }]
|
|
189145
|
+
"dangerous-inner-html-dynamic": [{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2215" }]
|
|
189076
189146
|
};
|
|
189077
189147
|
// src/render-divergences.ts
|
|
189078
189148
|
var renderDivergences = {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,
|
|
1
|
+
{"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAK/B,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/xslate",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
4
4
|
"description": "Text::Xslate (Kolon) adapter for BarefootJS — compiles IR to .tx templates and ships the Xslate rendering backend; runs under any PSGI/Plack app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -55,14 +55,14 @@
|
|
|
55
55
|
"directory": "packages/adapter-xslate"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@barefootjs/shared": "0.18.
|
|
58
|
+
"@barefootjs/shared": "0.18.7"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@barefootjs/jsx": ">=0.2.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@barefootjs/adapter-tests": "0.1.0",
|
|
65
|
-
"@barefootjs/jsx": "0.18.
|
|
65
|
+
"@barefootjs/jsx": "0.18.7",
|
|
66
66
|
"typescript": "^5.0.0"
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -421,3 +421,122 @@ export function Parent() {
|
|
|
421
421
|
// `filter-nested-find-predicate` (BF101 via `expectedDiagnostics` above) and
|
|
422
422
|
// `filter-nested-callback-predicate-client` (the `/* @client */` suppression
|
|
423
423
|
// twin, which must render clean).
|
|
424
|
+
|
|
425
|
+
// #2221: `_resolveLiteralConst` is a flat name lookup against
|
|
426
|
+
// `ir.metadata.localConstants` with no notion of AST scope — it used to
|
|
427
|
+
// substitute an outer const's literal value even at an occurrence that is
|
|
428
|
+
// actually an enclosing loop callback's own (shadowing) parameter, so every
|
|
429
|
+
// iteration rendered the same hard-coded literal. Guarded with the same
|
|
430
|
+
// coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
|
|
431
|
+
// anywhere in the component never inlines, falling back to the bare
|
|
432
|
+
// identifier.
|
|
433
|
+
describe('XslateAdapter - const inlining vs loop-param shadowing (#2221)', () => {
|
|
434
|
+
test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
|
|
435
|
+
const { template } = compileAndGenerate(`
|
|
436
|
+
function Widget() {
|
|
437
|
+
const label: string = 'x'
|
|
438
|
+
return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
|
|
439
|
+
}
|
|
440
|
+
`)
|
|
441
|
+
// The loop body must reference the per-iteration loop var...
|
|
442
|
+
expect(template).toContain('1 + $label')
|
|
443
|
+
// ...never the outer const's hard-coded value.
|
|
444
|
+
expect(template).not.toContain("1 + 'x'")
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('a numeric const shadowed by a loop param emits the identifier too', () => {
|
|
448
|
+
const { template } = compileAndGenerate(`
|
|
449
|
+
function Widget() {
|
|
450
|
+
const count = 7
|
|
451
|
+
return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
|
|
452
|
+
}
|
|
453
|
+
`)
|
|
454
|
+
expect(template).toContain('1 + $count')
|
|
455
|
+
expect(template).not.toContain('1 + 7')
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
|
|
459
|
+
const { template } = compileAndGenerate(`
|
|
460
|
+
function Widget({ values }: { values: number[] }) {
|
|
461
|
+
const totalPages = 5
|
|
462
|
+
return <div>
|
|
463
|
+
<p>Page 1 of {1 + totalPages}</p>
|
|
464
|
+
<ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
|
|
465
|
+
</div>
|
|
466
|
+
}
|
|
467
|
+
`)
|
|
468
|
+
expect(template).toContain('1 + 5')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
// The accepted coarse-exclusion trade-off (same as #2212): a name that is
|
|
472
|
+
// loop-bound ANYWHERE in the component never inlines, even at a genuinely
|
|
473
|
+
// non-shadowed occurrence outside the loop — the bare identifier is
|
|
474
|
+
// emitted instead of the value.
|
|
475
|
+
test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
|
|
476
|
+
const { template } = compileAndGenerate(`
|
|
477
|
+
function Widget({ values }: { values: number[] }) {
|
|
478
|
+
const label: string = 'x'
|
|
479
|
+
return <div>
|
|
480
|
+
<p>{1 + label}</p>
|
|
481
|
+
<ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
|
|
482
|
+
</div>
|
|
483
|
+
}
|
|
484
|
+
`)
|
|
485
|
+
expect(template).not.toContain("1 + 'x'")
|
|
486
|
+
expect(template).toContain('2 + $label')
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
// #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
|
|
491
|
+
// object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
|
|
492
|
+
// flat name lookup on `objectName` with no notion of AST scope, the
|
|
493
|
+
// record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
|
|
494
|
+
// substitute the outer const's member value even at an occurrence that is
|
|
495
|
+
// actually an enclosing loop callback's own (shadowing) parameter, so every
|
|
496
|
+
// iteration rendered the same hard-coded literal instead of the per-item
|
|
497
|
+
// value. Guarded with the same coarse `staticLoopSourceBoundNames`
|
|
498
|
+
// exclusion as #2221: any name a loop binds anywhere in the component
|
|
499
|
+
// never inlines, falling back to the bare `$cfg.x` member expression.
|
|
500
|
+
describe('XslateAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
|
|
501
|
+
test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
|
|
502
|
+
const { template } = compileAndGenerate(`
|
|
503
|
+
const cfg = { x: 'outer-lit' }
|
|
504
|
+
function Widget({ rows }: { rows: { x: string }[] }) {
|
|
505
|
+
return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
|
|
506
|
+
}
|
|
507
|
+
`)
|
|
508
|
+
// The loop body must reference the per-iteration member access...
|
|
509
|
+
expect(template).toContain('<: $cfg.x :>')
|
|
510
|
+
// ...never the outer const's hard-coded value.
|
|
511
|
+
expect(template).not.toContain("<: 'outer-lit' :>")
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
|
|
515
|
+
const { template } = compileAndGenerate(`
|
|
516
|
+
const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
|
|
517
|
+
function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
|
|
518
|
+
return <div>{variantClasses.ghost}</div>
|
|
519
|
+
}
|
|
520
|
+
`)
|
|
521
|
+
expect(template).toContain("<: 'bg-ghost' :>")
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// The accepted coarse-exclusion trade-off (same as #2221/#2212): an
|
|
525
|
+
// object name that is loop-bound ANYWHERE in the component never
|
|
526
|
+
// inlines its member lookups, even at a genuinely non-shadowed
|
|
527
|
+
// occurrence outside the loop — the bare member expression is emitted
|
|
528
|
+
// instead of the value.
|
|
529
|
+
test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
|
|
530
|
+
const { template } = compileAndGenerate(`
|
|
531
|
+
const cfg = { x: 'outer-lit' }
|
|
532
|
+
function Widget({ rows }: { rows: { x: string }[] }) {
|
|
533
|
+
return <div>
|
|
534
|
+
<p>{cfg.x}</p>
|
|
535
|
+
<ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
|
|
536
|
+
</div>
|
|
537
|
+
}
|
|
538
|
+
`)
|
|
539
|
+
expect(template).not.toContain("<: 'outer-lit' :>")
|
|
540
|
+
expect(template).toContain('<: $cfg.x :>')
|
|
541
|
+
})
|
|
542
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
|
|
3
|
+
* `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
|
|
4
|
+
* Text::Xslate (Kolon) literal. Used to inline a fully-static loop source
|
|
5
|
+
* (an inline array literal, or a function-scope local const with a static
|
|
6
|
+
* initializer) directly in a `for EXPR -> $item { ... }` header, rather
|
|
7
|
+
* than requiring a bound template variable.
|
|
8
|
+
*
|
|
9
|
+
* Booleans deliberately return `null` (defer to the caller's BF101
|
|
10
|
+
* refusal) rather than baking a `1`/absent-value stand-in — Kolon has no
|
|
11
|
+
* native boolean literal in this position, and guessing one would diverge
|
|
12
|
+
* from JS's `String(true) === "true"` at render.
|
|
13
|
+
*
|
|
14
|
+
* Returns `null` for a value this adapter can't represent as a literal —
|
|
15
|
+
* the caller falls back to its existing BF101 refusal instead of guessing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { escapeKolonSingleQuoted, kolonHashKey } from './kolon-naming.ts'
|
|
19
|
+
|
|
20
|
+
export function staticValueToKolon(value: unknown): string | null {
|
|
21
|
+
if (value === null || value === undefined) return 'nil'
|
|
22
|
+
if (typeof value === 'boolean') return null
|
|
23
|
+
if (typeof value === 'number') return String(value)
|
|
24
|
+
if (typeof value === 'string') return `'${escapeKolonSingleQuoted(value)}'`
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
const items: string[] = []
|
|
27
|
+
for (const el of value) {
|
|
28
|
+
const serialized = staticValueToKolon(el)
|
|
29
|
+
if (serialized === null) return null
|
|
30
|
+
items.push(serialized)
|
|
31
|
+
}
|
|
32
|
+
return `[${items.join(', ')}]`
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === 'object') {
|
|
35
|
+
const entries: string[] = []
|
|
36
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
37
|
+
const serialized = staticValueToKolon(val)
|
|
38
|
+
if (serialized === null) return null
|
|
39
|
+
entries.push(`${kolonHashKey(key)} => ${serialized}`)
|
|
40
|
+
}
|
|
41
|
+
return `{ ${entries.join(', ')} }`
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* adapter's `props/prop-types.ts`. No adapter instance state.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type
|
|
10
|
+
import { collectLoopBoundNames, type ComponentIR } from '@barefootjs/jsx'
|
|
11
11
|
import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -43,12 +43,30 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* String-typed signals
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
46
|
+
* String-typed signals, props, and same-file local consts (#2212). A
|
|
47
|
+
* signal is string-typed when its inferred type is `string` (or,
|
|
48
|
+
* defensively, when its initial value is a bare string literal); a prop
|
|
49
|
+
* when its annotated type is `string`; a local const the same way. Consumed
|
|
50
|
+
* by `isStringConcatBinary`/`isStringTypedOperand` (`@barefootjs/jsx`) to
|
|
51
|
+
* pick Kolon's `~` over JS `+`'s numeric fallback (#2163, #2212) —
|
|
52
|
+
* including now for a bare identifier operand, not just a prop/getter/
|
|
53
|
+
* literal. In the Mojo adapter this ALSO drives `eq`/`ne` selection for
|
|
54
|
+
* string equality; the Kolon emitters don't consume that distinction
|
|
55
|
+
* (Kolon's `==`/`!=` compare strings and numbers correctly), so that half
|
|
56
|
+
* of this set is carried only for parity with the Mojo adapter.
|
|
57
|
+
*
|
|
58
|
+
* Excludes any name bound as a `.map()`/`.filter()` loop callback's item
|
|
59
|
+
* or index parameter ANYWHERE in the component (Fable review, #2212): the
|
|
60
|
+
* lookup below is a flat, scope-blind `Set<string>` with no notion of a
|
|
61
|
+
* loop param shadowing an outer string-typed binding of the same name
|
|
62
|
+
* (`items.map((name) => 1 + name)` inside a component that also has a
|
|
63
|
+
* string `name` prop) — left unguarded, that shadowed `name` would be
|
|
64
|
+
* misdetected as string-typed and `1 + name` would silently lower to `~`
|
|
65
|
+
* instead of staying numeric `+`. Subtracting loop-bound names is coarse
|
|
66
|
+
* (it also suppresses a genuinely non-shadowed same-named string
|
|
67
|
+
* elsewhere in the component) but safe: the suppressed case just falls
|
|
68
|
+
* back to today's numeric `+` — the same, already-accepted residual as an
|
|
69
|
+
* unresolvable operand — never silently-wrong output.
|
|
52
70
|
*/
|
|
53
71
|
export function collectStringValueNames(ir: ComponentIR): Set<string> {
|
|
54
72
|
const names = new Set<string>()
|
|
@@ -60,5 +78,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
|
|
|
60
78
|
for (const p of ir.metadata.propsParams) {
|
|
61
79
|
if (isStringTypeInfo(p.type)) names.add(p.name)
|
|
62
80
|
}
|
|
81
|
+
for (const c of ir.metadata.localConstants) {
|
|
82
|
+
if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
|
|
83
|
+
}
|
|
84
|
+
for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
|
|
63
85
|
return names
|
|
64
86
|
}
|
|
@@ -73,6 +73,12 @@ import {
|
|
|
73
73
|
isValidHelperId,
|
|
74
74
|
sortComparatorFromArrow,
|
|
75
75
|
isLowerableLoopDestructure,
|
|
76
|
+
isDangerousInnerHtmlAttr,
|
|
77
|
+
resolveDangerousInnerHtml,
|
|
78
|
+
dangerousInnerHtmlMetacharViolation,
|
|
79
|
+
dangerousInnerHtmlDiagnostic,
|
|
80
|
+
resolveStaticLoopSource,
|
|
81
|
+
collectLoopBoundNames,
|
|
76
82
|
} from '@barefootjs/jsx'
|
|
77
83
|
import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
|
|
78
84
|
import ts from 'typescript'
|
|
@@ -87,6 +93,7 @@ import {
|
|
|
87
93
|
collectRootScopeNodes,
|
|
88
94
|
} from './lib/ir-scope.ts'
|
|
89
95
|
import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
|
|
96
|
+
import { staticValueToKolon } from './lib/static-value.ts'
|
|
90
97
|
import { XslateFilterEmitter, XslateTopLevelEmitter } from './expr/emitters.ts'
|
|
91
98
|
import type { XslateEmitContext, XslateSpreadContext, XslateMemoContext } from './emit-context.ts'
|
|
92
99
|
import {
|
|
@@ -240,6 +247,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
240
247
|
*/
|
|
241
248
|
private localConstants: IRMetadata['localConstants'] = []
|
|
242
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Every name a `.map()`/`.filter()` loop callback binds as its item/index
|
|
252
|
+
* parameter anywhere in the component (#2208 fable review). A static
|
|
253
|
+
* loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
|
|
254
|
+
* never resolve through `resolveStaticLoopSource` at a use site where a
|
|
255
|
+
* DIFFERENT, enclosing loop's own callback param shadows it — same
|
|
256
|
+
* shadowing hazard, and same coarse-but-safe mitigation, as #2212's
|
|
257
|
+
* `collectLoopBoundNames` use in `collectStringValueNames`.
|
|
258
|
+
*/
|
|
259
|
+
private staticLoopSourceBoundNames: Set<string> = new Set()
|
|
260
|
+
|
|
243
261
|
/**
|
|
244
262
|
* Optional, no-default props that are `undef` when the caller omits them.
|
|
245
263
|
* Their bare-reference attribute emission is guarded with Kolon `defined` so
|
|
@@ -272,6 +290,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
272
290
|
// Per-compile prop classifications (see `props/prop-classes.ts`).
|
|
273
291
|
this.booleanTypedProps = collectBooleanTypedProps(ir)
|
|
274
292
|
this.localConstants = ir.metadata.localConstants ?? []
|
|
293
|
+
this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
|
|
275
294
|
this.nullableOptionalProps = collectNullableOptionalProps(ir)
|
|
276
295
|
this.stringValueNames = collectStringValueNames(ir)
|
|
277
296
|
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
@@ -494,7 +513,8 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
494
513
|
renderElement(element: IRElement): string {
|
|
495
514
|
const tag = element.tag
|
|
496
515
|
const attrs = this.renderAttributes(element)
|
|
497
|
-
const
|
|
516
|
+
const dangerousHtml = this.renderDangerousInnerHtml(element)
|
|
517
|
+
const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
|
|
498
518
|
|
|
499
519
|
let hydrationAttrs = ''
|
|
500
520
|
if (element.needsScope) {
|
|
@@ -529,6 +549,28 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
529
549
|
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
530
550
|
}
|
|
531
551
|
|
|
552
|
+
/**
|
|
553
|
+
* `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
|
|
554
|
+
* adapter's identical helper for the full rationale. `null` means the
|
|
555
|
+
* attribute is absent (caller falls through to normal `renderChildren`);
|
|
556
|
+
* a non-`null` string (possibly `''`) replaces the children outright.
|
|
557
|
+
*/
|
|
558
|
+
private renderDangerousInnerHtml(element: IRElement): string | null {
|
|
559
|
+
const resolution = resolveDangerousInnerHtml(element)
|
|
560
|
+
if (!resolution) return null
|
|
561
|
+
if (resolution.kind === 'dynamic') {
|
|
562
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
|
|
563
|
+
return ''
|
|
564
|
+
}
|
|
565
|
+
const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
|
|
566
|
+
if (violation) {
|
|
567
|
+
const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
|
|
568
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
|
|
569
|
+
return ''
|
|
570
|
+
}
|
|
571
|
+
return resolution.html
|
|
572
|
+
}
|
|
573
|
+
|
|
532
574
|
// ===========================================================================
|
|
533
575
|
// Expression Rendering
|
|
534
576
|
// ===========================================================================
|
|
@@ -541,7 +583,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
541
583
|
return ''
|
|
542
584
|
}
|
|
543
585
|
|
|
544
|
-
|
|
586
|
+
// Thread the IR-carried `.parsed` tree through (mirrors go-template's
|
|
587
|
+
// `convertExpressionToGo(expr.expr, classify, expr.parsed)`) so a
|
|
588
|
+
// resolved bare-identifier `.map`/`.filter`/… callback
|
|
589
|
+
// (`resolveCallbackMethodFunctionReferences`, #2206) isn't lost to a
|
|
590
|
+
// fresh, unresolved re-parse of the raw string.
|
|
591
|
+
const perlExpr = this.convertExpressionToKolon(expr.expr, expr.parsed)
|
|
545
592
|
|
|
546
593
|
if (expr.slotId) {
|
|
547
594
|
return `<: $bf.text_start("${expr.slotId}") | mark_raw :><: ${perlExpr} :><: $bf.text_end() | mark_raw :>`
|
|
@@ -687,8 +734,27 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
687
734
|
// this adapter's test corpus only because the widened destructure gate
|
|
688
735
|
// (#2087 Phase A/B) no longer refuses this fixture's `([emoji, users])
|
|
689
736
|
// => ...` param first.
|
|
737
|
+
// #2208: a loop source that is a fully-static array literal — either
|
|
738
|
+
// inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
|
|
739
|
+
// bound to a FUNCTION-scope local const whose initializer has no
|
|
740
|
+
// prop/signal/function-call dependency — inlines as a native Kolon
|
|
741
|
+
// array/hash literal below, the same way a module-scope const's value
|
|
742
|
+
// is already seeded. A runtime-computed local (#2069, e.g.
|
|
743
|
+
// `Object.entries(props.tags).filter(...)`) still refuses below.
|
|
744
|
+
// `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
|
|
745
|
+
// param shadowing this identifier (fable review) — never resolve the
|
|
746
|
+
// static const in that case. `rawArray` then falls through to the
|
|
747
|
+
// bare identifier expression below, same as before #2208 — which
|
|
748
|
+
// still trips the pre-existing BF101 gate for an unresolvable local
|
|
749
|
+
// const reference (a loud, conservative refusal, not a silent wrong
|
|
750
|
+
// value).
|
|
751
|
+
const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
|
|
752
|
+
isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
|
|
753
|
+
})
|
|
754
|
+
const staticArray = staticItems !== null ? staticValueToKolon(staticItems) : null
|
|
755
|
+
|
|
690
756
|
const arrayName = loop.array.trim()
|
|
691
|
-
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
757
|
+
if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
692
758
|
const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
|
|
693
759
|
if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
|
|
694
760
|
this.errors.push({
|
|
@@ -704,7 +770,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
704
770
|
}
|
|
705
771
|
}
|
|
706
772
|
|
|
707
|
-
const rawArray = this.convertExpressionToKolon(loop.array)
|
|
773
|
+
const rawArray = staticArray ?? this.convertExpressionToKolon(loop.array)
|
|
708
774
|
// Apply sort if present: wrap the loop array in the shared `$bf.sort`
|
|
709
775
|
// helper, binding the sorted result to a per-iteration local so the
|
|
710
776
|
// helper runs once.
|
|
@@ -1344,6 +1410,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
1344
1410
|
// the unsupported-expression lowering is never reached for a deferred
|
|
1345
1411
|
// predicate (no BF101 / BF102). #1966
|
|
1346
1412
|
if (attr.clientOnly) continue
|
|
1413
|
+
// `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
|
|
1414
|
+
// handled by `renderDangerousInnerHtml` instead, which replaces the
|
|
1415
|
+
// element's children. Skip it here so its `{ __html: ... }` object
|
|
1416
|
+
// literal never reaches the generic object-literal BF101 refusal
|
|
1417
|
+
// (which would double-report alongside the purpose-built one).
|
|
1418
|
+
if (isDangerousInnerHtmlAttr(attr)) continue
|
|
1347
1419
|
// Rewrite JSX special-prop names to their HTML-attribute counterparts.
|
|
1348
1420
|
let attrName: string
|
|
1349
1421
|
if (attr.name === 'className') attrName = 'class'
|
|
@@ -1672,8 +1744,19 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
1672
1744
|
* single-quoted string literal (`const totalPages = 5`, #1897
|
|
1673
1745
|
* pagination) — function-scope consts never reach the per-render
|
|
1674
1746
|
* stash, so a bare `$totalPages` renders empty.
|
|
1747
|
+
*
|
|
1748
|
+
* The lookup is a flat name match with no notion of AST scope, so a
|
|
1749
|
+
* name that any loop callback binds as its item/index param never
|
|
1750
|
+
* inlines (#2221) — the occurrence may be the loop's own (shadowing)
|
|
1751
|
+
* binding, and substituting the outer const's value there renders every
|
|
1752
|
+
* iteration with the same hard-coded literal. Coarse (a genuinely
|
|
1753
|
+
* non-shadowed same-named const elsewhere in the component also stops
|
|
1754
|
+
* inlining, falling back to the bare identifier) but safe — the same
|
|
1755
|
+
* trade-off as #2212's `collectLoopBoundNames` use in
|
|
1756
|
+
* `collectStringValueNames`.
|
|
1675
1757
|
*/
|
|
1676
1758
|
private _resolveLiteralConst(name: string): string | null {
|
|
1759
|
+
if (this.staticLoopSourceBoundNames.has(name)) return null
|
|
1677
1760
|
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
1678
1761
|
if (c?.value === undefined) return null
|
|
1679
1762
|
const v = c.value.trim()
|
|
@@ -1683,7 +1766,22 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
|
|
|
1683
1766
|
return null
|
|
1684
1767
|
}
|
|
1685
1768
|
|
|
1769
|
+
/**
|
|
1770
|
+
* Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
|
|
1771
|
+
* (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
|
|
1772
|
+
*
|
|
1773
|
+
* The lookup is a flat name match on `objectName` with no notion of AST
|
|
1774
|
+
* scope, so an enclosing loop callback's own param of the same name
|
|
1775
|
+
* (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
|
|
1776
|
+
* still resolved to the OUTER const's member value at every iteration
|
|
1777
|
+
* (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
|
|
1778
|
+
* coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
|
|
1779
|
+
* binds anywhere in the component never inlines, falling back to the bare
|
|
1780
|
+
* `$cfg.x` member expression (which an Xslate `: for` loop binds
|
|
1781
|
+
* correctly at the shadowed occurrences).
|
|
1782
|
+
*/
|
|
1686
1783
|
private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
1784
|
+
if (this.staticLoopSourceBoundNames.has(objectName)) return null
|
|
1687
1785
|
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
1688
1786
|
if (!hit) return null
|
|
1689
1787
|
return hit.kind === 'number'
|