@barefootjs/xslate 0.30.6 → 0.31.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.
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Xslate;
2
- our $VERSION = "0.30.5";
2
+ our $VERSION = "0.31.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.30.6",
3
+ "version": "0.31.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",
@@ -17,9 +17,9 @@
17
17
  "./test-render": {
18
18
  "bun": "./src/test-render.ts"
19
19
  },
20
- "./build": {
21
- "types": "./dist/build.d.ts",
22
- "import": "./dist/build.js"
20
+ "./vite": {
21
+ "types": "./dist/vite.d.ts",
22
+ "import": "./dist/vite.js"
23
23
  }
24
24
  },
25
25
  "files": [
@@ -30,7 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "build": "bun run build:js && bun run build:types",
33
- "build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/build.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared",
33
+ "build:js": "bun build ./src/index.ts ./src/adapter/index.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared --external @barefootjs/vite --external vite && bun build ./src/vite.ts --outfile ./dist/vite.js --format esm --target node --external @barefootjs/vite --external vite --external typescript",
34
34
  "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
35
35
  "test": "bun test",
36
36
  "clean": "rm -rf dist",
@@ -55,14 +55,27 @@
55
55
  "directory": "packages/adapter-xslate"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.30.6"
58
+ "@barefootjs/shared": "0.31.1"
59
59
  },
60
60
  "peerDependencies": {
61
- "@barefootjs/jsx": ">=0.2.0"
61
+ "@barefootjs/jsx": ">=0.2.0",
62
+ "@barefootjs/vite": ">=0.2.0",
63
+ "vite": "^6.0.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "@barefootjs/vite": {
67
+ "optional": true
68
+ },
69
+ "vite": {
70
+ "optional": true
71
+ }
62
72
  },
63
73
  "devDependencies": {
64
74
  "@barefootjs/adapter-tests": "0.1.0",
65
- "@barefootjs/jsx": "0.30.6",
66
- "typescript": "^5.0.0"
75
+ "@barefootjs/jsx": "0.31.1",
76
+ "@barefootjs/vite": "0.31.1",
77
+ "@barefootjs/client": "0.31.1",
78
+ "typescript": "^5.0.0",
79
+ "vite": "^6.0.0"
67
80
  }
