@barefootjs/mojolicious 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.
@@ -7,7 +7,7 @@
7
7
  * adapter's `props/prop-types.ts`. No adapter instance state.
8
8
  */
9
9
 
10
- import type { ComponentIR } from '@barefootjs/jsx'
10
+ import { collectLoopBoundNames, type ComponentIR } from '@barefootjs/jsx'
11
11
  import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
12
12
 
13
13
  /**
@@ -67,11 +67,28 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
67
67
  }
68
68
 
69
69
  /**
70
- * String-typed signals and props, so equality comparisons against them lower
71
- * to `eq`/`ne` (#1672). A signal is string-typed when its inferred type is
72
- * `string` (the analyzer infers this from a string-literal initial value) or,
73
- * defensively, when its initial value is a bare string literal; a prop when
74
- * its annotated type is `string`.
70
+ * String-typed signals, props, and same-file local consts, so equality
71
+ * comparisons against them lower to `eq`/`ne` (#1672) and `+` concatenation
72
+ * against them lowers to Perl's `.` instead of numeric `+` (#2163, #2212 —
73
+ * `isStringConcatBinary`/`isStringTypedOperand` in `@barefootjs/jsx`, which
74
+ * now also recognizes a bare identifier operand, not just a prop/getter/
75
+ * literal). A signal is string-typed when its inferred type is `string`
76
+ * (the analyzer infers this from a string-literal initial value) or,
77
+ * defensively, when its initial value is a bare string literal; a prop or
78
+ * local const when its annotated (or inferred) type is `string`.
79
+ *
80
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item
81
+ * or index parameter ANYWHERE in the component (Fable review, #2212): the
82
+ * lookup below is a flat, scope-blind `Set<string>` with no notion of a
83
+ * loop param shadowing an outer string-typed binding of the same name
84
+ * (`items.map((name) => 1 + name)` inside a component that also has a
85
+ * string `name` prop) — left unguarded, that shadowed `name` would be
86
+ * misdetected as string-typed and `1 + name` would silently lower to `.`
87
+ * instead of staying numeric `+`. Subtracting loop-bound names is coarse
88
+ * (it also suppresses a genuinely non-shadowed same-named string
89
+ * elsewhere in the component) but safe: the suppressed case just falls
90
+ * back to today's numeric `+` — the same, already-accepted residual as an
91
+ * unresolvable operand — never silently-wrong output.
75
92
  */
76
93
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
77
94
  const names = new Set<string>()
@@ -83,5 +100,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
83
100
  for (const p of ir.metadata.propsParams) {
84
101
  if (isStringTypeInfo(p.type)) names.add(p.name)
85
102
  }
103
+ for (const c of ir.metadata.localConstants) {
104
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
105
+ }
106
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
86
107
  return names
87
108
  }
