@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
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@barefootjs/vite` — the Vite plugin that turns JSX components into
|
|
3
|
+
* templates and bundled client JS. Vite/Rollup owns bundling, hashing,
|
|
4
|
+
* chunking, tree-shaking and minification; BarefootJS keeps only the
|
|
5
|
+
* JSX → (template, client JS) compile. See `spike-findings.md` (R1-R3)
|
|
6
|
+
* for the mechanics this plugin is built on, and the design doc for the
|
|
7
|
+
* two-engine architecture:
|
|
8
|
+
*
|
|
9
|
+
* 1. graph pass (`transform`) — Rollup visits `.tsx` modules it can
|
|
10
|
+
* reach from `build.rollupOptions.input`; this plugin compiles each
|
|
11
|
+
* one and hands back plain client JS for Rollup to bundle, hash,
|
|
12
|
+
* tree-shake, chunk, and minify like any other module.
|
|
13
|
+
* 2. eager pass (`writeBundle` for `vite build`, `configureServer` for
|
|
14
|
+
* `vite dev`) — walks every `.tsx` under `components` directly, NOT
|
|
15
|
+
* via the module graph. Server-only components (no `'use client'`)
|
|
16
|
+
* never appear in the graph at all (nothing imports them as a
|
|
17
|
+
* script, so Rollup never visits them) but still need a template —
|
|
18
|
+
* this pass is the only place that happens. The build variant runs
|
|
19
|
+
* in `writeBundle` specifically because the Vite manifest is only
|
|
20
|
+
* final once Rollup has finished hashing output filenames; the dev
|
|
21
|
+
* variant runs once the dev server starts listening (the resolved
|
|
22
|
+
* port is needed to build dev-origin URLs) and again on every
|
|
23
|
+
* tracked `.tsx` change.
|
|
24
|
+
*
|
|
25
|
+
* Both passes share one `CompileCache` (§4) so a given file's content is
|
|
26
|
+
* compiled at most twice: once canonically (`scriptAssets: []`, cached,
|
|
27
|
+
* shared by both passes — this is the ONLY compile a server-only file, or
|
|
28
|
+
* any file whose real `scriptAssets` also turns out to be `[]`, ever
|
|
29
|
+
* needs) and, only for a `'use client'` file whose real script list
|
|
30
|
+
* (manifest-resolved for build, dev-origin-based for dev) resolves to a
|
|
31
|
+
* non-empty URL list, one further compile with the real `scriptAssets` to
|
|
32
|
+
* bake the correct URL into the template. That second compile is
|
|
33
|
+
* unavoidable within `compileJSX`'s current API shape: a component's
|
|
34
|
+
* template and its client JS are produced by ONE call, but only the
|
|
35
|
+
* template depends on `scriptAssets` (client JS comes from an entirely
|
|
36
|
+
* separate codegen path `adapter.generate()` never touches) — and for the
|
|
37
|
+
* build variant, `scriptAssets` can't be known until Rollup has already
|
|
38
|
+
* hashed the bundle, which requires `transform` to have already run. So
|
|
39
|
+
* `transform` unavoidably compiles once per file before the real URL
|
|
40
|
+
* exists, and the eager pass MUST recompile once more, only for the
|
|
41
|
+
* strict subset of files whose true `scriptAssets` differs from the
|
|
42
|
+
* cached `[]` canonical form, to get a template with the correct URL
|
|
43
|
+
* baked in.
|
|
44
|
+
*
|
|
45
|
+
* Dev's `configureServer` intentionally does NOT diff what changed and
|
|
46
|
+
* recompile only that file's dependents — it re-runs the ENTIRE eager
|
|
47
|
+
* pass on every tracked change. A change to a shared signal module or a
|
|
48
|
+
* child component changes the *parent's* template too, so anything less
|
|
49
|
+
* than a full re-run needs dependency tracking, which this design avoids
|
|
50
|
+
* entirely. The content-hash `CompileCache` makes the full pass cheap:
|
|
51
|
+
* every unchanged file's `compileCanonical` call is a cache hit.
|
|
52
|
+
*
|
|
53
|
+
* `options.afterEmit`, if supplied, fires once at the end of EITHER eager
|
|
54
|
+
* pass (`writeBundle`'s `mode: 'build'`, `runDevEagerPass`'s `mode: 'dev'`)
|
|
55
|
+
* with a narrow `AfterEmitContext` (`types`, `projectDir`, `templatesDir`,
|
|
56
|
+
* `outDir`, `mode` — see its docstring in `types.ts`). It exists so an
|
|
57
|
+
* adapter's own `/vite` subpath can combine per-file `types` output into
|
|
58
|
+
* one backend-native file (Go's `components.go`, stripping headers,
|
|
59
|
+
* deduping, injecting shared helpers — real per-language work core has no
|
|
60
|
+
* business doing generically) without a `postBuild`-style rewrite hook on
|
|
61
|
+
* emitted client JS ever being on the table. It fires from BOTH passes,
|
|
62
|
+
* not just the build one, because e.g. Go's `components.go` has to exist
|
|
63
|
+
* for `go run .` to compile even in dev.
|
|
64
|
+
*/
|
|
65
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
66
|
+
import { relative, resolve, sep } from 'node:path'
|
|
67
|
+
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
|
|
68
|
+
import {
|
|
69
|
+
compileJSX,
|
|
70
|
+
formatError,
|
|
71
|
+
type CompileResult,
|
|
72
|
+
} from '@barefootjs/jsx'
|
|
73
|
+
import type { BarefootPluginApi, BarefootViteOptions } from './types.ts'
|
|
74
|
+
import { CompileCache } from './compile-cache.ts'
|
|
75
|
+
import { CorpusProgramManager } from './corpus-program.ts'
|
|
76
|
+
import { BF_CHILD_NOOP_ID, bfChildMarkerName } from './child-marker.ts'
|
|
77
|
+
import {
|
|
78
|
+
buildChildNameIndex,
|
|
79
|
+
discoverComponents,
|
|
80
|
+
isComponentSourceFile,
|
|
81
|
+
type DiscoveredComponent,
|
|
82
|
+
type ResolvedComponentDirEntry,
|
|
83
|
+
} from './discover.ts'
|
|
84
|
+
import { resolveClientJsSpecifier } from './resolve-client-js.ts'
|
|
85
|
+
import { buildRelativeImportRewriter, relativeUnderComponentDir, safeRollupEntryName, toPosixRelative } from './paths.ts'
|
|
86
|
+
import { loadManifest, resolvePreloadAssets, resolveScriptAssets } from './manifest.ts'
|
|
87
|
+
import { planEmits, writeEmits, type EmitTarget } from './emit.ts'
|
|
88
|
+
import { buildManifestEntry, type ManifestEntry } from './component-manifest.ts'
|
|
89
|
+
import {
|
|
90
|
+
DEFAULT_DEV_CORS_ORIGIN,
|
|
91
|
+
DEV_ARTIFACT_MARKER_CONTENT,
|
|
92
|
+
DEV_ARTIFACT_MARKER_FILENAME,
|
|
93
|
+
DEV_WATCH_DEBOUNCE_MS,
|
|
94
|
+
devScriptAssets,
|
|
95
|
+
devSentinelPath,
|
|
96
|
+
resolveDevOrigin,
|
|
97
|
+
} from './dev-server.ts'
|
|
98
|
+
import { createDebouncedSerialRunner } from './debounced-serial-runner.ts'
|
|
99
|
+
|
|
100
|
+
// Exported so tooling can find this plugin by name in a resolved Vite
|
|
101
|
+
// config's `plugins` array (e.g. `bf`'s `context.ts`, reading `api` off it —
|
|
102
|
+
// see `BarefootPluginApi`'s docstring) without hardcoding the string
|
|
103
|
+
// independently of what this file actually names the plugin.
|
|
104
|
+
export const PLUGIN_NAME = 'barefoot'
|
|
105
|
+
|
|
106
|
+
function reportErrors(result: CompileResult, source: string, projectDir: string): void {
|
|
107
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
108
|
+
const warnings = result.errors.filter(e => e.severity === 'warning')
|
|
109
|
+
for (const warning of warnings) {
|
|
110
|
+
console.warn(formatError(warning, source, { projectDir }))
|
|
111
|
+
}
|
|
112
|
+
if (errors.length > 0) {
|
|
113
|
+
const messages = errors.map(e => formatError(e, source, { projectDir })).join('\n\n')
|
|
114
|
+
throw new Error(`[${PLUGIN_NAME}] compile failed:\n\n${messages}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolves `options.components` (each entry a plain string OR a
|
|
120
|
+
* `ComponentDirEntry`) against `root` into `ResolvedComponentDirEntry[]` —
|
|
121
|
+
* `dir` always absolute, `cssLayerPrefix`/`skipDirs` carried through
|
|
122
|
+
* unchanged, array order preserved. A plain string entry becomes
|
|
123
|
+
* `{ dir: resolve(root, entry) }` with neither field set, exactly
|
|
124
|
+
* equivalent to `{ dir: entry }` per `ComponentDirEntry`'s own contract.
|
|
125
|
+
*
|
|
126
|
+
* Called twice per plugin instance with two different roots — `config`'s
|
|
127
|
+
* best-effort `guessedRoot` (Vite's real root isn't resolved yet at that
|
|
128
|
+
* point in its lifecycle) and `configResolved`'s authoritative
|
|
129
|
+
* `config.root` — which is why this takes `root` as a parameter instead of
|
|
130
|
+
* closing over one.
|
|
131
|
+
*/
|
|
132
|
+
function normalizeComponents(
|
|
133
|
+
components: BarefootViteOptions['components'],
|
|
134
|
+
root: string,
|
|
135
|
+
): ResolvedComponentDirEntry[] {
|
|
136
|
+
return components.map(entry =>
|
|
137
|
+
typeof entry === 'string'
|
|
138
|
+
? { dir: resolve(root, entry) }
|
|
139
|
+
: { dir: resolve(root, entry.dir), cssLayerPrefix: entry.cssLayerPrefix, skipDirs: entry.skipDirs },
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function barefoot(options: BarefootViteOptions): Plugin {
|
|
144
|
+
const cache = new CompileCache()
|
|
145
|
+
|
|
146
|
+
// One shared ts.Program across every compile in this plugin instance —
|
|
147
|
+
// built once per pass from the discovered files that need type-based
|
|
148
|
+
// detection, incrementally rebuilt on change. Without it, each such file
|
|
149
|
+
// pays its own ~500-600 ms `ts.createProgram` type-graph construction
|
|
150
|
+
// inside `compileJSX`'s per-file fallback (tens of seconds across a real
|
|
151
|
+
// corpus), and a Reactive<T>-brand importer fails the build outright
|
|
152
|
+
// with BF050. See `corpus-program.ts`.
|
|
153
|
+
const corpusProgram = new CorpusProgramManager()
|
|
154
|
+
|
|
155
|
+
// What `emitTemplatesFor` last wrote for a given source file, keyed by
|
|
156
|
+
// absolute path. The ONLY consumer is the dev watcher's `'unlink'`
|
|
157
|
+
// handler: when a component file is deleted, this is how it knows which
|
|
158
|
+
// on-disk template/ssrDefaults/types files to remove without having to
|
|
159
|
+
// re-derive the (possibly `templatesPerComponent`, i.e. named after the
|
|
160
|
+
// exported component rather than the source file) output path from a
|
|
161
|
+
// file that no longer exists to read.
|
|
162
|
+
const lastEmitsByAbsPath = new Map<string, EmitTarget[]>()
|
|
163
|
+
|
|
164
|
+
// Populated (redundantly but cheaply — a directory walk, no compiling)
|
|
165
|
+
// once in `config` with a best-effort root, so `rollupOptions.input` can
|
|
166
|
+
// be set; then again in `configResolved` with Vite's authoritative
|
|
167
|
+
// `root`, which is what `resolveId` / `transform` / `writeBundle` use.
|
|
168
|
+
//
|
|
169
|
+
// `dirEntries` is the normalized `options.components` — `dir` resolved
|
|
170
|
+
// to absolute, `cssLayerPrefix`/`skipDirs` carried through — in the
|
|
171
|
+
// OPTION'S OWN ORDER, which doubles as precedence: `entryForPath` below
|
|
172
|
+
// returns the FIRST entry whose `dir` prefixes a given path, so an
|
|
173
|
+
// earlier `components` entry shadows a later one for a file reachable
|
|
174
|
+
// under both (the same first-writer-wins precedence
|
|
175
|
+
// `buildChildNameIndex` already applies to `@bf-child:` name
|
|
176
|
+
// collisions). `componentDirs` is `dirEntries.map(e => e.dir)`, kept
|
|
177
|
+
// around unchanged because every existing bit of path arithmetic
|
|
178
|
+
// (`rewriterFor`, `planEmits`, `buildManifestEntry`,
|
|
179
|
+
// `safeRollupEntryName`) only ever needs the bare directory list, never
|
|
180
|
+
// the per-entry options.
|
|
181
|
+
let dirEntries: ResolvedComponentDirEntry[] = []
|
|
182
|
+
let componentDirs: string[] = []
|
|
183
|
+
// `undefined` exactly when `options.templates` was never set — the CSR
|
|
184
|
+
// degenerate case. Every write path below (`writeEmits`, `manifest.json`,
|
|
185
|
+
// the dev-artifact marker, `afterEmit`) is gated on this being defined;
|
|
186
|
+
// `assertNoRealTemplateOutput` is what makes skipping those writes safe
|
|
187
|
+
// rather than a silent drop.
|
|
188
|
+
let templatesDir: string | undefined
|
|
189
|
+
let resolvedConfig: ResolvedConfig | undefined
|
|
190
|
+
|
|
191
|
+
// Name → absolute-path index for resolving `@bf-child:<Name>` markers
|
|
192
|
+
// (see `child-marker.ts` and `discover.ts`'s `buildChildNameIndex`).
|
|
193
|
+
// Built once in `configResolved` — before Rollup's graph pass starts
|
|
194
|
+
// calling `resolveId`, which is the only consumer — from a dedicated
|
|
195
|
+
// discovery pass (cheap: a directory walk + a `'use client'` directive
|
|
196
|
+
// peek per file, no compiling).
|
|
197
|
+
let childNameIndex = new Map<string, string>()
|
|
198
|
+
|
|
199
|
+
// Re-anchors a relative import written in `absPath` so it still resolves
|
|
200
|
+
// once the template is emitted under `templatesDir`. Pure function of
|
|
201
|
+
// (absPath, componentDirs, templatesDir) — safe to compute for every
|
|
202
|
+
// compile, including the canonical `transform`-time one where it's
|
|
203
|
+
// unused (client JS generation never reads `rewriteRelativeImport`).
|
|
204
|
+
//
|
|
205
|
+
// `outputPathGuess` MUST mirror `planEmits`'s actual on-disk output
|
|
206
|
+
// location — the position of `absPath` under WHICHEVER `componentDirs`
|
|
207
|
+
// entry contains it, joined onto `templatesDir` (`relativeUnderComponentDir`,
|
|
208
|
+
// the same helper `planEmits`/`safeRollupEntryName` use) — not a
|
|
209
|
+
// root-relative guess. The two coincide only when every configured
|
|
210
|
+
// `components` dir IS the Vite root; this repo's real layouts commonly
|
|
211
|
+
// configure MULTIPLE sibling `components` dirs (e.g. `../shared/blog`)
|
|
212
|
+
// that are flattened directly under `templatesDir` with no `shared/blog/`
|
|
213
|
+
// prefix preserved. A root-relative guess computes a phantom nested path
|
|
214
|
+
// (`dist/shared/blog/Foo.tsx`) that diverges from where the file is
|
|
215
|
+
// actually written (`dist/components/Foo.tsx`), corrupting every
|
|
216
|
+
// relative import a same-directory sibling file re-anchors from it — only
|
|
217
|
+
// surfaced by an adapter whose templates carry real `import` syntax
|
|
218
|
+
// (Hono-shaped JS-runtime adapters; Go/Mojo/etc. templates have no
|
|
219
|
+
// imports and never call this at all).
|
|
220
|
+
function rewriterFor(absPath: string): (importPath: string) => string {
|
|
221
|
+
// No `templates` dir configured (the CSR degenerate case) — there is
|
|
222
|
+
// nowhere for a rewritten import to point, but that's harmless: a
|
|
223
|
+
// relative import only reaches emitted template text (never client
|
|
224
|
+
// JS), and `assertNoRealTemplateOutput` refuses loudly the moment any
|
|
225
|
+
// component's template output turns out non-empty. Identity is a safe
|
|
226
|
+
// placeholder for output that can never survive to be read.
|
|
227
|
+
if (templatesDir === undefined) return importPath => importPath
|
|
228
|
+
const outputPathGuess = resolve(templatesDir, relativeUnderComponentDir(absPath, componentDirs))
|
|
229
|
+
return buildRelativeImportRewriter(absPath, outputPathGuess, componentDirs, templatesDir)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Refuses loudly when `options.templates` is omitted but `result` (a
|
|
234
|
+
* specific discovered component's compile) produced a REAL `markedTemplate`
|
|
235
|
+
* — non-empty content that would otherwise be silently dropped by the
|
|
236
|
+
* `templatesDir === undefined` skip below. `ssrDefaults`/`types` output is
|
|
237
|
+
* deliberately NOT checked here: a CSR `Counter` DOES produce non-empty
|
|
238
|
+
* `ssrDefaults` — proven by inspection — precisely because that
|
|
239
|
+
* computation reads IR metadata, not the adapter's `generate()` output,
|
|
240
|
+
* so treating them as loudness-worthy here would make `templates`
|
|
241
|
+
* impossible to omit for the one adapter (`CSRAdapter`) this option
|
|
242
|
+
* exists to accommodate.
|
|
243
|
+
*/
|
|
244
|
+
function assertNoRealTemplateOutput(result: CompileResult, absPath: string): void {
|
|
245
|
+
const real = result.files.find(f => f.type === 'markedTemplate' && f.content.trim() !== '')
|
|
246
|
+
if (!real) return
|
|
247
|
+
throw new Error(
|
|
248
|
+
`[${PLUGIN_NAME}] adapter "${options.adapter.name}" produced a real template for ` +
|
|
249
|
+
`"${absPath}", but no \`templates\` option is configured on barefoot() — that output ` +
|
|
250
|
+
`would be silently dropped. Set \`templates: '<dir>'\`, or use an adapter whose ` +
|
|
251
|
+
`generate() output is always empty (e.g. CSRAdapter) if this project truly emits no ` +
|
|
252
|
+
`templates.`,
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The FIRST `dirEntries` entry (option order) whose `dir` prefixes
|
|
258
|
+
* `absPath`, or `undefined` if no configured `components` entry contains
|
|
259
|
+
* it. This is the single source of precedence: whichever entry a path
|
|
260
|
+
* resolves to here is also the entry `isUnderComponentDir`'s `skipDirs`
|
|
261
|
+
* check consults, so a file's `cssLayerPrefix` and its skip/no-skip
|
|
262
|
+
* verdict always come from the SAME entry, never two different ones.
|
|
263
|
+
*/
|
|
264
|
+
function entryForPath(absPath: string): ResolvedComponentDirEntry | undefined {
|
|
265
|
+
return dirEntries.find(entry => absPath === entry.dir || absPath.startsWith(`${entry.dir}/`))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Does `entry.skipDirs` name any directory component on the path from
|
|
269
|
+
* `entry.dir` down to `absPath`? Mirrors `discoverComponentFiles`'s own
|
|
270
|
+
* recursive skip (a directory whose NAME is listed is skipped, along
|
|
271
|
+
* with everything under it) so a file discovery never walks into is
|
|
272
|
+
* never treated as a component by the transform gate either — the
|
|
273
|
+
* "half-fix" this module's docstring on `isUnderComponentDir` warns
|
|
274
|
+
* about. The file's own basename (the last path segment) is excluded:
|
|
275
|
+
* `skipDirs` names directories, not files. */
|
|
276
|
+
function isSkippedByEntry(absPath: string, entry: ResolvedComponentDirEntry): boolean {
|
|
277
|
+
if (!entry.skipDirs || entry.skipDirs.length === 0) return false
|
|
278
|
+
const relParts = relative(entry.dir, absPath).split(sep)
|
|
279
|
+
return relParts.slice(0, -1).some(part => entry.skipDirs!.includes(part))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function compileCanonical(absPath: string, content: string): CompileResult {
|
|
283
|
+
return cache.getOrCompile(absPath, content, () =>
|
|
284
|
+
compileJSX(content, absPath, {
|
|
285
|
+
adapter: options.adapter,
|
|
286
|
+
sourceMaps: true,
|
|
287
|
+
// `undefined` for the majority of files (no type-based detection
|
|
288
|
+
// needed — the analyzer never builds a checker for them); the
|
|
289
|
+
// shared corpus Program for the rest. Resolved INSIDE the cache
|
|
290
|
+
// thunk so a cache hit never touches the Program at all.
|
|
291
|
+
program: corpusProgram.programFor(absPath, content),
|
|
292
|
+
// Per-directory, from whichever `dirEntries` entry `absPath` falls
|
|
293
|
+
// under — `undefined` for a plain-string entry (or a file matching
|
|
294
|
+
// no entry, which shouldn't happen for anything reaching this
|
|
295
|
+
// function). `CompileCache` is keyed `(absPath, contentHash)` with
|
|
296
|
+
// NO `cssLayerPrefix` component — deliberately: the prefix is a
|
|
297
|
+
// pure function of `absPath` via `dirEntries`, and `dirEntries`
|
|
298
|
+
// only ever changes on a config reload (a full plugin restart,
|
|
299
|
+
// hence a fresh `cache` too), so two calls with the same
|
|
300
|
+
// `(absPath, content)` always agree on `cssLayerPrefix` and the
|
|
301
|
+
// cache key needs no widening. Do not "fix" this later.
|
|
302
|
+
cssLayerPrefix: entryForPath(absPath)?.cssLayerPrefix,
|
|
303
|
+
// The canonical, cacheable compile always uses an empty
|
|
304
|
+
// scriptAssets ("no scripts") — the one input every discovered
|
|
305
|
+
// file (server-only or client) shares regardless of its eventual
|
|
306
|
+
// manifest entry. Everything except the template's script
|
|
307
|
+
// registration is identical no matter what `scriptAssets` is, so
|
|
308
|
+
// this single compile is reused as-is for every server-only
|
|
309
|
+
// component and any client component that turns out to need no
|
|
310
|
+
// scripts. See this module's docstring.
|
|
311
|
+
scriptAssets: [],
|
|
312
|
+
rewriteRelativeImport: rewriterFor(absPath),
|
|
313
|
+
// The eager pass ALWAYS writes every discovered component's
|
|
314
|
+
// template into the SAME `templates` dir for the app to register
|
|
315
|
+
// together at request/startup time (that's the whole point of
|
|
316
|
+
// walking `components` directly instead of following the module
|
|
317
|
+
// graph) — the exact guarantee `siblingTemplatesRegistered`
|
|
318
|
+
// exists to assert. Without it, a DSL-template adapter (Go,
|
|
319
|
+
// ERB, Blade, Jinja, ...) refuses to compile ANY component that
|
|
320
|
+
// uses a sibling-imported child inside a `.map()` loop (BF103),
|
|
321
|
+
// even though the shape works fine once the app's own template
|
|
322
|
+
// registration (e.g. Go's `filepath.WalkDir` + `ParseFiles` over
|
|
323
|
+
// every `.tmpl`) puts every template on one instance — which this
|
|
324
|
+
// plugin's design already requires. Harmless for the client-JS
|
|
325
|
+
// graph pass that also calls this function — the option only
|
|
326
|
+
// ever reaches `adapter.generate()`'s TEMPLATE codegen, never
|
|
327
|
+
// client JS.
|
|
328
|
+
siblingTemplatesRegistered: true,
|
|
329
|
+
}),
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Is `absPath` a component this plugin should treat as live — under a
|
|
335
|
+
* configured `components` entry AND not inside that entry's `skipDirs`?
|
|
336
|
+
* Gates `transform` (the graph pass) and the dev watcher's
|
|
337
|
+
* `change`/`add`/`unlink` handlers. `skipDirs` must gate THIS, not just
|
|
338
|
+
* `discoverComponents`'s directory walk: discovery only decides what the
|
|
339
|
+
* EAGER pass walks up front, but a skipped file can still be reached by
|
|
340
|
+
* an ordinary `import` from a non-skipped sibling — `site/ui`'s
|
|
341
|
+
* `PageNavigation.tsx` (imported by pages, but living in a skipped
|
|
342
|
+
* `shared/` dir) is exactly that shape. Without this check the graph
|
|
343
|
+
* pass would compile it anyway: discovery silently skips it, but
|
|
344
|
+
* `transform` doesn't, which is the half-fix that used to bite that
|
|
345
|
+
* layout.
|
|
346
|
+
*/
|
|
347
|
+
function isUnderComponentDir(absPath: string): boolean {
|
|
348
|
+
const entry = entryForPath(absPath)
|
|
349
|
+
return entry !== undefined && !isSkippedByEntry(absPath, entry)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Shared eager-pass body: compile + emit a template for every discovered
|
|
354
|
+
* component, resolving each `'use client'` component's real
|
|
355
|
+
* `scriptAssets`/`preloadAssets` via the caller-supplied
|
|
356
|
+
* `resolveAssetsFor` (the manifest for `writeBundle`, dev-origin URLs
|
|
357
|
+
* for `configureServer`) — one callback returning both lists, since
|
|
358
|
+
* both come from the same single manifest lookup. See this module's
|
|
359
|
+
* docstring for why both callers need the FULL discovered set every
|
|
360
|
+
* time, not just what changed.
|
|
361
|
+
*
|
|
362
|
+
* Returns every `types`-typed output this pass produced, keyed by
|
|
363
|
+
* source absolute path — the raw material `afterEmit` receives (see
|
|
364
|
+
* `AfterEmitContext`). Collected here (not read back off disk) since
|
|
365
|
+
* `planEmits`/`writeEmits` already have the compiled `CompileResult` in
|
|
366
|
+
* hand; no adapter-specific knowledge is needed to harvest it.
|
|
367
|
+
*
|
|
368
|
+
* When `options.templates` is omitted (`templatesDir === undefined`),
|
|
369
|
+
* this still compiles every discovered component — the graph pass
|
|
370
|
+
* (`transform`) needs the same canonical compile anyway, and
|
|
371
|
+
* `assertNoRealTemplateOutput` needs a real `CompileResult` to check —
|
|
372
|
+
* but writes nothing to disk on any component's behalf and returns an
|
|
373
|
+
* empty `types` map. See `types.ts`'s docstring on `templates`.
|
|
374
|
+
*/
|
|
375
|
+
async function emitTemplatesFor(
|
|
376
|
+
discovered: DiscoveredComponent[],
|
|
377
|
+
projectDir: string,
|
|
378
|
+
resolveAssetsFor: (component: DiscoveredComponent) => { scriptAssets: string[]; preloadAssets: string[] },
|
|
379
|
+
): Promise<Map<string, string>> {
|
|
380
|
+
const types = new Map<string, string>()
|
|
381
|
+
// Combined `manifest.json` row per source file — see
|
|
382
|
+
// `component-manifest.ts`'s header for why this is written alongside
|
|
383
|
+
// (not instead of) the per-component `.ssr-defaults.json` files
|
|
384
|
+
// `writeEmits` already produces. Stays empty (and unwritten) when
|
|
385
|
+
// `templatesDir` is undefined.
|
|
386
|
+
const manifestEntries: Record<string, ManifestEntry> = {}
|
|
387
|
+
// Refresh the shared Program from this pass's full discovery snapshot
|
|
388
|
+
// BEFORE the compile loop, so the loop never triggers `programFor`'s
|
|
389
|
+
// one-root-at-a-time incremental rebuilds. A no-op when nothing
|
|
390
|
+
// type-needing changed — the common case, mirroring `CompileCache`.
|
|
391
|
+
corpusProgram.seed(discovered)
|
|
392
|
+
for (const component of discovered) {
|
|
393
|
+
const content = component.content
|
|
394
|
+
const canonical = compileCanonical(component.absPath, content)
|
|
395
|
+
reportErrors(canonical, content, projectDir)
|
|
396
|
+
|
|
397
|
+
let result = canonical
|
|
398
|
+
if (component.isClient) {
|
|
399
|
+
const { scriptAssets, preloadAssets } = resolveAssetsFor(component)
|
|
400
|
+
if (scriptAssets.length > 0) {
|
|
401
|
+
result = compileJSX(content, component.absPath, {
|
|
402
|
+
adapter: options.adapter,
|
|
403
|
+
sourceMaps: true,
|
|
404
|
+
// Same shared Program as the canonical compile — this second,
|
|
405
|
+
// uncached compile re-runs the same analysis with only
|
|
406
|
+
// `scriptAssets` differing, so skipping it here would re-open
|
|
407
|
+
// the per-file fallback (and BF050) for exactly the files
|
|
408
|
+
// that recompile every pass.
|
|
409
|
+
program: corpusProgram.programFor(component.absPath, content),
|
|
410
|
+
// Already stamped on `component` by `discoverComponents` from
|
|
411
|
+
// the same `dirEntries` entry `compileCanonical` would have
|
|
412
|
+
// derived via `entryForPath` — reused directly rather than
|
|
413
|
+
// re-derived, since the two always agree.
|
|
414
|
+
cssLayerPrefix: component.cssLayerPrefix,
|
|
415
|
+
scriptAssets,
|
|
416
|
+
preloadAssets,
|
|
417
|
+
rewriteRelativeImport: rewriterFor(component.absPath),
|
|
418
|
+
// See `compileCanonical`'s docstring on this same field.
|
|
419
|
+
siblingTemplatesRegistered: true,
|
|
420
|
+
})
|
|
421
|
+
reportErrors(result, content, projectDir)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (templatesDir === undefined) {
|
|
426
|
+
assertNoRealTemplateOutput(result, component.absPath)
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const targets = planEmits(result, component.absPath, componentDirs, options.adapter)
|
|
431
|
+
lastEmitsByAbsPath.set(component.absPath, targets)
|
|
432
|
+
await writeEmits(templatesDir, targets)
|
|
433
|
+
|
|
434
|
+
for (const file of result.files) {
|
|
435
|
+
if (file.type === 'types') types.set(component.absPath, file.content)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const manifestRow = buildManifestEntry(result, component.absPath, componentDirs, options.adapter)
|
|
439
|
+
if (manifestRow) manifestEntries[manifestRow.manifestKey] = manifestRow.entry
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (templatesDir === undefined) return types
|
|
443
|
+
|
|
444
|
+
// Written unconditionally every pass (matching `writeEmits`'s own
|
|
445
|
+
// no-diffing convention for this eager pass) — `discovered` is always
|
|
446
|
+
// the FULL current set (see this module's docstring on why this
|
|
447
|
+
// plugin never does a partial/diffed re-run), so this naturally drops
|
|
448
|
+
// a deleted component's row without any separate cleanup step.
|
|
449
|
+
// `mkdir` first: an empty `discovered` set means `writeEmits` never
|
|
450
|
+
// ran (no targets to create `templatesDir` for), so it may not exist
|
|
451
|
+
// yet.
|
|
452
|
+
await mkdir(templatesDir, { recursive: true })
|
|
453
|
+
await writeFile(resolve(templatesDir, 'manifest.json'), JSON.stringify(manifestEntries, null, 2))
|
|
454
|
+
|
|
455
|
+
return types
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Remove whatever `emitTemplatesFor` last wrote for each of `absPaths`
|
|
460
|
+
* (a deleted component's template, `ssrDefaults`, and `.types` fragment)
|
|
461
|
+
* and forget it — both from the emit-tracking map and the compile
|
|
462
|
+
* cache, so a file later recreated at the same path never reuses a
|
|
463
|
+
* stale cached result. Best-effort: `rm(..., { force: true })` so a
|
|
464
|
+
* file already gone (or never successfully written) isn't an error.
|
|
465
|
+
*/
|
|
466
|
+
async function removeEmitsFor(absPaths: Iterable<string>): Promise<void> {
|
|
467
|
+
for (const absPath of absPaths) {
|
|
468
|
+
const targets = lastEmitsByAbsPath.get(absPath)
|
|
469
|
+
lastEmitsByAbsPath.delete(absPath)
|
|
470
|
+
cache.delete(absPath)
|
|
471
|
+
// `lastEmitsByAbsPath` is only ever populated inside `emitTemplatesFor`
|
|
472
|
+
// when `templatesDir` is defined (see its `continue` for the CSR
|
|
473
|
+
// degenerate case), so `targets` is never non-empty here without
|
|
474
|
+
// `templatesDir` also being defined — this check is for the type
|
|
475
|
+
// checker, not a real runtime possibility.
|
|
476
|
+
if (!targets || templatesDir === undefined) continue
|
|
477
|
+
for (const target of targets) {
|
|
478
|
+
await rm(resolve(templatesDir, target.relPath), { force: true })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Dev variant of the eager pass: discovers every component fresh (a
|
|
485
|
+
* changed file's new content must be picked up — `CompileCache` keys on
|
|
486
|
+
* content hash, so a stale in-memory listing is harmless, but a stale
|
|
487
|
+
* listing that MISSES a newly added file would not be) and bakes
|
|
488
|
+
* `devOrigin`-based `scriptAssets` instead of manifest-resolved ones.
|
|
489
|
+
* Also (re)writes the dev-artifact marker — see `dev-server.ts` — and
|
|
490
|
+
* refreshes `childNameIndex` (a component added or removed mid-session
|
|
491
|
+
* must be reflected in `@bf-child:` marker resolution the same way it's
|
|
492
|
+
* reflected in everything else this pass recomputes from scratch).
|
|
493
|
+
*/
|
|
494
|
+
async function runDevEagerPass(devOrigin: string): Promise<void> {
|
|
495
|
+
const config = resolvedConfig
|
|
496
|
+
if (!config) return
|
|
497
|
+
|
|
498
|
+
const discovered = await discoverComponents(dirEntries, absPath => readFile(absPath, 'utf8'))
|
|
499
|
+
childNameIndex = buildChildNameIndex(discovered)
|
|
500
|
+
const types = await emitTemplatesFor(discovered, config.root, component => ({
|
|
501
|
+
scriptAssets: devScriptAssets(config, devOrigin, component.absPath),
|
|
502
|
+
// Dev serves unbundled modules and runs its own on-demand dep
|
|
503
|
+
// pre-bundling — there is no stable, hashed chunk graph to walk (no
|
|
504
|
+
// manifest exists in dev mode at all), so there is nothing correct
|
|
505
|
+
// to preload. Emitting hints here would just be noise the browser
|
|
506
|
+
// has to fetch and discard.
|
|
507
|
+
preloadAssets: [],
|
|
508
|
+
}))
|
|
509
|
+
|
|
510
|
+
// Both the marker and `afterEmit` exist to annotate/post-process
|
|
511
|
+
// `templatesDir` — neither has anything to do when there isn't one
|
|
512
|
+
// (the CSR degenerate case).
|
|
513
|
+
if (templatesDir === undefined) return
|
|
514
|
+
|
|
515
|
+
await writeFile(resolve(templatesDir, DEV_ARTIFACT_MARKER_FILENAME), DEV_ARTIFACT_MARKER_CONTENT)
|
|
516
|
+
|
|
517
|
+
// Cross-language dev-reload sentinel (see `devSentinelPath`'s
|
|
518
|
+
// docstring) — written unconditionally on every pass, initial pass
|
|
519
|
+
// included. A fresh timestamp is enough: consumers only compare it
|
|
520
|
+
// against their own last-seen value.
|
|
521
|
+
const sentinelPath = devSentinelPath(templatesDir)
|
|
522
|
+
await mkdir(resolve(sentinelPath, '..'), { recursive: true })
|
|
523
|
+
await writeFile(sentinelPath, String(Date.now()))
|
|
524
|
+
|
|
525
|
+
if (options.afterEmit) {
|
|
526
|
+
await options.afterEmit({
|
|
527
|
+
types,
|
|
528
|
+
projectDir: config.root,
|
|
529
|
+
templatesDir,
|
|
530
|
+
outDir: resolve(config.root, config.build.outDir),
|
|
531
|
+
mode: 'dev',
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
name: PLUGIN_NAME,
|
|
538
|
+
enforce: 'pre',
|
|
539
|
+
// Vite's own "plugin API" convention — see `BarefootPluginApi`'s
|
|
540
|
+
// docstring for who reads this and why it's populated here (at
|
|
541
|
+
// construction time) rather than from a lifecycle hook.
|
|
542
|
+
api: { options } satisfies BarefootPluginApi,
|
|
543
|
+
|
|
544
|
+
async config(userConfig) {
|
|
545
|
+
// Best-effort root for the eager discovery this hook needs to set
|
|
546
|
+
// `rollupOptions.input` — Vite hasn't resolved the real root yet at
|
|
547
|
+
// this point in its own lifecycle (that only exists once
|
|
548
|
+
// `configResolved` fires), so this assumes the common case of
|
|
549
|
+
// running Vite from the project root with no `root` override. If
|
|
550
|
+
// that assumption doesn't hold for a given project, `configResolved`
|
|
551
|
+
// still recomputes everything `resolveId`/`transform`/`writeBundle`
|
|
552
|
+
// actually use against Vite's real resolved root — only the
|
|
553
|
+
// convenience `rollupOptions.input` keys this hook picks could be
|
|
554
|
+
// off, not correctness.
|
|
555
|
+
const guessedRoot = userConfig.root ? resolve(process.cwd(), userConfig.root) : process.cwd()
|
|
556
|
+
const guessedEntries = normalizeComponents(options.components, guessedRoot)
|
|
557
|
+
const dirs = guessedEntries.map(e => e.dir)
|
|
558
|
+
const found = await discoverComponents(guessedEntries, absPath => readFile(absPath, 'utf8'))
|
|
559
|
+
|
|
560
|
+
const input: Record<string, string> = {}
|
|
561
|
+
for (const c of found) {
|
|
562
|
+
if (!c.isClient) continue
|
|
563
|
+
// The object KEY only names the output chunk (Rollup's `[name]`) —
|
|
564
|
+
// it must be safe even for a `components` dir outside `root` (see
|
|
565
|
+
// `safeRollupEntryName`). It is NOT the manifest lookup key
|
|
566
|
+
// (`writeBundle` computes that separately via `toPosixRelative`
|
|
567
|
+
// against Vite's real resolved root, matching Vite's own
|
|
568
|
+
// manifest keying, which this name has no effect on).
|
|
569
|
+
input[safeRollupEntryName(guessedRoot, c.absPath, dirs)] = c.absPath
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Cross-origin dev default: the page comes from the backend, its
|
|
573
|
+
// module scripts from Vite — two different origins. Vite 6+ defaults
|
|
574
|
+
// `cors` to same-origin-only, which would reject those cross-origin
|
|
575
|
+
// module requests outright. Fill in a localhost-only default ONLY
|
|
576
|
+
// when the user hasn't set `server.cors` themselves — this stays a
|
|
577
|
+
// 3-option plugin (`adapter` / `components` / `templates`); no 4th
|
|
578
|
+
// `devOrigin`-style option is added for this. Done here in `config`
|
|
579
|
+
// (not `configureServer`) so it lands before Vite installs its own
|
|
580
|
+
// CORS middleware, and so it's plain, synchronously-testable data
|
|
581
|
+
// instead of a hook-timing dependency on Vite's internal
|
|
582
|
+
// configureServer/middleware install order.
|
|
583
|
+
//
|
|
584
|
+
// Checked against `undefined` specifically, NOT falsiness: a user
|
|
585
|
+
// who writes `server.cors = false` to explicitly DISABLE CORS means
|
|
586
|
+
// exactly that, and `!false` is `true` — a truthiness check would
|
|
587
|
+
// silently override their choice with this default, the opposite of
|
|
588
|
+
// "only fill in when unset".
|
|
589
|
+
const serverDefaults: Record<string, unknown> = {}
|
|
590
|
+
if (userConfig.server?.cors === undefined) {
|
|
591
|
+
serverDefaults.cors = { origin: DEFAULT_DEV_CORS_ORIGIN }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
appType: 'custom',
|
|
596
|
+
build: {
|
|
597
|
+
manifest: true,
|
|
598
|
+
rollupOptions: { input },
|
|
599
|
+
},
|
|
600
|
+
server: serverDefaults,
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
async configResolved(config) {
|
|
605
|
+
resolvedConfig = config
|
|
606
|
+
dirEntries = normalizeComponents(options.components, config.root)
|
|
607
|
+
componentDirs = dirEntries.map(e => e.dir)
|
|
608
|
+
templatesDir = options.templates !== undefined ? resolve(config.root, options.templates) : undefined
|
|
609
|
+
const discovered = await discoverComponents(dirEntries, absPath => readFile(absPath, 'utf8'))
|
|
610
|
+
childNameIndex = buildChildNameIndex(discovered)
|
|
611
|
+
// Seed the shared Program now, before Rollup's graph pass starts
|
|
612
|
+
// calling `transform` — otherwise the first type-needing file the
|
|
613
|
+
// graph reaches would build the corpus one root at a time.
|
|
614
|
+
corpusProgram.seed(discovered)
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
resolveId(source, importer) {
|
|
618
|
+
// See `child-marker.ts`: a `@bf-child:` marker isn't a real module.
|
|
619
|
+
// Resolve it to the named child's REAL `.tsx` file when discovery
|
|
620
|
+
// found one — Rollup then treats it as an ordinary entry-to-entry
|
|
621
|
+
// import (the child is independently a Rollup entry too, being
|
|
622
|
+
// `'use client'`), which is what makes the browser fetch and
|
|
623
|
+
// execute the child's script as a side effect of loading the
|
|
624
|
+
// parent's. Falls back to a shared empty virtual module (elided by
|
|
625
|
+
// Rollup's tree-shaking, `moduleSideEffects: false`) for a name this
|
|
626
|
+
// simple map doesn't cover, rather than failing the build outright.
|
|
627
|
+
const childName = bfChildMarkerName(source)
|
|
628
|
+
if (childName !== null) {
|
|
629
|
+
const childAbsPath = childNameIndex.get(childName)
|
|
630
|
+
if (childAbsPath) return childAbsPath
|
|
631
|
+
return { id: BF_CHILD_NOOP_ID, moduleSideEffects: false }
|
|
632
|
+
}
|
|
633
|
+
return resolveClientJsSpecifier(source, importer)
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
load(id) {
|
|
637
|
+
if (id === BF_CHILD_NOOP_ID) return ''
|
|
638
|
+
return null
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
transform(code, id) {
|
|
642
|
+
if (!id.endsWith('.tsx')) return null
|
|
643
|
+
if (!isUnderComponentDir(id)) return null
|
|
644
|
+
|
|
645
|
+
const result = compileCanonical(id, code)
|
|
646
|
+
reportErrors(result, code, resolvedConfig?.root ?? process.cwd())
|
|
647
|
+
|
|
648
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
649
|
+
if (!clientJs) return null
|
|
650
|
+
|
|
651
|
+
const map = result.files.find(f => f.type === 'sourceMap' && f.path === `${clientJs.path}.map`)
|
|
652
|
+
return {
|
|
653
|
+
code: clientJs.content,
|
|
654
|
+
map: map ? JSON.parse(map.content) : null,
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
async writeBundle() {
|
|
659
|
+
const config = resolvedConfig
|
|
660
|
+
if (!config) return
|
|
661
|
+
|
|
662
|
+
// Authoritative discovery — Vite's real root, not `config`'s guess.
|
|
663
|
+
const discovered: DiscoveredComponent[] = await discoverComponents(
|
|
664
|
+
dirEntries,
|
|
665
|
+
absPath => readFile(absPath, 'utf8'),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
const outDir = resolve(config.root, config.build.outDir)
|
|
669
|
+
const manifest = await loadManifest(outDir, config.build.manifest)
|
|
670
|
+
|
|
671
|
+
const types = await emitTemplatesFor(discovered, config.root, component => {
|
|
672
|
+
const manifestKey = toPosixRelative(config.root, component.absPath)
|
|
673
|
+
return {
|
|
674
|
+
scriptAssets: resolveScriptAssets(manifest, manifestKey, config.base),
|
|
675
|
+
preloadAssets: resolvePreloadAssets(manifest, manifestKey, config.base),
|
|
676
|
+
}
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
// No `templates` dir configured (the CSR degenerate case) — nothing
|
|
680
|
+
// was written for `emitTemplatesFor` to have staled, and `afterEmit`
|
|
681
|
+
// exists to post-process `templatesDir`, which doesn't exist here.
|
|
682
|
+
if (templatesDir === undefined) return
|
|
683
|
+
|
|
684
|
+
// A prior `vite dev` run may have left the dev-artifact marker (and
|
|
685
|
+
// dev-origin URLs) behind — this pass just overwrote every template
|
|
686
|
+
// with production URLs, so the marker is now stale. Best-effort:
|
|
687
|
+
// there may never have been one.
|
|
688
|
+
await rm(resolve(templatesDir, DEV_ARTIFACT_MARKER_FILENAME), { force: true })
|
|
689
|
+
// Same for the dev-reload sentinel: a production build is not a dev
|
|
690
|
+
// rebuild, so a stale `.dev/build-id` left over from an earlier
|
|
691
|
+
// `vite dev` session should not trick a still-running Go/Perl dev
|
|
692
|
+
// server into firing a reload for output that didn't come from it.
|
|
693
|
+
await rm(devSentinelPath(templatesDir), { force: true })
|
|
694
|
+
|
|
695
|
+
if (options.afterEmit) {
|
|
696
|
+
await options.afterEmit({ types, projectDir: config.root, templatesDir, outDir, mode: 'build' })
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
configureServer(server: ViteDevServer) {
|
|
701
|
+
// Mandatory: Vite's dev watcher only reliably covers the module
|
|
702
|
+
// graph plus whatever's under its own project `root`. Server-only
|
|
703
|
+
// components (no `'use client'`) never enter the module graph at
|
|
704
|
+
// all (nothing imports them as a script), and in this monorepo's
|
|
705
|
+
// real layouts `components` dirs are commonly siblings of — not
|
|
706
|
+
// descendants of — the Vite project root (an app's `vite.config.ts`
|
|
707
|
+
// root is the backend app dir; components live in a shared `ui/`
|
|
708
|
+
// directory next to it). Without this explicit `add`, editing such
|
|
709
|
+
// a file is silently invisible to the dev server: no watcher event,
|
|
710
|
+
// no re-emit, no reload. See `e2e-vite-dev.test.ts`'s server-only /
|
|
711
|
+
// out-of-root regression coverage.
|
|
712
|
+
for (const dir of componentDirs) {
|
|
713
|
+
server.watcher.add(dir)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
let devOrigin: string | undefined
|
|
717
|
+
// Deleted files queued for cleanup by the `'unlink'` handler,
|
|
718
|
+
// drained by the runner's own task the next time it actually runs —
|
|
719
|
+
// NOT deleted synchronously in the handler, so an unlink that lands
|
|
720
|
+
// while a pass is already in flight for other reasons is still
|
|
721
|
+
// batched into the SAME follow-up run as everything else instead of
|
|
722
|
+
// racing it.
|
|
723
|
+
const pendingUnlinks = new Set<string>()
|
|
724
|
+
|
|
725
|
+
// One serialized, debounced entry point for every dev-watcher event
|
|
726
|
+
// this plugin reacts to (`change` / `add` / `unlink`). See
|
|
727
|
+
// `debounced-serial-runner.ts`: a burst of events collapses into one
|
|
728
|
+
// pass, an event arriving mid-pass is queued as exactly one
|
|
729
|
+
// follow-up (never dropped, never overlapped), and no distinction
|
|
730
|
+
// needs to be drawn between which files changed — the pass itself
|
|
731
|
+
// re-discovers everything from disk (see this module's docstring on
|
|
732
|
+
// why a diff-based re-run is the wrong shape here).
|
|
733
|
+
const devPassRunner = createDebouncedSerialRunner(
|
|
734
|
+
async () => {
|
|
735
|
+
if (!devOrigin) return // initial pass (below) will cover current disk state once it runs
|
|
736
|
+
await removeEmitsFor(pendingUnlinks)
|
|
737
|
+
pendingUnlinks.clear()
|
|
738
|
+
await runDevEagerPass(devOrigin)
|
|
739
|
+
server.ws.send({ type: 'full-reload' })
|
|
740
|
+
},
|
|
741
|
+
DEV_WATCH_DEBOUNCE_MS,
|
|
742
|
+
err => server.config.logger.error(String(err)),
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
async function runInitialPass(): Promise<void> {
|
|
746
|
+
devOrigin = resolveDevOrigin(server)
|
|
747
|
+
await runDevEagerPass(devOrigin)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (server.httpServer) {
|
|
751
|
+
// The resolved port isn't known until the server actually starts
|
|
752
|
+
// listening — Vite auto-increments past an in-use configured port
|
|
753
|
+
// unless `strictPort` is set, so anything read earlier could be
|
|
754
|
+
// wrong. `configureServer` itself always runs before `listen()`.
|
|
755
|
+
server.httpServer.once('listening', () => {
|
|
756
|
+
runInitialPass().catch(err => server.config.logger.error(String(err)))
|
|
757
|
+
})
|
|
758
|
+
} else {
|
|
759
|
+
// Middleware mode: no `httpServer`, so no `'listening'` event ever
|
|
760
|
+
// fires. Run immediately with whatever origin is already
|
|
761
|
+
// configured (or the bare `localhost:<configured port>` fallback
|
|
762
|
+
// inside `resolveDevOrigin`).
|
|
763
|
+
runInitialPass().catch(err => server.config.logger.error(String(err)))
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// `'change'`: an existing tracked file's content changed.
|
|
767
|
+
server.watcher.on('change', (file: string) => {
|
|
768
|
+
if (!isComponentSourceFile(file) || !isUnderComponentDir(file)) return
|
|
769
|
+
devPassRunner.trigger()
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
// `'add'`: a brand-new component file. Without this, a file created
|
|
773
|
+
// mid-session gets no template at all until some OTHER file happens
|
|
774
|
+
// to change and drags it along on the next full pass — creating a
|
|
775
|
+
// component is completely ordinary, not an edge case.
|
|
776
|
+
server.watcher.on('add', (file: string) => {
|
|
777
|
+
if (!isComponentSourceFile(file) || !isUnderComponentDir(file)) return
|
|
778
|
+
devPassRunner.trigger()
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
// `'unlink'`: a tracked file was deleted. Its template would
|
|
782
|
+
// otherwise linger on disk forever — queue it for `removeEmitsFor`
|
|
783
|
+
// inside the same debounced/serialized pass rather than deleting
|
|
784
|
+
// synchronously here (see `pendingUnlinks` above).
|
|
785
|
+
server.watcher.on('unlink', (file: string) => {
|
|
786
|
+
if (!isComponentSourceFile(file) || !isUnderComponentDir(file)) return
|
|
787
|
+
pendingUnlinks.add(file)
|
|
788
|
+
devPassRunner.trigger()
|
|
789
|
+
})
|
|
790
|
+
},
|
|
791
|
+
}
|
|
792
|
+
}
|