68
81
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Coverage of `@barefootjs/xslate/vite`'s `barefoot()`:
3
+ *
4
+ * - one real `vite build()` end to end (mirrors `@barefootjs/go-template/
5
+ * vite`, `@barefootjs/hono/vite`, `@barefootjs/blade/vite`'s,
6
+ * `@barefootjs/jinja/vite`'s, and `@barefootjs/erb/vite`'s own
7
+ * `vite.test.ts` rigor — a plugin that only passes mocked unit tests
8
+ * hasn't been shown to work) against a checked-in fixture (not a system
9
+ * tmpdir) so `@barefootjs/client` resolves through the monorepo's real
10
+ * node_modules symlinks;
11
+ * - the `assets` → generated `bf-assets.json` behavior, exercised the same
12
+ * way (a real build, since it needs the real manifest Vite writes).
13
+ *
14
+ * Unlike Go, there is no `afterEmit`-driven type-combination step to test
15
+ * here (see `vite.ts`'s module docstring) — `XslateAdapter.generate()` never
16
+ * produces a `types` section at all, so `barefoot()` always returns a
17
+ * single-element array unless `assets` is set.
18
+ */
19
+ import { describe, test, expect } from 'bun:test'
20
+ import { build } from 'vite'
21
+ import { mkdtemp, rm, readFile } from 'node:fs/promises'
22
+ import { tmpdir } from 'node:os'
23
+ import { join, resolve } from 'node:path'
24
+ import { barefoot, barefoot as defaultBarefoot } from '../vite.ts'
25
+
26
+ const FIXTURE_ROOT = resolve(import.meta.dirname, '../../e2e-fixture')
27
+
28
+ describe('@barefootjs/xslate/vite: real vite build', () => {
29
+ test('exports the same function as both named `barefoot` and default', () => {
30
+ expect(defaultBarefoot).toBe(barefoot)
31
+ })
32
+
33
+ test('returns a single-element plugin array when `assets` is omitted', () => {
34
+ const plugins = barefoot({ components: ['src/components'], templates: 'views' })
35
+ expect(plugins).toHaveLength(1)
36
+ })
37
+
38
+ test('writes a self-contained .tx template with scriptAssets baked in, same as core alone would do', async () => {
39
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-xslate-vite-dist-'))
40
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-xslate-vite-views-'))
41
+
42
+ try {
43
+ await build({
44
+ configFile: false,
45
+ root: FIXTURE_ROOT,
46
+ base: '/static/build/',
47
+ logLevel: 'warn',
48
+ build: { outDir, emptyOutDir: true },
49
+ plugins: barefoot({
50
+ components: ['src/components'],
51
+ templates: templatesDir,
52
+ }),
53
+ })
54
+
55
+ const template = await readFile(join(templatesDir, 'Counter.tx'), 'utf8')
56
+ expect(template).toContain("bf.register_script(")
57
+ } finally {
58
+ await rm(outDir, { recursive: true, force: true })
59
+ await rm(templatesDir, { recursive: true, force: true })
60
+ }
61
+ }, 60_000)
62
+
63
+ test('`assets` resolves a non-component entry\'s manifest-hashed URL into a generated JSON asset map', async () => {
64
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-xslate-vite-dist-assets-'))
65
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-xslate-vite-views-assets-'))
66
+ const assetsPath = join(FIXTURE_ROOT, 'dist/bf-assets.json')
67
+
68
+ try {
69
+ await build({
70
+ configFile: false,
71
+ root: FIXTURE_ROOT,
72
+ base: '/static/build/',
73
+ logLevel: 'warn',
74
+ build: {
75
+ outDir,
76
+ emptyOutDir: true,
77
+ // Registering the non-component entry is the CALLER's job (stock
78
+ // Vite config) — `assets` below only resolves the URL Vite
79
+ // already bundled it to, it doesn't request the bundling.
80
+ rollupOptions: { input: { bootstrap: resolve(FIXTURE_ROOT, 'client/bootstrap.ts') } },
81
+ },
82
+ plugins: barefoot({
83
+ components: ['src/components'],
84
+ templates: templatesDir,
85
+ assets: { Bootstrap: 'client/bootstrap.ts' },
86
+ }),
87
+ })
88
+
89
+ const manifest = JSON.parse(await readFile(join(outDir, '.vite/manifest.json'), 'utf8'))
90
+ const expectedUrl = `/static/build/${manifest['client/bootstrap.ts'].file}`
91
+
92
+ const content = JSON.parse(await readFile(assetsPath, 'utf8'))
93
+ expect(content).toEqual({ Bootstrap: expectedUrl })
94
+ } finally {
95
+ await rm(outDir, { recursive: true, force: true })
96
+ await rm(templatesDir, { recursive: true, force: true })
97
+ await rm(join(FIXTURE_ROOT, 'dist'), { recursive: true, force: true })
98
+ }
99
+ }, 60_000)
100
+
101
+ test('`assets` throws an actionable error when the entry was never registered as a Rollup input', async () => {
102
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-xslate-vite-dist-assets-missing-'))
103
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-xslate-vite-views-assets-missing-'))
104
+
105
+ try {
106
+ await expect(
107
+ build({
108
+ configFile: false,
109
+ root: FIXTURE_ROOT,
110
+ base: '/static/build/',
111
+ logLevel: 'silent',
112
+ build: { outDir, emptyOutDir: true },
113
+ plugins: barefoot({
114
+ components: ['src/components'],
115
+ templates: templatesDir,
116
+ assets: { Bootstrap: 'client/bootstrap.ts' },
117
+ }),
118
+ }),
119
+ ).rejects.toThrow(/was not found in the build manifest/)
120
+ } finally {
121
+ await rm(outDir, { recursive: true, force: true })
122
+ await rm(templatesDir, { recursive: true, force: true })
123
+ await rm(join(FIXTURE_ROOT, 'dist'), { recursive: true, force: true })
124
+ }
125
+ }, 60_000)
126
+ })
@@ -572,3 +572,149 @@ function Widget({ rows }: { rows: { x: string }[] }) {
572
572
  expect(template).toContain('<: $cfg.x :>')
573
573
  })
574
574
  })
