@zod-to-form/vite 0.1.1
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/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/cache.d.ts +39 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +56 -0
- package/dist/cache.js.map +1 -0
- package/dist/config/load.d.ts +59 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +117 -0
- package/dist/config/load.js.map +1 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +62 -0
- package/dist/errors.js.map +1 -0
- package/dist/generate-mode/babel-traverse.d.ts +5 -0
- package/dist/generate-mode/babel-traverse.d.ts.map +1 -0
- package/dist/generate-mode/babel-traverse.js +16 -0
- package/dist/generate-mode/babel-traverse.js.map +1 -0
- package/dist/generate-mode/generate-source.d.ts +51 -0
- package/dist/generate-mode/generate-source.d.ts.map +1 -0
- package/dist/generate-mode/generate-source.js +187 -0
- package/dist/generate-mode/generate-source.js.map +1 -0
- package/dist/generate-mode/resolve-schema.d.ts +43 -0
- package/dist/generate-mode/resolve-schema.d.ts.map +1 -0
- package/dist/generate-mode/resolve-schema.js +182 -0
- package/dist/generate-mode/resolve-schema.js.map +1 -0
- package/dist/generate-mode/scan-jsx.d.ts +86 -0
- package/dist/generate-mode/scan-jsx.d.ts.map +1 -0
- package/dist/generate-mode/scan-jsx.js +161 -0
- package/dist/generate-mode/scan-jsx.js.map +1 -0
- package/dist/hmr.d.ts +44 -0
- package/dist/hmr.d.ts.map +1 -0
- package/dist/hmr.js +28 -0
- package/dist/hmr.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +27 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +46 -0
- package/dist/logger.js.map +1 -0
- package/dist/plugin.d.ts +61 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +650 -0
- package/dist/plugin.js.map +1 -0
- package/dist/query-mode/parse-specifier.d.ts +23 -0
- package/dist/query-mode/parse-specifier.d.ts.map +1 -0
- package/dist/query-mode/parse-specifier.js +83 -0
- package/dist/query-mode/parse-specifier.js.map +1 -0
- package/dist/query-mode/resolve-id.d.ts +12 -0
- package/dist/query-mode/resolve-id.d.ts.map +1 -0
- package/dist/query-mode/resolve-id.js +61 -0
- package/dist/query-mode/resolve-id.js.map +1 -0
- package/dist/query-mode/transform.d.ts +33 -0
- package/dist/query-mode/transform.d.ts.map +1 -0
- package/dist/query-mode/transform.js +90 -0
- package/dist/query-mode/transform.js.map +1 -0
- package/dist/resolver-strip.d.ts +58 -0
- package/dist/resolver-strip.d.ts.map +1 -0
- package/dist/resolver-strip.js +141 -0
- package/dist/resolver-strip.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +242 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/write-guard.d.ts +26 -0
- package/dist/write-guard.d.ts.map +1 -0
- package/dist/write-guard.js +32 -0
- package/dist/write-guard.js.map +1 -0
- package/package.json +69 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin hook wiring.
|
|
3
|
+
*
|
|
4
|
+
* This module assembles the Vite plugin object. Each hook delegates to a
|
|
5
|
+
* pure helper that has its own unit-test coverage:
|
|
6
|
+
*
|
|
7
|
+
* - `resolveId` → `resolveZ2FId`
|
|
8
|
+
* - `load` → `parseZ2FId` + cache lookup + `compileTarget`
|
|
9
|
+
* - `handleHotUpdate` → `computeHmrInvalidation` + module-graph translation
|
|
10
|
+
* - `configureServer` → captures the dev server reference for `load`
|
|
11
|
+
*/
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import { createServer, transformWithEsbuild } from 'vite';
|
|
15
|
+
import { createCompilationCache } from './cache.js';
|
|
16
|
+
import { configHash } from './config/load.js';
|
|
17
|
+
import { Z2FViteError } from './errors.js';
|
|
18
|
+
import { computeHmrInvalidation } from './hmr.js';
|
|
19
|
+
import { createLogger } from './logger.js';
|
|
20
|
+
import { parseZ2FId, resolveZ2FId } from './query-mode/resolve-id.js';
|
|
21
|
+
import { compileTarget } from './query-mode/transform.js';
|
|
22
|
+
import { resolveSchemas } from './generate-mode/resolve-schema.js';
|
|
23
|
+
import { generateSource } from './generate-mode/generate-source.js';
|
|
24
|
+
import { scanJsx } from './generate-mode/scan-jsx.js';
|
|
25
|
+
import { isUseZodFormId, stripResolver } from './resolver-strip.js';
|
|
26
|
+
const PLUGIN_NAME = '@zod-to-form/vite';
|
|
27
|
+
/**
|
|
28
|
+
* Default config used when no `z2f.config.ts` is present and no
|
|
29
|
+
* `configOverride` was supplied. `exportName` is omitted so the plugin
|
|
30
|
+
* auto-detects the single Zod schema export.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_CONFIG = {
|
|
33
|
+
componentName: 'Form',
|
|
34
|
+
mode: 'submit',
|
|
35
|
+
ui: 'html'
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Vite plugin factory for `@zod-to-form/vite`.
|
|
39
|
+
*
|
|
40
|
+
* Registers Vite hooks for:
|
|
41
|
+
* - **Query mode** (`resolveId` + `load`): intercepts `*.ts?z2f[=variant]` imports,
|
|
42
|
+
* evaluates the schema via `ssrLoadModule`, and returns a virtual module containing
|
|
43
|
+
* the generated React form component.
|
|
44
|
+
* - **Generate mode** (`transform`): when `options.generate` is set, scans JSX source
|
|
45
|
+
* files for `<ZodForm schema={X}>` and rewrites resolvable call sites with generated
|
|
46
|
+
* components at build time.
|
|
47
|
+
* - **Resolver tree-shake** (`transform`): removes `zodResolver` calls from `useZodForm`
|
|
48
|
+
* at build time when `validationLevel` is set, allowing bundlers to drop the
|
|
49
|
+
* `@hookform/resolvers` dependency.
|
|
50
|
+
* - **HMR** (`handleHotUpdate`): invalidates cached compiled forms when their schema or
|
|
51
|
+
* the `z2f.config.ts` changes.
|
|
52
|
+
*
|
|
53
|
+
* @param options - Optional plugin configuration. All fields are optional; `z2fVite()`
|
|
54
|
+
* with no arguments produces a working plugin using auto-discovered config.
|
|
55
|
+
* @returns A Vite `Plugin` object to include in `vite.config.ts`.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* // vite.config.ts
|
|
60
|
+
* import { defineConfig } from 'vite';
|
|
61
|
+
* import { z2fVite } from '@zod-to-form/vite';
|
|
62
|
+
*
|
|
63
|
+
* export default defineConfig({
|
|
64
|
+
* plugins: [z2fVite()],
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @useWhen
|
|
69
|
+
* - You want `import SignupForm from './signup.schema?z2f'` to Just Work in a Vite app
|
|
70
|
+
* - You want HMR-aware form recompilation when schemas change in development
|
|
71
|
+
* - You want to run generate mode to pre-compile forms from `<ZodForm>` call sites
|
|
72
|
+
*
|
|
73
|
+
* @avoidWhen
|
|
74
|
+
* - You are building with webpack, esbuild, Rollup, or any non-Vite bundler
|
|
75
|
+
* - Your schemas have cyclic references — the walker will recurse infinitely on them
|
|
76
|
+
* - You need server-side form rendering without a React runtime
|
|
77
|
+
*
|
|
78
|
+
* @pitfalls
|
|
79
|
+
* - NEVER use `?z2f` on schemas with cyclic type references — the schema walker
|
|
80
|
+
* recurses on Zod's internal type graph and hangs on cycles
|
|
81
|
+
* - NEVER enable `generate` mode and then rely on HMR without testing — the
|
|
82
|
+
* generate-mode transform cache does not integrate with Vite's standard HMR
|
|
83
|
+
* module invalidation for rewritten JSX files
|
|
84
|
+
* - NEVER assume Zod types survive Vite's module graph isolation — always export
|
|
85
|
+
* schemas from a dedicated `.schema.ts` file; importing from a module that
|
|
86
|
+
* re-exports through complex chains can fail under `ssrLoadModule`
|
|
87
|
+
* - NEVER configure `configPath` to point outside the Vite `root` — the plugin
|
|
88
|
+
* uses `ssrLoadModule` with a dev server scoped to `root`, so files outside
|
|
89
|
+
* that boundary may fail to resolve their own imports
|
|
90
|
+
*
|
|
91
|
+
* @category Plugin
|
|
92
|
+
*/
|
|
93
|
+
export function z2fVite(options = {}) {
|
|
94
|
+
validateOptions(options);
|
|
95
|
+
const state = {
|
|
96
|
+
options,
|
|
97
|
+
logger: createLogger(options.logLevel ?? 'info'),
|
|
98
|
+
cache: createCompilationCache(),
|
|
99
|
+
generateFilesProcessed: 0,
|
|
100
|
+
generateSitesRewritten: 0,
|
|
101
|
+
// Default include = every TS/JS source file. Defaults set lazily so
|
|
102
|
+
// the empty `generate: {}` case still gets sensible globbing.
|
|
103
|
+
generateInclude: options.generate === undefined
|
|
104
|
+
? null
|
|
105
|
+
: compileGlobs(options.generate.include ?? ['**/*.{ts,tsx,js,jsx}']),
|
|
106
|
+
generateExclude: options.generate === undefined
|
|
107
|
+
? []
|
|
108
|
+
: compileGlobs([...(options.generate.exclude ?? []), '**/node_modules/**', '**/dist/**']),
|
|
109
|
+
resolvedConfig: null,
|
|
110
|
+
devServer: null,
|
|
111
|
+
buildModeServer: null,
|
|
112
|
+
z2fConfig: null,
|
|
113
|
+
lastValidConfig: null,
|
|
114
|
+
configGeneration: 0,
|
|
115
|
+
configFilePath: null
|
|
116
|
+
};
|
|
117
|
+
const isGenerateEnabled = options.generate !== undefined;
|
|
118
|
+
return {
|
|
119
|
+
name: PLUGIN_NAME,
|
|
120
|
+
enforce: 'pre',
|
|
121
|
+
configResolved(resolved) {
|
|
122
|
+
state.resolvedConfig = resolved;
|
|
123
|
+
},
|
|
124
|
+
configureServer(server) {
|
|
125
|
+
state.devServer = server;
|
|
126
|
+
},
|
|
127
|
+
async buildEnd() {
|
|
128
|
+
// Flush the generate-mode skip summary at info level (or higher).
|
|
129
|
+
if (isGenerateEnabled) {
|
|
130
|
+
state.logger.flushGenerateSummary(state.generateFilesProcessed, state.generateSitesRewritten);
|
|
131
|
+
}
|
|
132
|
+
// Tear down the transient SSR loader spun up for build-mode schema
|
|
133
|
+
// evaluation. Safe to call even if it was never created.
|
|
134
|
+
if (state.buildModeServer !== null) {
|
|
135
|
+
await state.buildModeServer.close();
|
|
136
|
+
state.buildModeServer = null;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
async resolveId(source, importer) {
|
|
140
|
+
// Cheap substring check before parseSpecifier
|
|
141
|
+
if (!source.includes('?z2f'))
|
|
142
|
+
return null;
|
|
143
|
+
// Resolve the path portion through Vite's standard resolver so
|
|
144
|
+
// aliases, tsconfig paths, and resolve.extensions all work normally.
|
|
145
|
+
const queryIndex = source.indexOf('?');
|
|
146
|
+
const pathPart = source.slice(0, queryIndex);
|
|
147
|
+
const resolvedPath = await this.resolve(pathPart, importer, { skipSelf: true });
|
|
148
|
+
if (resolvedPath === null) {
|
|
149
|
+
throw new Z2FViteError('Z2F_VITE_SCHEMA_NOT_FOUND', `Could not resolve schema path '${pathPart}' for specifier '${source}'.`);
|
|
150
|
+
}
|
|
151
|
+
const root = state.resolvedConfig?.root ?? process.cwd();
|
|
152
|
+
return resolveZ2FId(source, resolvedPath.id, root);
|
|
153
|
+
},
|
|
154
|
+
async load(id) {
|
|
155
|
+
const parsed = parseZ2FId(id);
|
|
156
|
+
if (parsed === null)
|
|
157
|
+
return null;
|
|
158
|
+
const z2fConfig = await ensureConfig(state);
|
|
159
|
+
// Hash the full z2fConfig (including `variants`). The variant name is
|
|
160
|
+
// carried separately by the cache key, so buildEffectiveConfig's
|
|
161
|
+
// per-variant merge doesn't need to be reflected in the hash — any
|
|
162
|
+
// change to the `variants` table bumps the hash for every variant.
|
|
163
|
+
const hash = configHash(z2fConfig);
|
|
164
|
+
// Build the cache-lookup target. `exportName` is set to the
|
|
165
|
+
// user-configured value or an empty sentinel — the actually-selected
|
|
166
|
+
// name lands on `finalTarget` below after compileTarget resolves it.
|
|
167
|
+
const target = {
|
|
168
|
+
schemaFile: parsed.schemaFile,
|
|
169
|
+
exportName: z2fConfig.exportName ?? '',
|
|
170
|
+
variant: parsed.variant,
|
|
171
|
+
configHash: hash,
|
|
172
|
+
componentName: z2fConfig.componentName ?? 'Form',
|
|
173
|
+
sourceKind: 'query'
|
|
174
|
+
};
|
|
175
|
+
const cached = state.cache.get(target);
|
|
176
|
+
if (cached !== undefined) {
|
|
177
|
+
state.logger.debug(`load cache hit for ${id}`);
|
|
178
|
+
return cached.generatedSource;
|
|
179
|
+
}
|
|
180
|
+
// Cache miss: load the schema module and compile it.
|
|
181
|
+
const namespace = await loadSchemaModule(state, parsed.schemaFile);
|
|
182
|
+
const compiled = compileTarget({
|
|
183
|
+
namespace,
|
|
184
|
+
schemaFile: parsed.schemaFile,
|
|
185
|
+
variant: parsed.variant,
|
|
186
|
+
config: z2fConfig
|
|
187
|
+
});
|
|
188
|
+
// generateFormComponent emits TSX, but the virtual module id keeps
|
|
189
|
+
// the schema's `.ts` extension — esbuild won't apply JSX parsing
|
|
190
|
+
// unless we tell it. Run the source through esbuild here so the
|
|
191
|
+
// returned `code` is valid JavaScript that the rest of Vite's
|
|
192
|
+
// pipeline can treat normally. Source maps from esbuild stack on
|
|
193
|
+
// top of the (currently null) plugin sourcemap.
|
|
194
|
+
//
|
|
195
|
+
// Wrap the call in try/catch so an esbuild rejection (a real bug
|
|
196
|
+
// we'd want to surface) is reported as a typed CODEGEN_FAILURE
|
|
197
|
+
// with the schema file attached, rather than leaking a raw
|
|
198
|
+
// esbuild stack with no breadcrumb.
|
|
199
|
+
let transformed;
|
|
200
|
+
try {
|
|
201
|
+
transformed = await transformWithEsbuild(compiled.generatedSource, `${id}.tsx`, {
|
|
202
|
+
loader: 'tsx',
|
|
203
|
+
sourcemap: true
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
throw new Z2FViteError('Z2F_VITE_CODEGEN_FAILURE', `esbuild failed to transform generated TSX for '${parsed.schemaFile}': ${err instanceof Error ? err.message : String(err)}`, { file: parsed.schemaFile });
|
|
208
|
+
}
|
|
209
|
+
const result = {
|
|
210
|
+
...compiled,
|
|
211
|
+
generatedSource: transformed.code
|
|
212
|
+
};
|
|
213
|
+
// Promote the actually-selected exportName so future cache lookups
|
|
214
|
+
// with the same `id` hit (the inferred name is part of the cache
|
|
215
|
+
// entry's identity).
|
|
216
|
+
const finalTarget = {
|
|
217
|
+
...target,
|
|
218
|
+
exportName: result.exportName,
|
|
219
|
+
componentName: result.effectiveConfig.componentName ?? 'Form'
|
|
220
|
+
};
|
|
221
|
+
state.cache.set(finalTarget, {
|
|
222
|
+
target: finalTarget,
|
|
223
|
+
generatedSource: result.generatedSource,
|
|
224
|
+
schemaLiteSource: result.schemaLiteSource,
|
|
225
|
+
sourceMap: null,
|
|
226
|
+
emittedAt: Date.now()
|
|
227
|
+
});
|
|
228
|
+
state.logger.debug(`load cache miss for ${id} → compiled`);
|
|
229
|
+
return result.generatedSource;
|
|
230
|
+
},
|
|
231
|
+
async transform(code, id) {
|
|
232
|
+
// Resolver tree-shake (FR-013): when the user's effective config
|
|
233
|
+
// has validation optimization enabled, replace every
|
|
234
|
+
// `zodResolver(...)` call in `useZodForm` with `undefined` so
|
|
235
|
+
// Rollup can tree-shake the @hookform/resolvers import. Build-mode
|
|
236
|
+
// only (state.resolvedConfig?.command === 'build') so dev keeps
|
|
237
|
+
// the runtime resolver path for fast iteration.
|
|
238
|
+
if (state.resolvedConfig?.command === 'build' && isUseZodFormId(id)) {
|
|
239
|
+
const z2fConfig = await ensureConfig(state);
|
|
240
|
+
if (z2fConfig.validationLevel !== undefined) {
|
|
241
|
+
const stripped = stripResolver({ source: code });
|
|
242
|
+
if (stripped.rewritten > 0) {
|
|
243
|
+
state.logger.debug(`resolver-strip removed ${stripped.rewritten} zodResolver call(s) from ${id}`);
|
|
244
|
+
return { code: stripped.code, map: stripped.map };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Generate mode is opt-in (FR-024); when `options.generate` is
|
|
249
|
+
// undefined the hook is a no-op for every file.
|
|
250
|
+
if (!isGenerateEnabled)
|
|
251
|
+
return null;
|
|
252
|
+
// Strip the query string before glob-matching so `?z2f` virtual ids
|
|
253
|
+
// aren't accidentally targeted (the substring check below would also
|
|
254
|
+
// exclude them, but this saves the parse).
|
|
255
|
+
const queryIdx = id.indexOf('?');
|
|
256
|
+
const filePath = queryIdx === -1 ? id : id.slice(0, queryIdx);
|
|
257
|
+
// Glob-filter against include / exclude. Filenames must match at
|
|
258
|
+
// least one include pattern and zero exclude patterns.
|
|
259
|
+
if (!matchesAny(filePath, state.generateInclude ?? []))
|
|
260
|
+
return null;
|
|
261
|
+
if (matchesAny(filePath, state.generateExclude))
|
|
262
|
+
return null;
|
|
263
|
+
// Substring fast-path inside scanJsx returns null for files without
|
|
264
|
+
// ZodForm — keep this hook's overhead at zero for the common case.
|
|
265
|
+
const scan = scanJsx(code);
|
|
266
|
+
if (scan === null)
|
|
267
|
+
return null;
|
|
268
|
+
state.generateFilesProcessed += 1;
|
|
269
|
+
// Buffer scan-time skip diagnostics through the logger.
|
|
270
|
+
for (const skip of scan.skipped) {
|
|
271
|
+
state.logger.bufferGenerateSkip(filePath, skip.loc.line, skip.loc.column, skip.reason);
|
|
272
|
+
}
|
|
273
|
+
if (scan.candidates.length === 0) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
// Resolve schemas via Vite's resolver so aliases / tsconfig paths fire.
|
|
277
|
+
const root = state.resolvedConfig?.root ?? process.cwd();
|
|
278
|
+
const resolvePluginContext = this;
|
|
279
|
+
const resolved = await resolveSchemas({
|
|
280
|
+
source: code,
|
|
281
|
+
candidates: scan.candidates,
|
|
282
|
+
sourceFile: filePath,
|
|
283
|
+
viteRoot: root,
|
|
284
|
+
resolveImport: async (specifier, importer) => {
|
|
285
|
+
const r = await resolvePluginContext.resolve(specifier, importer, { skipSelf: true });
|
|
286
|
+
return r === null ? null : r.id;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
// Buffer resolve-time skip diagnostics.
|
|
290
|
+
for (const skip of resolved.skipped) {
|
|
291
|
+
state.logger.bufferGenerateSkip(filePath, skip.candidate.loc.line, skip.candidate.loc.column, skip.reason);
|
|
292
|
+
}
|
|
293
|
+
if (resolved.resolved.length === 0) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const result = generateSource({
|
|
297
|
+
source: code,
|
|
298
|
+
resolved: resolved.resolved,
|
|
299
|
+
onWarn: (message) => state.logger.warn(`${filePath}: ${message}`)
|
|
300
|
+
});
|
|
301
|
+
state.generateSitesRewritten += result.rewritten;
|
|
302
|
+
return { code: result.code, map: result.map };
|
|
303
|
+
},
|
|
304
|
+
handleHotUpdate(ctx) {
|
|
305
|
+
const result = computeHmrInvalidation({
|
|
306
|
+
changedFile: ctx.file,
|
|
307
|
+
configFile: state.configFilePath ?? undefined,
|
|
308
|
+
cache: state.cache
|
|
309
|
+
});
|
|
310
|
+
if (result === null) {
|
|
311
|
+
// Not our file — let Vite's default HMR run.
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
// On a config-file change, drop the cached effective config so the
|
|
315
|
+
// next `load` call re-reads `z2f.config.ts` through ssrLoadModule.
|
|
316
|
+
// Bump the generation counter so any in-flight `ensureConfig` call
|
|
317
|
+
// notices the invalidation and refuses to commit its stale result.
|
|
318
|
+
// `lastValidConfig` is preserved so a syntax error rolls back
|
|
319
|
+
// gracefully (see ensureConfig's catch path).
|
|
320
|
+
if (result.kind === 'config') {
|
|
321
|
+
state.z2fConfig = null;
|
|
322
|
+
state.configGeneration += 1;
|
|
323
|
+
}
|
|
324
|
+
// Translate evicted cache keys to the Vite module graph entries that
|
|
325
|
+
// need invalidation. Each cache key has the form
|
|
326
|
+
// `<schemaFile>::<variant>::<configHash>` and maps to a virtual module
|
|
327
|
+
// whose id is `<schemaFile>?z2f[=variant]`. `getModulesByFile` wants
|
|
328
|
+
// the bare source path (not the id), while `getModuleById` wants the
|
|
329
|
+
// full id with query — we hit both so whichever index Vite has
|
|
330
|
+
// populated gets flushed.
|
|
331
|
+
const moduleGraph = state.devServer?.moduleGraph;
|
|
332
|
+
if (moduleGraph !== undefined) {
|
|
333
|
+
const seen = new Set();
|
|
334
|
+
let unmatched = 0;
|
|
335
|
+
for (const key of result.evictedKeys) {
|
|
336
|
+
const parts = key.split('::');
|
|
337
|
+
const schemaFile = parts[0];
|
|
338
|
+
const variant = parts[1];
|
|
339
|
+
if (parts.length !== 3 || schemaFile === undefined || variant === undefined) {
|
|
340
|
+
state.logger.warn(`malformed cache key during HMR: ${key}`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const query = variant === '' ? '?z2f' : `?z2f=${variant}`;
|
|
344
|
+
const moduleId = `${schemaFile}${query}`;
|
|
345
|
+
let matched = false;
|
|
346
|
+
const byFile = moduleGraph.getModulesByFile(schemaFile);
|
|
347
|
+
if (byFile !== undefined) {
|
|
348
|
+
for (const node of byFile) {
|
|
349
|
+
const nodeKey = node.id ?? node.url ?? '';
|
|
350
|
+
if (!seen.has(nodeKey)) {
|
|
351
|
+
seen.add(nodeKey);
|
|
352
|
+
ctx.modules.push(node);
|
|
353
|
+
matched = true;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const byId = moduleGraph.getModuleById(moduleId);
|
|
358
|
+
if (byId !== undefined) {
|
|
359
|
+
const nodeKey = byId.id ?? byId.url ?? '';
|
|
360
|
+
if (!seen.has(nodeKey)) {
|
|
361
|
+
seen.add(nodeKey);
|
|
362
|
+
ctx.modules.push(byId);
|
|
363
|
+
matched = true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (!matched)
|
|
367
|
+
unmatched += 1;
|
|
368
|
+
}
|
|
369
|
+
if (unmatched > 0) {
|
|
370
|
+
state.logger.warn(`HMR: ${unmatched}/${result.evictedKeys.length} evicted cache keys had no matching module in Vite's graph — those consumers may show stale output until the next full reload`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
state.logger.debug(`HMR ${result.kind} invalidation for ${ctx.file}: ${result.evictedKeys.length} entries`);
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
export default z2fVite;
|
|
379
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
380
|
+
/**
|
|
381
|
+
* Compile a list of glob patterns into RegExps. Supports `**` (any path),
|
|
382
|
+
* `*` (any segment), and `{a,b}` (alternation) — enough for the generate-mode
|
|
383
|
+
* include/exclude config without pulling in a full glob library.
|
|
384
|
+
*/
|
|
385
|
+
function compileGlobs(patterns) {
|
|
386
|
+
return patterns.map((p) => globToRegex(p));
|
|
387
|
+
}
|
|
388
|
+
function globToRegex(glob) {
|
|
389
|
+
// Convert glob to regex: handle braces, then `**`, then `*`, then `?`,
|
|
390
|
+
// escaping every other regex metacharacter.
|
|
391
|
+
let result = '';
|
|
392
|
+
let i = 0;
|
|
393
|
+
while (i < glob.length) {
|
|
394
|
+
const c = glob[i];
|
|
395
|
+
if (c === '*') {
|
|
396
|
+
if (glob[i + 1] === '*') {
|
|
397
|
+
result += '.*';
|
|
398
|
+
i += 2;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
result += '[^/]*';
|
|
402
|
+
i += 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (c === '?') {
|
|
406
|
+
result += '[^/]';
|
|
407
|
+
i += 1;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (c === '{') {
|
|
411
|
+
const close = glob.indexOf('}', i);
|
|
412
|
+
if (close === -1) {
|
|
413
|
+
result += '\\{';
|
|
414
|
+
i += 1;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const inner = glob
|
|
418
|
+
.slice(i + 1, close)
|
|
419
|
+
.split(',')
|
|
420
|
+
.map((s) => s.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
|
421
|
+
result += '(?:' + inner.join('|') + ')';
|
|
422
|
+
i = close + 1;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (/[.+^$(){}|[\]\\]/.test(c)) {
|
|
426
|
+
result += '\\' + c;
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
result += c;
|
|
430
|
+
}
|
|
431
|
+
i += 1;
|
|
432
|
+
}
|
|
433
|
+
return new RegExp('^' + result + '$');
|
|
434
|
+
}
|
|
435
|
+
function matchesAny(filePath, patterns) {
|
|
436
|
+
// Normalize separators for cross-platform matching.
|
|
437
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
438
|
+
return patterns.some((p) => p.test(normalized));
|
|
439
|
+
}
|
|
440
|
+
function validateOptions(options) {
|
|
441
|
+
const allowedKeys = new Set(['configPath', 'configOverride', 'generate', 'write', 'logLevel']);
|
|
442
|
+
for (const key of Object.keys(options)) {
|
|
443
|
+
if (!allowedKeys.has(key)) {
|
|
444
|
+
throw new Z2FViteError('Z2F_VITE_INVALID_OPTIONS', `Unknown plugin option: '${key}'. Allowed: ${Array.from(allowedKeys).sort().join(', ')}.`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (options.logLevel !== undefined) {
|
|
448
|
+
const valid = new Set(['silent', 'warn', 'info', 'debug']);
|
|
449
|
+
if (!valid.has(options.logLevel)) {
|
|
450
|
+
throw new Z2FViteError('Z2F_VITE_INVALID_OPTIONS', `Invalid logLevel '${String(options.logLevel)}'. Allowed: silent, warn, info, debug.`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (options.generate !== undefined) {
|
|
454
|
+
const generateAllowed = new Set(['include', 'exclude']);
|
|
455
|
+
for (const key of Object.keys(options.generate)) {
|
|
456
|
+
if (!generateAllowed.has(key)) {
|
|
457
|
+
throw new Z2FViteError('Z2F_VITE_INVALID_OPTIONS', `Unknown 'generate.${key}' option. Allowed: ${Array.from(generateAllowed).sort().join(', ')}.`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Standard `z2f.config.*` filenames probed by auto-discovery, in priority
|
|
464
|
+
* order. The first existing one wins.
|
|
465
|
+
*/
|
|
466
|
+
const CONFIG_CANDIDATES = ['z2f.config.ts', 'z2f.config.mts', 'z2f.config.js', 'z2f.config.mjs'];
|
|
467
|
+
/**
|
|
468
|
+
* Walk the candidate config filenames in `root` and return the absolute
|
|
469
|
+
* path of the first one that exists, or `null` if none do.
|
|
470
|
+
*/
|
|
471
|
+
async function discoverConfigPath(root) {
|
|
472
|
+
for (const name of CONFIG_CANDIDATES) {
|
|
473
|
+
const candidate = path.join(root, name);
|
|
474
|
+
try {
|
|
475
|
+
await fs.access(candidate);
|
|
476
|
+
return candidate;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// Not present — try the next.
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Load (or return cached) the user's `z2f.config.ts`, merging
|
|
486
|
+
* `options.configOverride` on top.
|
|
487
|
+
*
|
|
488
|
+
* Resolution order:
|
|
489
|
+
* 1. `options.configPath` if explicitly set
|
|
490
|
+
* 2. Auto-discovery of `z2f.config.{ts,mts,js,mjs}` in the Vite root
|
|
491
|
+
* 3. Fall back to `DEFAULT_CONFIG` merged with `options.configOverride`
|
|
492
|
+
*
|
|
493
|
+
* If a load attempt FAILS and we already had a previously-valid config
|
|
494
|
+
* (from before an HMR-triggered reload), we keep the old config and log
|
|
495
|
+
* a warning. This is the FR-010 / SC-008 "dev server stays alive when
|
|
496
|
+
* the config has a syntax error" contract — the user can fix the file
|
|
497
|
+
* and the next save will retry.
|
|
498
|
+
*/
|
|
499
|
+
async function ensureConfig(state) {
|
|
500
|
+
if (state.z2fConfig !== null)
|
|
501
|
+
return state.z2fConfig;
|
|
502
|
+
// Capture the generation at the start of the load. If a config-file
|
|
503
|
+
// HMR fires while we're awaiting ssrLoadModule the counter advances,
|
|
504
|
+
// and we'll skip our final assignment so the next caller re-runs
|
|
505
|
+
// against the new file. This is the lock-free way to keep a stale
|
|
506
|
+
// load from overwriting the just-cleared cache.
|
|
507
|
+
const generationAtStart = state.configGeneration;
|
|
508
|
+
// Resolve the config file path on first call (or after HMR invalidated
|
|
509
|
+
// it — discoverConfigPath re-runs on every cleared state).
|
|
510
|
+
if (state.configFilePath === null) {
|
|
511
|
+
if (state.options.configPath !== undefined) {
|
|
512
|
+
state.configFilePath = state.options.configPath;
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
const root = state.resolvedConfig?.root ?? process.cwd();
|
|
516
|
+
state.configFilePath = await discoverConfigPath(root);
|
|
517
|
+
if (state.configFilePath !== null) {
|
|
518
|
+
state.logger.debug(`auto-discovered config at ${state.configFilePath}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
let loaded = {};
|
|
523
|
+
if (state.configFilePath !== null) {
|
|
524
|
+
// Distinguish "file vanished" (ENOENT — user renamed/deleted the
|
|
525
|
+
// config) from "file is invalid" (syntax/runtime error inside it).
|
|
526
|
+
// ENOENT means the discovery was stale and should be re-run; we
|
|
527
|
+
// can't keep falling back to a previously-valid config indefinitely
|
|
528
|
+
// because the user might have intentionally migrated the filename.
|
|
529
|
+
let fileExists = true;
|
|
530
|
+
try {
|
|
531
|
+
await fs.access(state.configFilePath);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
fileExists = false;
|
|
535
|
+
}
|
|
536
|
+
if (!fileExists) {
|
|
537
|
+
state.logger.warn(`config file '${state.configFilePath}' no longer exists — re-running auto-discovery`);
|
|
538
|
+
state.configFilePath = null;
|
|
539
|
+
state.lastValidConfig = null;
|
|
540
|
+
// Re-discover from the root and continue with the result.
|
|
541
|
+
const root = state.resolvedConfig?.root ?? process.cwd();
|
|
542
|
+
state.configFilePath = await discoverConfigPath(root);
|
|
543
|
+
if (state.configFilePath === null) {
|
|
544
|
+
state.logger.info(`no z2f.config.* found after re-discovery — compiling forms with defaults`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (state.configFilePath !== null) {
|
|
549
|
+
const loader = state.devServer ?? (await ensureBuildModeServer(state));
|
|
550
|
+
try {
|
|
551
|
+
// Force re-read after a config-file HMR by invalidating Vite's
|
|
552
|
+
// own SSR module cache for the file. Without this the loader hands
|
|
553
|
+
// back the stale namespace.
|
|
554
|
+
const moduleNode = loader.moduleGraph.getModuleById(state.configFilePath);
|
|
555
|
+
if (moduleNode !== undefined) {
|
|
556
|
+
loader.moduleGraph.invalidateModule(moduleNode);
|
|
557
|
+
}
|
|
558
|
+
const mod = await loader.ssrLoadModule(state.configFilePath);
|
|
559
|
+
loaded =
|
|
560
|
+
mod.default ?? mod;
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
// If we have a previously-valid config (this is an HMR-triggered
|
|
564
|
+
// reload after the user introduced a syntax error), log and keep
|
|
565
|
+
// the old one so the dev server stays serving.
|
|
566
|
+
if (state.lastValidConfig !== null) {
|
|
567
|
+
state.logger.warn(`failed to reload config '${state.configFilePath}' (${err.message}); keeping previous valid config`);
|
|
568
|
+
// Only commit the fallback if no newer HMR has fired. If the
|
|
569
|
+
// user is in the middle of editing and saves twice quickly,
|
|
570
|
+
// the second save's HMR must win.
|
|
571
|
+
if (state.configGeneration === generationAtStart) {
|
|
572
|
+
state.z2fConfig = state.lastValidConfig;
|
|
573
|
+
}
|
|
574
|
+
return state.lastValidConfig;
|
|
575
|
+
}
|
|
576
|
+
throw new Z2FViteError('Z2F_VITE_CONFIG_INVALID', `Failed to load config '${state.configFilePath}': ${err.message}`, { file: state.configFilePath });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else if (state.options.configOverride === undefined) {
|
|
580
|
+
state.logger.info(`no z2f.config.* found in project root and no configOverride supplied — compiling forms with defaults`);
|
|
581
|
+
}
|
|
582
|
+
const computed = {
|
|
583
|
+
...DEFAULT_CONFIG,
|
|
584
|
+
...loaded,
|
|
585
|
+
...state.options.configOverride
|
|
586
|
+
};
|
|
587
|
+
// Generation race: if the config file changed underneath us during the
|
|
588
|
+
// ssrLoadModule await, refuse to commit our stale result. The next
|
|
589
|
+
// `ensureConfig` call (after the now-cleared state.z2fConfig is read
|
|
590
|
+
// as null) re-runs against the new file. We still return the computed
|
|
591
|
+
// value to the current caller so this load completes — but we don't
|
|
592
|
+
// poison the cache.
|
|
593
|
+
if (state.configGeneration === generationAtStart) {
|
|
594
|
+
state.z2fConfig = computed;
|
|
595
|
+
state.lastValidConfig = computed;
|
|
596
|
+
}
|
|
597
|
+
return computed;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Load a schema module via the appropriate SSR loader.
|
|
601
|
+
*
|
|
602
|
+
* In dev mode we use the captured `state.devServer.ssrLoadModule`. In
|
|
603
|
+
* build mode there is no dev server, so we lazily spin up a transient
|
|
604
|
+
* middleware-mode server with a near-empty config (no plugins beyond
|
|
605
|
+
* the user's config-resolved root) and use IT for `ssrLoadModule`. The
|
|
606
|
+
* transient server inherits the user's `resolve`, `define`, and
|
|
607
|
+
* tsconfig-paths plumbing through Vite's normal config discovery, so
|
|
608
|
+
* the schema evaluation matches what dev mode would see — guaranteeing
|
|
609
|
+
* SC-006 byte-for-byte parity.
|
|
610
|
+
*
|
|
611
|
+
* `ssrLoadModule` can throw for many reasons — TS syntax error, import
|
|
612
|
+
* failure, runtime error inside the schema file — so we wrap every
|
|
613
|
+
* failure in a typed `Z2F_VITE_CODEGEN_FAILURE` with the schema file
|
|
614
|
+
* attached, rather than letting raw Vite stack traces leak to the user.
|
|
615
|
+
*/
|
|
616
|
+
async function loadSchemaModule(state, schemaFile) {
|
|
617
|
+
const loader = state.devServer ?? (await ensureBuildModeServer(state));
|
|
618
|
+
try {
|
|
619
|
+
return await loader.ssrLoadModule(schemaFile);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
throw new Z2FViteError('Z2F_VITE_CODEGEN_FAILURE', `Failed to load schema module '${schemaFile}': ${err.message}`, { file: schemaFile });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Lazily create the transient build-mode SSR server. Reuses the user's
|
|
627
|
+
* resolved Vite config (root, resolve aliases, tsconfig paths) but skips
|
|
628
|
+
* the plugin pipeline — including this plugin itself — so we don't
|
|
629
|
+
* recursively trigger `?z2f` resolution while loading user schemas.
|
|
630
|
+
*/
|
|
631
|
+
async function ensureBuildModeServer(state) {
|
|
632
|
+
if (state.buildModeServer !== null)
|
|
633
|
+
return state.buildModeServer;
|
|
634
|
+
const root = state.resolvedConfig?.root ?? process.cwd();
|
|
635
|
+
const userResolve = state.resolvedConfig?.resolve;
|
|
636
|
+
const server = await createServer({
|
|
637
|
+
root,
|
|
638
|
+
configFile: false,
|
|
639
|
+
server: { middlewareMode: true },
|
|
640
|
+
appType: 'custom',
|
|
641
|
+
logLevel: 'silent',
|
|
642
|
+
optimizeDeps: { noDiscovery: true },
|
|
643
|
+
// Inherit the user's resolve config (aliases, tsconfig paths) so the
|
|
644
|
+
// transient server sees imports the same way the real build does.
|
|
645
|
+
...(userResolve !== undefined ? { resolve: userResolve } : {})
|
|
646
|
+
});
|
|
647
|
+
state.buildModeServer = server;
|
|
648
|
+
return server;
|
|
649
|
+
}
|
|
650
|
+
//# sourceMappingURL=plugin.js.map
|