@barefootjs/xslate 0.16.0 → 0.17.1

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.
Files changed (50) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +30 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/emit-context.d.ts +94 -0
  4. package/dist/adapter/emit-context.d.ts.map +1 -0
  5. package/dist/adapter/expr/array-method.d.ts +73 -0
  6. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  7. package/dist/adapter/expr/emitters.d.ts +96 -0
  8. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  9. package/dist/adapter/expr/operand.d.ts +25 -0
  10. package/dist/adapter/expr/operand.d.ts.map +1 -0
  11. package/dist/adapter/index.js +1412 -1323
  12. package/dist/adapter/lib/constants.d.ts +22 -0
  13. package/dist/adapter/lib/constants.d.ts.map +1 -0
  14. package/dist/adapter/lib/ir-scope.d.ts +29 -0
  15. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  16. package/dist/adapter/lib/kolon-naming.d.ts +22 -0
  17. package/dist/adapter/lib/kolon-naming.d.ts.map +1 -0
  18. package/dist/adapter/lib/types.d.ts +27 -0
  19. package/dist/adapter/lib/types.d.ts.map +1 -0
  20. package/dist/adapter/memo/seed.d.ts +38 -0
  21. package/dist/adapter/memo/seed.d.ts.map +1 -0
  22. package/dist/adapter/props/prop-classes.d.ts +32 -0
  23. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  24. package/dist/adapter/spread/spread-codegen.d.ts +69 -0
  25. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  26. package/dist/adapter/value/parsed-literal.d.ts +32 -0
  27. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  28. package/dist/adapter/xslate-adapter.d.ts +38 -92
  29. package/dist/adapter/xslate-adapter.d.ts.map +1 -1
  30. package/dist/build.js +1412 -1323
  31. package/dist/index.js +1412 -1323
  32. package/lib/BarefootJS/Backend/Xslate.pm +1 -1
  33. package/package.json +3 -3
  34. package/src/__tests__/query-href.test.ts +96 -0
  35. package/src/__tests__/xslate-adapter.test.ts +200 -8
  36. package/src/adapter/analysis/component-tree.ts +123 -0
  37. package/src/adapter/emit-context.ts +104 -0
  38. package/src/adapter/expr/array-method.ts +332 -0
  39. package/src/adapter/expr/emitters.ts +548 -0
  40. package/src/adapter/expr/operand.ts +35 -0
  41. package/src/adapter/lib/constants.ts +34 -0
  42. package/src/adapter/lib/ir-scope.ts +53 -0
  43. package/src/adapter/lib/kolon-naming.ts +27 -0
  44. package/src/adapter/lib/types.ts +30 -0
  45. package/src/adapter/memo/seed.ts +78 -0
  46. package/src/adapter/props/prop-classes.ts +64 -0
  47. package/src/adapter/spread/spread-codegen.ts +179 -0
  48. package/src/adapter/value/parsed-literal.ts +80 -0
  49. package/src/adapter/xslate-adapter.ts +190 -1236
  50. package/src/test-render.ts +3 -0
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Xslate;
2
- our $VERSION = "0.15.2";
2
+ our $VERSION = "0.17.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/xslate",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Text::Xslate (Kolon) adapter for BarefootJS — compiles IR to .tx templates and ships the Xslate rendering backend; runs under any PSGI/Plack app",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,14 +55,14 @@
55
55
  "directory": "packages/adapter-xslate"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.16.0"
58
+ "@barefootjs/shared": "0.17.1"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@barefootjs/adapter-tests": "0.1.0",
65
- "@barefootjs/jsx": "0.16.0",
65
+ "@barefootjs/jsx": "0.17.1",
66
66
  "typescript": "^5.0.0"
67
67
  }