@@ -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: Mojo emits
13
- // a cross-template call that needs separate registration. BF103
14
- // makes the requirement loud. (The barefoot CLI passes
15
- // `siblingTemplatesRegistered: true` so CLI builds suppress it.)
16
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
17
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
18
- // call it inside a keyed `.map`. Same BF103 surface as the
19
- // synthetic `static-array-children` above pinned at adapter
20
- // level so the shared-component corpus stays adapter-neutral.
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 Perl
21
+ // arrayref/hashref literal in the loop-bound expression, the same way a
22
+ // module-scope 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`
@@ -32,12 +32,11 @@ export const conformancePins: ConformancePins = {
32
32
  // check in `renderLoop`. This was always true; it was simply
33
33
  // unreachable before because BF104 refused the destructure shape 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
42
41
  // now lower via #2087 Phase B's `segments`-walking accessor:
43
42
  // - object-rest read via member access (`rest-destructure-object-in-map`):
@@ -130,20 +129,14 @@ export const conformancePins: ConformancePins = {
130
129
  // runtime `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
131
130
  // (per-element coderef predicate), matching Xslate. `.join` was never
132
131
  // pinned (handled by `renderArrayMethod`'s `case 'join'`).
133
- // #2073 follow-up: a function-reference `.map(format)` callback has no
134
- // arrow body to serialize not a CALLBACK_METHODS shape — so the
135
- // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
136
- // a broken template.
137
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
138
- // Edge-case sweep (Priority 12): `dangerouslySetInnerHTML` requires a
139
- // deliberate raw-HTML (unescaped) output affordance in the target
140
- // template language. No lowering exists yet, so the compiler refuses
141
- // the shape loudly instead of emitting entity-escaped markup that
142
- // silently renders tags as text.
143
- 'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
144
- // Edge-case sweep (Priority 12): `.replaceAll` has no lowering yet —
145
- // only first-occurrence `.replace` is wired to the runtime helpers.
146
- // Refused with BF101 rather than reusing the first-only lowering,
147
- // which would silently change semantics.
148
- 'string-replaceall': [{ code: 'BF101', severity: 'error' }],
132
+ // `array-map-function-reference` no longer pinned — a bare-identifier
133
+ // `.map(format)` callback now resolves one hop to its declaration
134
+ // (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
135
+ // #2090 established for `.sort(fnref)`.
136
+ // `dangerous-inner-html` no longer pinned a compile-time string-literal
137
+ // `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
138
+ // the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
139
+ // A dynamic/signal-derived value still refuses with BF101 see the
140
+ // `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
141
+ 'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
149
142
  }
@@ -15,24 +15,8 @@
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
17
  export const renderDivergences: RenderDivergences = {
18
- 'string-length-text':
19
- '`.length` on a STRING prop diverges (array-length lowering misapplied to a scalar)',
20
- 'number-tofixed':
21
- 'the literal `¥` in template text reaches the output as U+FFFD — a UTF-8 encoding gap for non-ASCII literal text adjacent to a dynamic slot',
22
- 'html-entity-text':
23
- '`&copy;` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes',
24
- 'math-methods':
25
- 'Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)',
26
- 'static-attr-escape':
27
- 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
28
- 'object-entries-map':
29
- '`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations',
30
- 'nested-loop-outer-binding':
31
- 'nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`',
32
- 'jsx-element-prop':
33
- 'a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped',
34
- 'string-slice':
35
- '`.slice()` on a STRING misfires through the array slice helper',
36
- 'string-trim-sided':
37
- '`.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 }))`.
38
22
  }
@@ -5,7 +5,7 @@
5
5
  * Used by adapter-tests conformance runner.
6
6
  */
7
7
 
8
- import { compileJSX, extractSsrDefaults, augmentInheritedPropAccesses, importsSearchParams } from '@barefootjs/jsx'
8
+ import { compileJSX, extractSsrDefaults, augmentInheritedPropAccesses, importsSearchParams, evaluateSignalInit, tryEvaluateSignalInit } from '@barefootjs/jsx'
9
9
  import type { ComponentIR } from '@barefootjs/jsx'
10
10
  import { mkdir, rm } from 'node:fs/promises'
11
11
  import { resolve } from 'node:path'
@@ -136,8 +136,15 @@ export async function renderMojoComponent(options: RenderOptions): Promise<strin
136
136
  }
137
137
  }
138
138
 
139
- // Compile parent source
140
- const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
139
+ // Compile parent source. `siblingTemplatesRegistered: Boolean(components)`
140
+ // matches this harness's real behavior every sibling child template is registered
141
+ // alongside the parent before rendering, so a loop-body cross-template
142
+ // call resolves at render time (#2205).
143
+ const result = compileJSX(source, 'component.tsx', {
144
+ adapter,
145
+ outputIR: true,
146
+ siblingTemplatesRegistered: Boolean(components),
147
+ })
141
148
 
142
149
  const errors = result.errors.filter(e => e.severity === 'error')
