@barefootjs/vite 0.30.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.
Files changed (61) hide show
  1. package/dist/child-marker.d.ts +62 -0
  2. package/dist/child-marker.d.ts.map +1 -0
  3. package/dist/compile-cache.d.ts +19 -0
  4. package/dist/compile-cache.d.ts.map +1 -0
  5. package/dist/component-manifest.d.ts +69 -0
  6. package/dist/component-manifest.d.ts.map +1 -0
  7. package/dist/corpus-program.d.ts +41 -0
  8. package/dist/corpus-program.d.ts.map +1 -0
  9. package/dist/debounced-serial-runner.d.ts +27 -0
  10. package/dist/debounced-serial-runner.d.ts.map +1 -0
  11. package/dist/dev-server.d.ts +99 -0
  12. package/dist/dev-server.d.ts.map +1 -0
  13. package/dist/discover.d.ts +117 -0
  14. package/dist/discover.d.ts.map +1 -0
  15. package/dist/emit.d.ts +9 -0
  16. package/dist/emit.d.ts.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +24626 -0
  20. package/dist/manifest.d.ts +39 -0
  21. package/dist/manifest.d.ts.map +1 -0
  22. package/dist/paths.d.ts +57 -0
  23. package/dist/paths.d.ts.map +1 -0
  24. package/dist/plugin.d.ts +5 -0
  25. package/dist/plugin.d.ts.map +1 -0
  26. package/dist/resolve-client-js.d.ts +6 -0
  27. package/dist/resolve-client-js.d.ts.map +1 -0
  28. package/dist/types.d.ts +141 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/package.json +55 -0
  31. package/src/__tests__/child-marker.test.ts +24 -0
  32. package/src/__tests__/compile-cache.test.ts +73 -0
  33. package/src/__tests__/component-dir-entry.test.ts +239 -0
  34. package/src/__tests__/component-manifest.test.ts +124 -0
  35. package/src/__tests__/corpus-program.test.ts +244 -0
  36. package/src/__tests__/debounced-serial-runner.test.ts +131 -0
  37. package/src/__tests__/dev-server.test.ts +138 -0
  38. package/src/__tests__/discover.test.ts +148 -0
  39. package/src/__tests__/e2e-vite-build.test.ts +191 -0
  40. package/src/__tests__/e2e-vite-dev.test.ts +478 -0
  41. package/src/__tests__/emit.test.ts +73 -0
  42. package/src/__tests__/manifest.test.ts +146 -0
  43. package/src/__tests__/paths.test.ts +93 -0
  44. package/src/__tests__/plugin.test.ts +417 -0
  45. package/src/__tests__/relative-import-rewrite.test.ts +79 -0
  46. package/src/__tests__/resolve-client-js.test.ts +55 -0
  47. package/src/__tests__/templates-optional.test.ts +139 -0
  48. package/src/child-marker.ts +67 -0
  49. package/src/compile-cache.ts +63 -0
  50. package/src/component-manifest.ts +139 -0
  51. package/src/corpus-program.ts +125 -0
  52. package/src/debounced-serial-runner.ts +67 -0
  53. package/src/dev-server.ts +184 -0
  54. package/src/discover.ts +230 -0
  55. package/src/emit.ts +66 -0
  56. package/src/index.ts +25 -0
  57. package/src/manifest.ts +89 -0
  58. package/src/paths.ts +114 -0
  59. package/src/plugin.ts +792 -0
  60. package/src/resolve-client-js.ts +34 -0
  61. package/src/types.ts +144 -0