68
68
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `queryHref(base, { … })` → `$bf.query(...)` lowering for the Xslate adapter
3
+ * (#2042). Parity with the go-template / Mojo lowering: the call + object literal
4
+ * are structured IR, so it lowers directly. The shared `query` runtime helper
5
+ * (BarefootJS.pm) includes a pair iff its guard is truthy AND its value is a
6
+ * non-empty string, so a plain `key: v` passes guard `1` and a conditional
7
+ * `key: cond ? v : undefined` passes the lowered condition.
8
+ */
9
+ import { describe, test, expect } from 'bun:test'
10
+ import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
11
+ import { XslateAdapter } from '../adapter/xslate-adapter'
12
+
13
+ function template(src: string): string {
14
+ const a = new XslateAdapter()
15
+ const r = compileJSX(src.trimStart(), 'T.tsx', { adapter: a, outputIR: true })
16
+ const ir = JSON.parse(r.files.find(f => f.type === 'ir')!.content) as ComponentIR
17
+ return a.generate(ir).template
18
+ }
19
+
20
+ describe('queryHref → $bf.query (Xslate, #2042)', () => {
21
+ test('a plain value passes guard 1', () => {
22
+ const t = template(`
23
+ 'use client'
24
+ import { queryHref } from '@barefootjs/client'
25
+ export function P(props: { base: string; tag: string }) {
26
+ return <a href={queryHref(props.base, { tag: props.tag })}>x</a>
27
+ }
28
+ `)
29
+ expect(t).toContain("$bf.query($base, 1, 'tag', $tag)")
30
+ })
31
+
32
+ test('a conditional include passes the lowered condition as the guard', () => {
33
+ const t = template(`
34
+ 'use client'
35
+ import { queryHref } from '@barefootjs/client'
36
+ export function P(props: { base: string; sort: string; tag: string }) {
37
+ return <a href={queryHref(props.base, { sort: props.sort !== 'date' ? props.sort : undefined, tag: props.tag })}>x</a>
38
+ }
39
+ `)
40
+ // Kolon uses `!=` for string inequality (no `ne` operator).
41
+ expect(t).toContain("$bf.query($base, ($sort != 'date'), 'sort', $sort, 1, 'tag', $tag)")
42
+ })
43
+
44
+ // A bare-value guard (`flag ? v : undefined`) is JS *string* truthiness — `'0'`
45
+ // is a truthy string in JS but false under Perl's `unless`. The lowering must
46
+ // normalise it to a non-empty-string test so SSR matches the client / go (where
47
+ // `lowerUrlGuard` emits `ne <value> ""`). Kolon renders the `!== ''` test as
48
+ // `!= ''` (its string inequality), matching the comparison guard above.
49
+ test('a bare-value guard is normalised to a non-empty-string test', () => {
50
+ const t = template(`
51
+ 'use client'
52
+ import { queryHref } from '@barefootjs/client'
53
+ export function P(props: { base: string; flag: string; val: string }) {
54
+ return <a href={queryHref(props.base, { q: props.flag ? props.val : undefined })}>x</a>
55
+ }
56
+ `)
57
+ expect(t).toContain("$bf.query($base, ($flag != ''), 'q', $val)")
58
+ })
59
+
60
+ // An array value (`{ tag: props.tags }`) lowers to the bare slice expression;
61
+ // the shared Perl `query` helper detects the arrayref at runtime and appends
62
+ // one pair per non-empty member (#2048). No adapter-side change beyond passing
63
+ // the value through.
64
+ test('an array value passes the slice expression for the helper to append', () => {
65
+ const t = template(`
66
+ 'use client'
67
+ import { queryHref } from '@barefootjs/client'
68
+ export function P(props: { base: string; tags: string[] }) {
69
+ return <a href={queryHref(props.base, { tag: props.tags })}>x</a>
70
+ }
71
+ `)
72
+ expect(t).toContain("$bf.query($base, 1, 'tag', $tags)")
73
+ })
74
+
75
+ test('an aliased import is recognised', () => {
76
+ const t = template(`
77
+ 'use client'
78
+ import { queryHref as qh } from '@barefootjs/client'
79
+ export function P(props: { base: string; tag: string }) {
80
+ return <a href={qh(props.base, { tag: props.tag })}>x</a>
81
+ }
82
+ `)
83
+ expect(t).toContain("$bf.query($base, 1, 'tag', $tag)")
84
+ })
85
+
86
+ test('a dynamic (non-literal) params object falls back (no $bf.query)', () => {
87
+ const t = template(`
88
+ 'use client'
89
+ import { queryHref } from '@barefootjs/client'
90
+ export function P(props: { base: string; q: Record<string, string> }) {
91
+ return <a href={queryHref(props.base, props.q)}>x</a>
92
+ }
93
+ `)
94
+ expect(t).not.toContain('.query')
95
+ })
96
+ })
@@ -101,10 +101,29 @@ runAdapterConformanceTests({
101
101
  // Tagged-template-literal call in a className — same family, same
102
102
  // refusal (BF101).
103
103
  'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
104
- // NB: `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are NOT
105
- // pinned here unlike mojo (which refuses them), Xslate lowers them to
106
- // `$bf.find` / `find_index` / `find_last` / `find_last_index` via the same
107
- // Kolon-lambda mechanism as `.filter` / `.every` / `.some`, so they render.
104
+ // #2038: a filter predicate whose body contains a NESTED callback call
105
+ // (`t => !picked().some(p => …)` / `t => picked().find(p => …)`). Kolon
106
+ // has no inline `grep` form, so `XslateFilterEmitter.callbackMethod` used
107
+ // to degrade the inner call to its receiver, silently changing predicate
108
+ // semantics — the compiler is loud instead of lossy. (Mojo is pinned only
109
+ // for the `.find` variant: it lowers a nested `.some` to a real inline
110
+ // Perl `grep`.) The `/* @client */` twin
111
+ // (`filter-nested-callback-predicate-client`) has no pin here: it must
112
+ // render clean on every adapter, which asserts the suppression contract.
113
+ // https://github.com/piconic-ai/barefootjs/issues/2038
114
+ 'filter-nested-callback-predicate': [{ code: 'BF101', severity: 'error' }],
115
+ 'filter-nested-find-predicate': [{ code: 'BF101', severity: 'error' }],
116
+ // NB: TOP-LEVEL `.find` / `.findIndex` / `.findLast` / `.findLastIndex`
117
+ // (text position) are NOT pinned here — unlike mojo (which refuses them),
118
+ // Xslate lowers them to `$bf.find` / `find_index` / `find_last` /
119
+ // `find_last_index` via the same Kolon-lambda mechanism as `.filter` /
120
+ // `.every` / `.some`, so they render. Only the NESTED-in-a-predicate form
121
+ // above is refused (#2038).
122
+ // #2073 follow-up: a function-reference `.map(format)` callback has no
123
+ // arrow body to serialize — not a CALLBACK_METHODS shape — so the
124
+ // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
125
+ // a broken template.
126
+ 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
108
127
  },
109
128
  // Template-primitive registry parity: same V1 surface as mojo, so the
110
129
  // same two cases stay skipped (bespoke user import + customSerialize
@@ -113,11 +132,15 @@ runAdapterConformanceTests({
113
132
  TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
114
133
  TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
115
134
  ]),
116
- // Loop boundary markers for `@client` loops aren't emitted by the
117
- // Xslate adapter yet (ported from mojo, which skips the same set).
135
+ // `client-only` / `client-only-loop-with-sibling-cond` /
136
+ // `filter-nested-callback-predicate-client` are no longer skipped
137
+ // `renderLoop` now emits the `$bf.comment("loop:<id>")` boundary pair
138
+ // for clientOnly loops (Hono / Go parity), so mapArray() can locate
139
+ // its insertion anchor at hydration time (#872 / #1087).
118
140
  skipMarkerConformance: new Set([
119
- 'client-only',
120
- 'client-only-loop-with-sibling-cond',
141
+ // Same as Hono / Mojo: `/* @client */` markers on TodoApp's keyed
142
+ // `.map` intentionally elide a slot id from the SSR template that
143
+ // the IR still declares (s6). See hono-adapter.test for the contract.
121
144
  'todo-app',
122
145
  // #1467 Phase 2e: same `/* @client */` keyed-map elision (data-table).
123
146
  'data-table',
@@ -291,3 +314,172 @@ export function C() {
291
314
  expect(deferred.template).not.toContain('data-x')
292
315
  })
293
316
  })
317
+
318
+ // #2018 P2: higher-order predicates lower through the runtime evaluator
319
+ // (`$bf.*_eval`), isomorphic with the Go / Mojo `*_eval` helpers. A predicate
320
+ // the evaluator can't model (a method-call predicate) falls back to the Kolon
321
+ // lambda runtime call. Template-text pins guard against silent divergence.
322
+ describe('XslateAdapter - #2075 searchParams()-derived memo seeding', () => {
323
+ test('seeds an aliased scalar derived memo from the canonical reader', () => {
324
+ const { template } = compileAndGenerate(`
325
+ 'use client'
326
+ import { createMemo, createSearchParams } from '@barefootjs/client'
327
+ export function SortStatus() {
328
+ const [sp] = createSearchParams()
329
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
330
+ return <p>sort: {sort()}</p>
331
+ }
332
+ `)
333
+ expect(template).toContain(": my $sort = ($searchParams.get('sort') // 'date');")
334
+ })
335
+
336
+ // The Kolon lambda param and the `$bf` runtime object are
337
+ // lowering-internal, not out-of-scope template vars (#2075).
338
+ test('seeds a filter memo chained off the derived memo', () => {
339
+ const { template } = compileAndGenerate(`
340
+ 'use client'
341
+ import { createMemo, createSearchParams } from '@barefootjs/client'
342
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
343
+ const [searchParams] = createSearchParams()
344
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
345
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
346
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
347
+ }
348
+ `)
349
+ expect(template).toContain(": my $tag = ($searchParams.get('tag') // '');")
350
+ expect(template).toContain(': my $visible = $bf.filter($items,')
351
+ })
352
+
353
+ // The seed-scope guard used to scan the LOWERED
354
+ // Kolon string, allowing every arrow-callback param tree-wide. That let an
355
+ // outer, unbound `p` (shadowed only inside the callback) slip past the
356
+ // guard as if it were the callback's own bound `$p` — emitting a bogus
357
+ // seed line. The guard now walks the parsed SOURCE tree with proper
358
+ // lexical scoping (`freeIdentifiers`), so this shape seeds nothing and
359
+ // falls back to the null/ssr-defaults path.
360
+ test('an outer unbound `p` shadowed only inside the callback does not seed', () => {
361
+ const { template } = compileAndGenerate(`
362
+ 'use client'
363
+ import { createMemo } from '@barefootjs/client'
364
+ export function C(props: { items: { ok: boolean }[] }) {
365
+ const visible = createMemo(() => props.items.filter((p) => p.ok) && p)
366
+ return <div>{String(visible())}</div>
367
+ }
368
+ `)
369
+ expect(template).not.toContain('my $visible')
370
+ })
371
+
372
+ // An out-of-scope bare `_` reference must not seed either — the old
373
+ // unconditional `allowed.add('_')` / `allowed.add('bf')` masked this.
374
+ test('an out-of-scope bare `_` reference does not seed', () => {
375
+ const { template } = compileAndGenerate(`
376
+ 'use client'
377
+ import { createMemo } from '@barefootjs/client'
378
+ export function C(props: { count: number }) {
379
+ const doubled = createMemo(() => props.count * 2 + _)
380
+ return <div>{doubled()}</div>
381
+ }
382
+ `)
383
+ expect(template).not.toContain('my $doubled')
384
+ })
385
+ })
386
+
387
+ describe('XslateAdapter - #2073 value-producing .map(cb)', () => {
388
+ // The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
389
+ // projection, not JSX) lowers through the evaluator — `$bf.map_eval`
390
+ // projects each element (no flatten) and composes through `$bf.join`.
391
+ test('.map(t => `#${t}`).join(" ") emits $bf.map_eval composed into $bf.join', () => {
392
+ const { template } = compileAndGenerate(`
393
+ function TagLine({ tags }: { tags: string[] }) {
394
+ return <p>{tags.map((t) => \`#\${t}\`).join(' ')}</p>
395
+ }
396
+ export { TagLine }
397
+ `)
398
+ expect(template).toContain("$bf.join($bf.map_eval($tags,")
399
+ expect(template).toContain('"kind":"template-literal"')
400
+ })
401
+
402
+ test('.map(u => u.name) emits $bf.map_eval with the field projection', () => {
403
+ const { template } = compileAndGenerate(`
404
+ function NameList({ users }: { users: { name: string }[] }) {
405
+ return <div>{users.map((u) => u.name).join(', ')}</div>
406
+ }
407
+ export { NameList }
408
+ `)
409
+ expect(template).toContain('$bf.map_eval($users,')
410
+ expect(template).toContain('"property":"name"')
411
+ })
412
+ })
413
+
414
+ describe('XslateAdapter - higher-order predicate lowering (#2018 P2)', () => {
415
+ test('a serializable predicate lowers to $bf.filter_eval with the JSON body + env', () => {
416
+ // A standalone `.filter().length` exercises the higher-order emitter (the
417
+ // `.filter().map()` form is a loop-hoist with an inline `: if`, handled by
418
+ // renderLoop, not this emitter).
419
+ const { template } = compileAndGenerate(`
420
+ function A({ items }: { items: { done: boolean }[] }) {
421
+ return <div>{items.filter(x => x.done).length}</div>
422
+ }
423
+ export { A }
424
+ `)
425
+ expect(template).toContain('$bf.filter_eval(')
426
+ expect(template).toContain('"property":"done"')
427
+ expect(template).toContain("'x'")
428
+ })
429
+
430
+ test('.find / .findLast share $bf.find_eval, distinguished by the forward flag', () => {
431
+ const find = compileAndGenerate(`
432
+ function A({ items }: { items: { done: boolean }[] }) {
433
+ return <div>{items.find(x => x.done) ? 'y' : 'n'}</div>
434
+ }
435
+ export { A }
436
+ `).template
437
+ expect(find).toContain('$bf.find_eval(')
438
+ expect(find).toContain(', 1, {})')
439
+
440
+ const findLast = compileAndGenerate(`
441
+ function A({ items }: { items: { done: boolean }[] }) {
442
+ return <div>{items.findLast(x => x.done) ? 'y' : 'n'}</div>
443
+ }
444
+ export { A }
445
+ `).template
446
+ expect(findLast).toContain('$bf.find_eval(')
447
+ expect(findLast).toContain(', 0, {})')
448
+ })
449
+
450
+ test('.includes() in a predicate now lowers via the evaluator, not the Kolon-lambda fallback', () => {
451
+ // #2075: `.includes(x)` joined the evaluator's `array-method` surface
452
+ // (shared with the Perl `Evaluator.pm` runtime), so a predicate built
453
+ // from it routes through `$bf.every_eval` like any other pure predicate.
454
+ const { template } = compileAndGenerate(`
455
+ function A({ items }: { items: { name: string }[] }) {
456
+ return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
457
+ }
458
+ export { A }
459
+ `)
460
+ expect(template).toContain('$bf.every_eval(')
461
+ expect(template).toContain('"method":"includes"')
462
+ expect(template).not.toContain('-> $x {')
463
+ })
464
+
465
+ test('a method-call predicate outside the evaluator surface falls back to the Kolon-lambda runtime call', () => {
466
+ const { template } = compileAndGenerate(`
467
+ function A({ items }: { items: { name: string }[] }) {
468
+ return <div>{items.every(x => x.name.toUpperCase() === 'A') ? 'y' : 'n'}</div>
469
+ }
470
+ export { A }
471
+ `)
472
+ // `.toUpperCase()` is outside the evaluator's `array-method` gate (only
473
+ // `includes` is recognized there), so the predicate keeps the
474
+ // `-> $x { … }` lambda form passed to the runtime `$bf.every`.
475
+ expect(template).not.toContain('every_eval')
476
+ expect(template).toContain('$bf.every(')
477
+ expect(template).toContain('-> $x {')
478
+ })
479
+ })
480
+
481
+ // #2038 nested-callback-predicate loudness is pinned at the shared
482
+ // conformance layer: `filter-nested-callback-predicate` /
483
+ // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics` above) and
484
+ // `filter-nested-callback-predicate-client` (the `/* @client */` suppression
485
+ // twin, which must render clean).
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Component-tree analysis for the Text::Xslate (Kolon) template adapter.
3
+ *
4
+ * Extracted from `xslate-adapter.ts` (domain-module refactor, issue #2018
5
+ * track D). Pure functions over the IR — they read no adapter instance
6
+ * state. `collectImportedLoopChildComponentErrors` returns its diagnostics
7
+ * instead of pushing onto the adapter's error list, so the adapter stays the
8
+ * sole owner of `errors`.
9
+ *
10
+ * SHARED CANDIDATE: `hasClientInteractivity` is byte-identical to the Mojo
11
+ * adapter's copy and adapter-agnostic; the BF103 loop-child check is the same
12
+ * structural walk in both, differing only in the Perl/Kolon diagnostic text —
13
+ * both are groundwork for a shared Perl-family codegen module (issue #2018
14
+ * track D).
15
+ */
16
+
17
+ import type {
18
+ ComponentIR,
19
+ IRNode,
20
+ IRComponent,
21
+ IRElement,
22
+ IRFragment,
23
+ IRConditional,
24
+ IRLoop,
25
+ IRIfStatement,
26
+ IRProvider,
27
+ IRAsync,
28
+ CompilerError,
29
+ } from '@barefootjs/jsx'
30
+
31
+ /**
32
+ * Whether the component needs the client runtime — it owns reactive state
33
+ * (signals / effects / onMount) or the analyzer flagged it as needing init.
34
+ */
35
+ export function hasClientInteractivity(ir: ComponentIR): boolean {
36
+ return (
37
+ ir.metadata.signals.length > 0 ||
38
+ ir.metadata.effects.length > 0 ||
39
+ ir.metadata.onMounts.length > 0 ||
40
+ (ir.metadata.clientAnalysis?.needsInit ?? false)
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Build a `BF103` diagnostic for every component reference inside a loop body
46
+ * whose name is imported from a relative-path module. Mirror of the Go
47
+ * adapter's check — the Xslate adapter has the same cross-template-registration
48
+ * constraint at request time. Returns the diagnostics so the caller pushes
49
+ * them onto its own error list.
50
+ */
51
+ export function collectImportedLoopChildComponentErrors(
52
+ ir: ComponentIR,
53
+ componentName: string,
54
+ ): CompilerError[] {
55
+ const errors: CompilerError[] = []
56
+ const relativeImports = new Set<string>()
57
+ for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
58
+ if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
59
+ if (imp.isTypeOnly) continue
60
+ for (const spec of imp.specifiers) {
61
+ relativeImports.add(spec.alias ?? spec.name)
62
+ }
63
+ }
64
+ if (relativeImports.size === 0) return errors
65
+
66
+ const loc = { file: componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
67
+ const visit = (node: IRNode, inLoop: boolean): void => {
68
+ switch (node.type) {
69
+ case 'component': {
70
+ const comp = node as IRComponent
71
+ if (inLoop && relativeImports.has(comp.name)) {
72
+ errors.push({
73
+ code: 'BF103',
74
+ severity: 'error',
75
+ message: `Component <${comp.name}> is imported from a sibling module and used inside a loop. The Xslate adapter emits a cross-template call; the child template must be registered alongside the parent at render time.`,
76
+ loc: comp.loc ?? loc,
77
+ suggestion: {
78
+ message:
79
+ `Options:\n` +
80
+ ` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting Xslate template alongside the parent at render time.\n` +
81
+ ` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
82
+ ` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
83
+ },
84
+ })
85
+ }
86
+ for (const child of comp.children) visit(child, inLoop)
87
+ break
88
+ }
89
+ case 'element':
90
+ for (const child of (node as IRElement).children) visit(child, inLoop)
91
+ break
92
+ case 'fragment':
93
+ for (const child of (node as IRFragment).children) visit(child, inLoop)
94
+ break
95
+ case 'conditional': {
96
+ const cond = node as IRConditional
97
+ visit(cond.whenTrue, inLoop)
98
+ if (cond.whenFalse) visit(cond.whenFalse, inLoop)
99
+ break
100
+ }
101
+ case 'loop':
102
+ for (const child of (node as IRLoop).children) visit(child, true)
103
+ break
104
+ case 'if-statement': {
105
+ const stmt = node as IRIfStatement
106
+ visit(stmt.consequent, inLoop)
107
+ if (stmt.alternate) visit(stmt.alternate, inLoop)
108
+ break
109
+ }
110
+ case 'provider':
111
+ for (const child of (node as IRProvider).children) visit(child, inLoop)
112
+ break
113
+ case 'async': {
114
+ const a = node as IRAsync
115
+ visit(a.fallback, inLoop)
116
+ for (const child of a.children) visit(child, inLoop)
117
+ break
118
+ }
119
+ }
120
+ }
121
+ visit(ir.root, false)
122
+ return errors
123
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The contract the extracted expression-emitter modules depend on instead of
3
+ * the concrete `XslateAdapter`.
4
+ *
5
+ * The Xslate adapter's top-level expression lowering is mutually recursive
6
+ * with the adapter's own const/record resolution and its filter-predicate
7
+ * emitter, so the extracted `XslateTopLevelEmitter` still needs to call back
8
+ * into shared per-compile state and recursive entry points. `XslateEmitContext`
9
+ * is that seam: the emitter takes a `XslateEmitContext` built by the adapter's
10
+ * private `emitCtx` getter (the adapter does NOT `implements` this interface,
11
+ * so the wrapped members stay private and off its exported public type —
12
+ * matching the Go adapter's `emitCtx`). The emitter depends on this narrow
13
+ * interface rather than the full ~2.5k-line class, so the coupling is explicit
14
+ * and it's unit-testable against a stub.
15
+ *
16
+ * Keep this surface minimal: add a member only when an extracted module
17
+ * genuinely needs it, so the seam documents the real cross-module coupling
18
+ * rather than re-exposing the whole adapter.
19
+ */
20
+
21
+ import type { ParsedExpr, CompilerError, IRMetadata } from '@barefootjs/jsx'
22
+
23
+ export interface XslateEmitContext {
24
+ /**
25
+ * (#1922) Local binding names the request-scoped `searchParams()` env signal
26
+ * is imported under. Non-empty enables the env-signal method-call lowering.
27
+ */
28
+ readonly _searchParamsLocals: Set<string>
29
+
30
+ /**
31
+ * Inline a module-scope pure string-literal const by name as the resolved
32
+ * literal value, or null when the name is not such a const.
33
+ */
34
+ _resolveModuleStringConst(name: string): string | null
35
+
36
+ /** Resolve a literal const (`const totalPages = 5`) to its Kolon value, or null. */
37
+ _resolveLiteralConst(name: string): string | null
38
+
39
+ /**
40
+ * Resolve a static property access on a module object-literal const
41
+ * (`variantClasses.ghost`) to its Kolon value at compile time, or null.
42
+ */
43
+ _resolveStaticRecordLiteral(objectName: string, key: string): string | null
44
+
45
+ /** Record a BF101 unsupported-expression diagnostic. */
46
+ _recordExprBF101(message: string, reason?: string): void
47
+
48
+ /** Lower a filter/predicate body to its Kolon form, bound to `param`. */
49
+ _renderKolonFilterExprPublic(expr: ParsedExpr, param: string): string
50
+ }
51
+
52
+ /**
53
+ * The contract the extracted object-literal / conditional-spread lowering
54
+ * (`spread/spread-codegen.ts`) depends on. The spread lowering recurses into
55
+ * the core expression lowering and records its own BF101 diagnostics, so it
56
+ * needs the recursive entry point plus the per-compile bookkeeping the
57
+ * adapter owns. Declared separately from `XslateEmitContext` so each
58
+ * extracted module's real coupling is documented precisely.
59
+ */
60
+ export interface XslateSpreadContext {
61
+ /** Component name, for diagnostic source locations. */
62
+ readonly componentName: string
63
+
64
+ /** Per-compile diagnostic list the spread lowering appends to. */
65
+ readonly errors: CompilerError[]
66
+
67
+ /** Local-constant metadata, for resolving `Record[key]` spread values. */
68
+ readonly localConstants: IRMetadata['localConstants']
69
+
70
+ /** Prop params, for classifying a bare-identifier index as a prop. */
71
+ readonly propsParams: { name: string }[]
72
+
73
+ /**
74
+ * Lower a JS expression to its Kolon form (the core recursive entry).
75
+ *
76
+ * When the IR already carries a structured `ParsedExpr` tree, pass it as
77
+ * `preParsed` so the converter threads it straight through instead of
78
+ * re-parsing `expr` — mirrors go-template's
79
+ * `convertExpressionToGo(jsExpr, out?, preParsed?)`. With `preParsed` set,
80
+ * `expr` is unused for parsing (the converter derives any diagnostic text
81
+ * from the tree), so callers may pass `''`.
82
+ */
83
+ convertExpressionToKolon(expr: string, preParsed?: ParsedExpr): string
84
+ }
85
+
86
+ /**
87
+ * The contract the extracted in-template memo / context seeding
88
+ * (`memo/seed.ts`) depends on. The seed lowering recurses into the core
89
+ * expression lowering to compute a derived signal/memo value or a context
90
+ * default; that recursive entry is its only adapter coupling.
91
+ */
92
+ export interface XslateMemoContext {
93
+ /**
94
+ * Lower a JS expression to its Kolon form (the core recursive entry).
95
+ *
96
+ * When the IR already carries a structured `ParsedExpr` tree, pass it as
97
+ * `preParsed` so the converter threads it straight through instead of
98
+ * re-parsing `expr` — mirrors go-template's
99
+ * `convertExpressionToGo(jsExpr, out?, preParsed?)`. With `preParsed` set,
100
+ * `expr` is unused for parsing (the converter derives any diagnostic text
101
+ * from the tree), so callers may pass `''`.
102
+ */
103
+ convertExpressionToKolon(expr: string, preParsed?: ParsedExpr): string
104
+ }