@barefootjs/blade 0.18.5 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/blade-adapter.d.ts +48 -0
- package/dist/adapter/blade-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +83 -6
- package/dist/adapter/lib/static-value.d.ts +13 -0
- package/dist/adapter/lib/static-value.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +29 -11
- package/dist/adapter/props/prop-classes.d.ts.map +1 -1
- package/dist/build.js +83 -6
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +85 -15
- package/dist/render-divergences.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/blade-adapter-unit.test.ts +120 -0
- package/src/__tests__/blade-adapter.test.ts +13 -0
- package/src/adapter/blade-adapter.ts +110 -5
- package/src/adapter/lib/static-value.ts +39 -0
- package/src/adapter/props/prop-classes.ts +34 -12
- package/src/conformance-pins.ts +25 -28
- package/src/render-divergences.ts +6 -1
- package/src/test-render.ts +10 -119
package/dist/index.js
CHANGED
|
@@ -187308,7 +187308,13 @@ import {
|
|
|
187308
187308
|
prepareLoweringMatchers,
|
|
187309
187309
|
queryHrefArgs,
|
|
187310
187310
|
sortComparatorFromArrow as sortComparatorFromArrow2,
|
|
187311
|
-
isValidHelperId
|
|
187311
|
+
isValidHelperId,
|
|
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
|
|
@@ -187654,6 +187660,39 @@ function renderFlatMethod(recv, depth, emit) {
|
|
|
187654
187660
|
return `$bf->flat(${recv}, ${d})`;
|
|
187655
187661
|
}
|
|
187656
187662
|
|
|
187663
|
+
// src/adapter/lib/static-value.ts
|
|
187664
|
+
function staticValueToBlade(value) {
|
|
187665
|
+
if (value === null || value === undefined)
|
|
187666
|
+
return "null";
|
|
187667
|
+
if (typeof value === "boolean")
|
|
187668
|
+
return value ? "true" : "false";
|
|
187669
|
+
if (typeof value === "number")
|
|
187670
|
+
return String(value);
|
|
187671
|
+
if (typeof value === "string")
|
|
187672
|
+
return `'${escapeBladeSingleQuoted(value)}'`;
|
|
187673
|
+
if (Array.isArray(value)) {
|
|
187674
|
+
const items = [];
|
|
187675
|
+
for (const el of value) {
|
|
187676
|
+
const serialized = staticValueToBlade(el);
|
|
187677
|
+
if (serialized === null)
|
|
187678
|
+
return null;
|
|
187679
|
+
items.push(serialized);
|
|
187680
|
+
}
|
|
187681
|
+
return `[${items.join(", ")}]`;
|
|
187682
|
+
}
|
|
187683
|
+
if (typeof value === "object") {
|
|
187684
|
+
const entries = [];
|
|
187685
|
+
for (const [key, val] of Object.entries(value)) {
|
|
187686
|
+
const serialized = staticValueToBlade(val);
|
|
187687
|
+
if (serialized === null)
|
|
187688
|
+
return null;
|
|
187689
|
+
entries.push(`${bladeHashKey(key)} => ${serialized}`);
|
|
187690
|
+
}
|
|
187691
|
+
return `[${entries.join(", ")}]`;
|
|
187692
|
+
}
|
|
187693
|
+
return null;
|
|
187694
|
+
}
|
|
187695
|
+
|
|
187657
187696
|
// src/adapter/expr/emitters.ts
|
|
187658
187697
|
import {
|
|
187659
187698
|
groupBinaryOperand,
|
|
@@ -188193,6 +188232,9 @@ function generateDerivedMemoSeed(ctx, ir) {
|
|
|
188193
188232
|
` : "";
|
|
188194
188233
|
}
|
|
188195
188234
|
|
|
188235
|
+
// src/adapter/props/prop-classes.ts
|
|
188236
|
+
import { collectLoopBoundNames } from "@barefootjs/jsx";
|
|
188237
|
+
|
|
188196
188238
|
// src/adapter/value/parsed-literal.ts
|
|
188197
188239
|
import { evalStringArrayJoin } from "@barefootjs/jsx";
|
|
188198
188240
|
function isStringTypeInfo(type2) {
|
|
@@ -188210,7 +188252,7 @@ function collectBooleanTypedProps(ir) {
|
|
|
188210
188252
|
return new Set(ir.metadata.propsParams.filter((prop) => prop.type?.primitive === "boolean" || prop.type?.raw === "boolean").map((prop) => prop.name));
|
|
188211
188253
|
}
|
|
188212
188254
|
function collectNullableOptionalProps(ir) {
|
|
188213
|
-
return new Set(ir.metadata.propsParams.filter((p) => p.defaultValue === undefined && !p.isRest && p.type?.kind !== "primitive").map((p) => p.name));
|
|
188255
|
+
return new Set(ir.metadata.propsParams.filter((p) => p.defaultValue === undefined && !p.isRest && (p.type?.kind !== "primitive" || p.optional)).map((p) => p.name));
|
|
188214
188256
|
}
|
|
188215
188257
|
function collectStringValueNames(ir) {
|
|
188216
188258
|
const names = new Set;
|
|
@@ -188223,6 +188265,12 @@ function collectStringValueNames(ir) {
|
|
|
188223
188265
|
if (isStringTypeInfo(p.type))
|
|
188224
188266
|
names.add(p.name);
|
|
188225
188267
|
}
|
|
188268
|
+
for (const c of ir.metadata.localConstants) {
|
|
188269
|
+
if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value))
|
|
188270
|
+
names.add(c.name);
|
|
188271
|
+
}
|
|
188272
|
+
for (const bound of collectLoopBoundNames(ir))
|
|
188273
|
+
names.delete(bound);
|
|
188226
188274
|
return names;
|
|
188227
188275
|
}
|
|
188228
188276
|
|
|
@@ -188247,6 +188295,7 @@ class BladeAdapter extends BaseAdapter {
|
|
|
188247
188295
|
_searchParamsLocals = new Set;
|
|
188248
188296
|
_loweringMatchers = [];
|
|
188249
188297
|
localConstants = [];
|
|
188298
|
+
staticLoopSourceBoundNames = new Set;
|
|
188250
188299
|
nullableOptionalProps = new Set;
|
|
188251
188300
|
constructor(options = {}) {
|
|
188252
188301
|
super();
|
|
@@ -188262,6 +188311,7 @@ class BladeAdapter extends BaseAdapter {
|
|
|
188262
188311
|
this.propsParams = ir.metadata.propsParams.map((p) => ({ name: p.name }));
|
|
188263
188312
|
this.booleanTypedProps = collectBooleanTypedProps(ir);
|
|
188264
188313
|
this.localConstants = ir.metadata.localConstants ?? [];
|
|
188314
|
+
this.staticLoopSourceBoundNames = collectLoopBoundNames2(ir);
|
|
188265
188315
|
this.nullableOptionalProps = collectNullableOptionalProps(ir);
|
|
188266
188316
|
this.stringValueNames = collectStringValueNames(ir);
|
|
188267
188317
|
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants);
|
|
@@ -188383,7 +188433,8 @@ class BladeAdapter extends BaseAdapter {
|
|
|
188383
188433
|
renderElement(element) {
|
|
188384
188434
|
const tag = element.tag;
|
|
188385
188435
|
const attrs = this.renderAttributes(element);
|
|
188386
|
-
const
|
|
188436
|
+
const dangerousHtml = this.renderDangerousInnerHtml(element);
|
|
188437
|
+
const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children);
|
|
188387
188438
|
let hydrationAttrs = "";
|
|
188388
188439
|
if (element.needsScope) {
|
|
188389
188440
|
hydrationAttrs += ` ${this.renderScopeMarker("")}`;
|
|
@@ -188418,6 +188469,22 @@ class BladeAdapter extends BaseAdapter {
|
|
|
188418
188469
|
}
|
|
188419
188470
|
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`;
|
|
188420
188471
|
}
|
|
188472
|
+
renderDangerousInnerHtml(element) {
|
|
188473
|
+
const resolution = resolveDangerousInnerHtml(element);
|
|
188474
|
+
if (!resolution)
|
|
188475
|
+
return null;
|
|
188476
|
+
if (resolution.kind === "dynamic") {
|
|
188477
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc));
|
|
188478
|
+
return "";
|
|
188479
|
+
}
|
|
188480
|
+
const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name);
|
|
188481
|
+
if (violation) {
|
|
188482
|
+
const attr = element.attrs.find(isDangerousInnerHtmlAttr);
|
|
188483
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation));
|
|
188484
|
+
return "";
|
|
188485
|
+
}
|
|
188486
|
+
return resolution.html;
|
|
188487
|
+
}
|
|
188421
188488
|
renderExpression(expr) {
|
|
188422
188489
|
if (expr.clientOnly) {
|
|
188423
188490
|
if (expr.slotId) {
|
|
@@ -188425,7 +188492,7 @@ class BladeAdapter extends BaseAdapter {
|
|
|
188425
188492
|
}
|
|
188426
188493
|
return "";
|
|
188427
188494
|
}
|
|
188428
|
-
const bladeExpr = `$bf->string(${this.convertExpressionToBlade(expr.expr)})`;
|
|
188495
|
+
const bladeExpr = `$bf->string(${this.convertExpressionToBlade(expr.expr, expr.parsed)})`;
|
|
188429
188496
|
if (expr.slotId) {
|
|
188430
188497
|
return `{!! $bf->text_start("${expr.slotId}") !!}{!! e(${bladeExpr}) !!}{!! $bf->text_end() !!}`;
|
|
188431
188498
|
}
|
|
@@ -188516,8 +188583,12 @@ ${whenTrue}
|
|
|
188516
188583
|
}
|
|
188517
188584
|
});
|
|
188518
188585
|
}
|
|
188586
|
+
const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
|
|
188587
|
+
isNameShadowed: (name) => this.staticLoopSourceBoundNames.has(name)
|
|
188588
|
+
});
|
|
188589
|
+
const staticArray = staticItems !== null ? staticValueToBlade(staticItems) : null;
|
|
188519
188590
|
const arrayName = loop.array.trim();
|
|
188520
|
-
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
188591
|
+
if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
188521
188592
|
const arrayConst = (this.localConstants ?? []).find((c) => c.name === arrayName);
|
|
188522
188593
|
if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
|
|
188523
188594
|
this.errors.push({
|
|
@@ -188531,7 +188602,7 @@ ${whenTrue}
|
|
|
188531
188602
|
});
|
|
188532
188603
|
}
|
|
188533
188604
|
}
|
|
188534
|
-
const rawArray = this.convertExpressionToBlade(loop.array);
|
|
188605
|
+
const rawArray = staticArray ?? this.convertExpressionToBlade(loop.array);
|
|
188535
188606
|
let array = rawArray;
|
|
188536
188607
|
if (loop.sortComparator) {
|
|
188537
188608
|
const sort = loop.sortComparator;
|
|
@@ -188850,6 +188921,8 @@ ${name}="{!! e($bf->string(${val})) !!}"
|
|
|
188850
188921
|
for (const attr of element.attrs) {
|
|
188851
188922
|
if (attr.clientOnly)
|
|
188852
188923
|
continue;
|
|
188924
|
+
if (isDangerousInnerHtmlAttr(attr))
|
|
188925
|
+
continue;
|
|
188853
188926
|
let attrName;
|
|
188854
188927
|
if (attr.name === "className")
|
|
188855
188928
|
attrName = "class";
|
|
@@ -189050,6 +189123,8 @@ Options:
|
|
|
189050
189123
|
return isBooleanResultExpr(expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(expr);
|
|
189051
189124
|
}
|
|
189052
189125
|
_resolveLiteralConst(name) {
|
|
189126
|
+
if (this.staticLoopSourceBoundNames.has(name))
|
|
189127
|
+
return null;
|
|
189053
189128
|
const c = (this.localConstants ?? []).find((lc) => lc.name === name);
|
|
189054
189129
|
if (c?.value === undefined)
|
|
189055
189130
|
return null;
|
|
@@ -189062,6 +189137,8 @@ Options:
|
|
|
189062
189137
|
return null;
|
|
189063
189138
|
}
|
|
189064
189139
|
_resolveStaticRecordLiteral(objectName, key) {
|
|
189140
|
+
if (this.staticLoopSourceBoundNames.has(objectName))
|
|
189141
|
+
return null;
|
|
189065
189142
|
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants);
|
|
189066
189143
|
if (!hit)
|
|
189067
189144
|
return null;
|
|
@@ -189099,22 +189176,15 @@ Options:
|
|
|
189099
189176
|
var bladeAdapter = new BladeAdapter;
|
|
189100
189177
|
// src/conformance-pins.ts
|
|
189101
189178
|
var conformancePins = {
|
|
189102
|
-
"static-array-children": [{ code: "BF103", severity: "error" }],
|
|
189103
|
-
"todo-app": [{ code: "BF103", severity: "error" }],
|
|
189104
|
-
"todo-app-ssr": [{ code: "BF103", severity: "error" }],
|
|
189105
189179
|
"static-array-from-props": [{ code: "BF101", severity: "error" }],
|
|
189106
|
-
"static-array-from-props-with-component": [
|
|
189107
|
-
{ code: "BF103", severity: "error" },
|
|
189108
|
-
{ code: "BF101", severity: "error" }
|
|
189109
|
-
],
|
|
189180
|
+
"static-array-from-props-with-component": [{ code: "BF101", severity: "error" }],
|
|
189110
189181
|
"filter-nested-callback-predicate": [
|
|
189111
189182
|
{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
|
|
189112
189183
|
],
|
|
189113
189184
|
"filter-nested-find-predicate": [
|
|
189114
189185
|
{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
|
|
189115
189186
|
],
|
|
189116
|
-
"
|
|
189117
|
-
"dangerous-inner-html": [{ code: "BF101", severity: "error" }]
|
|
189187
|
+
"dangerous-inner-html-dynamic": [{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2215" }]
|
|
189118
189188
|
};
|
|
189119
189189
|
// src/render-divergences.ts
|
|
189120
189190
|
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/blade",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Laravel Blade (PHP) adapter for BarefootJS — compiles IR to .blade.php templates and ships the PHP BarefootJS rendering runtime on illuminate/view standalone",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -54,14 +54,14 @@
|
|
|
54
54
|
"directory": "packages/adapter-blade"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@barefootjs/shared": "0.
|
|
57
|
+
"@barefootjs/shared": "0.19.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@barefootjs/jsx": ">=0.2.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@barefootjs/adapter-tests": "0.1.0",
|
|
64
|
-
"@barefootjs/jsx": "0.
|
|
64
|
+
"@barefootjs/jsx": "0.19.0",
|
|
65
65
|
"typescript": "^5.0.0"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -406,6 +406,126 @@ export function Parent() {
|
|
|
406
406
|
})
|
|
407
407
|
})
|
|
408
408
|
|
|
409
|
+
// #2221: `_resolveLiteralConst` is a flat name lookup against
|
|
410
|
+
// `ir.metadata.localConstants` with no notion of AST scope — it used to
|
|
411
|
+
// substitute an outer const's literal value even at an occurrence that is
|
|
412
|
+
// actually an enclosing loop callback's own (shadowing) parameter, so every
|
|
413
|
+
// iteration rendered the same hard-coded literal. Guarded with the same
|
|
414
|
+
// coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
|
|
415
|
+
// anywhere in the component never inlines, falling back to the bare
|
|
416
|
+
// identifier.
|
|
417
|
+
describe('BladeAdapter - const inlining vs loop-param shadowing (#2221)', () => {
|
|
418
|
+
test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
|
|
419
|
+
const { template } = compileAndGenerate(`
|
|
420
|
+
function Widget() {
|
|
421
|
+
const label: string = 'x'
|
|
422
|
+
return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
|
|
423
|
+
}
|
|
424
|
+
`)
|
|
425
|
+
// The loop body must reference the per-iteration loop var...
|
|
426
|
+
expect(template).toContain('1 + $label')
|
|
427
|
+
// ...never the outer const's hard-coded value.
|
|
428
|
+
expect(template).not.toContain("1 + 'x'")
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('a numeric const shadowed by a loop param emits the identifier too', () => {
|
|
432
|
+
const { template } = compileAndGenerate(`
|
|
433
|
+
function Widget() {
|
|
434
|
+
const count = 7
|
|
435
|
+
return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
|
|
436
|
+
}
|
|
437
|
+
`)
|
|
438
|
+
expect(template).toContain('1 + $count')
|
|
439
|
+
expect(template).not.toContain('1 + 7')
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
|
|
443
|
+
const { template } = compileAndGenerate(`
|
|
444
|
+
function Widget({ values }: { values: number[] }) {
|
|
445
|
+
const totalPages = 5
|
|
446
|
+
return <div>
|
|
447
|
+
<p>Page 1 of {1 + totalPages}</p>
|
|
448
|
+
<ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
|
|
449
|
+
</div>
|
|
450
|
+
}
|
|
451
|
+
`)
|
|
452
|
+
expect(template).toContain('1 + 5')
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// The accepted coarse-exclusion trade-off (same as #2212): a name that is
|
|
456
|
+
// loop-bound ANYWHERE in the component never inlines, even at a genuinely
|
|
457
|
+
// non-shadowed occurrence outside the loop — the bare identifier is
|
|
458
|
+
// emitted instead of the value.
|
|
459
|
+
test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
|
|
460
|
+
const { template } = compileAndGenerate(`
|
|
461
|
+
function Widget({ values }: { values: number[] }) {
|
|
462
|
+
const label: string = 'x'
|
|
463
|
+
return <div>
|
|
464
|
+
<p>{1 + label}</p>
|
|
465
|
+
<ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
|
|
466
|
+
</div>
|
|
467
|
+
}
|
|
468
|
+
`)
|
|
469
|
+
expect(template).not.toContain("1 + 'x'")
|
|
470
|
+
expect(template).toContain('2 + $label')
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
// #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
|
|
475
|
+
// object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
|
|
476
|
+
// flat name lookup on `objectName` with no notion of AST scope, the
|
|
477
|
+
// record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
|
|
478
|
+
// substitute the outer const's member value even at an occurrence that is
|
|
479
|
+
// actually an enclosing loop callback's own (shadowing) parameter, so every
|
|
480
|
+
// iteration rendered the same hard-coded literal instead of the per-item
|
|
481
|
+
// value. Guarded with the same coarse `staticLoopSourceBoundNames`
|
|
482
|
+
// exclusion as #2221: any name a loop binds anywhere in the component
|
|
483
|
+
// never inlines, falling back to the bare `data_get($cfg, 'x')` member
|
|
484
|
+
// expression.
|
|
485
|
+
describe('BladeAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
|
|
486
|
+
test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
|
|
487
|
+
const { template } = compileAndGenerate(`
|
|
488
|
+
const cfg = { x: 'outer-lit' }
|
|
489
|
+
function Widget({ rows }: { rows: { x: string }[] }) {
|
|
490
|
+
return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
|
|
491
|
+
}
|
|
492
|
+
`)
|
|
493
|
+
// The loop body must reference the per-iteration member access...
|
|
494
|
+
expect(template).toContain("bf->string(data_get($cfg, 'x'))")
|
|
495
|
+
// ...never the outer const's hard-coded value.
|
|
496
|
+
expect(template).not.toContain("bf->string('outer-lit')")
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
|
|
500
|
+
const { template } = compileAndGenerate(`
|
|
501
|
+
const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
|
|
502
|
+
function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
|
|
503
|
+
return <div>{variantClasses.ghost}</div>
|
|
504
|
+
}
|
|
505
|
+
`)
|
|
506
|
+
expect(template).toContain("bf->string('bg-ghost')")
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// The accepted coarse-exclusion trade-off (same as #2221/#2212): an
|
|
510
|
+
// object name that is loop-bound ANYWHERE in the component never
|
|
511
|
+
// inlines its member lookups, even at a genuinely non-shadowed
|
|
512
|
+
// occurrence outside the loop — the bare member expression is emitted
|
|
513
|
+
// instead of the value.
|
|
514
|
+
test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
|
|
515
|
+
const { template } = compileAndGenerate(`
|
|
516
|
+
const cfg = { x: 'outer-lit' }
|
|
517
|
+
function Widget({ rows }: { rows: { x: string }[] }) {
|
|
518
|
+
return <div>
|
|
519
|
+
<p>{cfg.x}</p>
|
|
520
|
+
<ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
|
|
521
|
+
</div>
|
|
522
|
+
}
|
|
523
|
+
`)
|
|
524
|
+
expect(template).not.toContain("bf->string('outer-lit')")
|
|
525
|
+
expect(template).toContain("bf->string(data_get($cfg, 'x'))")
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
409
529
|
// #2038 nested-callback-predicate loudness is pinned at the shared
|
|
410
530
|
// conformance layer (workstream C): `filter-nested-callback-predicate` /
|
|
411
531
|
// `filter-nested-find-predicate` (BF101 via `expectedDiagnostics`) and
|
|
@@ -50,6 +50,19 @@ runAdapterConformanceTests({
|
|
|
50
50
|
// Same `/* @client */` keyed-map elision (data-table).
|
|
51
51
|
'data-table',
|
|
52
52
|
]),
|
|
53
|
+
skipDataPoints: new Set<string>([
|
|
54
|
+
// #2255 — PHP mb_strlen counts codepoints; JS counts UTF-16 code
|
|
55
|
+
// units, so a surrogate-pair character is 2 in JS, 1 here.
|
|
56
|
+
'string-length-text:astral',
|
|
57
|
+
// #2260 — controlled boolean props: the SSR seed evaluates only the
|
|
58
|
+
// static fallback of `props.X ?? internal()` chains.
|
|
59
|
+
'toggle:gen:pressed:true',
|
|
60
|
+
'switch:gen:checked:true',
|
|
61
|
+
'checkbox:gen:checked:true',
|
|
62
|
+
// #2261 — invalid dynamic CSS value kept (escaped) where the oracle
|
|
63
|
+
// drops the property.
|
|
64
|
+
'style-object-dynamic:gen:color:markup',
|
|
65
|
+
]),
|
|
53
66
|
onRenderError: (err, id) => {
|
|
54
67
|
if (err instanceof BladeNotAvailableError) {
|
|
55
68
|
console.log(`Skipping [${id}]: ${err.message}`)
|
|
@@ -289,6 +289,12 @@ import {
|
|
|
289
289
|
queryHrefArgs,
|
|
290
290
|
sortComparatorFromArrow,
|
|
291
291
|
isValidHelperId,
|
|
292
|
+
isDangerousInnerHtmlAttr,
|
|
293
|
+
resolveDangerousInnerHtml,
|
|
294
|
+
dangerousInnerHtmlMetacharViolation,
|
|
295
|
+
dangerousInnerHtmlDiagnostic,
|
|
296
|
+
resolveStaticLoopSource,
|
|
297
|
+
collectLoopBoundNames,
|
|
292
298
|
} from '@barefootjs/jsx'
|
|
293
299
|
import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
|
|
294
300
|
import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
|
|
@@ -308,6 +314,7 @@ import {
|
|
|
308
314
|
collectRootScopeNodes,
|
|
309
315
|
} from './lib/ir-scope.ts'
|
|
310
316
|
import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
|
|
317
|
+
import { staticValueToBlade } from './lib/static-value.ts'
|
|
311
318
|
import { BladeFilterEmitter, BladeTopLevelEmitter, truthyTest } from './expr/emitters.ts'
|
|
312
319
|
import type { BladeEmitContext, BladeSpreadContext, BladeMemoContext } from './emit-context.ts'
|
|
313
320
|
import {
|
|
@@ -417,6 +424,17 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
417
424
|
*/
|
|
418
425
|
private localConstants: IRMetadata['localConstants'] = []
|
|
419
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Every name a `.map()`/`.filter()` loop callback binds as its item/index
|
|
429
|
+
* parameter anywhere in the component (#2208 fable review). A static
|
|
430
|
+
* loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
|
|
431
|
+
* never resolve through `resolveStaticLoopSource` at a use site where a
|
|
432
|
+
* DIFFERENT, enclosing loop's own callback param shadows it — same
|
|
433
|
+
* shadowing hazard, and same coarse-but-safe mitigation, as #2212's
|
|
434
|
+
* `collectLoopBoundNames` use in `collectStringValueNames`.
|
|
435
|
+
*/
|
|
436
|
+
private staticLoopSourceBoundNames: Set<string> = new Set()
|
|
437
|
+
|
|
420
438
|
/**
|
|
421
439
|
* Optional, no-default props that are `None` when the caller omits them.
|
|
422
440
|
* Their bare-reference attribute emission is guarded with a Blade
|
|
@@ -449,6 +467,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
449
467
|
// ("1"/"") (#1897, pagination's data-active).
|
|
450
468
|
this.booleanTypedProps = collectBooleanTypedProps(ir)
|
|
451
469
|
this.localConstants = ir.metadata.localConstants ?? []
|
|
470
|
+
this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
|
|
452
471
|
this.nullableOptionalProps = collectNullableOptionalProps(ir)
|
|
453
472
|
this.stringValueNames = collectStringValueNames(ir)
|
|
454
473
|
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
@@ -669,7 +688,8 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
669
688
|
renderElement(element: IRElement): string {
|
|
670
689
|
const tag = element.tag
|
|
671
690
|
const attrs = this.renderAttributes(element)
|
|
672
|
-
const
|
|
691
|
+
const dangerousHtml = this.renderDangerousInnerHtml(element)
|
|
692
|
+
const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
|
|
673
693
|
|
|
674
694
|
let hydrationAttrs = ''
|
|
675
695
|
if (element.needsScope) {
|
|
@@ -704,6 +724,35 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
704
724
|
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
705
725
|
}
|
|
706
726
|
|
|
727
|
+
/**
|
|
728
|
+
* `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — replaces the
|
|
729
|
+
* element's normal children with a compile-time-literal raw-HTML string,
|
|
730
|
+
* spliced directly as template text (never through a `{!! !!}`-style
|
|
731
|
+
* runtime raw-output primitive: the value is already fully known at
|
|
732
|
+
* compile time, so no runtime escape hatch is needed, and using one would
|
|
733
|
+
* reopen a template-source injection surface for no benefit). Returns
|
|
734
|
+
* `null` when the attribute is absent (caller falls through to normal
|
|
735
|
+
* `renderChildren`); a non-`null` string (possibly `''`) means the caller
|
|
736
|
+
* must use it as-is instead — including the dynamic/guard-violation
|
|
737
|
+
* refusal cases, where the empty string is safe filler for a compile that
|
|
738
|
+
* fails anyway.
|
|
739
|
+
*/
|
|
740
|
+
private renderDangerousInnerHtml(element: IRElement): string | null {
|
|
741
|
+
const resolution = resolveDangerousInnerHtml(element)
|
|
742
|
+
if (!resolution) return null
|
|
743
|
+
if (resolution.kind === 'dynamic') {
|
|
744
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
|
|
745
|
+
return ''
|
|
746
|
+
}
|
|
747
|
+
const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
|
|
748
|
+
if (violation) {
|
|
749
|
+
const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
|
|
750
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
|
|
751
|
+
return ''
|
|
752
|
+
}
|
|
753
|
+
return resolution.html
|
|
754
|
+
}
|
|
755
|
+
|
|
707
756
|
// ===========================================================================
|
|
708
757
|
// Expression Rendering
|
|
709
758
|
// ===========================================================================
|
|
@@ -717,8 +766,13 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
717
766
|
}
|
|
718
767
|
|
|
719
768
|
// Text-position interpolation of a possibly-non-string value — see the
|
|
720
|
-
// file header, "Stringification" (carried unchanged from Twig).
|
|
721
|
-
|
|
769
|
+
// file header, "Stringification" (carried unchanged from Twig). Thread
|
|
770
|
+
// the IR-carried `.parsed` tree through (mirrors go-template's
|
|
771
|
+
// `convertExpressionToGo(expr.expr, classify, expr.parsed)`) so a
|
|
772
|
+
// resolved bare-identifier `.map`/`.filter`/… callback
|
|
773
|
+
// (`resolveCallbackMethodFunctionReferences`, #2206) isn't lost to a
|
|
774
|
+
// fresh, unresolved re-parse of the raw string.
|
|
775
|
+
const bladeExpr = `$bf->string(${this.convertExpressionToBlade(expr.expr, expr.parsed)})`
|
|
722
776
|
|
|
723
777
|
if (expr.slotId) {
|
|
724
778
|
return `{!! $bf->text_start("${expr.slotId}") !!}{!! e(${bladeExpr}) !!}{!! $bf->text_end() !!}`
|
|
@@ -863,8 +917,27 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
863
917
|
// corpus only because the widened destructure gate (#2087 Phase A/B)
|
|
864
918
|
// no longer refuses this fixture's `([emoji, users]) => ...` param
|
|
865
919
|
// first. Same policy and shape as the Jinja / ERB adapters' check.
|
|
920
|
+
// #2208: a loop source that is a fully-static array literal — either
|
|
921
|
+
// inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
|
|
922
|
+
// bound to a FUNCTION-scope local const whose initializer has no
|
|
923
|
+
// prop/signal/function-call dependency — inlines as a native PHP
|
|
924
|
+
// array literal below, the same way a module-scope const's value is
|
|
925
|
+
// already seeded. A runtime-computed local (#2069, e.g.
|
|
926
|
+
// `Object.entries(props.tags).filter(...)`) still refuses below.
|
|
927
|
+
// `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
|
|
928
|
+
// param shadowing this identifier (fable review) — never resolve the
|
|
929
|
+
// static const in that case. `rawArray` then falls through to the
|
|
930
|
+
// bare identifier expression below, same as before #2208 — which
|
|
931
|
+
// still trips the pre-existing BF101 gate for an unresolvable local
|
|
932
|
+
// const reference (a loud, conservative refusal, not a silent wrong
|
|
933
|
+
// value).
|
|
934
|
+
const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
|
|
935
|
+
isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
|
|
936
|
+
})
|
|
937
|
+
const staticArray = staticItems !== null ? staticValueToBlade(staticItems) : null
|
|
938
|
+
|
|
866
939
|
const arrayName = loop.array.trim()
|
|
867
|
-
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
940
|
+
if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
868
941
|
const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
|
|
869
942
|
if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
|
|
870
943
|
this.errors.push({
|
|
@@ -880,7 +953,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
880
953
|
}
|
|
881
954
|
}
|
|
882
955
|
|
|
883
|
-
const rawArray = this.convertExpressionToBlade(loop.array)
|
|
956
|
+
const rawArray = staticArray ?? this.convertExpressionToBlade(loop.array)
|
|
884
957
|
// Apply sort if present: wrap the loop array in the shared `$bf->sort`
|
|
885
958
|
// helper, binding the sorted result to a per-iteration local so the
|
|
886
959
|
// helper runs once.
|
|
@@ -1554,6 +1627,12 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1554
1627
|
// the unsupported-expression lowering is never reached for a deferred
|
|
1555
1628
|
// predicate (no BF101 / BF102). #1966
|
|
1556
1629
|
if (attr.clientOnly) continue
|
|
1630
|
+
// `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
|
|
1631
|
+
// handled by `renderDangerousInnerHtml` instead, which replaces the
|
|
1632
|
+
// element's children. Skip it here so its `{ __html: ... }` object
|
|
1633
|
+
// literal never reaches `refuseUnsupportedAttrExpression`'s generic
|
|
1634
|
+
// BF101 (which would double-report alongside the purpose-built one).
|
|
1635
|
+
if (isDangerousInnerHtmlAttr(attr)) continue
|
|
1557
1636
|
// Rewrite JSX special-prop names to their HTML-attribute counterparts.
|
|
1558
1637
|
let attrName: string
|
|
1559
1638
|
if (attr.name === 'className') attrName = 'class'
|
|
@@ -1924,8 +2003,19 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1924
2003
|
* single-quoted string literal (`const totalPages = 5`, #1897
|
|
1925
2004
|
* pagination) — function-scope consts never reach the per-render
|
|
1926
2005
|
* context, so a bare reference would resolve to an undefined variable.
|
|
2006
|
+
*
|
|
2007
|
+
* The lookup is a flat name match with no notion of AST scope, so a
|
|
2008
|
+
* name that any loop callback binds as its item/index param never
|
|
2009
|
+
* inlines (#2221) — the occurrence may be the loop's own (shadowing)
|
|
2010
|
+
* binding, and substituting the outer const's value there renders every
|
|
2011
|
+
* iteration with the same hard-coded literal. Coarse (a genuinely
|
|
2012
|
+
* non-shadowed same-named const elsewhere in the component also stops
|
|
2013
|
+
* inlining, falling back to the bare identifier) but safe — the same
|
|
2014
|
+
* trade-off as #2212's `collectLoopBoundNames` use in
|
|
2015
|
+
* `collectStringValueNames`.
|
|
1927
2016
|
*/
|
|
1928
2017
|
private _resolveLiteralConst(name: string): string | null {
|
|
2018
|
+
if (this.staticLoopSourceBoundNames.has(name)) return null
|
|
1929
2019
|
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
1930
2020
|
if (c?.value === undefined) return null
|
|
1931
2021
|
const v = c.value.trim()
|
|
@@ -1935,7 +2025,22 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1935
2025
|
return null
|
|
1936
2026
|
}
|
|
1937
2027
|
|
|
2028
|
+
/**
|
|
2029
|
+
* Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
|
|
2030
|
+
* (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
|
|
2031
|
+
*
|
|
2032
|
+
* The lookup is a flat name match on `objectName` with no notion of AST
|
|
2033
|
+
* scope, so an enclosing loop callback's own param of the same name
|
|
2034
|
+
* (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
|
|
2035
|
+
* still resolved to the OUTER const's member value at every iteration
|
|
2036
|
+
* (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
|
|
2037
|
+
* coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
|
|
2038
|
+
* binds anywhere in the component never inlines, falling back to the bare
|
|
2039
|
+
* `data_get($cfg, 'x')` member expression (which a Blade `@foreach`
|
|
2040
|
+
* binds correctly at the shadowed occurrences).
|
|
2041
|
+
*/
|
|
1938
2042
|
private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
2043
|
+
if (this.staticLoopSourceBoundNames.has(objectName)) return null
|
|
1939
2044
|
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
1940
2045
|
if (!hit) return null
|
|
1941
2046
|
return hit.kind === 'number'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
|
|
3
|
+
* `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
|
|
4
|
+
* PHP literal. Used to inline a fully-static loop source (an inline array
|
|
5
|
+
* literal, or a function-scope local const with a static initializer)
|
|
6
|
+
* directly in a `@foreach` header, rather than requiring a bound template
|
|
7
|
+
* variable.
|
|
8
|
+
*
|
|
9
|
+
* Returns `null` for a value this adapter can't represent as a literal —
|
|
10
|
+
* the caller falls back to its existing BF101 refusal instead of guessing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { escapeBladeSingleQuoted, bladeHashKey } from './blade-naming.ts'
|
|
14
|
+
|
|
15
|
+
export function staticValueToBlade(value: unknown): string | null {
|
|
16
|
+
if (value === null || value === undefined) return 'null'
|
|
17
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
18
|
+
if (typeof value === 'number') return String(value)
|
|
19
|
+
if (typeof value === 'string') return `'${escapeBladeSingleQuoted(value)}'`
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
const items: string[] = []
|
|
22
|
+
for (const el of value) {
|
|
23
|
+
const serialized = staticValueToBlade(el)
|
|
24
|
+
if (serialized === null) return null
|
|
25
|
+
items.push(serialized)
|
|
26
|
+
}
|
|
27
|
+
return `[${items.join(', ')}]`
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === 'object') {
|
|
30
|
+
const entries: string[] = []
|
|
31
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
32
|
+
const serialized = staticValueToBlade(val)
|
|
33
|
+
if (serialized === null) return null
|
|
34
|
+
entries.push(`${bladeHashKey(key)} => ${serialized}`)
|
|
35
|
+
}
|
|
36
|
+
return `[${entries.join(', ')}]`
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|