@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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/dist/cache.d.ts +39 -0
  4. package/dist/cache.d.ts.map +1 -0
  5. package/dist/cache.js +56 -0
  6. package/dist/cache.js.map +1 -0
  7. package/dist/config/load.d.ts +59 -0
  8. package/dist/config/load.d.ts.map +1 -0
  9. package/dist/config/load.js +117 -0
  10. package/dist/config/load.js.map +1 -0
  11. package/dist/errors.d.ts +50 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +62 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/generate-mode/babel-traverse.d.ts +5 -0
  16. package/dist/generate-mode/babel-traverse.d.ts.map +1 -0
  17. package/dist/generate-mode/babel-traverse.js +16 -0
  18. package/dist/generate-mode/babel-traverse.js.map +1 -0
  19. package/dist/generate-mode/generate-source.d.ts +51 -0
  20. package/dist/generate-mode/generate-source.d.ts.map +1 -0
  21. package/dist/generate-mode/generate-source.js +187 -0
  22. package/dist/generate-mode/generate-source.js.map +1 -0
  23. package/dist/generate-mode/resolve-schema.d.ts +43 -0
  24. package/dist/generate-mode/resolve-schema.d.ts.map +1 -0
  25. package/dist/generate-mode/resolve-schema.js +182 -0
  26. package/dist/generate-mode/resolve-schema.js.map +1 -0
  27. package/dist/generate-mode/scan-jsx.d.ts +86 -0
  28. package/dist/generate-mode/scan-jsx.d.ts.map +1 -0
  29. package/dist/generate-mode/scan-jsx.js +161 -0
  30. package/dist/generate-mode/scan-jsx.js.map +1 -0
  31. package/dist/hmr.d.ts +44 -0
  32. package/dist/hmr.d.ts.map +1 -0
  33. package/dist/hmr.js +28 -0
  34. package/dist/hmr.js.map +1 -0
  35. package/dist/index.d.ts +61 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +59 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/logger.d.ts +27 -0
  40. package/dist/logger.d.ts.map +1 -0
  41. package/dist/logger.js +46 -0
  42. package/dist/logger.js.map +1 -0
  43. package/dist/plugin.d.ts +61 -0
  44. package/dist/plugin.d.ts.map +1 -0
  45. package/dist/plugin.js +650 -0
  46. package/dist/plugin.js.map +1 -0
  47. package/dist/query-mode/parse-specifier.d.ts +23 -0
  48. package/dist/query-mode/parse-specifier.d.ts.map +1 -0
  49. package/dist/query-mode/parse-specifier.js +83 -0
  50. package/dist/query-mode/parse-specifier.js.map +1 -0
  51. package/dist/query-mode/resolve-id.d.ts +12 -0
  52. package/dist/query-mode/resolve-id.d.ts.map +1 -0
  53. package/dist/query-mode/resolve-id.js +61 -0
  54. package/dist/query-mode/resolve-id.js.map +1 -0
  55. package/dist/query-mode/transform.d.ts +33 -0
  56. package/dist/query-mode/transform.d.ts.map +1 -0
  57. package/dist/query-mode/transform.js +90 -0
  58. package/dist/query-mode/transform.js.map +1 -0
  59. package/dist/resolver-strip.d.ts +58 -0
  60. package/dist/resolver-strip.d.ts.map +1 -0
  61. package/dist/resolver-strip.js +141 -0
  62. package/dist/resolver-strip.js.map +1 -0
  63. package/dist/tsconfig.tsbuildinfo +1 -0
  64. package/dist/types.d.ts +242 -0
  65. package/dist/types.d.ts.map +1 -0
  66. package/dist/types.js +2 -0
  67. package/dist/types.js.map +1 -0
  68. package/dist/write-guard.d.ts +26 -0
  69. package/dist/write-guard.d.ts.map +1 -0
  70. package/dist/write-guard.js +32 -0
  71. package/dist/write-guard.js.map +1 -0
  72. 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