@barefootjs/blade 0.18.4 → 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/blade-adapter.d.ts +56 -0
- package/dist/adapter/blade-adapter.d.ts.map +1 -1
- package/dist/adapter/expr/array-method.d.ts.map +1 -1
- package/dist/adapter/expr/emitters.d.ts +2 -2
- package/dist/adapter/expr/emitters.d.ts.map +1 -1
- package/dist/adapter/index.js +129 -18
- package/dist/adapter/lib/constants.d.ts.map +1 -1
- 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 +26 -9
- package/dist/adapter/props/prop-classes.d.ts.map +1 -1
- package/dist/build.js +129 -18
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +132 -38
- package/dist/render-divergences.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/blade-adapter-unit.test.ts +141 -0
- package/src/adapter/blade-adapter.ts +180 -12
- package/src/adapter/expr/array-method.ts +19 -0
- package/src/adapter/expr/emitters.ts +2 -2
- package/src/adapter/lib/constants.ts +3 -0
- package/src/adapter/lib/static-value.ts +39 -0
- package/src/adapter/props/prop-classes.ts +30 -9
- package/src/conformance-pins.ts +25 -33
- package/src/render-divergences.ts +4 -16
- package/src/test-render.ts +10 -119
|
@@ -289,10 +289,16 @@ 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'
|
|
295
|
-
import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
|
|
301
|
+
import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
|
|
296
302
|
|
|
297
303
|
import type { BladeRenderCtx } from './lib/types.ts'
|
|
298
304
|
import { BLADE_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
|
|
@@ -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 {
|
|
@@ -356,6 +363,14 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
356
363
|
private options: Required<BladeAdapterOptions>
|
|
357
364
|
private errors: CompilerError[] = []
|
|
358
365
|
private inLoop: boolean = false
|
|
366
|
+
/**
|
|
367
|
+
* `IRLoop.depth` of the loop currently being rendered (save/restore
|
|
368
|
+
* around `renderChildren(loop.children)`, mirroring `inLoop` above).
|
|
369
|
+
* `renderAttributes` reads this to derive the `key` → `data-key`/
|
|
370
|
+
* `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
|
|
371
|
+
* re-derived here (#2168 nested-loop-outer-binding).
|
|
372
|
+
*/
|
|
373
|
+
private currentLoopKeyDepth = 0
|
|
359
374
|
/**
|
|
360
375
|
* SolidJS-style props identifier (`function(props: P)`) and the
|
|
361
376
|
* analyzer-extracted prop names. Stashed at `generate()` entry so the
|
|
@@ -409,6 +424,17 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
409
424
|
*/
|
|
410
425
|
private localConstants: IRMetadata['localConstants'] = []
|
|
411
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
|
+
|
|
412
438
|
/**
|
|
413
439
|
* Optional, no-default props that are `None` when the caller omits them.
|
|
414
440
|
* Their bare-reference attribute emission is guarded with a Blade
|
|
@@ -441,6 +467,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
441
467
|
// ("1"/"") (#1897, pagination's data-active).
|
|
442
468
|
this.booleanTypedProps = collectBooleanTypedProps(ir)
|
|
443
469
|
this.localConstants = ir.metadata.localConstants ?? []
|
|
470
|
+
this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
|
|
444
471
|
this.nullableOptionalProps = collectNullableOptionalProps(ir)
|
|
445
472
|
this.stringValueNames = collectStringValueNames(ir)
|
|
446
473
|
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
@@ -550,7 +577,9 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
550
577
|
}
|
|
551
578
|
|
|
552
579
|
emitText(node: IRText): string {
|
|
553
|
-
|
|
580
|
+
// IRText carries the entity-DECODED value (Phase 1 decodes JSX
|
|
581
|
+
// character references); re-escape for direct HTML emission.
|
|
582
|
+
return escapeHtml(node.value)
|
|
554
583
|
}
|
|
555
584
|
|
|
556
585
|
emitExpression(node: IRExpression): string {
|
|
@@ -659,7 +688,8 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
659
688
|
renderElement(element: IRElement): string {
|
|
660
689
|
const tag = element.tag
|
|
661
690
|
const attrs = this.renderAttributes(element)
|
|
662
|
-
const
|
|
691
|
+
const dangerousHtml = this.renderDangerousInnerHtml(element)
|
|
692
|
+
const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
|
|
663
693
|
|
|
664
694
|
let hydrationAttrs = ''
|
|
665
695
|
if (element.needsScope) {
|
|
@@ -694,6 +724,35 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
694
724
|
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
695
725
|
}
|
|
696
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
|
+
|
|
697
756
|
// ===========================================================================
|
|
698
757
|
// Expression Rendering
|
|
699
758
|
// ===========================================================================
|
|
@@ -707,8 +766,13 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
707
766
|
}
|
|
708
767
|
|
|
709
768
|
// Text-position interpolation of a possibly-non-string value — see the
|
|
710
|
-
// file header, "Stringification" (carried unchanged from Twig).
|
|
711
|
-
|
|
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)})`
|
|
712
776
|
|
|
713
777
|
if (expr.slotId) {
|
|
714
778
|
return `{!! $bf->text_start("${expr.slotId}") !!}{!! e(${bladeExpr}) !!}{!! $bf->text_end() !!}`
|
|
@@ -853,8 +917,27 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
853
917
|
// corpus only because the widened destructure gate (#2087 Phase A/B)
|
|
854
918
|
// no longer refuses this fixture's `([emoji, users]) => ...` param
|
|
855
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
|
+
|
|
856
939
|
const arrayName = loop.array.trim()
|
|
857
|
-
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
940
|
+
if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
858
941
|
const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
|
|
859
942
|
if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
|
|
860
943
|
this.errors.push({
|
|
@@ -870,7 +953,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
870
953
|
}
|
|
871
954
|
}
|
|
872
955
|
|
|
873
|
-
const rawArray = this.convertExpressionToBlade(loop.array)
|
|
956
|
+
const rawArray = staticArray ?? this.convertExpressionToBlade(loop.array)
|
|
874
957
|
// Apply sort if present: wrap the loop array in the shared `$bf->sort`
|
|
875
958
|
// helper, binding the sorted result to a per-iteration local so the
|
|
876
959
|
// helper runs once.
|
|
@@ -922,7 +1005,11 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
922
1005
|
// other than member-access / spread (already refused by the gate)
|
|
923
1006
|
// can't observe a sibling field the pattern destructured explicitly.
|
|
924
1007
|
const indexLocalLines: string[] = []
|
|
925
|
-
if (loop.
|
|
1008
|
+
if (loop.objectIteration) {
|
|
1009
|
+
// `key`/`value` bind directly in the `@foreach` header (see below)
|
|
1010
|
+
// via `$bf->entries`/`$bf->keys`/`$bf->values` — no derived
|
|
1011
|
+
// `$loop->index` local needed, unlike the array `iterationShape` cases.
|
|
1012
|
+
} else if (loop.iterationShape === 'keys') {
|
|
926
1013
|
indexLocalLines.push(`@php(${bladeVar(param)} = $loop->index)`)
|
|
927
1014
|
} else if (loop.index) {
|
|
928
1015
|
indexLocalLines.push(`@php(${bladeVar(loop.index)} = $loop->index)`)
|
|
@@ -950,10 +1037,13 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
950
1037
|
|
|
951
1038
|
const prevInLoop = this.inLoop
|
|
952
1039
|
this.inLoop = true
|
|
1040
|
+
const prevLoopKeyDepth = this.currentLoopKeyDepth
|
|
1041
|
+
this.currentLoopKeyDepth = loop.depth
|
|
953
1042
|
// Re-render children now that inLoop is set (so nested components use the
|
|
954
1043
|
// loop-child naming convention). renderedChildren above was computed with
|
|
955
1044
|
// the previous flag; recompute under the loop flag.
|
|
956
1045
|
const childrenUnderLoop = this.renderChildren(loop.children)
|
|
1046
|
+
this.currentLoopKeyDepth = prevLoopKeyDepth
|
|
957
1047
|
this.inLoop = prevInLoop
|
|
958
1048
|
void renderedChildren
|
|
959
1049
|
|
|
@@ -973,7 +1063,23 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
973
1063
|
// See this file's header, divergence 2: raw PHP `foreach` over a
|
|
974
1064
|
// null/undefined array raises, unlike Twig's `strict_variables: false`
|
|
975
1065
|
// tolerance — the `?? []` guard restores that same tolerance uniformly.
|
|
976
|
-
|
|
1066
|
+
//
|
|
1067
|
+
// `objectIteration` (#2168 object-entries-map): PHP's native `foreach`
|
|
1068
|
+
// CAN iterate a `stdClass` object's public properties directly (unlike
|
|
1069
|
+
// Twig's `for` tag, which needs `Traversable`) — but this still routes
|
|
1070
|
+
// through `$bf->entries`/`$bf->keys`/`$bf->values` for the same
|
|
1071
|
+
// defensive non-object guard Twig needs and for a single source of
|
|
1072
|
+
// truth on "is this a JS object" (`isJsObject`, `BarefootJS.php`); the
|
|
1073
|
+
// `(array)` cast those methods do preserves the object's own insertion
|
|
1074
|
+
// order either way.
|
|
1075
|
+
const forHeader = loop.objectIteration === 'entries'
|
|
1076
|
+
? `@foreach($bf->entries(${array} ?? []) as ${bladeVar(loop.index ?? param)} => ${bladeVar(param)})`
|
|
1077
|
+
: loop.objectIteration === 'keys'
|
|
1078
|
+
? `@foreach($bf->keys(${array} ?? []) as ${bladeVar(param)})`
|
|
1079
|
+
: loop.objectIteration === 'values'
|
|
1080
|
+
? `@foreach($bf->values(${array} ?? []) as ${bladeVar(param)})`
|
|
1081
|
+
: `@foreach((${array} ?? []) as ${bladeVar(loopVar)})`
|
|
1082
|
+
lines.push(forHeader)
|
|
977
1083
|
for (const il of indexLocalLines) lines.push(il)
|
|
978
1084
|
|
|
979
1085
|
// Handle filter().map() pattern by wrapping children in if-condition
|
|
@@ -1114,11 +1220,37 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1114
1220
|
type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
|
|
1115
1221
|
const segments: Segment[] = [{ kind: 'entries', parts: [] }]
|
|
1116
1222
|
const currentEntries = () => this.componentPropSegmentEntries(segments)
|
|
1223
|
+
// Named JSX-valued props OTHER than the reserved `children`
|
|
1224
|
+
// (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
|
|
1225
|
+
// their own output-buffering capture, prepended to the final returned
|
|
1226
|
+
// string below — same mechanism as the reserved children capture,
|
|
1227
|
+
// just keyed by the prop's own name instead of `children`.
|
|
1228
|
+
const namedSlotCaptures: string[] = []
|
|
1117
1229
|
|
|
1118
1230
|
for (const p of comp.props) {
|
|
1119
1231
|
// Skip callback props (onXxx) and `ref` — both are client-only for
|
|
1120
1232
|
// SSR (Hono renders neither; the client JS wires them at hydration).
|
|
1121
1233
|
if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
|
|
1234
|
+
if (p.value.kind === 'jsx-children' && p.name !== 'children') {
|
|
1235
|
+
const prevInLoop = this.inLoop
|
|
1236
|
+
this.inLoop = false
|
|
1237
|
+
const slotBody = this.renderChildren(p.value.children)
|
|
1238
|
+
this.inLoop = prevInLoop
|
|
1239
|
+
// Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
|
|
1240
|
+
// A JSX prop name can contain characters (`data-slot`) `bladeIdent`
|
|
1241
|
+
// doesn't sanitize (it only guards reserved words), producing an
|
|
1242
|
+
// invalid PHP variable name; `comp.slotId` alone would also collide
|
|
1243
|
+
// across two named-slot props on the same component invocation
|
|
1244
|
+
// (unlike the reserved children slot, there's only ever one of
|
|
1245
|
+
// those per invocation).
|
|
1246
|
+
const captureVar = bladeVar(`bf_prop_${this.childrenCaptureCounter++}`)
|
|
1247
|
+
namedSlotCaptures.push(
|
|
1248
|
+
`@php(ob_start())\n${slotBody}\n` +
|
|
1249
|
+
`@php(${captureVar} = $bf->backend->mark_raw(preg_replace('/\\n\\z/', '', ob_get_clean(), 1)))`,
|
|
1250
|
+
)
|
|
1251
|
+
currentEntries().push(`${bladeHashKey(p.name)} => ${captureVar}`)
|
|
1252
|
+
continue
|
|
1253
|
+
}
|
|
1122
1254
|
if (p.value.kind === 'spread') {
|
|
1123
1255
|
const trimmed = p.value.expr.trim()
|
|
1124
1256
|
// SolidJS-style props identifier (`function(props: P)`) has no
|
|
@@ -1193,6 +1325,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1193
1325
|
currentEntries().push(`${bladeHashKey('children')} => ${captureVar}`)
|
|
1194
1326
|
const dict = this.combineComponentPropSegments(segments)
|
|
1195
1327
|
return (
|
|
1328
|
+
namedSlotCaptures.join('') +
|
|
1196
1329
|
`@php(ob_start())\n${childrenBody}\n` +
|
|
1197
1330
|
`@php(${captureVar} = $bf->backend->mark_raw(preg_replace('/\\n\\z/', '', ob_get_clean(), 1)))` +
|
|
1198
1331
|
`{!! $bf->render_child('${tplName}', ${dict}) !!}`
|
|
@@ -1201,7 +1334,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1201
1334
|
|
|
1202
1335
|
const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
|
|
1203
1336
|
const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
|
|
1204
|
-
return
|
|
1337
|
+
return `${namedSlotCaptures.join('')}{!! $bf->render_child('${tplName}'${dictEntries}) !!}`
|
|
1205
1338
|
}
|
|
1206
1339
|
|
|
1207
1340
|
private childrenCaptureCounter = 0
|
|
@@ -1291,7 +1424,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1291
1424
|
* AttrValue lowering for intrinsic-element attributes (Blade).
|
|
1292
1425
|
*/
|
|
1293
1426
|
private readonly elementAttrEmitter: AttrValueEmitter = {
|
|
1294
|
-
emitLiteral: (value, name) => `${name}="${value.value}"`,
|
|
1427
|
+
emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
|
|
1295
1428
|
emitExpression: (value, name) => {
|
|
1296
1429
|
// `style={{ … }}` object literal → a CSS string with dynamic values
|
|
1297
1430
|
// interpolated, instead of refusing the bare object with BF101 (#1322).
|
|
@@ -1494,10 +1627,19 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1494
1627
|
// the unsupported-expression lowering is never reached for a deferred
|
|
1495
1628
|
// predicate (no BF101 / BF102). #1966
|
|
1496
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
|
|
1497
1636
|
// Rewrite JSX special-prop names to their HTML-attribute counterparts.
|
|
1498
1637
|
let attrName: string
|
|
1499
1638
|
if (attr.name === 'className') attrName = 'class'
|
|
1500
|
-
else if (attr.name === 'key')
|
|
1639
|
+
else if (attr.name === 'key') {
|
|
1640
|
+
const depth = this.currentLoopKeyDepth
|
|
1641
|
+
attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
|
|
1642
|
+
}
|
|
1501
1643
|
else attrName = attr.name
|
|
1502
1644
|
const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
|
|
1503
1645
|
if (lowered) parts.push(lowered)
|
|
@@ -1861,8 +2003,19 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1861
2003
|
* single-quoted string literal (`const totalPages = 5`, #1897
|
|
1862
2004
|
* pagination) — function-scope consts never reach the per-render
|
|
1863
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`.
|
|
1864
2016
|
*/
|
|
1865
2017
|
private _resolveLiteralConst(name: string): string | null {
|
|
2018
|
+
if (this.staticLoopSourceBoundNames.has(name)) return null
|
|
1866
2019
|
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
1867
2020
|
if (c?.value === undefined) return null
|
|
1868
2021
|
const v = c.value.trim()
|
|
@@ -1872,7 +2025,22 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
|
|
|
1872
2025
|
return null
|
|
1873
2026
|
}
|
|
1874
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
|
+
*/
|
|
1875
2042
|
private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
2043
|
+
if (this.staticLoopSourceBoundNames.has(objectName)) return null
|
|
1876
2044
|
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
1877
2045
|
if (!hit) return null
|
|
1878
2046
|
return hit.kind === 'number'
|
|
@@ -90,6 +90,15 @@ export function renderArrayMethod(
|
|
|
90
90
|
const recv = emit(object)
|
|
91
91
|
return `$bf->trim(${recv})`
|
|
92
92
|
}
|
|
93
|
+
case 'trimStart':
|
|
94
|
+
case 'trimEnd': {
|
|
95
|
+
// `.trimStart()` / `.trimEnd()` — the one-sided siblings of
|
|
96
|
+
// `.trim()` (#2183 follow-up). Dedicated `$bf->trim_start` /
|
|
97
|
+
// `$bf->trim_end` helpers, not `$bf->trim` with a flag.
|
|
98
|
+
const fn = method === 'trimStart' ? 'trim_start' : 'trim_end'
|
|
99
|
+
const recv = emit(object)
|
|
100
|
+
return `$bf->${fn}(${recv})`
|
|
101
|
+
}
|
|
93
102
|
case 'toFixed': {
|
|
94
103
|
// `.toFixed(digits?)` — `$bf->to_fixed` mirrors JS rounding +
|
|
95
104
|
// zero-padding (default 0 digits). #1897.
|
|
@@ -125,6 +134,16 @@ export function renderArrayMethod(
|
|
|
125
134
|
const newS = emit(args[1])
|
|
126
135
|
return `$bf->replace(${recv}, ${oldS}, ${newS})`
|
|
127
136
|
}
|
|
137
|
+
case 'replaceAll': {
|
|
138
|
+
// `.replaceAll(old, new)` — string-pattern form, EVERY occurrence,
|
|
139
|
+
// via the dedicated `$bf->replace_all` helper (not `$bf->replace`
|
|
140
|
+
// with a flag) — the regex-pattern form is refused upstream at
|
|
141
|
+
// the parser, same as `.replace`. See #2182.
|
|
142
|
+
const recv = emit(object)
|
|
143
|
+
const oldS = emit(args[0])
|
|
144
|
+
const newS = emit(args[1])
|
|
145
|
+
return `$bf->replace_all(${recv}, ${oldS}, ${newS})`
|
|
146
|
+
}
|
|
128
147
|
case 'repeat': {
|
|
129
148
|
const recv = emit(object)
|
|
130
149
|
const count = args.length === 0 ? '0' : emit(args[0])
|
|
@@ -176,7 +176,7 @@ export class BladeFilterEmitter implements ParsedExprEmitter {
|
|
|
176
176
|
return String(value)
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
179
|
+
member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
|
|
180
180
|
// `.length` — route through `$bf->length` (handles both array element
|
|
181
181
|
// count and string char count, JS-compatibly).
|
|
182
182
|
if (property === 'length') {
|
|
@@ -346,7 +346,7 @@ export class BladeTopLevelEmitter implements ParsedExprEmitter {
|
|
|
346
346
|
return String(value)
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
-
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
349
|
+
member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
|
|
350
350
|
// `props.x` flattens to the bare context var the SSR caller binds each
|
|
351
351
|
// prop to (props arrive as individual top-level context entries, not a
|
|
352
352
|
// nested `props` hash).
|
|
@@ -22,6 +22,9 @@ export const BLADE_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
|
|
|
22
22
|
'Math.floor': { arity: 1, emit: (args) => `$bf->floor(${args[0]})` },
|
|
23
23
|
'Math.ceil': { arity: 1, emit: (args) => `$bf->ceil(${args[0]})` },
|
|
24
24
|
'Math.round': { arity: 1, emit: (args) => `$bf->round(${args[0]})` },
|
|
25
|
+
'Math.min': { arity: 2, emit: (args) => `$bf->min(${args[0]}, ${args[1]})` },
|
|
26
|
+
'Math.max': { arity: 2, emit: (args) => `$bf->max(${args[0]}, ${args[1]})` },
|
|
27
|
+
'Math.abs': { arity: 1, emit: (args) => `$bf->abs(${args[0]})` },
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sets the adapter consults during lowering. No adapter instance state.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type
|
|
9
|
+
import { collectLoopBoundNames, type ComponentIR } from '@barefootjs/jsx'
|
|
10
10
|
import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -43,14 +43,31 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* String-typed signals
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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 `.` over JS `+`'s numeric fallback (#2163, #2212) — including now
|
|
52
|
+
* for a bare identifier operand, not just a prop/getter/literal. In the
|
|
53
|
+
* Mojo adapter this ALSO drives `eq`/`ne` selection for string equality;
|
|
54
|
+
* the Blade emitters don't consume the distinction there — `===`/`!==`
|
|
55
|
+
* ALWAYS route through `bf.eq`/`bf.neq` regardless of operand type (see
|
|
56
|
+
* `expr/emitters.ts`'s file header, divergence 4) — so that half of this
|
|
57
|
+
* set is carried only for parity with the Perl-family adapters.
|
|
58
|
+
*
|
|
59
|
+
* Excludes any name bound as a `.map()`/`.filter()` loop callback's item
|
|
60
|
+
* or index parameter ANYWHERE in the component (Fable review, #2212): the
|
|
61
|
+
* lookup below is a flat, scope-blind `Set<string>` with no notion of a
|
|
62
|
+
* loop param shadowing an outer string-typed binding of the same name
|
|
63
|
+
* (`items.map((name) => 1 + name)` inside a component that also has a
|
|
64
|
+
* string `name` prop) — left unguarded, that shadowed `name` would be
|
|
65
|
+
* misdetected as string-typed and `1 + name` would silently lower to `.`
|
|
66
|
+
* instead of staying numeric `+`. Subtracting loop-bound names is coarse
|
|
67
|
+
* (it also suppresses a genuinely non-shadowed same-named string
|
|
68
|
+
* elsewhere in the component) but safe: the suppressed case just falls
|
|
69
|
+
* back to today's numeric `+` — the same, already-accepted residual as an
|
|
70
|
+
* unresolvable operand — never silently-wrong output.
|
|
54
71
|
*/
|
|
55
72
|
export function collectStringValueNames(ir: ComponentIR): Set<string> {
|
|
56
73
|
const names = new Set<string>()
|
|
@@ -62,5 +79,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
|
|
|
62
79
|
for (const p of ir.metadata.propsParams) {
|
|
63
80
|
if (isStringTypeInfo(p.type)) names.add(p.name)
|
|
64
81
|
}
|
|
82
|
+
for (const c of ir.metadata.localConstants) {
|
|
83
|
+
if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
|
|
84
|
+
}
|
|
85
|
+
for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
|
|
65
86
|
return names
|
|
66
87
|
}
|
package/src/conformance-pins.ts
CHANGED
|
@@ -11,15 +11,17 @@
|
|
|
11
11
|
import type { ConformancePins } from '@barefootjs/jsx'
|
|
12
12
|
|
|
13
13
|
export const conformancePins: ConformancePins = {
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
// `todo-app` / `todo-app-ssr` no longer pinned (#2205) — the conformance
|
|
15
|
+
// harness now passes `siblingTemplatesRegistered: true` for fixtures with
|
|
16
|
+
// sibling `components`, matching `bf build`'s real semantics, so the
|
|
17
|
+
// BF103 loop-body cross-template check no longer fires spuriously. (Both
|
|
18
|
+
// fixtures are still skipped on this adapter via `render-divergences.ts`
|
|
19
|
+
// — #2209 — for an unrelated signal-seeding gap.)
|
|
20
|
+
// `static-array-children` no longer pinned (#2208) — `items`'s
|
|
21
|
+
// array-literal initializer is now recognized as fully-static
|
|
22
|
+
// (`resolveStaticLoopSource`) and inlined as a native PHP array literal
|
|
23
|
+
// in the `@foreach` header, the same way a module-scope const's value is
|
|
24
|
+
// already seeded.
|
|
23
25
|
// The `([emoji, users]) => …` array-destructure param itself now lowers
|
|
24
26
|
// (#2087 Phase B — see the destructure comment below), but the loop
|
|
25
27
|
// ARRAY is a function-scope computed const (`const entries =
|
|
@@ -28,12 +30,10 @@ export const conformancePins: ConformancePins = {
|
|
|
28
30
|
// check and policy as Jinja / ERB) instead of silently iterating zero
|
|
29
31
|
// times over an unbound name.
|
|
30
32
|
'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
{ code: 'BF101', severity: 'error' },
|
|
36
|
-
],
|
|
33
|
+
// BF101 (computed local-const loop array, as above) fires; BF103
|
|
34
|
+
// (imported child in the loop body) no longer does now that the
|
|
35
|
+
// conformance harness passes `siblingTemplatesRegistered: true` (#2205).
|
|
36
|
+
'static-array-from-props-with-component': [{ code: 'BF101', severity: 'error' }],
|
|
37
37
|
// #2087 Phase B: every `.map()` destructure shape in the shared corpus
|
|
38
38
|
// now lowers on Blade via an `@php(...)` local built from the binding's
|
|
39
39
|
// structured `segments` path (`bladeLoopBindingAccessor` in
|
|
@@ -76,22 +76,14 @@ export const conformancePins: ConformancePins = {
|
|
|
76
76
|
// / etc. via the same evaluator-JSON mechanism as `.filter` / `.every` /
|
|
77
77
|
// `.some`, so they render. Only the NESTED-in-a-predicate form above is
|
|
78
78
|
// refused (#2038).
|
|
79
|
-
//
|
|
80
|
-
// callback
|
|
81
|
-
// (`
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
// the shape loudly instead of emitting entity-escaped markup that
|
|
90
|
-
// silently renders tags as text.
|
|
91
|
-
'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
|
|
92
|
-
// Edge-case sweep (Priority 12): `.replaceAll` has no lowering yet —
|
|
93
|
-
// only first-occurrence `.replace` is wired to the runtime helpers.
|
|
94
|
-
// Refused with BF101 rather than reusing the first-only lowering,
|
|
95
|
-
// which would silently change semantics.
|
|
96
|
-
'string-replaceall': [{ code: 'BF101', severity: 'error' }],
|
|
79
|
+
// `array-map-function-reference` no longer pinned — a bare-identifier
|
|
80
|
+
// `.map(format)` callback now resolves one hop to its declaration
|
|
81
|
+
// (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
|
|
82
|
+
// #2090 established for `.sort(fnref)`.
|
|
83
|
+
// `dangerous-inner-html` no longer pinned — a compile-time string-literal
|
|
84
|
+
// `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
|
|
85
|
+
// the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
|
|
86
|
+
// A dynamic/signal-derived value still refuses with BF101 — see the
|
|
87
|
+
// `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
|
|
88
|
+
'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
|
|
97
89
|
}
|
|
@@ -15,20 +15,8 @@
|
|
|
15
15
|
import type { RenderDivergences } from '@barefootjs/jsx'
|
|
16
16
|
|
|
17
17
|
export const renderDivergences: RenderDivergences = {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'static-attr-escape':
|
|
23
|
-
'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
|
|
24
|
-
'object-entries-map':
|
|
25
|
-
'`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations',
|
|
26
|
-
'nested-loop-outer-binding':
|
|
27
|
-
'nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`',
|
|
28
|
-
'jsx-element-prop':
|
|
29
|
-
'a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped',
|
|
30
|
-
'string-slice':
|
|
31
|
-
'`.slice()` on a STRING renders empty (array-slice helper misfires on strings)',
|
|
32
|
-
'string-trim-sided':
|
|
33
|
-
'`.trimStart()` / `.trimEnd()` render empty (no lowering)',
|
|
18
|
+
// `todo-app` / `todo-app-ssr` no longer diverge (#2209) — the shared
|
|
19
|
+
// `evaluateSignalInit` (`@barefootjs/jsx`, sandboxed real-JS evaluation
|
|
20
|
+
// instead of a fixed regex-shape catalogue) now correctly seeds `todos`
|
|
21
|
+
// from `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`.
|
|
34
22
|
}
|