@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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: `rewriterFor`'s `outputPathGuess` (`plugin.ts`) MUST mirror
|
|
3
|
+
* `planEmits`'s actual on-disk output location — a same-directory sibling
|
|
4
|
+
* import must stay `./Sibling` after emission, not turn into a phantom
|
|
5
|
+
* `../blog/Sibling`-shaped path.
|
|
6
|
+
*
|
|
7
|
+
* Only an adapter whose templates carry real `import` syntax exercises
|
|
8
|
+
* this at all (`paths.ts`'s `buildRelativeImportRewriter` docstring) — Go/
|
|
9
|
+
* Mojo/etc. templates have no import syntax. `HonoAdapter` is that adapter
|
|
10
|
+
* here (mirrors `integrations/hono`'s real PageShell → Sidekick-shaped
|
|
11
|
+
* `ReaderToolbar` same-directory import).
|
|
12
|
+
*
|
|
13
|
+
* Fixture (`../../e2e-fixture-relimport`) reproduces the bug's precondition
|
|
14
|
+
* exactly: `app/` (the Vite root, empty of components) and `blog/` (a
|
|
15
|
+
* `components` dir that is a SIBLING of root, not a descendant — this
|
|
16
|
+
* monorepo's real layouts, see `plugin.ts`'s `configureServer` docstring).
|
|
17
|
+
* The root-relative guess and the component-dir-relative REAL output path
|
|
18
|
+
* only diverge when a component's dir isn't the root itself — a fixture
|
|
19
|
+
* with `components` under `root` would not reproduce this at all. Lives
|
|
20
|
+
* under `node_modules`-having `packages/vite/` (not a system tmpdir) so
|
|
21
|
+
* `@barefootjs/client` resolves through the monorepo's real workspace
|
|
22
|
+
* symlinks, same reason `e2e-fixture`/`e2e-fixture-dev` do.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, test, expect, afterAll } from 'bun:test'
|
|
25
|
+
import { build } from 'vite'
|
|
26
|
+
import { mkdtemp, rm, readFile } from 'node:fs/promises'
|
|
27
|
+
import { tmpdir } from 'node:os'
|
|
28
|
+
import { join, resolve } from 'node:path'
|
|
29
|
+
import { HonoAdapter } from '@barefootjs/hono/adapter'
|
|
30
|
+
import { barefoot } from '../plugin.ts'
|
|
31
|
+
|
|
32
|
+
const FIXTURE_ROOT = resolve(import.meta.dirname, '../../e2e-fixture-relimport')
|
|
33
|
+
const APP_ROOT = join(FIXTURE_ROOT, 'app')
|
|
34
|
+
const BLOG_DIR = join(FIXTURE_ROOT, 'blog')
|
|
35
|
+
|
|
36
|
+
describe('rewriteRelativeImport re-anchoring: a components dir outside the Vite root', () => {
|
|
37
|
+
let outDir: string
|
|
38
|
+
let templatesDir: string
|
|
39
|
+
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
await rm(outDir, { recursive: true, force: true })
|
|
42
|
+
await rm(templatesDir, { recursive: true, force: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('a same-directory sibling import survives emission as `./Sidekick`, not a phantom `../blog/Sidekick`', async () => {
|
|
46
|
+
outDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-relimport-dist-'))
|
|
47
|
+
templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-vite-relimport-views-'))
|
|
48
|
+
|
|
49
|
+
await build({
|
|
50
|
+
configFile: false,
|
|
51
|
+
root: APP_ROOT,
|
|
52
|
+
base: '/static/',
|
|
53
|
+
logLevel: 'warn',
|
|
54
|
+
build: { outDir, emptyOutDir: true },
|
|
55
|
+
plugins: [
|
|
56
|
+
barefoot({
|
|
57
|
+
adapter: new HonoAdapter(),
|
|
58
|
+
components: [BLOG_DIR],
|
|
59
|
+
templates: templatesDir,
|
|
60
|
+
}),
|
|
61
|
+
],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const template = await readFile(join(templatesDir, 'PageShell.tsx'), 'utf8')
|
|
65
|
+
// The bug re-anchored this to a phantom `../blog/Sidekick`-shaped path
|
|
66
|
+
// (root-relative, guessing the file would land nested under
|
|
67
|
+
// `templatesDir/../blog/`) — but `planEmits` actually flattens every
|
|
68
|
+
// `components` dir's contents directly under `templatesDir`, so
|
|
69
|
+
// `Sidekick.tsx` is a FLAT sibling of `PageShell.tsx`.
|
|
70
|
+
expect(template).toContain("from './Sidekick'")
|
|
71
|
+
expect(template).not.toContain('../blog/Sidekick')
|
|
72
|
+
expect(template).not.toContain('../../blog/Sidekick')
|
|
73
|
+
|
|
74
|
+
// Also assert the emitted Sidekick template actually lives where
|
|
75
|
+
// PageShell's rewritten import now points.
|
|
76
|
+
const sidekick = await readFile(join(templatesDir, 'Sidekick.tsx'), 'utf8')
|
|
77
|
+
expect(sidekick).toContain('function Sidekick')
|
|
78
|
+
}, 60_000)
|
|
79
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { resolveClientJsSpecifier } from '../resolve-client-js.ts'
|
|
6
|
+
|
|
7
|
+
describe('resolveClientJsSpecifier', () => {
|
|
8
|
+
let dir: string
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('maps a relative ./foo.client.js specifier back to ./foo.tsx', async () => {
|
|
15
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-resolve-'))
|
|
16
|
+
await writeFile(join(dir, 'signals.tsx'), '\'use client\'\nexport const x = 1')
|
|
17
|
+
const importer = join(dir, 'consumer.tsx')
|
|
18
|
+
|
|
19
|
+
const resolved = resolveClientJsSpecifier('./signals.client.js', importer)
|
|
20
|
+
expect(resolved).toBe(join(dir, 'signals.tsx'))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('maps a ../ relative specifier from a nested importer', async () => {
|
|
24
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-resolve-'))
|
|
25
|
+
await mkdir(join(dir, 'nested'), { recursive: true })
|
|
26
|
+
await writeFile(join(dir, 'signals.tsx'), '\'use client\'\nexport const x = 1')
|
|
27
|
+
const importer = join(dir, 'nested', 'consumer.tsx')
|
|
28
|
+
|
|
29
|
+
const resolved = resolveClientJsSpecifier('../signals.client.js', importer)
|
|
30
|
+
expect(resolved).toBe(join(dir, 'signals.tsx'))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('returns null when the target .tsx file does not exist on disk', async () => {
|
|
34
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-resolve-'))
|
|
35
|
+
const importer = join(dir, 'consumer.tsx')
|
|
36
|
+
expect(resolveClientJsSpecifier('./missing.client.js', importer)).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('returns null for a non-.client.js specifier', () => {
|
|
40
|
+
expect(resolveClientJsSpecifier('./signals.ts', '/a/consumer.tsx')).toBeNull()
|
|
41
|
+
expect(resolveClientJsSpecifier('./signals', '/a/consumer.tsx')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('returns null for an alias (bare) specifier — Vite\'s resolve.alias handles it natively (R2)', () => {
|
|
45
|
+
expect(resolveClientJsSpecifier('@/components/signals.client.js', '/a/consumer.tsx')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('returns null for a bare package specifier', () => {
|
|
49
|
+
expect(resolveClientJsSpecifier('some-package/signals.client.js', '/a/consumer.tsx')).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('returns null when there is no importer', () => {
|
|
53
|
+
expect(resolveClientJsSpecifier('./signals.client.js', undefined)).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `templates` is optional on `BarefootViteOptions` for an adapter whose
|
|
3
|
+
* `generate()` output is ALWAYS empty (CSR — see `@barefootjs/client`'s
|
|
4
|
+
* `CSRAdapter`). These tests pin both halves of that contract directly
|
|
5
|
+
* against `plugin.ts`'s hooks (no real Vite build — see
|
|
6
|
+
* `e2e-vite-build.test.ts` for that):
|
|
7
|
+
*
|
|
8
|
+
* - a CSR-shaped project (CSRAdapter, `templates` omitted) builds clean:
|
|
9
|
+
* no template files, no manifest.json, no thrown error — even for a
|
|
10
|
+
* component whose `ssrDefaults` output IS real (proving the guard is
|
|
11
|
+
* scoped to `markedTemplate` content specifically, not any adapter
|
|
12
|
+
* output — see `plugin.ts`'s `assertNoRealTemplateOutput` docstring).
|
|
13
|
+
* - a non-CSR adapter (one that actually emits template text) with
|
|
14
|
+
* `templates` omitted refuses loudly instead of silently dropping that
|
|
15
|
+
* output.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
18
|
+
import { mkdtemp, rm, mkdir, writeFile, access } from 'node:fs/promises'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { join, resolve } from 'node:path'
|
|
21
|
+
import { testAdapter } from '@barefootjs/jsx'
|
|
22
|
+
import { CSRAdapter } from '@barefootjs/client/csr-adapter'
|
|
23
|
+
import { barefoot } from '../plugin.ts'
|
|
24
|
+
|
|
25
|
+
// biome-ignore lint: hooks are called directly, bypassing Vite's own
|
|
26
|
+
// dispatch/typing — casting to `any` is the standard way to unit-test a
|
|
27
|
+
// Vite plugin's hooks in isolation.
|
|
28
|
+
type AnyPlugin = any
|
|
29
|
+
|
|
30
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
await access(p)
|
|
33
|
+
return true
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('templates: optional for an adapter with always-empty output (CSR)', () => {
|
|
40
|
+
let dir: string
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('builds clean with no template files, no manifest.json, and no error — including a component with real ssrDefaults', async () => {
|
|
47
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-templates-optional-csr-'))
|
|
48
|
+
await mkdir(join(dir, 'src/components'), { recursive: true })
|
|
49
|
+
// A signal with a literal initializer produces non-empty `ssrDefaults`
|
|
50
|
+
// regardless of adapter (that computation reads IR metadata, not
|
|
51
|
+
// `generate()`'s output) — this is the case that would wrongly trip an
|
|
52
|
+
// over-broad guard checking ANY adapter output, not just the template.
|
|
53
|
+
await writeFile(
|
|
54
|
+
join(dir, 'src/components/Counter.tsx'),
|
|
55
|
+
'\'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',
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const plugin: AnyPlugin = barefoot({
|
|
59
|
+
adapter: new CSRAdapter(),
|
|
60
|
+
components: ['src/components'],
|
|
61
|
+
})
|
|
62
|
+
await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
|
|
63
|
+
plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
|
|
64
|
+
await mkdir(join(dir, 'dist/.vite'), { recursive: true })
|
|
65
|
+
await writeFile(
|
|
66
|
+
join(dir, 'dist/.vite/manifest.json'),
|
|
67
|
+
JSON.stringify({ 'src/components/Counter.tsx': { file: 'assets/Counter-abc123.js', isEntry: true } }),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
await expect(plugin.writeBundle()).resolves.toBeUndefined()
|
|
71
|
+
|
|
72
|
+
// No `templates` option was given, so there is no directory this
|
|
73
|
+
// plugin could have written a template/manifest into in the first
|
|
74
|
+
// place — the absence of a stray output directory anywhere under the
|
|
75
|
+
// project root is the observable half of "no template files".
|
|
76
|
+
expect(await pathExists(join(dir, 'manifest.json'))).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('dev pass also builds clean with `templates` omitted', async () => {
|
|
80
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-templates-optional-csr-dev-'))
|
|
81
|
+
await mkdir(join(dir, 'src/components'), { recursive: true })
|
|
82
|
+
await writeFile(join(dir, 'src/components/Greeting.tsx'), 'export function Greeting() { return <p>Hi</p> }')
|
|
83
|
+
|
|
84
|
+
const plugin: AnyPlugin = barefoot({
|
|
85
|
+
adapter: new CSRAdapter(),
|
|
86
|
+
components: ['src/components'],
|
|
87
|
+
})
|
|
88
|
+
await plugin.config({ root: dir }, { command: 'serve', mode: 'development' })
|
|
89
|
+
plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
|
|
90
|
+
|
|
91
|
+
// Drive `configureServer`'s middleware-mode path (no `httpServer`)
|
|
92
|
+
// directly, matching how `e2e-vite-dev.test.ts` exercises this without
|
|
93
|
+
// a real listening server.
|
|
94
|
+
const watchedDirs: string[] = []
|
|
95
|
+
const listeners: Record<string, (arg: string) => void> = {}
|
|
96
|
+
plugin.configureServer({
|
|
97
|
+
httpServer: null,
|
|
98
|
+
config: { logger: { error: () => {} } },
|
|
99
|
+
watcher: {
|
|
100
|
+
add: (d: string) => watchedDirs.push(d),
|
|
101
|
+
on: (event: string, cb: (arg: string) => void) => { listeners[event] = cb },
|
|
102
|
+
},
|
|
103
|
+
ws: { send: () => {} },
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// The initial pass runs asynchronously (middleware-mode branch) —
|
|
107
|
+
// give it a tick to complete and surface any thrown error.
|
|
108
|
+
await new Promise(r => setTimeout(r, 50))
|
|
109
|
+
expect(watchedDirs).toContain(join(dir, 'src/components'))
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('templates: refuses loudly when omitted but the adapter produces a real template', () => {
|
|
114
|
+
let dir: string
|
|
115
|
+
|
|
116
|
+
afterEach(async () => {
|
|
117
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('writeBundle throws a clear error naming the offending file', async () => {
|
|
121
|
+
dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-templates-optional-refuse-'))
|
|
122
|
+
await mkdir(join(dir, 'src/components'), { recursive: true })
|
|
123
|
+
// `testAdapter` (unlike CSRAdapter) emits a REAL, non-empty template —
|
|
124
|
+
// exactly the output that would be silently dropped without this guard.
|
|
125
|
+
await writeFile(join(dir, 'src/components/Greeting.tsx'), 'export function Greeting() { return <p>Hi</p> }')
|
|
126
|
+
|
|
127
|
+
const plugin: AnyPlugin = barefoot({
|
|
128
|
+
adapter: testAdapter,
|
|
129
|
+
components: ['src/components'],
|
|
130
|
+
})
|
|
131
|
+
await plugin.config({ root: dir }, { command: 'build', mode: 'production' })
|
|
132
|
+
plugin.configResolved({ root: dir, base: '/', build: { outDir: 'dist', manifest: true } })
|
|
133
|
+
await mkdir(join(dir, 'dist/.vite'), { recursive: true })
|
|
134
|
+
await writeFile(join(dir, 'dist/.vite/manifest.json'), '{}')
|
|
135
|
+
|
|
136
|
+
await expect(plugin.writeBundle()).rejects.toThrow(/templates/)
|
|
137
|
+
await expect(plugin.writeBundle()).rejects.toThrow(/Greeting\.tsx/)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@bf-child:<Name>` marker resolution.
|
|
3
|
+
*
|
|
4
|
+
* The compiler emits `import '/* @bf-child:ChildName *\/'` inside a
|
|
5
|
+
* component's client JS for every OTHER component it references (loop-body
|
|
6
|
+
* children, `initChild`-driven nested components, etc.) — see
|
|
7
|
+
* `packages/jsx/src/ir-to-client-js/child-components.ts`. That specifier
|
|
8
|
+
* is not a real module: it's a marker this plugin must resolve at build
|
|
9
|
+
* time, one way or another, for every reference it stands for.
|
|
10
|
+
*
|
|
11
|
+
* Simply DROPPING the marker (resolving it to an empty no-op module) is
|
|
12
|
+
* not a safe choice for every child reference the marker stands for, only
|
|
13
|
+
* for SOME of them. The distinction (found empirically against gin's real
|
|
14
|
+
* TodoApp/TodoItem — no PR01-03 fixture exercised this shape):
|
|
15
|
+
*
|
|
16
|
+
* - A child rendered via `initChild()` (an SSR-hydrated child, or one
|
|
17
|
+
* whose scope a cross-template Go/ERB/… render already put on the
|
|
18
|
+
* page) is genuinely safe to drop: `@barefootjs/client`'s registry
|
|
19
|
+
* (`packages/client/src/runtime/registry.ts`) queues an `initChild`
|
|
20
|
+
* call for a not-yet-registered name (`pendingChildInits`) and drains
|
|
21
|
+
* it the moment the child's OWN script loads and calls
|
|
22
|
+
* `registerComponent` — however that script physically reached the
|
|
23
|
+
* page. As long as the child's own template renders somewhere (which
|
|
24
|
+
* is what registers its script — see `plugin.ts`'s docstring), this
|
|
25
|
+
* is load-order-tolerant by design.
|
|
26
|
+
* - A child created via `createComponent(name, …)` for a PURELY
|
|
27
|
+
* client-rendered loop (no server-side row template at all — e.g.
|
|
28
|
+
* TodoApp's `.map()` over `initialTodos`, as opposed to TodoAppSSR's
|
|
29
|
+
* server-rendered rows) is NOT tolerant: `materializeComponent`
|
|
30
|
+
* (`packages/client/src/runtime/component.ts`) does one synchronous
|
|
31
|
+
* `getTemplate(name)` registry lookup with NO queueing — if the
|
|
32
|
+
* child's script hasn't run yet, it silently renders a placeholder
|
|
33
|
+
* and NEVER retries. If nothing else on the page happens to reference
|
|
34
|
+
* the child, ITS SCRIPT NEVER LOADS AT ALL, and the row permanently
|
|
35
|
+
* fails to render.
|
|
36
|
+
*
|
|
37
|
+
* Resolving every marker to a REAL import of the named child's `.tsx`
|
|
38
|
+
* source (when discovered) closes this gap the same way Rollup already
|
|
39
|
+
* handles every other cross-module reference in this design: the
|
|
40
|
+
* bare `import '/* @bf-child:ChildName *\/'` statement's TEXT doesn't
|
|
41
|
+
* need to change, only what it resolves to. Once it resolves to
|
|
42
|
+
* `ChildName.tsx`, Rollup's OWN module graph puts an entry-to-entry
|
|
43
|
+
* static import in the output (the child is ALSO independently an entry
|
|
44
|
+
* point — every discovered `'use client'` file is), which is exactly
|
|
45
|
+
* what makes the browser fetch and execute the child's script as a side
|
|
46
|
+
* effect of loading the parent's — no registry timing dependent on
|
|
47
|
+
* anything server-side at all. A name that can't be resolved (unknown,
|
|
48
|
+
* or a multi-component-per-file export the simple name→file map below
|
|
49
|
+
* doesn't cover) falls back to the empty no-op module rather than
|
|
50
|
+
* failing the build outright — the SAME degraded-but-shippable behavior
|
|
51
|
+
* as before this fix for whatever slice of cases it doesn't cover,
|
|
52
|
+
* rather than a regression for OTHER apps that build cleanly today.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const BF_CHILD_MARKER_RE = /^\/\* @bf-child:(\w+) \*\/$/
|
|
56
|
+
|
|
57
|
+
/** The single virtual module id an UNRESOLVED `@bf-child:` marker falls
|
|
58
|
+
* back to — one shared id (not one per child name) because the module's
|
|
59
|
+
* content is always the same (empty) and Rollup dedupes same-id imports
|
|
60
|
+
* for free. */
|
|
61
|
+
export const BF_CHILD_NOOP_ID = '\0barefoot-bf-child-noop'
|
|
62
|
+
|
|
63
|
+
/** The child component name embedded in a `@bf-child:` marker, or `null`
|
|
64
|
+
* if `source` doesn't look like a compiler-emitted one. */
|
|
65
|
+
export function bfChildMarkerName(source: string): string | null {
|
|
66
|
+
return source.match(BF_CHILD_MARKER_RE)?.[1] ?? null
|
|
67
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-hash keyed cache of full `CompileResult` objects.
|
|
3
|
+
*
|
|
4
|
+
* Both the graph pass (`transform`, driven by Rollup visiting `.tsx`
|
|
5
|
+
* modules) and the eager pass (`writeBundle`, driven by a directory walk
|
|
6
|
+
* that isn't gated on the module graph at all — see the design's rationale
|
|
7
|
+
* for why server-only components need it) call `compileFile()` for the
|
|
8
|
+
* same source files. Keying by content hash rather than just by file path
|
|
9
|
+
* means a file edited between the two passes (or across a `--watch`
|
|
10
|
+
* rebuild) always recompiles, while an unchanged file is compiled exactly
|
|
11
|
+
* once no matter which pass reaches it first.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import type { CompileResult } from '@barefootjs/jsx'
|
|
15
|
+
|
|
16
|
+
function hashContent(content: string): string {
|
|
17
|
+
return createHash('sha256').update(content).digest('hex')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CacheRow {
|
|
21
|
+
hash: string
|
|
22
|
+
result: CompileResult
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class CompileCache {
|
|
26
|
+
private rows = new Map<string, CacheRow>()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return the cached `CompileResult` for `absPath` if its content hash
|
|
30
|
+
* matches what's cached; otherwise call `compile()`, cache the result,
|
|
31
|
+
* and return it. `compile()` runs at most once per distinct
|
|
32
|
+
* `(absPath, content)` pair.
|
|
33
|
+
*/
|
|
34
|
+
getOrCompile(
|
|
35
|
+
absPath: string,
|
|
36
|
+
content: string,
|
|
37
|
+
compile: () => CompileResult,
|
|
38
|
+
): CompileResult {
|
|
39
|
+
const hash = hashContent(content)
|
|
40
|
+
const cached = this.rows.get(absPath)
|
|
41
|
+
if (cached && cached.hash === hash) return cached.result
|
|
42
|
+
|
|
43
|
+
const result = compile()
|
|
44
|
+
this.rows.set(absPath, { hash, result })
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Look up a previously cached result without recompiling. */
|
|
49
|
+
peek(absPath: string): CompileResult | undefined {
|
|
50
|
+
return this.rows.get(absPath)?.result
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Drop a single file's cached entry — used when a file is deleted
|
|
54
|
+
* (dev watcher `'unlink'`) so a later file recreated at the same path
|
|
55
|
+
* never reuses a stale result keyed only by path, not content. */
|
|
56
|
+
delete(absPath: string): void {
|
|
57
|
+
this.rows.delete(absPath)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
clear(): void {
|
|
61
|
+
this.rows.clear()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reassembles ONE source file's row for the combined `manifest.json` this
|
|
3
|
+
* plugin writes to `templatesDir` alongside the per-component
|
|
4
|
+
* `<Name>.ssr-defaults.json` files `emit.ts`'s `planEmits` already produces.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS (not just the per-component files): every PHP/Python/Ruby
|
|
7
|
+
* backend driving a `templatesPerComponent` adapter (Blade/Jinja2/ERB) reads
|
|
8
|
+
* `ssrDefaults` — an optional-prop-derived signal's SSR seed value — from
|
|
9
|
+
* disk at REQUEST time (there is no compile step to bake it into source the
|
|
10
|
+
* way Go's generated `NewXxxProps` constructor or Hono's self-contained
|
|
11
|
+
* `.tsx` file can). Combining every source file's row into one manifest
|
|
12
|
+
* means each backend reads a single file instead of glob-and-reassembling
|
|
13
|
+
* the identical `{ [component]: { ssrDefaults } }` shape itself — avoiding
|
|
14
|
+
* seven copies of the same reconstruction logic across three languages,
|
|
15
|
+
* each of which could drift or break independently. One place in core
|
|
16
|
+
* beats three `/vite` packages reimplementing it, and
|
|
17
|
+
* `@barefootjs/go-template/vite` / `@barefootjs/hono/vite` get the same
|
|
18
|
+
* manifest for free even though neither adapter's own backend currently
|
|
19
|
+
* reads it (Go bakes `ssrDefaults` into generated source; Hono's `.tsx`
|
|
20
|
+
* inlines them as JS defaults) — see this module's own callers for the
|
|
21
|
+
* confirmation that neither reads a manifest today.
|
|
22
|
+
*
|
|
23
|
+
* Implemented standalone rather than by importing from `@barefootjs/cli`,
|
|
24
|
+
* for the same reason as `paths.ts`'s header comment: that package's only
|
|
25
|
+
* published entry point is the full `bf` binary.
|
|
26
|
+
*
|
|
27
|
+
* Two fields are deliberately NOT part of `ManifestEntry`:
|
|
28
|
+
*
|
|
29
|
+
* - `stubDeps` is not emitted: Rollup's own module graph resolution
|
|
30
|
+
* handles dependency wiring, so there is no separate stub-dependency
|
|
31
|
+
* bookkeeping to produce, and no consumer reads such a field.
|
|
32
|
+
* - `clientJs` is not emitted: there is no single static path to put
|
|
33
|
+
* there. The real client JS URL is content-hashed and mode-dependent
|
|
34
|
+
* (dev-origin vs. build-manifest-resolved) — exactly what
|
|
35
|
+
* `scriptAssets` already resolves and bakes directly into the compiled
|
|
36
|
+
* template's own script-registration call. No adapter backend or native
|
|
37
|
+
* runtime reads `manifest[name].clientJs`; however, `@barefootjs/hono`'s
|
|
38
|
+
* `BfPreload` component can read `clientJs` from a caller-supplied
|
|
39
|
+
* manifest object (legacy site builds emit one). Plugin-manifest
|
|
40
|
+
* consumers wanting preloads use the `preloadAssets` path instead
|
|
41
|
+
* (`registerComponentPreloads` → `<link rel="modulepreload">`).
|
|
42
|
+
*/
|
|
43
|
+
import type { CompileResult, TemplateAdapter } from '@barefootjs/jsx'
|
|
44
|
+
import { perComponentRelPath, relativeUnderComponentDir, withExtension } from './paths.ts'
|
|
45
|
+
|
|
46
|
+
/** One exported component's row inside a multi-export source file's
|
|
47
|
+
* `components` map (`templatesPerComponent` adapters only). */
|
|
48
|
+
export interface ManifestComponentEntry {
|
|
49
|
+
markedTemplate: string
|
|
50
|
+
ssrDefaults?: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One SOURCE FILE's row in the combined manifest (see this module's
|
|
54
|
+
* header for the fields intentionally not included). */
|
|
55
|
+
export interface ManifestEntry {
|
|
56
|
+
markedTemplate: string
|
|
57
|
+
ssrDefaults?: Record<string, unknown>
|
|
58
|
+
/** Per-exported-component rows for `templatesPerComponent` adapters
|
|
59
|
+
* (piconic-ai/barefootjs#2132) — present even for a single-component
|
|
60
|
+
* file. */
|
|
61
|
+
components?: Record<string, ManifestComponentEntry>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Builds one source file's `{ manifestKey, entry }` pair from its
|
|
66
|
+
* already-compiled `CompileResult`. Returns `null` when the compile
|
|
67
|
+
* produced no `markedTemplate` at all (nothing to register).
|
|
68
|
+
*/
|
|
69
|
+
export function buildManifestEntry(
|
|
70
|
+
result: CompileResult,
|
|
71
|
+
absPath: string,
|
|
72
|
+
componentDirs: readonly string[],
|
|
73
|
+
adapter: TemplateAdapter,
|
|
74
|
+
): { manifestKey: string; entry: ManifestEntry } | null {
|
|
75
|
+
const markedTemplates = result.files.filter(f => f.type === 'markedTemplate')
|
|
76
|
+
if (markedTemplates.length === 0) return null
|
|
77
|
+
|
|
78
|
+
const relUnderComponentDir = relativeUnderComponentDir(absPath, componentDirs)
|
|
79
|
+
// Source file's path relative to its `components` dir, extension
|
|
80
|
+
// stripped (e.g. `Counter`, or `ui/toast/index` for a multi-export
|
|
81
|
+
// file). `relativeUnderComponentDir` gives the position under whichever
|
|
82
|
+
// configured `componentDirs` entry contains it, still WITH the
|
|
83
|
+
// extension; `withExtension(..., '')` strips it.
|
|
84
|
+
const manifestKey = withExtension(relUnderComponentDir, '')
|
|
85
|
+
|
|
86
|
+
const ssrDefaultsByComponent = new Map<string, Record<string, unknown>>()
|
|
87
|
+
for (const f of result.files) {
|
|
88
|
+
if (f.type !== 'ssrDefaults' || !f.componentName) continue
|
|
89
|
+
try {
|
|
90
|
+
ssrDefaultsByComponent.set(f.componentName, JSON.parse(f.content) as Record<string, unknown>)
|
|
91
|
+
} catch {
|
|
92
|
+
// Malformed ssrDefaults content is dropped silently rather than
|
|
93
|
+
// failing the build.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Every `markedTemplate`/`ssrDefaults` FileOutput is unconditionally
|
|
98
|
+
// stamped with `componentName` (see `packages/jsx/src/compiler.ts`,
|
|
99
|
+
// both the single- and multi-component-per-file code paths) — pairing on
|
|
100
|
+
// it directly is exact and adapter-agnostic, with no filename-based
|
|
101
|
+
// heuristic needed.
|
|
102
|
+
const relPathFor = (componentName: string | undefined): string =>
|
|
103
|
+
adapter.templatesPerComponent && componentName
|
|
104
|
+
? perComponentRelPath(relUnderComponentDir, componentName, adapter.extension)
|
|
105
|
+
: withExtension(relUnderComponentDir, adapter.extension)
|
|
106
|
+
|
|
107
|
+
let componentsMap: Record<string, ManifestComponentEntry> | undefined
|
|
108
|
+
if (adapter.templatesPerComponent) {
|
|
109
|
+
componentsMap = {}
|
|
110
|
+
for (const tpl of markedTemplates) {
|
|
111
|
+
if (!tpl.componentName) continue
|
|
112
|
+
const ssrDefaults = ssrDefaultsByComponent.get(tpl.componentName)
|
|
113
|
+
componentsMap[tpl.componentName] = {
|
|
114
|
+
markedTemplate: relPathFor(tpl.componentName),
|
|
115
|
+
...(ssrDefaults ? { ssrDefaults } : {}),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (Object.keys(componentsMap).length === 0) componentsMap = undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Primary (top-level) template is `markedTemplates[0]`. For a
|
|
122
|
+
// `templatesPerComponent` adapter this is the sole/first component —
|
|
123
|
+
// the source file's own basename in every real component in this repo
|
|
124
|
+
// (`Counter.tsx` exports `Counter`), or, for a multi-export file where
|
|
125
|
+
// no per-component name equals the file's own basename (e.g.
|
|
126
|
+
// `ui/toast/index.tsx`), simply the first exported component. For a
|
|
127
|
+
// combined-file adapter there is only ever one entry, so the same
|
|
128
|
+
// index works unconditionally.
|
|
129
|
+
const primary = markedTemplates[0]!
|
|
130
|
+
const primarySsrDefaults = primary.componentName ? ssrDefaultsByComponent.get(primary.componentName) : undefined
|
|
131
|
+
|
|
132
|
+
const entry: ManifestEntry = {
|
|
133
|
+
markedTemplate: relPathFor(primary.componentName),
|
|
134
|
+
...(primarySsrDefaults ? { ssrDefaults: primarySsrDefaults } : {}),
|
|
135
|
+
...(componentsMap ? { components: componentsMap } : {}),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { manifestKey, entry }
|
|
139
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `ts.Program` management for the plugin's compile passes.
|
|
3
|
+
*
|
|
4
|
+
* Type-based reactivity detection (Reactive<T> brand classification, the
|
|
5
|
+
* BF023/BF024 nullable-loop-key check) needs a `ts.TypeChecker`. Without a
|
|
6
|
+
* caller-supplied Program, `compileJSX` falls back to building one Program
|
|
7
|
+
* PER FILE — and `ts.createProgram`'s dominant cost is constructing the
|
|
8
|
+
* lib.d.ts/node_modules type graph, not parsing the one source file
|
|
9
|
+
* (~500-600 ms per call regardless of file size; ~44-109 s measured across
|
|
10
|
+
* site/ui's 71 type-needing files). Worse than slow: for a file importing
|
|
11
|
+
* a Reactive<T>-branded package (`@barefootjs/form`) the analyzer emits
|
|
12
|
+
* BF050 at severity `error` when no shared Program was supplied, and the
|
|
13
|
+
* plugin throws on error diagnostics — so without this manager such a file
|
|
14
|
+
* cannot build through the plugin at all. See #2537.
|
|
15
|
+
*
|
|
16
|
+
* This manager keeps ONE Program whose roots are every discovered file
|
|
17
|
+
* that `needsTypeBasedDetection` says needs a checker (a cheap content
|
|
18
|
+
* test — the majority of components don't and never pay anything). The
|
|
19
|
+
* type graph is built once per build (~hundreds of ms, amortized), and
|
|
20
|
+
* watch-mode rebuilds go through `ts.createProgram`'s `oldProgram`
|
|
21
|
+
* incremental path: unchanged files reuse their parsed SourceFiles, so a
|
|
22
|
+
* single-file edit re-parses only that file (tens of ms).
|
|
23
|
+
*
|
|
24
|
+
* Contract with the analyzer: `compileJSX` only uses a supplied Program if
|
|
25
|
+
* `program.getSourceFile(filePath).text` EXACTLY matches the source being
|
|
26
|
+
* compiled (a mismatched Program is silently discarded, which would
|
|
27
|
+
* re-open the per-file fallback and, for brand importers, BF050). Both
|
|
28
|
+
* entry points verify that text match and rebuild — or fall back to a
|
|
29
|
+
* virtual single-file Program for in-memory content that diverges from
|
|
30
|
+
* disk — rather than ever handing back a Program the analyzer would
|
|
31
|
+
* reject.
|
|
32
|
+
*/
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
import type ts from 'typescript'
|
|
35
|
+
import {
|
|
36
|
+
createProgramForCorpus,
|
|
37
|
+
createProgramForFile,
|
|
38
|
+
needsTypeBasedDetection,
|
|
39
|
+
} from '@barefootjs/jsx'
|
|
40
|
+
|
|
41
|
+
export class CorpusProgramManager {
|
|
42
|
+
private program: ts.Program | undefined
|
|
43
|
+
private roots = new Set<string>()
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* (Re)build the corpus Program from a full discovery pass's snapshot.
|
|
47
|
+
* Filters `files` down to the ones needing type-based detection; no-ops
|
|
48
|
+
* entirely (keeping the existing Program) when the root set and every
|
|
49
|
+
* root's on-Program text are unchanged — the common case for the dev
|
|
50
|
+
* watcher's full re-runs, where `CompileCache` already makes unchanged
|
|
51
|
+
* files free and this keeps the Program free too.
|
|
52
|
+
*
|
|
53
|
+
* Callers pass the content DISCOVERY read, and `createProgramForCorpus`'s
|
|
54
|
+
* host re-reads from disk — the two can only diverge if the file changed
|
|
55
|
+
* in the microseconds between, and `programFor`'s per-file text check
|
|
56
|
+
* catches exactly that before any compile trusts the Program.
|
|
57
|
+
*/
|
|
58
|
+
seed(files: readonly { absPath: string; content: string }[]): void {
|
|
59
|
+
const needing = files
|
|
60
|
+
.filter(f => needsTypeBasedDetection(f.content))
|
|
61
|
+
.map(f => ({ abs: path.resolve(f.absPath), content: f.content }))
|
|
62
|
+
|
|
63
|
+
if (needing.length === 0) {
|
|
64
|
+
this.program = undefined
|
|
65
|
+
this.roots.clear()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sameRoots =
|
|
70
|
+
needing.length === this.roots.size && needing.every(f => this.roots.has(f.abs))
|
|
71
|
+
const sameText =
|
|
72
|
+
sameRoots &&
|
|
73
|
+
this.program !== undefined &&
|
|
74
|
+
needing.every(f => this.program!.getSourceFile(f.abs)?.text === f.content)
|
|
75
|
+
if (sameText) return
|
|
76
|
+
|
|
77
|
+
this.roots = new Set(needing.map(f => f.abs))
|
|
78
|
+
this.rebuild()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The Program to pass as `CompileOptions.program` when compiling
|
|
83
|
+
* `absPath` with `content` — or `undefined` when the file doesn't need
|
|
84
|
+
* type-based detection at all (the analyzer then never builds a checker,
|
|
85
|
+
* and passing nothing costs nothing).
|
|
86
|
+
*
|
|
87
|
+
* Handles the two ways the seeded Program can be behind reality:
|
|
88
|
+
* - `absPath` isn't a root yet (a needing file created after the last
|
|
89
|
+
* seed, reached by the graph pass before the next eager pass) or its
|
|
90
|
+
* disk content changed: add the root and rebuild incrementally.
|
|
91
|
+
* - `content` diverges from what's on disk even after a rebuild (an
|
|
92
|
+
* in-flight edit): fall back to a virtual single-file Program built
|
|
93
|
+
* from `content` itself — the pre-manager per-file behavior, correct
|
|
94
|
+
* and BF050-suppressing, just slow, and only for that one file until
|
|
95
|
+
* disk catches up.
|
|
96
|
+
*/
|
|
97
|
+
programFor(absPath: string, content: string): ts.Program | undefined {
|
|
98
|
+
if (!needsTypeBasedDetection(content)) return undefined
|
|
99
|
+
const abs = path.resolve(absPath)
|
|
100
|
+
|
|
101
|
+
const cached = this.program?.getSourceFile(abs)
|
|
102
|
+
if (cached && cached.text === content) return this.program
|
|
103
|
+
|
|
104
|
+
this.roots.add(abs)
|
|
105
|
+
this.rebuild()
|
|
106
|
+
|
|
107
|
+
const rebuilt = this.program?.getSourceFile(abs)
|
|
108
|
+
if (rebuilt && rebuilt.text === content) return this.program
|
|
109
|
+
|
|
110
|
+
return createProgramForFile(content, abs)?.program
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private rebuild(): void {
|
|
114
|
+
try {
|
|
115
|
+
this.program = createProgramForCorpus([...this.roots], {
|
|
116
|
+
oldProgram: this.program,
|
|
117
|
+
})
|
|
118
|
+
} catch {
|
|
119
|
+
// A failed corpus build degrades to `programFor`'s virtual
|
|
120
|
+
// single-file fallback per needing file — slow but correct, and the
|
|
121
|
+
// same failure would almost certainly hit the per-file path too.
|
|
122
|
+
this.program = undefined
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|