@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.
- package/dist/child-marker.d.ts +62 -0
- package/dist/child-marker.d.ts.map +1 -0
- package/dist/compile-cache.d.ts +19 -0
- package/dist/compile-cache.d.ts.map +1 -0
- package/dist/component-manifest.d.ts +69 -0
- package/dist/component-manifest.d.ts.map +1 -0
- package/dist/corpus-program.d.ts +41 -0
- package/dist/corpus-program.d.ts.map +1 -0
- package/dist/debounced-serial-runner.d.ts +27 -0
- package/dist/debounced-serial-runner.d.ts.map +1 -0
- package/dist/dev-server.d.ts +99 -0
- package/dist/dev-server.d.ts.map +1 -0
- package/dist/discover.d.ts +117 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/emit.d.ts +9 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24626 -0
- package/dist/manifest.d.ts +39 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/paths.d.ts +57 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/resolve-client-js.d.ts +6 -0
- package/dist/resolve-client-js.d.ts.map +1 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/child-marker.test.ts +24 -0
- package/src/__tests__/compile-cache.test.ts +73 -0
- package/src/__tests__/component-dir-entry.test.ts +239 -0
- package/src/__tests__/component-manifest.test.ts +124 -0
- package/src/__tests__/corpus-program.test.ts +244 -0
- package/src/__tests__/debounced-serial-runner.test.ts +131 -0
- package/src/__tests__/dev-server.test.ts +138 -0
- package/src/__tests__/discover.test.ts +148 -0
- package/src/__tests__/e2e-vite-build.test.ts +191 -0
- package/src/__tests__/e2e-vite-dev.test.ts +478 -0
- package/src/__tests__/emit.test.ts +73 -0
- package/src/__tests__/manifest.test.ts +146 -0
- package/src/__tests__/paths.test.ts +93 -0
- package/src/__tests__/plugin.test.ts +417 -0
- package/src/__tests__/relative-import-rewrite.test.ts +79 -0
- package/src/__tests__/resolve-client-js.test.ts +55 -0
- package/src/__tests__/templates-optional.test.ts +139 -0
- package/src/child-marker.ts +67 -0
- package/src/compile-cache.ts +63 -0
- package/src/component-manifest.ts +139 -0
- package/src/corpus-program.ts +125 -0
- package/src/debounced-serial-runner.ts +67 -0
- package/src/dev-server.ts +184 -0
- package/src/discover.ts +230 -0
- package/src/emit.ts +66 -0
- package/src/index.ts +25 -0
- package/src/manifest.ts +89 -0
- package/src/paths.ts +114 -0
- package/src/plugin.ts +792 -0
- package/src/resolve-client-js.ts +34 -0
- package/src/types.ts +144 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `components` entries widened from `string[]` to `(string | ComponentDirEntry)[]`
|
|
3
|
+
* (`types.ts`) — per-directory `cssLayerPrefix`/`skipDirs` riding on the
|
|
4
|
+
* `components` entry itself instead of a 4th/5th top-level plugin option.
|
|
5
|
+
* See `types.ts`'s docstring and `plugin.ts`'s `normalizeComponents`/
|
|
6
|
+
* `entryForPath`/`isSkippedByEntry` for the mechanics these tests pin:
|
|
7
|
+
*
|
|
8
|
+
* - a plain string entry stays exactly equivalent to `{ dir: string }`
|
|
9
|
+
* - `cssLayerPrefix` reaches compiled output (template AND, for a
|
|
10
|
+
* `'use client'` file, the hydration template embedded in client JS)
|
|
11
|
+
* only for files under the entry that set it
|
|
12
|
+
* - `skipDirs` excludes matching subdirectories from BOTH discovery (the
|
|
13
|
+
* eager pass) and the `transform`/watcher gate (the graph pass) — a
|
|
14
|
+
* half-fix on just one side is exactly what bit `site/ui`'s
|
|
15
|
+
* `PageNavigation.tsx` (imported by pages from a `shared/` dir)
|
|
16
|
+
* - precedence: a file reachable under more than one `components` entry
|
|
17
|
+
* takes the FIRST entry's options, matching `buildChildNameIndex`'s
|
|
18
|
+
* already-documented first-writer-wins rule
|
|
19
|
+
*/
|
|
20
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
21
|
+
import { mkdtemp, rm, mkdir, writeFile, readFile, readdir } from 'node:fs/promises'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { testAdapter } from '@barefootjs/jsx'
|
|
25
|
+
import { barefoot } from '../plugin.ts'
|
|
26
|
+
|
|
27
|
+
// biome-ignore lint: hooks are called directly, bypassing Vite's own
|
|
28
|
+
// dispatch/typing — casting to `any` is the standard way to unit-test a
|
|
29
|
+
// Vite plugin's hooks in isolation (same convention as `plugin.test.ts`).
|
|
30
|
+
type AnyPlugin = any
|
|
31
|
+
|
|
32
|
+
/** Drives `config` → `configResolved` → (fabricated empty manifest) →
|
|
33
|
+
* `writeBundle`, the same minimal sequence `plugin.test.ts`'s writeBundle
|
|
34
|
+
* describe block uses. */
|
|
35
|
+
async function driveBuild(plugin: AnyPlugin, dir: string): Promise<void> {
|
|
36
|
+
await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
|
|
37
|
+
await plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
|
|
38
|
+
await mkdir(join(dir, 'dist/.vite'), { recursive: true })
|
|
39
|
+
await writeFile(join(dir, 'dist/.vite/manifest.json'), '{}')
|
|
40
|
+
await plugin.writeBundle()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `config` → `configResolved` only — enough to exercise `transform`
|
|
44
|
+
* without a real Vite build. */
|
|
45
|
+
async function driveConfig(plugin: AnyPlugin, dir: string): Promise<void> {
|
|
46
|
+
await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
|
|
47
|
+
await plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ext = testAdapter.extension
|
|
51
|
+
|
|
52
|
+
describe('ComponentDirEntry: string entry ≡ { dir } entry', () => {
|
|
53
|
+
let dir: string
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('same discovery and byte-identical compiled template as a plain string entry', async () => {
|
|
60
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-entry-string-eq-'))
|
|
61
|
+
await mkdir(join(dir, 'a'), { recursive: true })
|
|
62
|
+
await mkdir(join(dir, 'b'), { recursive: true })
|
|
63
|
+
const source = 'export function Widget() { return <div className="bg-primary p-4">Hi</div> }'
|
|
64
|
+
await writeFile(join(dir, 'a/Widget.tsx'), source)
|
|
65
|
+
await writeFile(join(dir, 'b/Widget.tsx'), source)
|
|
66
|
+
|
|
67
|
+
const templatesA = join(dir, 'views-a')
|
|
68
|
+
const templatesB = join(dir, 'views-b')
|
|
69
|
+
const pluginString = barefoot({ adapter: testAdapter, components: ['a'], templates: templatesA })
|
|
70
|
+
const pluginObject = barefoot({ adapter: testAdapter, components: [{ dir: 'b' }], templates: templatesB })
|
|
71
|
+
|
|
72
|
+
await driveBuild(pluginString, dir)
|
|
73
|
+
await driveBuild(pluginObject, dir)
|
|
74
|
+
|
|
75
|
+
const outString = await readFile(join(templatesA, `Widget${ext}`), 'utf8')
|
|
76
|
+
const outObject = await readFile(join(templatesB, `Widget${ext}`), 'utf8')
|
|
77
|
+
expect(outString).toBe(outObject)
|
|
78
|
+
// Neither carries a cssLayerPrefix — the object form set none.
|
|
79
|
+
expect(outString).not.toContain('layer-')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('ComponentDirEntry: cssLayerPrefix', () => {
|
|
84
|
+
let dir: string
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('prefixes static classes in the compiled template only for the entry that set it', async () => {
|
|
91
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-entry-css-layer-'))
|
|
92
|
+
await mkdir(join(dir, 'lib'), { recursive: true })
|
|
93
|
+
await mkdir(join(dir, 'app'), { recursive: true })
|
|
94
|
+
await writeFile(join(dir, 'lib/Card.tsx'), 'export function Card() { return <div className="bg-primary p-4">Hi</div> }')
|
|
95
|
+
await writeFile(join(dir, 'app/Panel.tsx'), 'export function Panel() { return <div className="bg-primary p-4">Hi</div> }')
|
|
96
|
+
|
|
97
|
+
const templatesDir = join(dir, 'views')
|
|
98
|
+
const plugin = barefoot({
|
|
99
|
+
adapter: testAdapter,
|
|
100
|
+
components: [
|
|
101
|
+
{ dir: 'lib', cssLayerPrefix: 'components' },
|
|
102
|
+
'app',
|
|
103
|
+
],
|
|
104
|
+
templates: templatesDir,
|
|
105
|
+
})
|
|
106
|
+
await driveBuild(plugin, dir)
|
|
107
|
+
|
|
108
|
+
const libTpl = await readFile(join(templatesDir, `Card${ext}`), 'utf8')
|
|
109
|
+
const appTpl = await readFile(join(templatesDir, `Panel${ext}`), 'utf8')
|
|
110
|
+
|
|
111
|
+
expect(libTpl).toContain('layer-components:bg-primary')
|
|
112
|
+
expect(libTpl).toContain('layer-components:p-4')
|
|
113
|
+
expect(appTpl).not.toContain('layer-')
|
|
114
|
+
expect(appTpl).toContain('bg-primary p-4')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('reaches the hydration template embedded in compiled client JS too (the `transform` / graph-pass path)', async () => {
|
|
118
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-entry-css-layer-clientjs-'))
|
|
119
|
+
await mkdir(join(dir, 'lib'), { recursive: true })
|
|
120
|
+
const clientSource = [
|
|
121
|
+
'\'use client\'',
|
|
122
|
+
'import { createSignal } from \'@barefootjs/client\'',
|
|
123
|
+
'export function Counter() {',
|
|
124
|
+
' const [count, setCount] = createSignal(0)',
|
|
125
|
+
' return <div className="bg-primary p-4"><button onClick={() => setCount(count() + 1)}>{count()}</button></div>',
|
|
126
|
+
'}',
|
|
127
|
+
].join('\n')
|
|
128
|
+
await writeFile(join(dir, 'lib/Counter.tsx'), clientSource)
|
|
129
|
+
|
|
130
|
+
const plugin = barefoot({
|
|
131
|
+
adapter: testAdapter,
|
|
132
|
+
components: [{ dir: 'lib', cssLayerPrefix: 'components' }],
|
|
133
|
+
templates: join(dir, 'views'),
|
|
134
|
+
})
|
|
135
|
+
await driveConfig(plugin, dir)
|
|
136
|
+
|
|
137
|
+
const out = plugin.transform(clientSource, join(dir, 'lib/Counter.tsx'))
|
|
138
|
+
expect(out).not.toBeNull()
|
|
139
|
+
expect(out.code).toContain('layer-components:bg-primary')
|
|
140
|
+
expect(out.code).toContain('layer-components:p-4')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('ComponentDirEntry: skipDirs', () => {
|
|
145
|
+
let dir: string
|
|
146
|
+
|
|
147
|
+
afterEach(async () => {
|
|
148
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('excludes files under a matching subdirectory name from discovery (the eager pass)', async () => {
|
|
152
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-entry-skipdirs-discovery-'))
|
|
153
|
+
await mkdir(join(dir, 'src/components/shared'), { recursive: true })
|
|
154
|
+
await writeFile(join(dir, 'src/components/Page.tsx'), 'export function Page() { return <p>Hi</p> }')
|
|
155
|
+
await writeFile(join(dir, 'src/components/shared/Helper.tsx'), 'export function Helper() { return <p>Hi</p> }')
|
|
156
|
+
|
|
157
|
+
const templatesDir = join(dir, 'views')
|
|
158
|
+
const plugin = barefoot({
|
|
159
|
+
adapter: testAdapter,
|
|
160
|
+
components: [{ dir: 'src/components', skipDirs: ['shared'] }],
|
|
161
|
+
templates: templatesDir,
|
|
162
|
+
})
|
|
163
|
+
await driveBuild(plugin, dir)
|
|
164
|
+
|
|
165
|
+
const emitted = await readdir(templatesDir)
|
|
166
|
+
expect(emitted).toContain(`Page${ext}`)
|
|
167
|
+
expect(emitted).not.toContain(`Helper${ext}`)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('also gates the transform path — a file under a skipped subdir is not a component at all, even if directly imported', async () => {
|
|
171
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-entry-skipdirs-transform-'))
|
|
172
|
+
await mkdir(join(dir, 'src/components/shared'), { recursive: true })
|
|
173
|
+
|
|
174
|
+
const clientTemplate = (name: string) => [
|
|
175
|
+
'\'use client\'',
|
|
176
|
+
'import { createSignal } from \'@barefootjs/client\'',
|
|
177
|
+
`export function ${name}() {`,
|
|
178
|
+
' const [x, setX] = createSignal(0)',
|
|
179
|
+
' return <button onClick={() => setX(x() + 1)}>{x()}</button>',
|
|
180
|
+
'}',
|
|
181
|
+
].join('\n')
|
|
182
|
+
|
|
183
|
+
await writeFile(join(dir, 'src/components/Page.tsx'), clientTemplate('Page'))
|
|
184
|
+
// Lives inside the skipped `shared/` dir but is still a normal relative
|
|
185
|
+
// import target from a non-skipped sibling — the exact shape that used
|
|
186
|
+
// to reach `transform` anyway (discovery skips it, but nothing gated
|
|
187
|
+
// the graph pass) and got compiled despite being "skipped".
|
|
188
|
+
await writeFile(join(dir, 'src/components/shared/PageNavigation.tsx'), clientTemplate('PageNavigation'))
|
|
189
|
+
|
|
190
|
+
const plugin = barefoot({
|
|
191
|
+
adapter: testAdapter,
|
|
192
|
+
components: [{ dir: 'src/components', skipDirs: ['shared'] }],
|
|
193
|
+
templates: join(dir, 'views'),
|
|
194
|
+
})
|
|
195
|
+
await driveConfig(plugin, dir)
|
|
196
|
+
|
|
197
|
+
const pageOut = plugin.transform(clientTemplate('Page'), join(dir, 'src/components/Page.tsx'))
|
|
198
|
+
expect(pageOut).not.toBeNull()
|
|
199
|
+
|
|
200
|
+
const skippedOut = plugin.transform(
|
|
201
|
+
clientTemplate('PageNavigation'),
|
|
202
|
+
join(dir, 'src/components/shared/PageNavigation.tsx'),
|
|
203
|
+
)
|
|
204
|
+
expect(skippedOut).toBeNull()
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('ComponentDirEntry: precedence', () => {
|
|
209
|
+
let dir: string
|
|
210
|
+
|
|
211
|
+
afterEach(async () => {
|
|
212
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('a file reachable under two entries (an outer dir and a nested dir both configured) takes the FIRST entry\'s cssLayerPrefix', async () => {
|
|
216
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-entry-precedence-'))
|
|
217
|
+
await mkdir(join(dir, 'src/nested'), { recursive: true })
|
|
218
|
+
await writeFile(join(dir, 'src/nested/Card.tsx'), 'export function Card() { return <div className="bg-primary">Hi</div> }')
|
|
219
|
+
|
|
220
|
+
const templatesDir = join(dir, 'views')
|
|
221
|
+
const plugin = barefoot({
|
|
222
|
+
adapter: testAdapter,
|
|
223
|
+
components: [
|
|
224
|
+
{ dir: 'src', cssLayerPrefix: 'outer' },
|
|
225
|
+
{ dir: 'src/nested', cssLayerPrefix: 'inner' },
|
|
226
|
+
],
|
|
227
|
+
templates: templatesDir,
|
|
228
|
+
})
|
|
229
|
+
await driveBuild(plugin, dir)
|
|
230
|
+
|
|
231
|
+
// Emitted at `nested/Card...` — `planEmits` mirrors the file's position
|
|
232
|
+
// under whichever `componentDirs` entry contains it, and since `src`
|
|
233
|
+
// (the FIRST entry) also matches, that's `src`'s own relative position
|
|
234
|
+
// (`nested/Card.tsx`), not `src/nested`'s.
|
|
235
|
+
const tpl = await readFile(join(templatesDir, 'nested', `Card${ext}`), 'utf8')
|
|
236
|
+
expect(tpl).toContain('layer-outer:bg-primary')
|
|
237
|
+
expect(tpl).not.toContain('layer-inner:')
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage of `buildManifestEntry` — the combined `manifest.json` row
|
|
3
|
+
* builder (see `component-manifest.ts`'s header for the fields
|
|
4
|
+
* intentionally not included).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect } from 'bun:test'
|
|
7
|
+
import type { CompileResult, TemplateAdapter } from '@barefootjs/jsx'
|
|
8
|
+
import { buildManifestEntry } from '../component-manifest.ts'
|
|
9
|
+
|
|
10
|
+
const fakeAdapter = { extension: '.tmpl', templatesPerComponent: false } as TemplateAdapter
|
|
11
|
+
const perComponentAdapter = { extension: '.tmpl', templatesPerComponent: true } as TemplateAdapter
|
|
12
|
+
|
|
13
|
+
describe('buildManifestEntry', () => {
|
|
14
|
+
test('single-component, non-templatesPerComponent, no ssrDefaults: absent key, no components map', () => {
|
|
15
|
+
const result: CompileResult = {
|
|
16
|
+
files: [{ path: '/x', content: '<button/>', type: 'markedTemplate', componentName: 'Counter' }],
|
|
17
|
+
errors: [],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const row = buildManifestEntry(result, '/src/components/Counter.tsx', ['/src/components'], fakeAdapter)
|
|
21
|
+
|
|
22
|
+
expect(row).not.toBeNull()
|
|
23
|
+
expect(row!.manifestKey).toBe('Counter')
|
|
24
|
+
expect(row!.entry).toEqual({ markedTemplate: 'Counter.tmpl' })
|
|
25
|
+
// The absent-key contract matters: a consumer checking
|
|
26
|
+
// `'ssrDefaults' in entry` (or PHP's `array_key_exists`) must see FALSE
|
|
27
|
+
// for a component with no SSR defaults, not an empty object — see the
|
|
28
|
+
// `...(ssrDefaults ? { ssrDefaults } : {})` spread in
|
|
29
|
+
// `buildManifestEntry`.
|
|
30
|
+
expect('ssrDefaults' in row!.entry).toBe(false)
|
|
31
|
+
expect('components' in row!.entry).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('single-component, non-templatesPerComponent, WITH ssrDefaults', () => {
|
|
35
|
+
const result: CompileResult = {
|
|
36
|
+
files: [
|
|
37
|
+
{ path: '/x', content: '<button/>', type: 'markedTemplate', componentName: 'Counter' },
|
|
38
|
+
{ path: '/x', content: '{"initial":{"propName":"initial","value":0}}', type: 'ssrDefaults', componentName: 'Counter' },
|
|
39
|
+
],
|
|
40
|
+
errors: [],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const row = buildManifestEntry(result, '/src/components/Counter.tsx', ['/src/components'], fakeAdapter)
|
|
44
|
+
|
|
45
|
+
expect(row!.entry).toEqual({
|
|
46
|
+
markedTemplate: 'Counter.tmpl',
|
|
47
|
+
ssrDefaults: { initial: { propName: 'initial', value: 0 } },
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('templatesPerComponent adapter, single component: top-level fields mirror the components sub-map', () => {
|
|
52
|
+
const result: CompileResult = {
|
|
53
|
+
files: [
|
|
54
|
+
{ path: '/x', content: 'body', type: 'markedTemplate', componentName: 'Counter' },
|
|
55
|
+
{ path: '/x', content: '{"initial":{"value":0}}', type: 'ssrDefaults', componentName: 'Counter' },
|
|
56
|
+
],
|
|
57
|
+
errors: [],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const row = buildManifestEntry(result, '/src/components/Counter.tsx', ['/src/components'], perComponentAdapter)
|
|
61
|
+
|
|
62
|
+
expect(row!.manifestKey).toBe('Counter')
|
|
63
|
+
expect(row!.entry).toEqual({
|
|
64
|
+
markedTemplate: 'Counter.tmpl',
|
|
65
|
+
ssrDefaults: { initial: { value: 0 } },
|
|
66
|
+
components: {
|
|
67
|
+
Counter: { markedTemplate: 'Counter.tmpl', ssrDefaults: { initial: { value: 0 } } },
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('templatesPerComponent adapter, multi-export file: one manifestKey, per-component rows, partial ssrDefaults', () => {
|
|
73
|
+
const result: CompileResult = {
|
|
74
|
+
files: [
|
|
75
|
+
{ path: '/x', content: 'toast body', type: 'markedTemplate', componentName: 'Toast' },
|
|
76
|
+
{ path: '/x', content: 'toaster body', type: 'markedTemplate', componentName: 'Toaster' },
|
|
77
|
+
{ path: '/x', content: '{"open":{"value":false}}', type: 'ssrDefaults', componentName: 'Toast' },
|
|
78
|
+
// Toaster has no ssrDefaults file at all.
|
|
79
|
+
],
|
|
80
|
+
errors: [],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const row = buildManifestEntry(
|
|
84
|
+
result,
|
|
85
|
+
'/src/components/ui/toast/index.tsx',
|
|
86
|
+
['/src/components'],
|
|
87
|
+
perComponentAdapter,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// Neither exported component's name is 'index' (the file's own
|
|
91
|
+
// basename), so `markedTemplates[0]` is used for a multi-export file
|
|
92
|
+
// (see `component-manifest.ts`'s "Primary (top-level) template"
|
|
93
|
+
// comment).
|
|
94
|
+
expect(row!.manifestKey).toBe('ui/toast/index')
|
|
95
|
+
expect(row!.entry.markedTemplate).toBe('ui/toast/Toast.tmpl')
|
|
96
|
+
expect(row!.entry.ssrDefaults).toEqual({ open: { value: false } })
|
|
97
|
+
expect(row!.entry.components).toEqual({
|
|
98
|
+
Toast: { markedTemplate: 'ui/toast/Toast.tmpl', ssrDefaults: { open: { value: false } } },
|
|
99
|
+
Toaster: { markedTemplate: 'ui/toast/Toaster.tmpl' },
|
|
100
|
+
})
|
|
101
|
+
expect('ssrDefaults' in row!.entry.components!.Toaster!).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('returns null for a state-only compile with no markedTemplate output', () => {
|
|
105
|
+
const result: CompileResult = {
|
|
106
|
+
files: [{ path: '/x', content: 'export const x = 1', type: 'clientJs' }],
|
|
107
|
+
errors: [],
|
|
108
|
+
}
|
|
109
|
+
expect(buildManifestEntry(result, '/src/components/state.tsx', ['/src/components'], fakeAdapter)).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('drops malformed ssrDefaults content instead of throwing', () => {
|
|
113
|
+
const result: CompileResult = {
|
|
114
|
+
files: [
|
|
115
|
+
{ path: '/x', content: 'body', type: 'markedTemplate', componentName: 'Counter' },
|
|
116
|
+
{ path: '/x', content: 'not json', type: 'ssrDefaults', componentName: 'Counter' },
|
|
117
|
+
],
|
|
118
|
+
errors: [],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const row = buildManifestEntry(result, '/src/components/Counter.tsx', ['/src/components'], fakeAdapter)
|
|
122
|
+
expect('ssrDefaults' in row!.entry).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CorpusProgramManager` (#2537): one shared `ts.Program` across every
|
|
3
|
+
* compile, instead of a ~500-600 ms per-file `ts.createProgram` inside
|
|
4
|
+
* `compileJSX`'s fallback for each type-needing file.
|
|
5
|
+
*
|
|
6
|
+
* The unit half exercises the manager's contract directly (gating,
|
|
7
|
+
* instance reuse, incremental rebuild, in-memory divergence fallback).
|
|
8
|
+
* The plugin half drives `barefoot()`'s hooks against a temp project with
|
|
9
|
+
* a fake `node_modules/@barefootjs/form` and pins the two build-breaking
|
|
10
|
+
* symptoms this exists to fix:
|
|
11
|
+
*
|
|
12
|
+
* - a Reactive<T>-brand importer compiles through the plugin at all
|
|
13
|
+
* (BF050 is severity `error` and the plugin throws on error
|
|
14
|
+
* diagnostics — pre-fix, this file could not build);
|
|
15
|
+
* - zero per-file Program creations across the whole pass, measured by
|
|
16
|
+
* the compiler's own `programCreations` counter.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
19
|
+
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import {
|
|
23
|
+
testAdapter,
|
|
24
|
+
enableCompilerInstrumentation,
|
|
25
|
+
disableCompilerInstrumentation,
|
|
26
|
+
resetCompilerCounters,
|
|
27
|
+
getCompilerCounters,
|
|
28
|
+
} from '@barefootjs/jsx'
|
|
29
|
+
import { CorpusProgramManager } from '../corpus-program.ts'
|
|
30
|
+
import { barefoot } from '../plugin.ts'
|
|
31
|
+
|
|
32
|
+
// Same convention as templates-optional.test.ts: hooks are called
|
|
33
|
+
// directly, bypassing Vite's own dispatch/typing.
|
|
34
|
+
type AnyPlugin = any
|
|
35
|
+
|
|
36
|
+
const NEEDING_SOURCE = `export function List(props: { items: string[] }) {
|
|
37
|
+
return <ul>{props.items.map(item => <li key={item}>{item}</li>)}</ul>
|
|
38
|
+
}
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
const PLAIN_SOURCE = `export function Greeting() { return <p>Hi</p> }
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
describe('CorpusProgramManager', () => {
|
|
45
|
+
let dir: string
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('returns undefined for a file that needs no type-based detection', async () => {
|
|
52
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-corpus-unit-'))
|
|
53
|
+
const abs = join(dir, 'Greeting.tsx')
|
|
54
|
+
await writeFile(abs, PLAIN_SOURCE)
|
|
55
|
+
|
|
56
|
+
const manager = new CorpusProgramManager()
|
|
57
|
+
manager.seed([{ absPath: abs, content: PLAIN_SOURCE }])
|
|
58
|
+
expect(manager.programFor(abs, PLAIN_SOURCE)).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('seeded needing files share ONE Program instance, reused across calls', async () => {
|
|
62
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-corpus-unit-'))
|
|
63
|
+
const a = join(dir, 'A.tsx')
|
|
64
|
+
const b = join(dir, 'B.tsx')
|
|
65
|
+
await writeFile(a, NEEDING_SOURCE)
|
|
66
|
+
await writeFile(b, NEEDING_SOURCE)
|
|
67
|
+
|
|
68
|
+
const manager = new CorpusProgramManager()
|
|
69
|
+
manager.seed([
|
|
70
|
+
{ absPath: a, content: NEEDING_SOURCE },
|
|
71
|
+
{ absPath: b, content: NEEDING_SOURCE },
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
const programA = manager.programFor(a, NEEDING_SOURCE)
|
|
75
|
+
const programB = manager.programFor(b, NEEDING_SOURCE)
|
|
76
|
+
expect(programA).toBeDefined()
|
|
77
|
+
expect(programA).toBe(programB!)
|
|
78
|
+
// Re-seeding an unchanged snapshot keeps the same instance — the dev
|
|
79
|
+
// watcher re-seeds on EVERY pass, so this is what keeps quiet passes
|
|
80
|
+
// free.
|
|
81
|
+
manager.seed([
|
|
82
|
+
{ absPath: a, content: NEEDING_SOURCE },
|
|
83
|
+
{ absPath: b, content: NEEDING_SOURCE },
|
|
84
|
+
])
|
|
85
|
+
expect(manager.programFor(a, NEEDING_SOURCE)).toBe(programA!)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('a changed file rebuilds the Program and the rebuilt SourceFile carries the new text', async () => {
|
|
89
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-corpus-unit-'))
|
|
90
|
+
const abs = join(dir, 'A.tsx')
|
|
91
|
+
await writeFile(abs, NEEDING_SOURCE)
|
|
92
|
+
|
|
93
|
+
const manager = new CorpusProgramManager()
|
|
94
|
+
manager.seed([{ absPath: abs, content: NEEDING_SOURCE }])
|
|
95
|
+
const before = manager.programFor(abs, NEEDING_SOURCE)
|
|
96
|
+
|
|
97
|
+
const edited = NEEDING_SOURCE.replace('<ul>', '<ol>').replace('</ul>', '</ol>')
|
|
98
|
+
await writeFile(abs, edited)
|
|
99
|
+
const after = manager.programFor(abs, edited)
|
|
100
|
+
|
|
101
|
+
expect(after).toBeDefined()
|
|
102
|
+
expect(after).not.toBe(before!)
|
|
103
|
+
expect(after!.getSourceFile(abs)?.text).toBe(edited)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('a needing file added after the seed still gets a Program (graph pass reaching a brand-new file)', async () => {
|
|
107
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-corpus-unit-'))
|
|
108
|
+
const abs = join(dir, 'Late.tsx')
|
|
109
|
+
|
|
110
|
+
const manager = new CorpusProgramManager()
|
|
111
|
+
manager.seed([])
|
|
112
|
+
await writeFile(abs, NEEDING_SOURCE)
|
|
113
|
+
const program = manager.programFor(abs, NEEDING_SOURCE)
|
|
114
|
+
expect(program).toBeDefined()
|
|
115
|
+
expect(program!.getSourceFile(abs)?.text).toBe(NEEDING_SOURCE)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('in-memory content diverging from disk falls back to a Program that still matches the in-memory text', async () => {
|
|
119
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-corpus-unit-'))
|
|
120
|
+
const abs = join(dir, 'A.tsx')
|
|
121
|
+
await writeFile(abs, NEEDING_SOURCE)
|
|
122
|
+
|
|
123
|
+
const manager = new CorpusProgramManager()
|
|
124
|
+
manager.seed([{ absPath: abs, content: NEEDING_SOURCE }])
|
|
125
|
+
|
|
126
|
+
// Content the caller holds that is NOT what's on disk — the analyzer
|
|
127
|
+
// discards any Program whose SourceFile text mismatches, so whatever
|
|
128
|
+
// comes back here MUST carry the in-memory text.
|
|
129
|
+
const inMemory = NEEDING_SOURCE + '// trailing edit not yet on disk\n'
|
|
130
|
+
const program = manager.programFor(abs, inMemory)
|
|
131
|
+
expect(program).toBeDefined()
|
|
132
|
+
expect(program!.getSourceFile(abs)?.text).toBe(inMemory)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('barefoot() with a Reactive<T>-brand component (the BF050 unblock)', () => {
|
|
137
|
+
let dir: string
|
|
138
|
+
let templatesDir: string
|
|
139
|
+
|
|
140
|
+
afterEach(async () => {
|
|
141
|
+
disableCompilerInstrumentation()
|
|
142
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
143
|
+
if (templatesDir) await rm(templatesDir, { recursive: true, force: true })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A self-contained fake `@barefootjs/form` — same Reactive<T> brand
|
|
148
|
+
* shape as the real package's types, resolvable from the temp project
|
|
149
|
+
* without depending on a built `packages/form/dist`.
|
|
150
|
+
*/
|
|
151
|
+
async function writeFakeFormPackage(root: string): Promise<void> {
|
|
152
|
+
const pkgDir = join(root, 'node_modules/@barefootjs/form')
|
|
153
|
+
await mkdir(pkgDir, { recursive: true })
|
|
154
|
+
await writeFile(
|
|
155
|
+
join(pkgDir, 'package.json'),
|
|
156
|
+
JSON.stringify({ name: '@barefootjs/form', version: '0.0.0', types: 'index.d.ts', main: 'index.js' }),
|
|
157
|
+
)
|
|
158
|
+
await writeFile(
|
|
159
|
+
join(pkgDir, 'index.d.ts'),
|
|
160
|
+
`export type Reactive<T> = T & { readonly __reactive: true };
|
|
161
|
+
export interface FormReturn {
|
|
162
|
+
isSubmitting: Reactive<() => boolean>;
|
|
163
|
+
handleSubmit: (e: Event) => Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
export declare function createForm(opts?: unknown): FormReturn;
|
|
166
|
+
`,
|
|
167
|
+
)
|
|
168
|
+
await writeFile(join(pkgDir, 'index.js'), 'export function createForm() { return {} }\n')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
test('builds a form-importing component without BF050, resolves the brand, and creates ZERO per-file Programs', async () => {
|
|
172
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-corpus-plugin-'))
|
|
173
|
+
templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-corpus-templates-'))
|
|
174
|
+
await mkdir(join(dir, 'src/components'), { recursive: true })
|
|
175
|
+
await writeFakeFormPackage(dir)
|
|
176
|
+
|
|
177
|
+
// Multi-export on purpose: pre-fix, the multi-component path masked
|
|
178
|
+
// BF050 while the single path threw — both shapes must now build.
|
|
179
|
+
await writeFile(
|
|
180
|
+
join(dir, 'src/components/Forms.tsx'),
|
|
181
|
+
`'use client'
|
|
182
|
+
import { createForm } from '@barefootjs/form'
|
|
183
|
+
|
|
184
|
+
export function ProfileForm() {
|
|
185
|
+
const form = createForm()
|
|
186
|
+
return <form onSubmit={form.handleSubmit}><button disabled={form.isSubmitting()}>Save</button></form>
|
|
187
|
+
}
|
|
188
|
+
`,
|
|
189
|
+
)
|
|
190
|
+
await writeFile(join(dir, 'src/components/SingleForm.tsx'), `'use client'
|
|
191
|
+
import { createForm } from '@barefootjs/form'
|
|
192
|
+
|
|
193
|
+
export function SingleForm() {
|
|
194
|
+
const form = createForm()
|
|
195
|
+
return <form onSubmit={form.handleSubmit}><button disabled={form.isSubmitting()}>Go</button></form>
|
|
196
|
+
}
|
|
197
|
+
`)
|
|
198
|
+
await writeFile(join(dir, 'src/components/Greeting.tsx'), PLAIN_SOURCE)
|
|
199
|
+
|
|
200
|
+
const plugin: AnyPlugin = barefoot({
|
|
201
|
+
adapter: testAdapter,
|
|
202
|
+
components: ['src/components'],
|
|
203
|
+
templates: templatesDir,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
enableCompilerInstrumentation()
|
|
207
|
+
resetCompilerCounters()
|
|
208
|
+
|
|
209
|
+
await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
|
|
210
|
+
await plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
|
|
211
|
+
|
|
212
|
+
// Graph pass over the client files, exactly as Rollup would drive it.
|
|
213
|
+
const clientJsByName = new Map<string, string>()
|
|
214
|
+
for (const name of ['Forms.tsx', 'SingleForm.tsx']) {
|
|
215
|
+
const abs = join(dir, 'src/components', name)
|
|
216
|
+
const out = plugin.transform(await readFile(abs, 'utf8'), abs)
|
|
217
|
+
clientJsByName.set(name, out?.code ?? '')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await mkdir(join(dir, 'dist/.vite'), { recursive: true })
|
|
221
|
+
await writeFile(join(dir, 'dist/.vite/manifest.json'), '{}')
|
|
222
|
+
// Pre-fix this throws `[barefoot] compile failed: ... BF050`.
|
|
223
|
+
await plugin.writeBundle()
|
|
224
|
+
|
|
225
|
+
// The brand resolved through the SHARED Program: `form.isSubmitting()`
|
|
226
|
+
// auto-defers (#1638). In the CLIENT bundle that means the hydrate
|
|
227
|
+
// template lambda drops the `disabled` attribute and init wires it
|
|
228
|
+
// instead — neither happens if the brand collapsed to `any` (the
|
|
229
|
+
// regex fallback can't classify a library getter, so the attribute
|
|
230
|
+
// would just render inline). The SSR template is not asserted on:
|
|
231
|
+
// a JSX-runtime adapter's template re-runs the real component code,
|
|
232
|
+
// so it legitimately keeps the live attribute expression.
|
|
233
|
+
for (const clientJs of clientJsByName.values()) {
|
|
234
|
+
expect(clientJs).toMatch(/\.disabled = !!\(form\.isSubmitting\(\)\)/)
|
|
235
|
+
expect(clientJs).not.toMatch(/template:.*disabled/)
|
|
236
|
+
}
|
|
237
|
+
const template = await readFile(join(templatesDir, 'Forms.test.tsx'), 'utf8')
|
|
238
|
+
expect(template).toContain('<button')
|
|
239
|
+
|
|
240
|
+
// The whole point: no compile anywhere in either pass fell back to a
|
|
241
|
+
// per-file `ts.createProgram`.
|
|
242
|
+
expect(getCompilerCounters().programCreations).toBe(0)
|
|
243
|
+
})
|
|
244
|
+
})
|