@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,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
+ }