143
150
  if (errors.length > 0) {
@@ -240,6 +247,11 @@ export async function renderMojoComponent(options: RenderOptions): Promise<strin
240
247
  use strict;
241
248
  use warnings;
242
249
  use utf8;
250
+ # The template is read through :utf8, so $output holds decoded wide
251
+ # characters; without a matching layer on STDOUT Perl emits them as
252
+ # latin-1 bytes and the harness reads U+FFFD (©/¥ mangling). A real
253
+ # Mojolicious response encodes on the way out — this is harness-only.
254
+ binmode STDOUT, ':encoding(UTF-8)';
243
255
 
244
256
  use lib '${LIB_DIR}', '${PERL_CORE_LIB_DIR}';
245
257
  use Mojolicious;
@@ -445,9 +457,9 @@ function buildChildDefaultsPerl(ir: ComponentIR): string {
445
457
  for (const param of ir.metadata.propsParams) {
446
458
  declared.add(param.name)
447
459
  if (param.defaultValue) {
448
- const perlValue = jsToPerlValue(param.defaultValue)
449
- if (perlValue !== null) {
450
- entries.push(`${param.name} => ${perlValue}`)
460
+ const result = tryEvaluateSignalInit(param.defaultValue.trim())
461
+ if (result.ok) {
462
+ entries.push(`${param.name} => ${toPerlLiteral(result.value)}`)
451
463
  continue
452
464
  }
453
465
  }
@@ -495,9 +507,9 @@ function buildPerlProps(
495
507
  for (const param of ir.metadata.propsParams) {
496
508
  if (props && param.name in props) continue
497
509
  if (param.defaultValue) {
498
- const perlValue = jsToPerlValue(param.defaultValue)
499
- if (perlValue !== null) {
500
- entries.push(`${param.name} => ${perlValue}`)
510
+ const result = tryEvaluateSignalInit(param.defaultValue.trim(), props)
511
+ if (result.ok) {
512
+ entries.push(`${param.name} => ${toPerlLiteral(result.value)}`)
501
513
  continue
502
514
  }
503
515
  }
@@ -671,176 +683,6 @@ function collectPropsObjectAccesses(ir: ComponentIR, propsObj: string): Set<stri
671
683
  return out
672
684
  }
673
685
 
674
- /**
675
- * Evaluate a signal initializer expression using provided props.
676
- * Handles patterns like: props.initial ?? 0, props.value, literal values.
677
- */
678
- export function evaluateSignalInit(
679
- expr: string,
680
- props?: Record<string, unknown>,
681
- ): unknown {
682
- // props.xxx ?? default
683
- const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
684
- if (nullishMatch) {
685
- const propName = nullishMatch[1]
686
- const defaultExpr = nullishMatch[2].trim()
687
- if (props && propName in props) {
688
- return props[propName]
689
- }
690
- return parseLiteral(defaultExpr)
691
- }
692
-
693
- // props.xxx (no default)
694
- const propsMatch = expr.match(/^props\.(\w+)$/)
695
- if (propsMatch) {
696
- if (props && propsMatch[1] in props) {
697
- return props[propsMatch[1]]
698
- }
699
- return null
700
- }
701
-
702
- // Literal value
703
- return parseLiteral(expr)
704
- }
705
-
706
- function parseLiteral(expr: string): unknown {
707
- if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
708
- if (expr === 'true') return true
709
- if (expr === 'false') return false
710
- if (expr === '[]') return []
711
-
712
- // Non-empty array literal (`[{ id: 'a' }, { id: 'b' }]`, `['x', 'y']`).
713
- // Each element is parsed recursively; if any element can't be parsed
714
- // (identifier, call, member access, …) the whole array bails to null so
715
- // the harness falls back to its `undef` behaviour. Mirrors the object-
716
- // literal branch below. Needed so signal initial values that are inline
717
- // object/scalar arrays seed the Mojo SSR stash (e.g. the whole-item loop
718
- // conditional fixture, whose `items` is `[{ id: 'a' }, …]`).
719
- {
720
- const t = expr.trim()
721
- if (t.startsWith('[') && t.endsWith(']')) {
722
- const inner = t.slice(1, -1).trim()
723
- if (!inner) return []
724
- const out: unknown[] = []
725
- for (const seg of splitTopLevelCommas(inner)) {
726
- if (!seg.trim()) continue
727
- const parsed = parseLiteral(seg.trim())
728
- if (parsed === null && seg.trim() !== 'null') return null
729
- out.push(parsed)
730
- }
731
- return out
732
- }
733
- }
734
- // String literal — require matching opener/closer (the previous
735
- // regex `^['"]…['"]$` accepted mixed quotes like `'foo"`) and
736
- // unescape JS-style escape sequences so `'a\\'b'` round-trips as
737
- // `a\'b` instead of leaking the source-level escapes into the
738
- // Perl literal (#1413 review).
739
- const stringMatch = expr.match(/^(['"])(.*)\1$/s)
740
- if (stringMatch) return unescapeJsString(stringMatch[2])
741
- // JS object literal (#1407 follow-up): `{ id: 'a', class: 'on' }`.
742
- // Used for spread-bag signal initial values in the `jsx-spread-*`
743
- // fixture family. Keys may be bare identifiers or string
744
- // literals; values are scalars (string / number / boolean /
745
- // null) or nested object literals via recursive `parseLiteral`.
746
- // Non-empty array values (`[1, 2]`) are NOT supported — only
747
- // the `[]` empty-array literal recognised by the early-return
748
- // above lowers. Trailing commas (`{ id: 'a', }`) are accepted
749
- // by skipping empty segments (#1413 review). Anything the
750
- // recursive call can't handle (identifiers, function calls,
751
- // member access, non-empty arrays) surfaces as null and bubbles
752
- // up so the harness falls back to its existing `undef`
753
- // behaviour.
754
- const trimmed = expr.trim()
755
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
756
- const inner = trimmed.slice(1, -1).trim()
757
- if (!inner) return {}
758
- const obj: Record<string, unknown> = {}
759
- const pairs = splitTopLevelCommas(inner)
760
- for (const pair of pairs) {
761
- // Skip empty segments — typically a trailing comma's tail
762
- // (#1413 review).
763
- if (!pair.trim()) continue
764
- const colonIdx = pair.indexOf(':')
765
- if (colonIdx < 0) return null
766
- let key = pair.slice(0, colonIdx).trim()
767
- const val = pair.slice(colonIdx + 1).trim()
768
- // Strip key quotes if any — require matching open/close
769
- // quote and unescape, same shape as the value-side string
770
- // literal handling above (#1413 review).
771
- const keyMatch = key.match(/^(['"])(.*)\1$/s)
772
- if (keyMatch) key = unescapeJsString(keyMatch[2])
773
- const parsedVal = parseLiteral(val)
774
- if (parsedVal === null && val !== 'null') return null
775
- obj[key] = parsedVal
776
- }
777
- return obj
778
- }
779
- return null
780
- }
781
-
782
- /**
783
- * Split a comma-separated literal body (object-pair list or array element
784
- * list) on top-level commas only — commas nested inside braces, brackets, or
785
- * string literals don't split. Backslash-escaped quotes inside strings are
786
- * honoured (an odd run of backslashes before a quote keeps the string open).
787
- * Shared by the object- and array-literal branches of {@link parseLiteral}.
788
- */
789
- function splitTopLevelCommas(inner: string): string[] {
790
- const segments: string[] = []
791
- let depth = 0
792
- let start = 0
793
- let quote: string | null = null
794
- for (let i = 0; i < inner.length; i++) {
795
- const c = inner[i]
796
- if (quote) {
797
- if (c === quote) {
798
- let backslashes = 0
799
- for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
800
- if (backslashes % 2 === 0) quote = null
801
- }
802
- continue
803
- }
804
- if (c === '"' || c === "'") {
805
- quote = c
806
- continue
807
- }
808
- if (c === '{' || c === '[') depth++
809
- else if (c === '}' || c === ']') depth--
810
- else if (c === ',' && depth === 0) {
811
- segments.push(inner.slice(start, i))
812
- start = i + 1
813
- }
814
- }
815
- segments.push(inner.slice(start))
816
- return segments
817
- }
818
-
819
- /**
820
- * Unescape a JS string-literal body (the content between the
821
- * matching opening and closing quotes, not the quotes themselves).
822
- * Handles the common single-character escapes `\\`, `\'`, `\"`,
823
- * `\n`, `\r`, `\t`, `\0`, and the backslash-anything fallback that
824
- * mirrors JS's "unknown escape is the character itself" semantics.
825
- * Hex / unicode / octal escapes are intentionally out of scope —
826
- * the spread-bag fixture corpus uses ASCII identifiers and short
827
- * literal values, so the harness doesn't need a full JS string
828
- * decoder (#1413 review).
829
- */
830
- function unescapeJsString(s: string): string {
831
- return s.replace(/\\(.)/g, (_, c) => {
832
- switch (c) {
833
- case 'n': return '\n'
834
- case 'r': return '\r'
835
- case 't': return '\t'
836
- case '0': return '\0'
837
- // `\\`, `\'`, `\"`, and any other single-character escape
838
- // collapse to the literal character (matches JS semantics
839
- // for unrecognised escapes).
840
- default: return c
841
- }
842
- })
843
- }
844
686
 
845
687
  /**
846
688
  * Perl single-quoted string escape: `'` AND `\` need escaping.
@@ -886,34 +728,3 @@ function toPerlLiteral(value: unknown): string {
886
728
  return 'undef'
887
729
  }
888
730
 
889
- /**
890
- * Convert a JS literal value to a Perl literal.
891
- * Handles: numbers, strings, booleans, empty arrays, props.xxx ?? default patterns.
892
- */
893
- function jsToPerlValue(jsValue: string): string | null {
894
- const v = jsValue.trim()
895
-
896
- // Number
897
- if (/^-?\d+(\.\d+)?$/.test(v)) return v
898
-
899
- // String literal
900
- if (/^['"].*['"]$/.test(v)) return v
901
-
902
- // Boolean
903
- if (v === 'true') return '1'
904
- if (v === 'false') return '0'
905
-
906
- // Empty array
907
- if (v === '[]') return '[]'
908
-
909
- // props.xxx ?? default — extract the default value
910
- const nullishMatch = v.match(/\?\?\s*(.+)$/)
911
- if (nullishMatch) {
912
- return jsToPerlValue(nullishMatch[1])
913
- }
914
-
915
- // props.xxx (no default) — return undef
916
- if (v.startsWith('props.')) return 'undef'
917
-
918
- return null
919
- }
@@ -1,35 +0,0 @@
1
- import { describe, test, expect } from 'bun:test'
2
- import { evaluateSignalInit } from '../test-render'
3
-
4
- describe('evaluateSignalInit — SSR signal seeding (#1672)', () => {
5
- test('parses an inline object-array initial value', () => {
6
- // The whole-item loop-conditional fixture seeds `items` from an inline
7
- // object array. Without array support this returned null, so `$items` was
8
- // undefined in the Mojo SSR render and the loop rendered empty.
9
- expect(evaluateSignalInit(`[{ id: 'a' }, { id: 'b' }, { id: 'c' }]`)).toEqual([
10
- { id: 'a' },
11
- { id: 'b' },
12
- { id: 'c' },
13
- ])
14
- })
15
-
16
- test('parses scalar and mixed arrays, including nested objects', () => {
17
- expect(evaluateSignalInit(`['x', 'y']`)).toEqual(['x', 'y'])
18
- expect(evaluateSignalInit(`[1, 2, 3]`)).toEqual([1, 2, 3])
19
- expect(evaluateSignalInit(`[{ id: 'a', n: 1, ok: true }]`)).toEqual([
20
- { id: 'a', n: 1, ok: true },
21
- ])
22
- })
23
-
24
- test('still parses scalars, empty array, and props passthrough', () => {
25
- expect(evaluateSignalInit(`'b'`)).toBe('b')
26
- expect(evaluateSignalInit(`5`)).toBe(5)
27
- expect(evaluateSignalInit(`[]`)).toEqual([])
28
- expect(evaluateSignalInit(`props.value`, { value: 42 })).toBe(42)
29
- })
30
-
31
- test('bails to null for arrays with non-literal elements', () => {
32
- // A call / identifier element can't be evaluated at seed time.
33
- expect(evaluateSignalInit(`[foo(), bar]`)).toBeNull()
34
- })
35
- })