@@ -0,0 +1,146 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import type { Manifest } from 'vite'
3
+ import { joinBaseAndFile, resolvePreloadAssets, resolveScriptAssets } from '../manifest.ts'
4
+
5
+ describe('joinBaseAndFile', () => {
6
+ test('joins a trailing-slash base with the manifest file path', () => {
7
+ expect(joinBaseAndFile('/static/build/', 'assets/Counter-abc123.js')).toBe(
8
+ '/static/build/assets/Counter-abc123.js',
9
+ )
10
+ })
11
+
12
+ test('joins a base without a trailing slash', () => {
13
+ expect(joinBaseAndFile('/static/build', 'assets/Counter-abc123.js')).toBe(
14
+ '/static/build/assets/Counter-abc123.js',
15
+ )
16
+ })
17
+
18
+ test('treats "./" and "" as no-prefix', () => {
19
+ expect(joinBaseAndFile('./', 'assets/Counter-abc123.js')).toBe('assets/Counter-abc123.js')
20
+ expect(joinBaseAndFile('', 'assets/Counter-abc123.js')).toBe('assets/Counter-abc123.js')
21
+ })
22
+
23
+ test('works with a full absolute-origin base', () => {
24
+ expect(joinBaseAndFile('https://cdn.example.com/app/', 'assets/x.js')).toBe(
25
+ 'https://cdn.example.com/app/assets/x.js',
26
+ )
27
+ })
28
+ })
29
+
30
+ describe('resolveScriptAssets', () => {
31
+ const manifest: Manifest = {
32
+ 'src/components/Counter.tsx': {
33
+ file: 'assets/Counter-abc123.js',
34
+ isEntry: true,
35
+ imports: ['_shared.js'],
36
+ },
37
+ '_shared.js': {
38
+ file: 'assets/shared-def456.js',
39
+ },
40
+ }
41
+
42
+ test('resolves the single entry URL for a known manifest key', () => {
43
+ expect(resolveScriptAssets(manifest, 'src/components/Counter.tsx', '/static/build/')).toEqual([
44
+ '/static/build/assets/Counter-abc123.js',
45
+ ])
46
+ })
47
+
48
+ test('returns [] for a manifest key with no entry — the server-only-component case', () => {
49
+ expect(resolveScriptAssets(manifest, 'src/components/Greeting.tsx', '/static/build/')).toEqual([])
50
+ })
51
+
52
+ test('does NOT include shared-chunk imports — only the entry\'s own file', () => {
53
+ const assets = resolveScriptAssets(manifest, 'src/components/Counter.tsx', '/static/build/')
54
+ expect(assets).toHaveLength(1)
55
+ expect(assets[0]).not.toContain('shared-def456')
56
+ })
57
+ })
58
+
59
+ describe('resolvePreloadAssets', () => {
60
+ const manifest: Manifest = {
61
+ 'src/components/TodoApp.tsx': {
62
+ file: 'assets/TodoApp-CtatJ74J.js',
63
+ isEntry: true,
64
+ imports: ['_index.js', 'src/components/TodoItem.tsx'],
65
+ },
66
+ 'src/components/TodoItem.tsx': {
67
+ file: 'assets/TodoItem-abc123.js',
68
+ imports: ['_index.js'],
69
+ },
70
+ '_index.js': {
71
+ file: 'assets/index-xrhpkKRC.js',
72
+ },
73
+ }
74
+
75
+ test('walks entry.imports transitively, excluding the entry\'s own file', () => {
76
+ const assets = resolvePreloadAssets(manifest, 'src/components/TodoApp.tsx', '/static/build/')
77
+ expect(assets).not.toContain('/static/build/assets/TodoApp-CtatJ74J.js')
78
+ expect(assets).toContain('/static/build/assets/index-xrhpkKRC.js')
79
+ expect(assets).toContain('/static/build/assets/TodoItem-abc123.js')
80
+ })
81
+
82
+ test('is breadth-first from the entry: direct imports sort before their own transitive imports', () => {
83
+ const assets = resolvePreloadAssets(manifest, 'src/components/TodoApp.tsx', '/static/build/')
84
+ // Entry.imports = ['_index.js', 'TodoItem.tsx'] — both direct, so both
85
+ // precede TodoItem's own import of `_index.js` (already visited, so
86
+ // deduped, not re-appended at the deeper level).
87
+ expect(assets).toEqual([
88
+ '/static/build/assets/index-xrhpkKRC.js',
89
+ '/static/build/assets/TodoItem-abc123.js',
90
+ ])
91
+ })
92
+
93
+ test('dedupes by manifest key: a chunk reachable via two paths appears once', () => {
94
+ const diamond: Manifest = {
95
+ 'src/components/Parent.tsx': {
96
+ file: 'assets/Parent.js',
97
+ imports: ['src/components/ChildA.tsx', 'src/components/ChildB.tsx'],
98
+ },
99
+ 'src/components/ChildA.tsx': {
100
+ file: 'assets/ChildA.js',
101
+ imports: ['_shared.js'],
102
+ },
103
+ 'src/components/ChildB.tsx': {
104
+ file: 'assets/ChildB.js',
105
+ imports: ['_shared.js'],
106
+ },
107
+ '_shared.js': { file: 'assets/shared.js' },
108
+ }
109
+ const assets = resolvePreloadAssets(diamond, 'src/components/Parent.tsx', '/static/build/')
110
+ expect(assets.filter((a) => a === '/static/build/assets/shared.js')).toHaveLength(1)
111
+ })
112
+
113
+ test('is cycle-safe: an import cycle does not loop forever', () => {
114
+ const cyclic: Manifest = {
115
+ 'src/components/A.tsx': {
116
+ file: 'assets/A.js',
117
+ imports: ['src/components/B.tsx'],
118
+ },
119
+ 'src/components/B.tsx': {
120
+ file: 'assets/B.js',
121
+ imports: ['src/components/A.tsx'],
122
+ },
123
+ }
124
+ const assets = resolvePreloadAssets(cyclic, 'src/components/A.tsx', '/static/build/')
125
+ expect(assets).toEqual(['/static/build/assets/B.js'])
126
+ })
127
+
128
+ test('returns [] for a manifest key with no entry', () => {
129
+ expect(resolvePreloadAssets(manifest, 'src/components/Missing.tsx', '/static/build/')).toEqual([])
130
+ })
131
+
132
+ test('does NOT follow dynamicImports — only static imports are walked', () => {
133
+ const withDynamic: Manifest = {
134
+ 'src/components/Parent.tsx': {
135
+ file: 'assets/Parent.js',
136
+ imports: ['_shared.js'],
137
+ dynamicImports: ['src/components/LazyChild.tsx'],
138
+ },
139
+ '_shared.js': { file: 'assets/shared.js' },
140
+ 'src/components/LazyChild.tsx': { file: 'assets/LazyChild.js' },
141
+ }
142
+ const assets = resolvePreloadAssets(withDynamic, 'src/components/Parent.tsx', '/static/build/')
143
+ expect(assets).toEqual(['/static/build/assets/shared.js'])
144
+ expect(assets).not.toContain('/static/build/assets/LazyChild.js')
145
+ })
146
+ })
@@ -0,0 +1,93 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import {
3
+ toPosixRelative,
4
+ relativeUnderComponentDir,
5
+ withExtension,
6
+ perComponentRelPath,
7
+ buildRelativeImportRewriter,
8
+ safeRollupEntryName,
9
+ } from '../paths.ts'
10
+
11
+ describe('toPosixRelative', () => {
12
+ test('produces a forward-slash relative path', () => {
13
+ expect(toPosixRelative('/proj', '/proj/src/components/Counter.tsx')).toBe('src/components/Counter.tsx')
14
+ })
15
+ })
16
+
17
+ describe('relativeUnderComponentDir', () => {
18
+ test('returns the path under the matching componentDir, with extension', () => {
19
+ expect(relativeUnderComponentDir('/proj/src/components/ui/Button.tsx', ['/proj/src/components'])).toBe(
20
+ 'ui/Button.tsx',
21
+ )
22
+ })
23
+
24
+ test('falls back to the basename when no componentDir matches', () => {
25
+ expect(relativeUnderComponentDir('/elsewhere/Button.tsx', ['/proj/src/components'])).toBe('Button.tsx')
26
+ })
27
+ })
28
+
29
+ describe('withExtension', () => {
30
+ test('swaps .tsx for the given extension', () => {
31
+ expect(withExtension('ui/Button.tsx', '.tmpl')).toBe('ui/Button.tmpl')
32
+ })
33
+
34
+ test('swaps .ts for the given extension', () => {
35
+ expect(withExtension('state.ts', '.ssr-defaults.json')).toBe('state.ssr-defaults.json')
36
+ })
37
+ })
38
+
39
+ describe('perComponentRelPath', () => {
40
+ test('names the file after the component, in the same directory', () => {
41
+ expect(perComponentRelPath('ui/toast/index.tsx', 'Toast', '.tmpl')).toBe('ui/toast/Toast.tmpl')
42
+ })
43
+
44
+ test('handles a top-level file with no subdirectory', () => {
45
+ expect(perComponentRelPath('Button.tsx', 'Button', '.tmpl')).toBe('Button.tmpl')
46
+ })
47
+ })
48
+
49
+ describe('buildRelativeImportRewriter', () => {
50
+ test('re-anchors an import to a sibling still under componentDirs', () => {
51
+ const rewrite = buildRelativeImportRewriter(
52
+ '/proj/src/components/ui/button/index.tsx',
53
+ '/views/ui/button/index.tmpl',
54
+ ['/proj/src/components'],
55
+ '/views',
56
+ )
57
+ expect(rewrite('../slot')).toBe('../slot')
58
+ })
59
+
60
+ test('re-relativises an import to a file outside componentDirs from the new output position', () => {
61
+ const rewrite = buildRelativeImportRewriter(
62
+ '/proj/src/components/ui/button/index.tsx',
63
+ '/views/ui/button/index.tmpl',
64
+ ['/proj/src/components'],
65
+ '/views',
66
+ )
67
+ // '../../../types' from the source resolves to /proj/src/types — the
68
+ // shared file didn't move, only the template's own position did, so
69
+ // the rewritten specifier re-relativises from the template's new home
70
+ // (/views/ui/button/) back to that same absolute file.
71
+ expect(rewrite('../../../types')).toBe('../../../proj/src/types')
72
+ })
73
+ })
74
+
75
+ describe('safeRollupEntryName', () => {
76
+ test('uses the plain root-relative path when the file is under root', () => {
77
+ expect(safeRollupEntryName('/proj', '/proj/src/components/Counter.tsx', ['/proj/src/components'])).toBe(
78
+ 'src/components/Counter.tsx',
79
+ )
80
+ })
81
+
82
+ test('falls back to the componentDir-relative path when the file is OUTSIDE root (a `..`-prefixed name Rollup rejects as [name])', () => {
83
+ expect(
84
+ safeRollupEntryName('/proj/gin', '/proj/shared/blog/LikeButton.tsx', ['/proj/shared/blog']),
85
+ ).toBe('LikeButton.tsx')
86
+ })
87
+
88
+ test('the out-of-root fallback never starts with ".." (what Rollup actually rejects)', () => {
89
+ const name = safeRollupEntryName('/proj/gin', '/proj/shared/blog/nested/LikeButton.tsx', ['/proj/shared/blog'])
90
+ expect(name.startsWith('..')).toBe(false)
91
+ expect(name).toBe('nested/LikeButton.tsx')
92
+ })
93
+ })
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Unit tests calling the plugin's own hooks directly (no real Vite build —
3
+ * see `e2e-vite-build.test.ts` for that). Each test targets one bullet
4
+ * from the PR's testing requirements: config-hook output shape, the
5
+ * resolveId mapping, transform returning compiled client JS, and the
6
+ * writeBundle→manifest→scriptAssets resolution including the `[]`
7
+ * server-only case.
8
+ */
9
+ import { describe, test, expect, afterEach } from 'bun:test'
10
+ import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'
11
+ import { tmpdir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
13
+ import { testAdapter } from '@barefootjs/jsx'
14
+ import { GoTemplateAdapter } from '@barefootjs/go-template/adapter'
15
+ import { barefoot, PLUGIN_NAME } from '../plugin.ts'
16
+ import type { BarefootPluginApi, BarefootViteOptions } from '../types.ts'
17
+
18
+ // biome-ignore lint: hooks are called directly, bypassing Vite's own
19
+ // dispatch/typing — casting to `any` is the standard way to unit-test a
20
+ // Vite plugin's hooks in isolation.
21
+ type AnyPlugin = any
22
+
23
+ function makePlugin(
24
+ componentsDir: string,
25
+ templatesDir: string,
26
+ adapter: BarefootViteOptions['adapter'] = testAdapter,
27
+ ): AnyPlugin {
28
+ return barefoot({
29
+ adapter,
30
+ components: [componentsDir],
31
+ templates: templatesDir,
32
+ })
33
+ }
34
+
35
+ describe('plugin.api', () => {
36
+ // Reconnaissance PR (bf#future-07a): `bf`'s CLI reads this to derive
37
+ // `sourceDirs` from `vite.config.ts` without parsing it as text — see
38
+ // `packages/cli/src/context.ts` and `BarefootPluginApi`'s docstring.
39
+ test('exposes the exact options object under plugin.name === PLUGIN_NAME, with no Vite lifecycle hook run first', () => {
40
+ const options: BarefootViteOptions = {
41
+ adapter: testAdapter,
42
+ components: ['src/components', '../shared/blog'],
43
+ templates: 'internal/views',
44
+ }
45
+ const plugin = barefoot(options) as AnyPlugin
46
+
47
+ expect(plugin.name).toBe(PLUGIN_NAME)
48
+ expect(PLUGIN_NAME).toBe('barefoot')
49
+ const api = plugin.api as BarefootPluginApi
50
+ expect(api.options).toBe(options)
51
+ expect(api.options.components).toEqual(['src/components', '../shared/blog'])
52
+ })
53
+
54
+ test('the `templates` option omitted (CSR degenerate case) still surfaces via api.options', () => {
55
+ const plugin = barefoot({ adapter: testAdapter, components: ['src'] }) as AnyPlugin
56
+ expect((plugin.api as BarefootPluginApi).options.templates).toBeUndefined()
57
+ })
58
+ })
59
+
60
+ describe('config hook', () => {
61
+ let dir: string
62
+
63
+ afterEach(async () => {
64
+ if (dir) await rm(dir, { recursive: true, force: true })
65
+ })
66
+
67
+ test('sets appType custom, forces build.manifest, and keys rollupOptions.input by ONLY "use client" files', async () => {
68
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-config-'))
69
+ await mkdir(join(dir, 'src/components'), { recursive: true })
70
+ await writeFile(join(dir, 'src/components/Counter.tsx'), '\'use client\'\nexport function Counter() { return <div/> }')
71
+ await writeFile(join(dir, 'src/components/Greeting.tsx'), 'export function Greeting() { return <div/> }')
72
+
73
+ const plugin = makePlugin('src/components', 'internal/views')
74
+ const result = await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
75
+
76
+ expect(result.appType).toBe('custom')
77
+ expect(result.build.manifest).toBe(true)
78
+ const inputPaths = Object.values(result.build.rollupOptions.input) as string[]
79
+ expect(inputPaths).toEqual([resolve(dir, 'src/components/Counter.tsx')])
80
+ })
81
+
82
+ test('produces no entries when nothing under components has "use client"', async () => {
83
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-config-'))
84
+ await mkdir(join(dir, 'src/components'), { recursive: true })
85
+ await writeFile(join(dir, 'src/components/Greeting.tsx'), 'export function Greeting() { return <div/> }')
86
+
87
+ const plugin = makePlugin('src/components', 'internal/views')
88
+ const result = await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
89
+
90
+ expect(Object.keys(result.build.rollupOptions.input)).toEqual([])
91
+ })
92
+
93
+ test('fills in a localhost-only server.cors default when the user set none', async () => {
94
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-config-'))
95
+ await mkdir(join(dir, 'src/components'), { recursive: true })
96
+
97
+ const plugin = makePlugin('src/components', 'internal/views')
98
+ const result = await plugin.config({ root: dir }, { command: 'serve', mode: 'development' })
99
+
100
+ expect(result.server.cors).toBeDefined()
101
+ expect(result.server.cors.origin.test('http://localhost:3010')).toBe(true)
102
+ expect(result.server.cors.origin.test('https://evil.example.com')).toBe(false)
103
+ })
104
+
105
+ test('does NOT overwrite a user-supplied server.cors', async () => {
106
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-config-'))
107
+ await mkdir(join(dir, 'src/components'), { recursive: true })
108
+
109
+ const plugin = makePlugin('src/components', 'internal/views')
110
+ const userCors = { origin: 'https://my-own-cors-policy.example.com' }
111
+ const result = await plugin.config(
112
+ { root: dir, server: { cors: userCors } },
113
+ { command: 'serve', mode: 'development' },
114
+ )
115
+
116
+ // The plugin's `server` return has no `cors` key at all in this case —
117
+ // Vite's config merge leaves the user's `server.cors` untouched only if
118
+ // we don't hand it a competing value to merge in.
119
+ expect(result.server.cors).toBeUndefined()
120
+ })
121
+
122
+ test('does NOT override an explicit server.cors: false (a falsy-but-set value)', async () => {
123
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-config-'))
124
+ await mkdir(join(dir, 'src/components'), { recursive: true })
125
+
126
+ const plugin = makePlugin('src/components', 'internal/views')
127
+ const result = await plugin.config(
128
+ { root: dir, server: { cors: false } },
129
+ { command: 'serve', mode: 'development' },
130
+ )
131
+
132
+ // `server.cors = false` explicitly DISABLES cors — `!false` is `true`,
133
+ // so a naive `if (!userConfig.server?.cors)` check would wrongly treat
134
+ // this the same as "unset" and clobber it with the localhost default.
135
+ // The fix checks `=== undefined` specifically; this pins that.
136
+ expect(result.server.cors).toBeUndefined()
137
+ })
138
+ })
139
+
140
+ describe('resolveId hook', () => {
141
+ let dir: string
142
+
143
+ afterEach(async () => {
144
+ if (dir) await rm(dir, { recursive: true, force: true })
145
+ })
146
+
147
+ test('resolves the compiler\'s ./foo.client.js specifier back to ./foo.tsx', async () => {
148
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-resolveid-'))
149
+ await mkdir(join(dir, 'src/components'), { recursive: true })
150
+ await writeFile(join(dir, 'src/components/signals.tsx'), '\'use client\'\nexport const x = 1')
151
+
152
+ const plugin = makePlugin('src/components', 'internal/views')
153
+ const importer = join(dir, 'src/components/consumer.tsx')
154
+ const resolved = plugin.resolveId('./signals.client.js', importer)
155
+
156
+ expect(resolved).toBe(join(dir, 'src/components/signals.tsx'))
157
+ })
158
+
159
+ test('leaves a bare/alias specifier untouched (returns null)', () => {
160
+ const plugin = makePlugin('src/components', 'internal/views')
161
+ expect(plugin.resolveId('@/components/signals.client.js', '/a/consumer.tsx')).toBeNull()
162
+ })
163
+
164
+ test('resolves a @bf-child: marker to the named child\'s real absolute path once discovered', async () => {
165
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-resolveid-bfchild-'))
166
+ await mkdir(join(dir, 'src/components'), { recursive: true })
167
+ await writeFile(join(dir, 'src/components/Parent.tsx'), '\'use client\'\nexport function Parent() { return <div/> }')
168
+ await writeFile(join(dir, 'src/components/Child.tsx'), '\'use client\'\nexport function Child() { return <div/> }')
169
+
170
+ const plugin = makePlugin('src/components', 'internal/views')
171
+ await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
172
+ await plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
173
+
174
+ const resolved = plugin.resolveId('/* @bf-child:Child */', join(dir, 'src/components/Parent.tsx'))
175
+ expect(resolved).toBe(join(dir, 'src/components/Child.tsx'))
176
+ })
177
+
178
+ test('falls back to the shared no-op virtual module for an unresolvable @bf-child: name', async () => {
179
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-resolveid-bfchild-unknown-'))
180
+ await mkdir(join(dir, 'src/components'), { recursive: true })
181
+
182
+ const plugin = makePlugin('src/components', 'internal/views')
183
+ await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
184
+ await plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
185
+
186
+ const resolved = plugin.resolveId('/* @bf-child:Nonexistent */', join(dir, 'src/components/Parent.tsx'))
187
+ expect(resolved).toEqual({ id: '\0barefoot-bf-child-noop', moduleSideEffects: false })
188
+
189
+ // `load` serves that id as an empty module — Rollup's own tree-shaking
190
+ // (moduleSideEffects: false) then elides the bare import entirely.
191
+ expect(plugin.load('\0barefoot-bf-child-noop')).toBe('')
192
+ expect(plugin.load('/some/other/module.tsx')).toBeNull()
193
+ })
194
+ })
195
+
196
+ describe('transform hook', () => {
197
+ let dir: string
198
+
199
+ afterEach(async () => {
200
+ if (dir) await rm(dir, { recursive: true, force: true })
201
+ })
202
+
203
+ async function setup() {
204
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-transform-'))
205
+ await mkdir(join(dir, 'src/components'), { recursive: true })
206
+ const plugin = makePlugin('src/components', 'internal/views')
207
+ await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
208
+ plugin.configResolved({
209
+ root: dir,
210
+ base: '/',
211
+ build: { outDir: 'dist', manifest: true },
212
+ })
213
+ return plugin
214
+ }
215
+
216
+ test('returns compiled client JS (not raw JSX) for a "use client" .tsx under components', async () => {
217
+ const plugin = await setup()
218
+ const source = '\'use client\'\nimport { createSignal } from \'@barefootjs/client\'\nexport function Counter() {\n const [count, setCount] = createSignal(0)\n return <button onClick={() => setCount(count() + 1)}>{count()}</button>\n}\n'
219
+ const id = join(dir, 'src/components/Counter.tsx')
220
+
221
+ const out = plugin.transform(source, id)
222
+
223
+ expect(out).not.toBeNull()
224
+ expect(out.code).toContain('createSignal')
225
+ expect(out.code).not.toContain('use client')
226
+ // The compiled output is plain JS: any JSX syntax that remains only
227
+ // does so as a quoted string inside the hydration template literal
228
+ // (`template: (_p) => \`<button ...>\``), never as a live JSX
229
+ // expression the browser would need a JSX transform to parse.
230
+ expect(out.code).toContain('hydrate(')
231
+ expect(out.code).toMatch(/template:\s*\(_p\) => `<button/)
232
+ })
233
+
234
+ test('returns null for a .tsx file outside the configured components dirs', async () => {
235
+ const plugin = await setup()
236
+ const out = plugin.transform('export function X() { return <div/> }', join(dir, 'Outside.tsx'))
237
+ expect(out).toBeNull()
238
+ })
239
+
240
+ test('returns null for a non-.tsx file', async () => {
241
+ const plugin = await setup()
242
+ const out = plugin.transform('export const x = 1', join(dir, 'src/components/util.ts'))
243
+ expect(out).toBeNull()
244
+ })
245
+ })
246
+
247
+ describe('writeBundle: manifest → scriptAssets resolution', () => {
248
+ let dir: string
249
+
250
+ afterEach(async () => {
251
+ if (dir) await rm(dir, { recursive: true, force: true })
252
+ })
253
+
254
+ test('bakes the manifest-resolved URL into the template for a "use client" component, and emits no script registration for a server-only one', async () => {
255
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-writebundle-'))
256
+ await mkdir(join(dir, 'src/components'), { recursive: true })
257
+ await writeFile(
258
+ join(dir, 'src/components/Counter.tsx'),
259
+ '\'use client\'\nimport { createSignal } from \'@barefootjs/client\'\nexport function Counter() {\n const [count, setCount] = createSignal(0)\n return <button onClick={() => setCount(count() + 1)}>{count()}</button>\n}\n',
260
+ )
261
+ await writeFile(
262
+ join(dir, 'src/components/Greeting.tsx'),
263
+ 'export function Greeting() { return <p>Hi</p> }',
264
+ )
265
+
266
+ const templatesDir = join(dir, 'internal/views')
267
+ // GoTemplateAdapter, not testAdapter: script registration ({{.Scripts
268
+ // .Register "..."}}}) is exactly the surface this test asserts on, and
269
+ // testAdapter (used elsewhere in this file for cheap transform/resolveId
270
+ // coverage) doesn't implement `scriptAssets` at all — see PR1's
271
+ // changeset, which wires scriptAssets into every DSL-template adapter
272
+ // but not the CSR-oriented test adapter.
273
+ const adapter = new GoTemplateAdapter({ packageName: 'main' })
274
+ const plugin = makePlugin('src/components', 'internal/views', adapter)
275
+ await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
276
+ plugin.configResolved({
277
+ root: dir,
278
+ base: '/static/build/',
279
+ build: { outDir: 'dist', manifest: true },
280
+ })
281
+
282
+ // Fabricate the manifest Vite would have written by the time
283
+ // `writeBundle` fires — this test targets scriptAssets resolution in
284
+ // isolation, without paying for a real Vite build (see
285
+ // e2e-vite-build.test.ts for that).
286
+ await mkdir(join(dir, 'dist/.vite'), { recursive: true })
287
+ await writeFile(
288
+ join(dir, 'dist/.vite/manifest.json'),
289
+ JSON.stringify({
290
+ 'src/components/Counter.tsx': { file: 'assets/Counter-abc123.js', isEntry: true },
291
+ }),
292
+ )
293
+
294
+ await plugin.writeBundle()
295
+
296
+ const counterTpl = await readFile(join(templatesDir, `Counter${adapter.extension}`), 'utf8')
297
+ const greetingTpl = await readFile(join(templatesDir, `Greeting${adapter.extension}`), 'utf8')
298
+
299
+ expect(counterTpl).toContain('{{.Scripts.Register "/static/build/assets/Counter-abc123.js"}}')
300
+ // Server-only: never in the manifest → scriptAssets resolves to [] →
301
+ // no script registration text at all.
302
+ expect(greetingTpl).not.toContain('Scripts.Register')
303
+ expect(greetingTpl).toContain('Hi')
304
+ })
305
+
306
+ test('does not refuse (BF103) a sibling-imported child rendered inside a .map() loop — the eager pass always registers every template together', async () => {
307
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-writebundle-bf103-'))
308
+ await mkdir(join(dir, 'src/components'), { recursive: true })
309
+ await writeFile(join(dir, 'src/components/Row.tsx'), '\'use client\'\nexport function Row(props: { label: string }) { return <li>{props.label}</li> }')
310
+ await writeFile(
311
+ join(dir, 'src/components/List.tsx'),
312
+ [
313
+ '\'use client\'',
314
+ 'import { createSignal } from \'@barefootjs/client\'',
315
+ 'import { Row } from \'./Row\'',
316
+ 'export function List() {',
317
+ ' const [items] = createSignal([{ id: 1, label: \'a\' }])',
318
+ ' return <ul>{items().map(item => <Row key={item.id} label={item.label} />)}</ul>',
319
+ '}',
320
+ ].join('\n'),
321
+ )
322
+
323
+ const templatesDir = join(dir, 'internal/views')
324
+ const adapter = new GoTemplateAdapter({ packageName: 'main' })
325
+ const plugin = makePlugin('src/components', 'internal/views', adapter)
326
+ await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
327
+ plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
328
+ await mkdir(join(dir, 'dist/.vite'), { recursive: true })
329
+ await writeFile(join(dir, 'dist/.vite/manifest.json'), '{}')
330
+
331
+ // Would throw `[barefoot] compile failed: ... BF103` without
332
+ // `siblingTemplatesRegistered: true` on the compileJSX calls.
333
+ await expect(plugin.writeBundle()).resolves.toBeUndefined()
334
+
335
+ const listTpl = await readFile(join(templatesDir, `List${adapter.extension}`), 'utf8')
336
+ expect(listTpl).toContain('{{template "Row"')
337
+ })
338
+ })
339
+
340
+ describe('afterEmit', () => {
341
+ let dir: string
342
+
343
+ afterEach(async () => {
344
+ if (dir) await rm(dir, { recursive: true, force: true })
345
+ })
346
+
347
+ test('writeBundle calls afterEmit once with mode "build", per-file types, and resolved dir paths', async () => {
348
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-aftertemit-build-'))
349
+ await mkdir(join(dir, 'src/components'), { recursive: true })
350
+ await writeFile(
351
+ join(dir, 'src/components/Counter.tsx'),
352
+ '\'use client\'\nimport { createSignal } from \'@barefootjs/client\'\nexport function Counter() {\n const [count, setCount] = createSignal(0)\n return <button onClick={() => setCount(count() + 1)}>{count()}</button>\n}\n',
353
+ )
354
+
355
+ const templatesDir = join(dir, 'internal/views')
356
+ const adapter = new GoTemplateAdapter({ packageName: 'main' })
357
+ const calls: unknown[] = []
358
+ const plugin = barefoot({
359
+ adapter,
360
+ components: ['src/components'],
361
+ templates: templatesDir,
362
+ afterEmit: async ctx => { calls.push(ctx) },
363
+ })
364
+ await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
365
+ plugin.configResolved({
366
+ root: dir,
367
+ base: '/static/build/',
368
+ build: { outDir: 'dist', manifest: true },
369
+ })
370
+
371
+ await mkdir(join(dir, 'dist/.vite'), { recursive: true })
372
+ await writeFile(
373
+ join(dir, 'dist/.vite/manifest.json'),
374
+ JSON.stringify({
375
+ 'src/components/Counter.tsx': { file: 'assets/Counter-abc123.js', isEntry: true },
376
+ }),
377
+ )
378
+
379
+ await plugin.writeBundle()
380
+
381
+ expect(calls).toHaveLength(1)
382
+ const ctx = calls[0] as {
383
+ types: Map<string, string>
384
+ projectDir: string
385
+ templatesDir: string
386
+ outDir: string
387
+ mode: string
388
+ }
389
+ expect(ctx.mode).toBe('build')
390
+ expect(ctx.projectDir).toBe(dir)
391
+ expect(ctx.templatesDir).toBe(templatesDir)
392
+ expect(ctx.outDir).toBe(join(dir, 'dist'))
393
+ expect(ctx.types.size).toBe(1)
394
+ const [[key, content]] = ctx.types
395
+ expect(key).toBe(join(dir, 'src/components/Counter.tsx'))
396
+ expect(content).toContain('CounterProps')
397
+ // Never handed emitted client JS — narrow by construction, not just by
398
+ // convention. `ctx` has no field that could carry it.
399
+ expect(Object.keys(ctx).sort()).toEqual(['mode', 'outDir', 'projectDir', 'templatesDir', 'types'])
400
+ })
401
+
402
+ test('writeBundle does not call afterEmit when the option is omitted', async () => {
403
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-aftertemit-omitted-'))
404
+ await mkdir(join(dir, 'src/components'), { recursive: true })
405
+ await writeFile(join(dir, 'src/components/Greeting.tsx'), 'export function Greeting() { return <p>Hi</p> }')
406
+
407
+ const plugin = makePlugin('src/components', join(dir, 'internal/views'), new GoTemplateAdapter({ packageName: 'main' }))
408
+ await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
409
+ plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
410
+ await mkdir(join(dir, 'dist/.vite'), { recursive: true })
411
+ await writeFile(join(dir, 'dist/.vite/manifest.json'), '{}')
412
+
413
+ // Would throw if the plugin unconditionally called a non-existent
414
+ // afterEmit — this just needs to not blow up.
415
+ await expect(plugin.writeBundle()).resolves.toBeUndefined()
416
+ })
417
+ })