@barefootjs/xslate 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.
@@ -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 children = this.renderChildren(element.children)
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
- const perlExpr = this.convertExpressionToKolon(expr.expr)
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'
@@ -9,17 +9,17 @@
9
9
  import type { ConformancePins } from '@barefootjs/jsx'
10
10
 
11
11
  export const conformancePins: ConformancePins = {
12
- // Sibling-imported child component in a loop body: emits a
13
- // cross-template call needing separate registration. BF103 makes
14
- // the requirement loud (same as mojo).
15
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
16
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
17
- // call it inside a keyed `.map`. With the standalone-filter fix in
18
- // place these reach the SAME BF103 (imported child in `.map`) as
19
- // mojo NOT BF101 confirming the `.filter(...)` chain itself now
20
- // lowers and the only remaining gate is the imported-child one.
21
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
22
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
12
+ // `todo-app` / `todo-app-ssr` no longer pinned (#2205) the conformance
13
+ // harness now passes `siblingTemplatesRegistered: true` for fixtures with
14
+ // sibling `components`, matching `bf build`'s real semantics, so the
15
+ // BF103 loop-body cross-template check no longer fires spuriously. (Both
16
+ // fixtures are still skipped on this adapter via `render-divergences.ts`
17
+ // #2209 for an unrelated signal-seeding gap.)
18
+ // `static-array-children` no longer pinned (#2208) `items`'s
19
+ // array-literal initializer is now recognized as fully-static
20
+ // (`resolveStaticLoopSource`) and inlined as a native Kolon array/hash
21
+ // literal in the `for EXPR -> $item` header, the same way a module-scope
22
+ // const's value is already seeded.
23
23
  // `([emoji, users]) => ...` / `([id, t]) => ...` are plain array-index
24
24
  // (tuple) destructures, no rest — #2087 Phase B's `segments`-walking
25
25
  // accessor lowers both to `$__bf_item[0]` / `$__bf_item[1]` `: my` locals
@@ -32,12 +32,11 @@ export const conformancePins: ConformancePins = {
32
32
  // simply unreachable before because BF104 refused the destructure shape
33
33
  // first.
34
34
  'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
35
- // Both BF103 (sibling-imported `<Tag>` child component) and the BF101
36
- // above fire; BF104 no longer does (see above).
37
- 'static-array-from-props-with-component': [
38
- { code: 'BF103', severity: 'error' },
39
- { code: 'BF101', severity: 'error' },
40
- ],
35
+ // The BF101 above fires; BF104 no longer does (see above), and BF103
36
+ // (sibling-imported `<Tag>` child component in the loop body) no longer
37
+ // does either now that the conformance harness passes
38
+ // `siblingTemplatesRegistered: true` (#2205).
39
+ 'static-array-from-props-with-component': [{ code: 'BF101', severity: 'error' }],
41
40
  // #1310 / #2087: rest destructure in .map() callback. All four shapes now
42
41
  // lower via #2087 Phase B's `segments`-walking accessor:
43
42
  // - object-rest read via member access (`rest-destructure-object-in-map`):
@@ -104,15 +103,14 @@ export const conformancePins: ConformancePins = {
104
103
  // `find_last_index` via the same Kolon-lambda mechanism as `.filter` /
105
104
  // `.every` / `.some`, so they render. Only the NESTED-in-a-predicate form
106
105
  // above is refused (#2038).
107
- // #2073 follow-up: a function-reference `.map(format)` callback has no
108
- // arrow body to serialize not a CALLBACK_METHODS shape — so the
109
- // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
110
- // a broken template.
111
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
112
- // Edge-case sweep (Priority 12): `dangerouslySetInnerHTML` requires a
113
- // deliberate raw-HTML (unescaped) output affordance in the target
114
- // template language. No lowering exists yet, so the compiler refuses
115
- // the shape loudly instead of emitting entity-escaped markup that
116
- // silently renders tags as text.
117
- 'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
106
+ // `array-map-function-reference` no longer pinned — a bare-identifier
107
+ // `.map(format)` callback now resolves one hop to its declaration
108
+ // (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
109
+ // #2090 established for `.sort(fnref)`.
110
+ // `dangerous-inner-html` no longer pinned a compile-time string-literal
111
+ // `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
112
+ // the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
113
+ // A dynamic/signal-derived value still refuses with BF101 see the
114
+ // `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
115
+ 'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
118
116
  }
@@ -14,4 +14,9 @@
14
14
 
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
- export const renderDivergences: RenderDivergences = {}
17
+ export const renderDivergences: RenderDivergences = {
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 }))`.
22
+ }
@@ -17,7 +17,7 @@
17
17
  * than the Mojo harness's literal `test_<sN>`.
18
18
  */
19
19
 
20
- import { compileJSX, extractSsrDefaults, importsSearchParams } from '@barefootjs/jsx'
20
+ import { compileJSX, extractSsrDefaults, importsSearchParams, evaluateSignalInit, tryEvaluateSignalInit } from '@barefootjs/jsx'
21
21
  import type { ComponentIR } from '@barefootjs/jsx'
22
22
  import { mkdir, rm } from 'node:fs/promises'
23
23
  import { resolve } from 'node:path'
@@ -135,8 +135,15 @@ export async function renderXslateComponent(options: RenderOptions): Promise<str
135
135
  }
136
136
  }
137
137
 
138
- // Compile parent source.
139
- const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
138
+ // Compile parent source. `siblingTemplatesRegistered: Boolean(components)`
139
+ // matches this harness's real behavior every sibling child template is registered
140
+ // alongside the parent before rendering, so a loop-body cross-template
141
+ // call resolves at render time (#2205).
142
+ const result = compileJSX(source, 'component.tsx', {
143
+ adapter,
144
+ outputIR: true,
145
+ siblingTemplatesRegistered: Boolean(components),
146
+ })
140
147
 
141
148
  const errors = result.errors.filter(e => e.severity === 'error')
142
149
  if (errors.length > 0) {
@@ -467,9 +474,9 @@ function buildPerlProps(
467
474
  for (const param of ir.metadata.propsParams) {
468
475
  if (props && param.name in props) continue
469
476
  if (param.defaultValue) {
470
- const perlValue = jsToPerlValue(param.defaultValue)
471
- if (perlValue !== null) {
472
- entries.push(`${param.name} => ${perlValue}`)
477
+ const result = tryEvaluateSignalInit(param.defaultValue.trim(), props)
478
+ if (result.ok) {
479
+ entries.push(`${param.name} => ${toPerlLiteral(result.value)}`)
473
480
  continue
474
481
  }
475
482
  }
@@ -547,119 +554,6 @@ function buildPerlProps(
547
554
  return `{${entries.join(', ')}}`
548
555
  }
549
556
 
550
- /**
551
- * Evaluate a signal initializer expression using provided props.
552
- * Handles: props.initial ?? 0, props.value, literal values.
553
- */
554
- export function evaluateSignalInit(
555
- expr: string,
556
- props?: Record<string, unknown>,
557
- ): unknown {
558
- const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
559
- if (nullishMatch) {
560
- const propName = nullishMatch[1]
561
- const defaultExpr = nullishMatch[2].trim()
562
- if (props && propName in props) return props[propName]
563
- return parseLiteral(defaultExpr)
564
- }
565
-
566
- const propsMatch = expr.match(/^props\.(\w+)$/)
567
- if (propsMatch) {
568
- if (props && propsMatch[1] in props) return props[propsMatch[1]]
569
- return null
570
- }
571
-
572
- return parseLiteral(expr)
573
- }
574
-
575
- function parseLiteral(expr: string): unknown {
576
- if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
577
- if (expr === 'true') return true
578
- if (expr === 'false') return false
579
- if (expr === '[]') return []
580
-
581
- {
582
- const t = expr.trim()
583
- if (t.startsWith('[') && t.endsWith(']')) {
584
- const inner = t.slice(1, -1).trim()
585
- if (!inner) return []
586
- const out: unknown[] = []
587
- for (const seg of splitTopLevelCommas(inner)) {
588
- if (!seg.trim()) continue
589
- const parsed = parseLiteral(seg.trim())
590
- if (parsed === null && seg.trim() !== 'null') return null
591
- out.push(parsed)
592
- }
593
- return out
594
- }
595
- }
596
-
597
- const stringMatch = expr.match(/^(['"])(.*)\1$/s)
598
- if (stringMatch) return unescapeJsString(stringMatch[2])
599
-
600
- const trimmed = expr.trim()
601
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
602
- const inner = trimmed.slice(1, -1).trim()
603
- if (!inner) return {}
604
- const obj: Record<string, unknown> = {}
605
- for (const pair of splitTopLevelCommas(inner)) {
606
- if (!pair.trim()) continue
607
- const colonIdx = pair.indexOf(':')
608
- if (colonIdx < 0) return null
609
- let key = pair.slice(0, colonIdx).trim()
610
- const val = pair.slice(colonIdx + 1).trim()
611
- const keyMatch = key.match(/^(['"])(.*)\1$/s)
612
- if (keyMatch) key = unescapeJsString(keyMatch[2])
613
- const parsedVal = parseLiteral(val)
614
- if (parsedVal === null && val !== 'null') return null
615
- obj[key] = parsedVal
616
- }
617
- return obj
618
- }
619
- return null
620
- }
621
-
622
- function splitTopLevelCommas(inner: string): string[] {
623
- const segments: string[] = []
624
- let depth = 0
625
- let start = 0
626
- let quote: string | null = null
627
- for (let i = 0; i < inner.length; i++) {
628
- const c = inner[i]
629
- if (quote) {
630
- if (c === quote) {
631
- let backslashes = 0
632
- for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
633
- if (backslashes % 2 === 0) quote = null
634
- }
635
- continue
636
- }
637
- if (c === '"' || c === "'") {
638
- quote = c
639
- continue
640
- }
641
- if (c === '{' || c === '[') depth++
642
- else if (c === '}' || c === ']') depth--
643
- else if (c === ',' && depth === 0) {
644
- segments.push(inner.slice(start, i))
645
- start = i + 1
646
- }
647
- }
648
- segments.push(inner.slice(start))
649
- return segments
650
- }
651
-
652
- function unescapeJsString(s: string): string {
653
- return s.replace(/\\(.)/g, (_, c) => {
654
- switch (c) {
655
- case 'n': return '\n'
656
- case 'r': return '\r'
657
- case 't': return '\t'
658
- case '0': return '\0'
659
- default: return c
660
- }
661
- })
662
- }
663
557
 
664
558
  /** Perl single-quoted string escape: `'` AND `\` need escaping. */
665
559
  function perlSingleQuote(s: string): string {
@@ -685,23 +579,3 @@ function toPerlLiteral(value: unknown): string {
685
579
  return 'undef'
686
580
  }
687
581
 
688
- /**
689
- * Convert a JS literal value to a Perl literal.
690
- * Handles: numbers, strings, booleans, empty arrays, props.xxx ?? default.
691
- */
692
- function jsToPerlValue(jsValue: string): string | null {
693
- const v = jsValue.trim()
694
-
695
- if (/^-?\d+(\.\d+)?$/.test(v)) return v
696
- if (/^['"].*['"]$/.test(v)) return v
697
- if (v === 'true') return '1'
698
- if (v === 'false') return '0'
699
- if (v === '[]') return '[]'
700
-
701
- const nullishMatch = v.match(/\?\?\s*(.+)$/)
702
- if (nullishMatch) return jsToPerlValue(nullishMatch[1])
703
-
704
- if (v.startsWith('props.')) return 'undef'
705
-
706
- return null
707
- }