575
+
576
+ describe('XslateAdapter - scriptAssets (Vite late-binding, PR1)', () => {
577
+ const CLIENT_COMPONENT = `
578
+ 'use client'
579
+ import { createSignal } from '@barefootjs/client'
580
+ export function Counter() {
581
+ const [count, setCount] = createSignal(0)
582
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
583
+ }
584
+ `
585
+
586
+ test('emits one register_script per URL, in order, when scriptAssets is set', () => {
587
+ const ir = compileToIR(CLIENT_COMPONENT)
588
+ const { template } = new XslateAdapter().generate(ir, {
589
+ scriptAssets: ['/assets/runtime-abc123.js', '/assets/counter-def456.js'],
590
+ })
591
+ const runtimeIdx = template.indexOf("$bf.register_script('/assets/runtime-abc123.js')")
592
+ const compIdx = template.indexOf("$bf.register_script('/assets/counter-def456.js')")
593
+ expect(runtimeIdx).toBeGreaterThanOrEqual(0)
594
+ expect(compIdx).toBeGreaterThanOrEqual(0)
595
+ expect(runtimeIdx).toBeLessThan(compIdx)
596
+ expect(template).toContain('_bf_reg0')
597
+ expect(template).toContain('_bf_reg1')
598
+ expect(template).not.toContain('/static/components/barefoot.js')
599
+ expect(template).not.toContain('Counter.client.js')
600
+ })
601
+
602
+ test('emits a single registration for a single-element scriptAssets array', () => {
603
+ const ir = compileToIR(CLIENT_COMPONENT)
604
+ const { template } = new XslateAdapter().generate(ir, {
605
+ scriptAssets: ['/assets/only-one.js'],
606
+ })
607
+ expect(template).toContain("$bf.register_script('/assets/only-one.js')")
608
+ expect(template.match(/register_script/g)?.length).toBe(1)
609
+ })
610
+
611
+ test('an empty scriptAssets array emits no script registrations', () => {
612
+ const ir = compileToIR(CLIENT_COMPONENT)
613
+ const { template } = new XslateAdapter().generate(ir, { scriptAssets: [] })
614
+ expect(template).not.toContain('register_script')
615
+ })
616
+
617
+ test('skipScriptRegistration still wins when scriptAssets is also set', () => {
618
+ const ir = compileToIR(CLIENT_COMPONENT)
619
+ const { template } = new XslateAdapter().generate(ir, {
620
+ skipScriptRegistration: true,
621
+ scriptAssets: ['/assets/should-not-appear.js'],
622
+ })
623
+ expect(template).not.toContain('register_script')
624
+ })
625
+
626
+ test('absent scriptAssets falls back to adapter-computed script paths', () => {
627
+ const ir = compileToIR(CLIENT_COMPONENT)
628
+ const computed = new XslateAdapter().generate(ir).template
629
+ const explicitUndefined = new XslateAdapter().generate(ir, { scriptAssets: undefined }).template
630
+ expect(computed).toContain("$bf.register_script('/static/components/barefoot.js')")
631
+ expect(computed).toContain("$bf.register_script('/static/components/Counter.client.js')")
632
+ expect(explicitUndefined).toBe(computed)
633
+ })
634
+ })
635
+
636
+ describe('XslateAdapter - preloadAssets', () => {
637
+ const CLIENT_COMPONENT = `
638
+ 'use client'
639
+ import { createSignal } from '@barefootjs/client'
640
+ export function Counter() {
641
+ const [count, setCount] = createSignal(0)
642
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
643
+ }
644
+ `
645
+
646
+ test('non-empty preloadAssets + non-empty scriptAssets: preload registrations emitted, in order, before script registrations', () => {
647
+ const ir = compileToIR(CLIENT_COMPONENT)
648
+ const { template } = new XslateAdapter().generate(ir, {
649
+ scriptAssets: ['/assets/runtime-abc123.js', '/assets/counter-def456.js'],
650
+ preloadAssets: ['/assets/index-pre1.js', '/assets/shared-pre2.js'],
651
+ })
652
+ const pre1Idx = template.indexOf(": my $_bf_pre0 = $bf.register_preload('/assets/index-pre1.js');")
653
+ const pre2Idx = template.indexOf(": my $_bf_pre1 = $bf.register_preload('/assets/shared-pre2.js');")
654
+ const script1Idx = template.indexOf(": my $_bf_reg0 = $bf.register_script('/assets/runtime-abc123.js');")
655
+ const script2Idx = template.indexOf(": my $_bf_reg1 = $bf.register_script('/assets/counter-def456.js');")
656
+ expect(pre1Idx).toBeGreaterThanOrEqual(0)
657
+ expect(pre2Idx).toBeGreaterThan(pre1Idx)
658
+ expect(script1Idx).toBeGreaterThan(pre2Idx)
659
+ expect(script2Idx).toBeGreaterThan(script1Idx)
660
+ })
661
+
662
+ test('preloadAssets: [] emits no preload registration', () => {
663
+ const ir = compileToIR(CLIENT_COMPONENT)
664
+ const { template } = new XslateAdapter().generate(ir, {
665
+ scriptAssets: ['/assets/runtime-abc123.js'],
666
+ preloadAssets: [],
667
+ })
668
+ expect(template).not.toContain('register_preload')
669
+ expect(template).toContain("$bf.register_script('/assets/runtime-abc123.js')")
670
+ })
671
+
672
+ test('preloadAssets: undefined emits no preload registration', () => {
673
+ const ir = compileToIR(CLIENT_COMPONENT)
674
+ const { template } = new XslateAdapter().generate(ir, {
675
+ scriptAssets: ['/assets/runtime-abc123.js'],
676
+ preloadAssets: undefined,
677
+ })
678
+ expect(template).not.toContain('register_preload')
679
+ expect(template).toContain("$bf.register_script('/assets/runtime-abc123.js')")
680
+ })
681
+
682
+ test('preloadAssets non-empty but scriptAssets: [] emits no preload registration (preloads are only meaningful alongside a real script)', () => {
683
+ const ir = compileToIR(CLIENT_COMPONENT)
684
+ const { template } = new XslateAdapter().generate(ir, {
685
+ scriptAssets: [],
686
+ preloadAssets: ['/assets/index-pre1.js'],
687
+ })
688
+ expect(template).not.toContain('register_preload')
689
+ expect(template).not.toContain('register_script')
690
+ })
691
+
692
+ test('skipScriptRegistration: true suppresses both preloads and scripts', () => {
693
+ const ir = compileToIR(CLIENT_COMPONENT)
694
+ const { template } = new XslateAdapter().generate(ir, {
695
+ skipScriptRegistration: true,
696
+ scriptAssets: ['/assets/runtime-abc123.js'],
697
+ preloadAssets: ['/assets/index-pre1.js'],
698
+ })
699
+ expect(template).not.toContain('register_preload')
700
+ expect(template).not.toContain('register_script')
701
+ })
702
+
703
+ // Regression guard: a previous attempt emitted a literal
704
+ // `<link rel="modulepreload">` tag directly into the component template,
705
+ // which injected a rendered DOM node before the component's root and
706
+ // broke hydration across all eight integrations (blade, erb,
707
+ // go-template, jinja, mojolicious, rust, twig, xslate). Preload hints
708
+ // must ONLY ever be emitted as no-output register statements (here,
709
+ // `: my $_bf_preN = $bf.register_preload(...);`) that the adapter's
710
+ // runtime later renders itself — never as literal markup baked into the
711
+ // template.
712
+ test('never emits a literal <link tag into the template', () => {
713
+ const ir = compileToIR(CLIENT_COMPONENT)
714
+ const { template } = new XslateAdapter().generate(ir, {
715
+ scriptAssets: ['/assets/runtime-abc123.js'],
716
+ preloadAssets: ['/assets/index-pre1.js'],
717
+ })
718
+ expect(template).not.toContain('<link')
719
+ })
720
+ })
@@ -70,15 +70,33 @@ const PREDICATE_METHODS = new Set<HigherOrderMethod>([
70
70
  * (#2038) instead of silently degrading to the callback's receiver.
71
71
  */
72
72
  export class XslateFilterEmitter implements ParsedExprEmitter {
73
+ // Plain field declarations + assignment, NOT TS constructor-parameter-
74
+ // property shorthand: Vite's `bundleConfigFile` externalizes any bare
75
+ // (non-relative) import when loading `vite.config.ts` (see
76
+ // `@barefootjs/xslate/vite`'s docstring), so this file can be loaded
77
+ // directly by Node's OWN native TypeScript type-stripping (enabled by
78
+ // default since Node 22.18/23.6) rather than esbuild — and Node's
79
+ // strip-only mode does not support parameter properties (`SyntaxError
80
+ // [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]`), only plain type annotations.
81
+ private readonly param: string
82
+ private readonly localVarMap: Map<string, string>
83
+ private readonly isStringName: (n: string) => boolean
84
+ // Records a BF101 for predicate shapes this emitter can only degrade
85
+ // (#2038). Optional so emitter construction stays possible without an
86
+ // adapter; a missing hook keeps the old silent-degrade emit.
87
+ private readonly onUnsupported?: (message: string, reason?: string) => void
88
+
73
89
  constructor(
74
- private readonly param: string,
75
- private readonly localVarMap: Map<string, string>,
76
- private readonly isStringName: (n: string) => boolean = () => false,
77
- // Records a BF101 for predicate shapes this emitter can only degrade
78
- // (#2038). Optional so emitter construction stays possible without an
79
- // adapter; a missing hook keeps the old silent-degrade emit.
80
- private readonly onUnsupported?: (message: string, reason?: string) => void,
81
- ) {}
90
+ param: string,
91
+ localVarMap: Map<string, string>,
92
+ isStringName: (n: string) => boolean = () => false,
93
+ onUnsupported?: (message: string, reason?: string) => void,
94
+ ) {
95
+ this.param = param
96
+ this.localVarMap = localVarMap
97
+ this.isStringName = isStringName
98
+ this.onUnsupported = onUnsupported
99
+ }
82
100
 
83
101
  identifier(name: string): string {
84
102
  if (name === this.param) return `$${this.param}`
@@ -239,7 +257,13 @@ export class XslateFilterEmitter implements ParsedExprEmitter {
239
257
  * - higher-order methods route through `$bf` array helpers.
240
258
  */
241
259
  export class XslateTopLevelEmitter implements ParsedExprEmitter {
242
- constructor(private readonly ctx: XslateEmitContext) {}
260
+ // Plain field + assignment, not a parameter property — see
261
+ // `XslateFilterEmitter`'s constructor comment above for why.
262
+ private readonly ctx: XslateEmitContext
263
+
264
+ constructor(ctx: XslateEmitContext) {
265
+ this.ctx = ctx
266
+ }
243
267
 
244
268
  identifier(name: string): string {
245
269
  // `undefined` / `null` nested inside a larger expression tree —
@@ -163,9 +163,6 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
163
163
  name = 'xslate'
164
164
  extension = '.tx'
165
165
  templatesPerComponent = true
166
- // Template-string target with no component layer: `bf build` emits a static
167
- // import-map HTML snippet to include into the page <head>.
168
- importMapInjection = 'html-snippet' as const
169
166
 
170
167
  /**
171
168
  * Identifier-path callees the Xslate runtime can render in template scope.
@@ -334,7 +331,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
334
331
  // Generate script registration
335
332
  const scriptReg = options?.skipScriptRegistration
336
333
  ? ''
337
- : this.generateScriptRegistrations(ir, options?.scriptBaseName)
334
+ : this.generateScriptRegistrations(ir, options?.scriptBaseName, options?.scriptAssets, options?.preloadAssets)
338
335
 
339
336
  // SSR context consumers (`const x = useContext(Ctx)`): seed each local
340
337
  // from the active provider value (or the `createContext` default). The
@@ -376,7 +373,38 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
376
373
  // Script Registration
377
374
  // ===========================================================================
378
375
 
379
- private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
376
+ private generateScriptRegistrations(
377
+ ir: ComponentIR,
378
+ scriptBaseName?: string,
379
+ scriptAssets?: string[],
380
+ preloadAssets?: string[],
381
+ ): string {
382
+ // `scriptAssets`, when present (including `[]`), fully supersedes the
383
+ // adapter-computed `barefootJsPath` / `clientJsBasePath` pair — see
384
+ // `AdapterGenerateOptions.scriptAssets`. The caller (e.g. the Vite
385
+ // plugin) has already decided the exact ordered URL list, including
386
+ // whether any script is needed at all.
387
+ if (scriptAssets) {
388
+ if (scriptAssets.length === 0) return ''
389
+ // `preloadAssets` is only meaningful alongside a non-empty
390
+ // `scriptAssets` (see `AdapterGenerateOptions.preloadAssets`), and
391
+ // every preload registration is emitted BEFORE every script
392
+ // registration — a hint that arrives after the script it describes
393
+ // is useless. Distinct `_bf_pre*` var prefix keeps these `my`
394
+ // bindings from colliding with the `_bf_reg*` script ones below
395
+ // (Kolon forbids re-`my` of the same name in one scope). Same
396
+ // throwaway-`my` no-output trick as `register_script`:
397
+ // `register_preload`'s return value is never printed.
398
+ const lines = (preloadAssets ?? []).map(
399
+ (url, i) => `: my $_bf_pre${i} = $bf.register_preload('${url}');`,
400
+ )
401
+ lines.push(
402
+ ...scriptAssets.map((url, i) => `: my $_bf_reg${i} = $bf.register_script('${url}');`),
403
+ )
404
+ lines.push('')
405
+ return lines.join('\n')
406
+ }
407
+
380
408
  const hasInteractivity = hasClientInteractivity(ir)
381
409
  if (!hasInteractivity) return ''
382
410