@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,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
|
+
}
|
package/src/discover.ts
ADDED
|
@@ -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'
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|