@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,67 @@
1
+ /**
2
+ * Debounce + serialize an async task behind a single `trigger()` entry
3
+ * point. Built for `configureServer`'s watcher handlers: a burst of
4
+ * `'change'`/`'add'`/`'unlink'` events (save-twice-quickly, a multi-file
5
+ * save, a `git checkout` touching many files) must not start several
6
+ * overlapping eager passes writing the same template files.
7
+ *
8
+ * Two, deliberately separate, guarantees:
9
+ * - **debounce**: `trigger()` calls within `debounceMs` of each other
10
+ * collapse into a single scheduled run.
11
+ * - **serialize + coalesce**: if `task()` is still running when the
12
+ * debounce timer fires, this does NOT start a second, overlapping
13
+ * call — it marks exactly one follow-up run, which starts the instant
14
+ * the in-flight one finishes. A change arriving mid-pass is delayed,
15
+ * never dropped, and at most one run is ever in flight.
16
+ *
17
+ * Deliberately minimal: no queue of distinct payloads, no rehashing —
18
+ * `task()` itself (the caller's full eager pass) is the unit of work, and
19
+ * it re-discovers everything from disk on every call, so "run it again"
20
+ * is always correct regardless of how many trigger()s piled up.
21
+ */
22
+ export interface DebouncedSerialRunner {
23
+ /** Schedule a run, debounced. Safe to call from multiple event sources. */
24
+ trigger(): void
25
+ }
26
+
27
+ export function createDebouncedSerialRunner(
28
+ task: () => Promise<void>,
29
+ debounceMs: number,
30
+ onError: (err: unknown) => void,
31
+ ): DebouncedSerialRunner {
32
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
33
+ let running: Promise<void> | null = null
34
+ let rerunQueued = false
35
+
36
+ function runOnce(): void {
37
+ if (running) {
38
+ // A task is already in flight — don't start a second one racing it
39
+ // to write the same files. Queue exactly one follow-up instead;
40
+ // repeated triggers while running collapse into that same single
41
+ // follow-up (the `do`/`while` below re-checks the flag, not a count).
42
+ rerunQueued = true
43
+ return
44
+ }
45
+
46
+ running = (async () => {
47
+ do {
48
+ rerunQueued = false
49
+ await task()
50
+ } while (rerunQueued)
51
+ })()
52
+ .catch(onError)
53
+ .finally(() => {
54
+ running = null
55
+ })
56
+ }
57
+
58
+ return {
59
+ trigger() {
60
+ if (debounceTimer) clearTimeout(debounceTimer)
61
+ debounceTimer = setTimeout(() => {
62
+ debounceTimer = null
63
+ runOnce()
64
+ }, debounceMs)
65
+ },
66
+ }
67
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Dev-server-only helpers for the `configureServer` hook: computing the
3
+ * dev origin, building the two-URL `scriptAssets` list a `'use client'`
4
+ * component needs in dev (the `@vite/client` HMR/full-reload socket plus
5
+ * the component's own `.tsx` module), the localhost-only CORS default,
6
+ * the on-disk marker that flags a `templates` directory as holding dev
7
+ * artifacts (localhost URLs baked in) rather than production output, and
8
+ * the cross-language dev-reload sentinel path (see `devSentinelPath`'s
9
+ * docstring).
10
+ *
11
+ * The pure, easily-unit-tested pieces live here; the orchestration
12
+ * (compiling, writing files, wiring the watcher) stays in `plugin.ts`
13
+ * where the shared `CompileCache` / `componentDirs` / `templatesDir`
14
+ * closures already live.
15
+ */
16
+ import { resolve, sep } from 'node:path'
17
+ import type { ResolvedConfig, ViteDevServer } from 'vite'
18
+ import { joinBaseAndFile } from './manifest.ts'
19
+
20
+ /**
21
+ * Vite's absolute-path passthrough prefix (`/@fs/…`, `FS_PREFIX` in Vite's
22
+ * own source) for serving files outside the project root. Needed because
23
+ * `components` dirs are commonly siblings of the Vite project root in this
24
+ * monorepo's real layouts — an app's `vite.config.ts` root is the backend
25
+ * app directory, while shared components live in a sibling `ui/`-style
26
+ * directory Vite's default dev serving otherwise refuses (only `root` and
27
+ * its subdirectories are served as plain paths).
28
+ */
29
+ const FS_SERVE_PREFIX = '@fs'
30
+
31
+ /**
32
+ * Localhost-only CORS default this plugin fills in — ONLY when the user
33
+ * hasn't configured `server.cors` themselves (see the `config` hook in
34
+ * `plugin.ts`). Vite 6+ defaults `cors` to same-origin-only, which breaks
35
+ * the cross-origin split this plugin sets up (the page is rendered by the
36
+ * backend on its own origin; modules are served by Vite on another)
37
+ * unless something opts localhost origins in. Deliberately not a wildcard
38
+ * `true` — see the PR brief for the reasoning.
39
+ */
40
+ export const DEFAULT_DEV_CORS_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/
41
+
42
+ /**
43
+ * Debounce window (ms) for the dev watcher's `'change'` / `'add'` /
44
+ * `'unlink'` handlers — long enough to coalesce a save-twice-quickly or a
45
+ * multi-file save/`git checkout` into a single eager pass, short enough
46
+ * that a reload still feels instant.
47
+ */
48
+ export const DEV_WATCH_DEBOUNCE_MS = 100
49
+
50
+ /** Posix-normalize an absolute filesystem path (Windows uses `sep`; POSIX
51
+ * paths already use `/`). */
52
+ function toPosixAbsolute(absPath: string): string {
53
+ return absPath.split(sep).join('/')
54
+ }
55
+
56
+ /**
57
+ * The request path (relative to `config.base`, no leading slash) a browser
58
+ * must use to reach `absPath` through Vite's dev server: a root-relative
59
+ * path when `absPath` is under `config.root`, or Vite's `/@fs/`
60
+ * absolute-path passthrough when it isn't.
61
+ */
62
+ export function devRequestPath(config: Pick<ResolvedConfig, 'root'>, absPath: string): string {
63
+ const posixAbs = toPosixAbsolute(absPath)
64
+ const posixRoot = toPosixAbsolute(config.root)
65
+ if (posixAbs === posixRoot) return ''
66
+ if (posixAbs.startsWith(`${posixRoot}/`)) return posixAbs.slice(posixRoot.length + 1)
67
+ return `${FS_SERVE_PREFIX}${posixAbs}`
68
+ }
69
+
70
+ /** Full absolute URL (origin + `base` + request path) for `absPath` under
71
+ * the dev server. */
72
+ export function devModuleUrl(
73
+ config: Pick<ResolvedConfig, 'root' | 'base'>,
74
+ origin: string,
75
+ absPath: string,
76
+ ): string {
77
+ return `${origin}${joinBaseAndFile(config.base, devRequestPath(config, absPath))}`
78
+ }
79
+
80
+ /**
81
+ * The ordered `scriptAssets` a `'use client'` component needs in dev: the
82
+ * `@vite/client` HMR/full-reload socket first (so the page always gets a
83
+ * live-reload connection), then the component's own module — Vite serves
84
+ * it plain-JS via this plugin's `transform` hook exactly like it would any
85
+ * other dev module, no different from a production entry. Per the design,
86
+ * server-only components (no `'use client'`) get `[]` — computed by the
87
+ * caller without consulting this function at all, see `plugin.ts`.
88
+ */
89
+ export function devScriptAssets(
90
+ config: Pick<ResolvedConfig, 'root' | 'base'>,
91
+ origin: string,
92
+ absPath: string,
93
+ ): string[] {
94
+ return [
95
+ `${origin}${joinBaseAndFile(config.base, '@vite/client')}`,
96
+ devModuleUrl(config, origin, absPath),
97
+ ]
98
+ }
99
+
100
+ /**
101
+ * The dev origin to bake into `scriptAssets`: the user's own
102
+ * `server.origin` if they set one, otherwise `http://localhost:<port>`
103
+ * using the port Vite actually bound — NOT the configured port, which can
104
+ * be wrong (Vite auto-increments past an in-use port unless `strictPort`
105
+ * is set). Also writes the computed default back onto
106
+ * `server.config.server.origin` so Vite's OWN asset-URL rewriting
107
+ * (`import.meta.url`, CSS `url()`, etc.) agrees with the URLs this plugin
108
+ * bakes into templates — both need to match for the cross-origin split
109
+ * (page from the backend, assets from Vite) to work end to end.
110
+ *
111
+ * Call only after the server is actually listening (`httpServer`'s
112
+ * `'listening'` event) — the resolved port isn't known before then.
113
+ */
114
+ export function resolveDevOrigin(server: ViteDevServer): string {
115
+ const configured = server.config.server.origin
116
+ if (configured) return configured
117
+
118
+ const address = server.httpServer?.address()
119
+ const port = address && typeof address === 'object' ? address.port : (server.config.server.port ?? 5173)
120
+ const origin = `http://localhost:${port}`
121
+ server.config.server.origin = origin
122
+ return origin
123
+ }
124
+
125
+ /**
126
+ * Filename of the marker BarefootJS writes at the root of `templates`
127
+ * while the dev server is running, so a stray `git add` or a production
128
+ * deploy of dev-only output (localhost URLs baked into every template) is
129
+ * obvious before it ships. `writeBundle` (the `vite build` path) removes
130
+ * it — see `plugin.ts`.
131
+ *
132
+ * A per-template, per-adapter comment (Go `{{/* … *\/}}`, ERB `<%# … %>`,
133
+ * etc.) would pinpoint the problem more precisely, but needs new surface
134
+ * on every `TemplateAdapter` implementation across 9+ adapter packages —
135
+ * out of scope for a dev-server PR that touches none of them. The brief
136
+ * explicitly allows this single-file fallback in that case.
137
+ */
138
+ export const DEV_ARTIFACT_MARKER_FILENAME = '.barefootjs-dev-build'
139
+
140
+ export const DEV_ARTIFACT_MARKER_CONTENT = `This directory currently holds DEV BUILD OUTPUT from @barefootjs/vite's
141
+ dev server, not a production build.
142
+
143
+ Every template in this directory has dev-only URLs baked into it
144
+ (http://localhost:<port>/...) pointing at the Vite dev server. They will
145
+ break if committed, deployed, or served without that dev server running.
146
+
147
+ Run \`vite build\` to regenerate this directory with real, hashed,
148
+ production asset URLs — the build overwrites every template here and
149
+ removes this file.
150
+ `
151
+
152
+ /**
153
+ * Cross-language dev-reload sentinel: `<outDir>/.dev/build-id`, ONE
154
+ * DIRECTORY ABOVE `templates`. The path is fixed, not derived from
155
+ * `templatesDir`, because several server runtimes below poll this exact
156
+ * location.
157
+ *
158
+ * Several adapter runtimes poll this exact path for a value change and
159
+ * push an SSE `event: reload` on it — a mechanism that does NOT require
160
+ * the polling process to restart, only the file's mtime/content to
161
+ * change: `bfdev.NewReloadHandler` (Go — echo/gin/chi/nethttp),
162
+ * `Mojolicious::Plugin::BarefootJS::DevReload` /
163
+ * `BarefootJS::DevReload` (Perl — mojolicious/xslate), and
164
+ * `barefoot_js/dev_reload.rb` (Ruby — sinatra/rails, ERB). `vite dev` is
165
+ * the only piece of the dev loop those adapters' apps run alongside — if
166
+ * this plugin didn't write the sentinel, nothing would, and their reload
167
+ * handlers would never fire.
168
+ *
169
+ * Hono's dev-reload story does not consume this at all: both its
170
+ * Cloudflare Workers target (`dev-worker.ts`'s boot id) and its Node
171
+ * target (`barefootDevReload`'s SSE endpoint, wired up in the scaffold's
172
+ * `factory.ts`) detect a restart directly over their own SSE connection,
173
+ * no file involved. Writing this sentinel unconditionally whenever
174
+ * `templates` is configured is harmless there — nothing reads it — which
175
+ * is what keeps this a zero-config, adapter-agnostic signal rather than a
176
+ * 4th plugin option naming which adapters want it.
177
+ */
178
+ export const DEV_SENTINEL_SUBDIR = '.dev'
179
+ export const DEV_SENTINEL_FILENAME = 'build-id'
180
+
181
+ /** Absolute path of the dev-reload sentinel for a given `templates` dir. */
182
+ export function devSentinelPath(templatesDir: string): string {
183
+ return resolve(templatesDir, '..', DEV_SENTINEL_SUBDIR, DEV_SENTINEL_FILENAME)
184
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Component discovery: which `.tsx` files live under the configured
3
+ * `components` dirs, and which of those carry a `'use client'` directive.
4
+ *
5
+ * `hasUseClientDirective` and `discoverComponentFiles` are implemented
6
+ * standalone rather than imported from `@barefootjs/cli` — that package's
7
+ * `exports` map only publishes the `bf` binary entry point, not an
8
+ * internal module path, and pulling in the CLI's whole dependency graph
9
+ * for two small pure functions would be the wrong shape for a Vite
10
+ * plugin. See CLAUDE.md: "reuse or port it, don't reinvent."
11
+ */
12
+ import { readdir } from 'node:fs/promises'
13
+ import { basename, resolve } from 'node:path'
14
+ import { listExportedComponents } from '@barefootjs/jsx'
15
+
16
+ /** Does `content` start with a `'use client'` / `"use client"` directive
17
+ * (after skipping leading block/line comments)? */
18
+ export function hasUseClientDirective(content: string): boolean {
19
+ let trimmed = content.trimStart()
20
+ // Skip block comments
21
+ while (trimmed.startsWith('/*')) {
22
+ const endIndex = trimmed.indexOf('*/')
23
+ if (endIndex === -1) break
24
+ trimmed = trimmed.slice(endIndex + 2).trimStart()
25
+ }
26
+ // Skip line comments
27
+ while (trimmed.startsWith('//')) {
28
+ const endIndex = trimmed.indexOf('\n')
29
+ if (endIndex === -1) break
30
+ trimmed = trimmed.slice(endIndex + 1).trimStart()
31
+ }
32
+ return trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'")
33
+ }
34
+
35
+ /**
36
+ * Is `name` (a bare filename, not a path) a component source file this
37
+ * plugin should discover/compile? `.tsx`, excluding `.test.tsx`,
38
+ * `.spec.tsx`, and `.preview.tsx`. Exported separately from
39
+ * `discoverComponentFiles` so the dev-server file watcher (`plugin.ts`'s
40
+ * `configureServer`) can apply the exact same filter to a single changed
41
+ * path without re-walking a directory.
42
+ */
43
+ export function isComponentSourceFile(name: string): boolean {
44
+ return (
45
+ name.endsWith('.tsx') &&
46
+ !name.endsWith('.test.tsx') &&
47
+ !name.endsWith('.spec.tsx') &&
48
+ !name.endsWith('.preview.tsx')
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Recursively discover `.tsx` component files in a directory.
54
+ * Skips `.test.tsx`, `.spec.tsx`, and `.preview.tsx` files.
55
+ */
56
+ export async function discoverComponentFiles(
57
+ dir: string,
58
+ options?: { skipDirs?: string[] }
59
+ ): Promise<string[]> {
60
+ const results: string[] = []
61
+ const skipDirs = options?.skipDirs ? new Set(options.skipDirs) : null
62
+
63
+ let entries: { name: string; isDirectory(): boolean }[]
64
+ try {
65
+ entries = (await readdir(dir, { withFileTypes: true })).sort((a, b) =>
66
+ String(a.name).localeCompare(String(b.name))
67
+ )
68
+ } catch {
69
+ return results
70
+ }
71
+
72
+ for (const entry of entries) {
73
+ const fullPath = resolve(dir, String(entry.name))
74
+ if (entry.isDirectory()) {
75
+ if (skipDirs?.has(String(entry.name))) continue
76
+ results.push(...await discoverComponentFiles(fullPath, options))
77
+ } else if (isComponentSourceFile(String(entry.name))) {
78
+ results.push(fullPath)
79
+ }
80
+ }
81
+
82
+ return results
83
+ }
84
+
85
+ export interface DiscoveredComponent {
86
+ /** Absolute path to the `.tsx` source file. */
87
+ absPath: string
88
+ /**
89
+ * The file's full source text, as read by the discovery pass. Retained
90
+ * (discovery had to read it anyway for the directive/exports checks) so
91
+ * downstream consumers — the corpus-program seeding and the eager pass's
92
+ * compile loop — work from the SAME snapshot discovery classified,
93
+ * instead of re-reading and racing an edit that landed in between.
94
+ */
95
+ content: string
96
+ /** Whether the file's content starts with a `'use client'` directive. */
97
+ isClient: boolean
98
+ /**
99
+ * Every component this file exports, from `@barefootjs/jsx`'s TS-AST
100
+ * walk (`listExportedComponents`) — never a regex, and never the
101
+ * basename standing in for the name. A file exporting more than one
102
+ * component (`icon/index.tsx` → `CopyIcon` + `CheckIcon`) is why this
103
+ * exists; see `buildChildNameIndex`.
104
+ */
105
+ exportedComponents: string[]
106
+ /**
107
+ * `CompileOptions.cssLayerPrefix` this file should compile with, carried
108
+ * over unchanged from whichever `components` entry's `dir` this file was
109
+ * discovered under (see `ResolvedComponentDirEntry.cssLayerPrefix`).
110
+ * `undefined` when that entry set none (or was a plain string entry).
111
+ */
112
+ cssLayerPrefix?: string
113
+ }
114
+
115
+ /**
116
+ * A `components` directory to scan, already resolved to an absolute `dir`,
117
+ * plus the per-directory compile behavior `barefoot()`'s `ComponentDirEntry`
118
+ * (`types.ts`) carries. `discoverComponents` also accepts a plain absolute
119
+ * path string as shorthand for `{ dir: string }` — the same "string is
120
+ * exactly equivalent to `{ dir }`" equivalence `ComponentDirEntry` itself
121
+ * documents — so existing callers that only ever had bare directories
122
+ * (`integrations/h3`/`elysia`'s `vite.config.ts`, reusing this exported
123
+ * function to resolve every discovered client component's URL) keep
124
+ * compiling and behaving unchanged.
125
+ */
126
+ export interface ResolvedComponentDirEntry {
127
+ /** Absolute path to the source directory to scan. */
128
+ dir: string
129
+ /** Stamped onto every `DiscoveredComponent` found under `dir` — see
130
+ * `DiscoveredComponent.cssLayerPrefix`. */
131
+ cssLayerPrefix?: string
132
+ /** Directory NAMES to skip anywhere under `dir` — passed straight
133
+ * through to `discoverComponentFiles`. */
134
+ skipDirs?: string[]
135
+ }
136
+
137
+ /**
138
+ * Scan every configured `components` directory for `.tsx` files and
139
+ * classify each as client (`'use client'`) or server-only. Each entry may
140
+ * be a plain absolute path (shorthand for `{ dir }`, no `cssLayerPrefix`/
141
+ * `skipDirs`) or a `ResolvedComponentDirEntry`.
142
+ *
143
+ * A file reachable under more than one entry is discovered once, stamped
144
+ * with the FIRST matching entry's `cssLayerPrefix` — entries are walked in
145
+ * array order and `seen` short-circuits every later match, the same
146
+ * first-writer-wins precedence `buildChildNameIndex` already documents for
147
+ * `@bf-child:` name collisions.
148
+ */
149
+ export async function discoverComponents(
150
+ entries: readonly (string | ResolvedComponentDirEntry)[],
151
+ readFile: (absPath: string) => Promise<string>,
152
+ ): Promise<DiscoveredComponent[]> {
153
+ const seen = new Set<string>()
154
+ const out: DiscoveredComponent[] = []
155
+ for (const raw of entries) {
156
+ const entry: ResolvedComponentDirEntry = typeof raw === 'string' ? { dir: raw } : raw
157
+ for (const absPath of await discoverComponentFiles(entry.dir, { skipDirs: entry.skipDirs })) {
158
+ if (seen.has(absPath)) continue
159
+ seen.add(absPath)
160
+ const content = await readFile(absPath)
161
+ const isClient = hasUseClientDirective(content)
162
+ // Only client files can be `@bf-child:` targets, so only they need
163
+ // their export list parsed — this is a `ts.createSourceFile` per
164
+ // file and server-only components are the majority in most trees.
165
+ out.push({
166
+ absPath,
167
+ content,
168
+ isClient,
169
+ exportedComponents: isClient ? listExportedComponents(content, absPath) : [],
170
+ cssLayerPrefix: entry.cssLayerPrefix,
171
+ })
172
+ }
173
+ }
174
+ return out
175
+ }
176
+
177
+ /**
178
+ * Component-name → absolute-path index used to resolve `@bf-child:<Name>`
179
+ * markers (see `child-marker.ts`) to a real file: a bare-marker child
180
+ * reference embeds only the referenced component's NAME (the compiler has
181
+ * no filesystem access at that phase — see `child-components.ts`), so
182
+ * `resolveId` needs a name→file lookup built from a full discovery pass.
183
+ *
184
+ * Keyed by each exported component NAME, which is what the marker
185
+ * carries. Server-only files are excluded: a `@bf-child:` marker only
186
+ * ever names another component this one instantiates at runtime
187
+ * (`initChild`/`createComponent`), which requires an `init` function only
188
+ * a `'use client'` file has.
189
+ *
190
+ * This used to key on the file's basename, which worked only because the
191
+ * one-component-per-file convention makes the two coincide
192
+ * (`TodoItem.tsx` exports `TodoItem`). A file exporting several
193
+ * components broke it silently: `icon/index.tsx` was keyed `index`, so
194
+ * `@bf-child:CopyIcon` found nothing and fell through to the no-op module
195
+ * (`plugin.ts`'s `resolveId`) — a child that never hydrates, with no
196
+ * diagnostic.
197
+ *
198
+ * The blast radius was wider than multi-export files. Because the key was
199
+ * the bare basename, EVERY colocated `index.tsx` collided on the single
200
+ * key `"index"` — including single-export ones like `ui/button/index.tsx`
201
+ * exporting `Button`. No colocated component was reachable as a
202
+ * `@bf-child:` target at all, whatever its export count. Measured with
203
+ * `listExportedComponents` over `ui/components` + `site/ui/components`:
204
+ * 112 files export more than one component, 105 of them `'use client'`.
205
+ *
206
+ * First writer wins on a duplicate name, and discovery order is the
207
+ * `components` option's order — so an earlier directory shadows a later
208
+ * one, the same precedence the option list already implies.
209
+ */
210
+ export function buildChildNameIndex(
211
+ // Only the fields the index actually reads — callers with a full
212
+ // `DiscoveredComponent[]` pass it as-is, and tests can construct rows
213
+ // without dragging in `content`.
214
+ discovered: readonly Pick<DiscoveredComponent, 'absPath' | 'isClient' | 'exportedComponents'>[],
215
+ ): Map<string, string> {
216
+ const index = new Map<string, string>()
217
+ for (const c of discovered) {
218
+ if (!c.isClient) continue
219
+ // Fall back to the basename when the AST walk found no exports: a
220
+ // file can still be a marker target through the old convention, and
221
+ // losing that would be a regression rather than a fix.
222
+ const names = c.exportedComponents.length > 0
223
+ ? c.exportedComponents
224
+ : [basename(c.absPath).replace(/\.tsx?$/, '')]
225
+ for (const name of names) {
226
+ if (!index.has(name)) index.set(name, c.absPath)
227
+ }
228
+ }
229
+ return index
230
+ }
package/src/emit.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Turn a `CompileResult` into on-disk files under the configured
3
+ * `templates` dir, mirroring the component's position under its
4
+ * `components` source dir.
5
+ *
6
+ * Adapter-generated `types` output (e.g. Go's per-component Props struct +
7
+ * `NewXxxProps` constructor) is written RAW, one file per source, rather
8
+ * than combined into a single backend-native file (Go's `components.go`).
9
+ * Combining is a real per-language operation — for Go specifically it
10
+ * means stripping each fragment's `package`/import header and injecting a
11
+ * single shared `randomID` helper the individual fragments assume exists
12
+ * (see `@barefootjs/go-template/go-types.ts`'s `combineGoTypes`) — and it
13
+ * lives entirely OUTSIDE this core plugin, in each adapter's own `/vite`
14
+ * composition wrapper, driven by core's `afterEmit` escape hatch (see
15
+ * `AfterEmitContext`). Each `.types` fragment is written next to its
16
+ * template so it's visible on disk, but treat it as source material, not a
17
+ * ready-to-compile file.
18
+ */
19
+ import { mkdir, writeFile } from 'node:fs/promises'
20
+ import { dirname, resolve } from 'node:path'
21
+ import type { CompileResult, TemplateAdapter } from '@barefootjs/jsx'
22
+ import { perComponentRelPath, relativeUnderComponentDir, withExtension } from './paths.ts'
23
+
24
+ export interface EmitTarget {
25
+ /** POSIX path, relative to the `templates` dir. */
26
+ relPath: string
27
+ content: string
28
+ }
29
+
30
+ export function planEmits(
31
+ result: CompileResult,
32
+ absPath: string,
33
+ componentDirs: readonly string[],
34
+ adapter: TemplateAdapter,
35
+ ): EmitTarget[] {
36
+ const relUnderComponentDir = relativeUnderComponentDir(absPath, componentDirs)
37
+ const targets: EmitTarget[] = []
38
+
39
+ for (const tpl of result.files.filter(f => f.type === 'markedTemplate')) {
40
+ const relPath = adapter.templatesPerComponent && tpl.componentName
41
+ ? perComponentRelPath(relUnderComponentDir, tpl.componentName, adapter.extension)
42
+ : withExtension(relUnderComponentDir, adapter.extension)
43
+ targets.push({ relPath, content: tpl.content })
44
+ }
45
+
46
+ for (const ssr of result.files.filter(f => f.type === 'ssrDefaults')) {
47
+ const relPath = adapter.templatesPerComponent && ssr.componentName
48
+ ? perComponentRelPath(relUnderComponentDir, ssr.componentName, '.ssr-defaults.json')
49
+ : withExtension(relUnderComponentDir, '.ssr-defaults.json')
50
+ targets.push({ relPath, content: ssr.content })
51
+ }
52
+
53
+ for (const types of result.files.filter(f => f.type === 'types')) {
54
+ targets.push({ relPath: withExtension(relUnderComponentDir, '.types'), content: types.content })
55
+ }
56
+
57
+ return targets
58
+ }
59
+
60
+ export async function writeEmits(templatesDir: string, targets: EmitTarget[]): Promise<void> {
61
+ for (const target of targets) {
62
+ const outPath = resolve(templatesDir, target.relPath)
63
+ await mkdir(dirname(outPath), { recursive: true })
64
+ await writeFile(outPath, target.content)
65
+ }
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { barefoot, PLUGIN_NAME } from './plugin.ts'
2
+ export { barefoot as default } from './plugin.ts'
3
+ export type { AfterEmitContext, BarefootPluginApi, BarefootViteOptions, ComponentDirEntry } from './types.ts'
4
+
5
+ // Re-exported so an adapter's own `/vite` subpath (e.g.
6
+ // `@barefootjs/go-template/vite`) can resolve script/asset URLs the SAME
7
+ // way this plugin's own `writeBundle`/`configureServer` do, for entries
8
+ // this plugin's own component discovery never sees (e.g. a hand-written
9
+ // client bootstrap script) — reused, not re-derived, per CLAUDE.md.
10
+ export { loadManifest, resolveScriptAssets, joinBaseAndFile } from './manifest.ts'
11
+ export { devModuleUrl, devRequestPath, resolveDevOrigin } from './dev-server.ts'
12
+ export { toPosixRelative } from './paths.ts'
13
+
14
+ // Re-exported for the same reason: a host framework with no per-request
15
+ // script collector (h3, Elysia — see `@barefootjs/hono/vite`'s docstring)
16
+ // has to build its OWN full component-name -> URL map (there is no
17
+ // per-request SSR collector to derive it from at request time), via the
18
+ // SAME `assets` mechanism `@barefootjs/go-template/vite`/`@barefootjs/hono/
19
+ // vite` already expose for a single hand-written entry — just populated
20
+ // from every discovered `'use client'` file instead of one path. Reusing
21
+ // this plugin's own discovery (rather than re-walking `components` dirs
22
+ // with ad hoc, possibly-diverging logic) is exactly the CLAUDE.md
23
+ // "reuse or port it, don't reinvent" rule this module's own docstring
24
+ // already invokes.
25
+ export { discoverComponents, type DiscoveredComponent, type ResolvedComponentDirEntry } from './discover.ts'
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `scriptAssets` resolution from Vite's build manifest (`build.manifest =
3
+ * true`, forced on by this plugin's `config` hook). By `writeBundle` time
4
+ * the manifest is final — every entry's hashed output filename is known —
5
+ * which is exactly why template emission happens there and not in
6
+ * `transform` (see the design's "script URL late-binding" section).
7
+ */
8
+ import { readFile } from 'node:fs/promises'
9
+ import { resolve } from 'node:path'
10
+ import type { Manifest } from 'vite'
11
+
12
+ /** Read and parse the manifest Vite just wrote to `outDir`. `manifestOption`
13
+ * mirrors `build.manifest`: `true` → the default `.vite/manifest.json`
14
+ * path; a string → that custom path, relative to `outDir`. */
15
+ export async function loadManifest(outDir: string, manifestOption: boolean | string): Promise<Manifest> {
16
+ const relPath = typeof manifestOption === 'string' ? manifestOption : '.vite/manifest.json'
17
+ const content = await readFile(resolve(outDir, relPath), 'utf8')
18
+ return JSON.parse(content) as Manifest
19
+ }
20
+
21
+ /** Join a Vite `base` (may or may not have a trailing slash; may be a full
22
+ * URL, an absolute path, or `'./'`) with a manifest-relative file path
23
+ * (never starts with `/`) into the URL an adapter should register. */
24
+ export function joinBaseAndFile(base: string, file: string): string {
25
+ if (base === '' || base === './') return file
26
+ return base.endsWith('/') ? `${base}${file}` : `${base}/${file}`
27
+ }
28
+
29
+ /**
30
+ * The ordered `scriptAssets` list for one component's entry, per the
31
+ * design: just the entry's own hashed file — shared chunks (including the
32
+ * `@barefootjs/client` runtime) arrive as ESM imports the browser follows
33
+ * on its own, so they need no separate registration. `[]` when the entry
34
+ * isn't in the manifest (e.g. a `'use client'` file whose compile produced
35
+ * no client JS at all, or a stale discovery/build mismatch).
36
+ */
37
+ export function resolveScriptAssets(
38
+ manifest: Manifest,
39
+ manifestKey: string,
40
+ base: string,
41
+ ): string[] {
42
+ const entry = manifest[manifestKey]
43
+ if (!entry) return []
44
+ return [joinBaseAndFile(base, entry.file)]
45
+ }
46
+
47
+ /**
48
+ * The ordered `preloadAssets` list for one component's entry: every chunk
49
+ * the entry pulls in **transitively** via static `imports`, excluding the
50
+ * entry's own file (that one is already covered by `resolveScriptAssets`).
51
+ *
52
+ * Walked **breadth-first** from the entry so the chunks most likely to be
53
+ * shared across components (the runtime chunk, common child islands) sort
54
+ * first, and deduped by manifest key — both needed to keep the returned
55
+ * order deterministic across builds; a rebuild that reshuffles this list
56
+ * for no reason would show up as a spurious template diff. A `seen` set
57
+ * keyed by manifest key also guards against import cycles.
58
+ *
59
+ * Deliberately does NOT follow `dynamicImports`: a dynamic import is by
60
+ * definition not needed for first paint — the app chose to defer it — and
61
+ * preloading it would pull that deferred work forward, defeating the
62
+ * point of having split it out.
63
+ *
64
+ * `[]` when the entry isn't in the manifest, same as `resolveScriptAssets`.
65
+ */
66
+ export function resolvePreloadAssets(
67
+ manifest: Manifest,
68
+ manifestKey: string,
69
+ base: string,
70
+ ): string[] {
71
+ const entry = manifest[manifestKey]
72
+ if (!entry) return []
73
+
74
+ const seen = new Set<string>([manifestKey])
75
+ const queue = [...(entry.imports ?? [])]
76
+ const result: string[] = []
77
+
78
+ while (queue.length > 0) {
79
+ const key = queue.shift() as string
80
+ if (seen.has(key)) continue
81
+ seen.add(key)
82
+ const row = manifest[key]
83
+ if (!row) continue
84
+ result.push(joinBaseAndFile(base, row.file))
85
+ queue.push(...(row.imports ?? []))
86
+ }
87
+
88
+ return result
89
+ }