vite-plugin-typed-env 0.1.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/.claude/settings.local.json +8 -0
- package/.prettierrc.cjs +14 -0
- package/README.md +194 -0
- package/package.json +38 -0
- package/src/generator.ts +119 -0
- package/src/index.ts +174 -0
- package/src/inferrer.ts +165 -0
- package/src/parser.ts +72 -0
- package/tests/index.test.ts +154 -0
- package/tsconfig.json +12 -0
- package/tsdown.config.ts +9 -0
package/.prettierrc.cjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
printWidth: 120,
|
|
3
|
+
tabWidth: 2,
|
|
4
|
+
useTabs: false,
|
|
5
|
+
semi: false,
|
|
6
|
+
vueIndentScriptAndStyle: true,
|
|
7
|
+
trailingComma: 'none',
|
|
8
|
+
singleQuote: true,
|
|
9
|
+
bracketSpacing: true,
|
|
10
|
+
bracketSameLine: true,
|
|
11
|
+
arrowParens: 'always',
|
|
12
|
+
requirePragma: false,
|
|
13
|
+
insertPragma: false
|
|
14
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# vite-plugin-typed-env
|
|
2
|
+
|
|
3
|
+
A Vite plugin that automatically generates TypeScript types and Zod schemas from your `.env` files.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Auto-generated TypeScript types** - `env.d.ts` with proper type inference
|
|
8
|
+
- **Zod schema generation** - Runtime validation with `env.schema.ts`
|
|
9
|
+
- **Runtime loader** - `env.ts` that validates and exposes typed environment variables
|
|
10
|
+
- **Vite `import.meta.env` augmentation** - Full type support for Vite's env system
|
|
11
|
+
- **Hot reload** - Automatically regenerate types when `.env` files change
|
|
12
|
+
- **Smart type inference** - Auto-detects types from values (boolean, number, URL, arrays, etc.)
|
|
13
|
+
- **Annotation support** - Fine-grained type control via special comments
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install vite-plugin-typed-env -D
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### 1. Add to Vite config
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// vite.config.ts
|
|
27
|
+
import envTs from 'vite-plugin-typed-env'
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
plugins: [envTs()]
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 2. Write your `.env` file
|
|
35
|
+
|
|
36
|
+
```env
|
|
37
|
+
# Database configuration
|
|
38
|
+
DATABASE_URL=postgres://localhost:5432/mydb
|
|
39
|
+
|
|
40
|
+
# API keys
|
|
41
|
+
# @optional
|
|
42
|
+
API_KEY=
|
|
43
|
+
|
|
44
|
+
# Server settings
|
|
45
|
+
# @type: port
|
|
46
|
+
# @desc: The port the server listens on
|
|
47
|
+
PORT=3000
|
|
48
|
+
|
|
49
|
+
# Feature flags
|
|
50
|
+
# @type: boolean
|
|
51
|
+
DEBUG=true
|
|
52
|
+
|
|
53
|
+
# Allowed origins (comma-separated)
|
|
54
|
+
# @type: string[]
|
|
55
|
+
ALLOWED_ORIGINS=http://localhost,https://example.com
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Generated files
|
|
59
|
+
|
|
60
|
+
The plugin generates three files in your configured output directory (default: `src/`):
|
|
61
|
+
|
|
62
|
+
#### `env.d.ts` - TypeScript declarations
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
interface ImportMetaEnv {
|
|
66
|
+
readonly DATABASE_URL: string
|
|
67
|
+
readonly API_KEY?: string
|
|
68
|
+
/** The port the server listens on */
|
|
69
|
+
readonly PORT: number
|
|
70
|
+
readonly DEBUG: boolean
|
|
71
|
+
readonly ALLOWED_ORIGINS: string[]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### `env.schema.ts` - Zod validation schema
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { z } from 'zod'
|
|
79
|
+
|
|
80
|
+
export const envSchema = z.object({
|
|
81
|
+
DATABASE_URL: z.string().url(),
|
|
82
|
+
API_KEY: z.string().optional(),
|
|
83
|
+
// The port the server listens on
|
|
84
|
+
PORT: z.coerce.number().int().min(1).max(65535),
|
|
85
|
+
DEBUG: z.enum(['true', 'false', '1', '0']).transform((v) => v === 'true' || v === '1'),
|
|
86
|
+
ALLOWED_ORIGINS: z.string().transform((v) => v.split(',').map((s) => s.trim()))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
export type Env = z.infer<typeof envSchema>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### `env.ts` - Runtime loader
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { envSchema } from './env.schema'
|
|
96
|
+
|
|
97
|
+
const _parsed = envSchema.safeParse(import.meta.env)
|
|
98
|
+
|
|
99
|
+
if (!_parsed.success) {
|
|
100
|
+
throw new Error('[env-ts] Invalid environment variables')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const env = _parsed.data
|
|
104
|
+
export default env
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 4. Use in your code
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// With schema validation
|
|
111
|
+
import env from './env'
|
|
112
|
+
|
|
113
|
+
console.log(env.PORT) // fully typed!
|
|
114
|
+
|
|
115
|
+
// Or use Vite's import.meta.env
|
|
116
|
+
console.log(import.meta.env.PORT) // also typed!
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Annotations
|
|
120
|
+
|
|
121
|
+
Control type generation with special comments:
|
|
122
|
+
|
|
123
|
+
| Annotation | Example | Description |
|
|
124
|
+
| ----------- | ---------------------- | -------------------------------- |
|
|
125
|
+
| `@type` | `# @type: number` | Override inferred type |
|
|
126
|
+
| `@optional` | `# @optional` | Mark variable as optional |
|
|
127
|
+
| `@default` | `# @default: 8080` | Provide default value |
|
|
128
|
+
| `@desc` | `# @desc: Server port` | Add description (shows in JSDoc) |
|
|
129
|
+
|
|
130
|
+
### Supported `@type` values
|
|
131
|
+
|
|
132
|
+
| Type | TypeScript | Zod Schema |
|
|
133
|
+
| ------------- | ------------------- | ----------------------------------------------------- |
|
|
134
|
+
| `number` | `number` | `z.coerce.number()` |
|
|
135
|
+
| `boolean` | `boolean` | `z.enum([...]).transform()` |
|
|
136
|
+
| `url` | `string` | `z.string().url()` |
|
|
137
|
+
| `port` | `number` | `z.coerce.number().int().min(1).max(65535)` |
|
|
138
|
+
| `email` | `string` | `z.string().email()` |
|
|
139
|
+
| `string[]` | `string[]` | `z.string().transform(v => v.split(','))` |
|
|
140
|
+
| `number[]` | `number[]` | `z.string().transform(v => v.split(',').map(Number))` |
|
|
141
|
+
| `enum(a,b,c)` | `'a' \| 'b' \| 'c'` | `z.enum(['a','b','c'])` |
|
|
142
|
+
|
|
143
|
+
## Options
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
envTs({
|
|
147
|
+
// Generate Zod schema file
|
|
148
|
+
// @default 'zod'
|
|
149
|
+
schema: 'zod' | false,
|
|
150
|
+
|
|
151
|
+
// Output directory (relative to project root)
|
|
152
|
+
// @default 'src'
|
|
153
|
+
output: 'src',
|
|
154
|
+
|
|
155
|
+
// Augment Vite's ImportMetaEnv type
|
|
156
|
+
// @default true
|
|
157
|
+
augmentImportMeta: true,
|
|
158
|
+
|
|
159
|
+
// Fail build if required vars are missing
|
|
160
|
+
// @default true
|
|
161
|
+
strict: true,
|
|
162
|
+
|
|
163
|
+
// Additional .env files to watch
|
|
164
|
+
// @default []
|
|
165
|
+
envFiles: ['.env.custom']
|
|
166
|
+
})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Type Inference
|
|
170
|
+
|
|
171
|
+
The plugin automatically infers types from values:
|
|
172
|
+
|
|
173
|
+
| Value Pattern | Inferred Type |
|
|
174
|
+
| -------------------------------------- | ------------------------------ |
|
|
175
|
+
| `true`, `false`, `1`, `0`, `yes`, `no` | `boolean` |
|
|
176
|
+
| Pure numbers (`3000`, `3.14`) | `number` |
|
|
177
|
+
| URLs (`http://...`, `postgres://...`) | `string` (with URL validation) |
|
|
178
|
+
| Comma-separated numbers (`1,2,3`) | `number[]` |
|
|
179
|
+
| Comma-separated strings (`a,b,c`) | `string[]` |
|
|
180
|
+
| Empty value | `string` (optional) |
|
|
181
|
+
| Everything else | `string` |
|
|
182
|
+
|
|
183
|
+
## Env File Priority
|
|
184
|
+
|
|
185
|
+
Files are loaded in this order (later overrides earlier):
|
|
186
|
+
|
|
187
|
+
1. `.env`
|
|
188
|
+
2. `.env.local`
|
|
189
|
+
3. `.env.{NODE_ENV}`
|
|
190
|
+
4. `.env.{NODE_ENV}.local`
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vite-plugin-typed-env",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": {
|
|
11
|
+
"types": "./dist/index.d.mts",
|
|
12
|
+
"default": "./dist/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"require": {
|
|
15
|
+
"types": "./dist/index.d.cts",
|
|
16
|
+
"default": "./dist/index.cjs"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsdown",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"*.ts\" \"*.cjs\" \"*.json\" \"*.md\"",
|
|
25
|
+
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"*.ts\" \"*.cjs\" \"*.json\" \"*.md\""
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^25.5.2",
|
|
29
|
+
"prettier": "^3.8.2",
|
|
30
|
+
"tsdown": "^0.21.7",
|
|
31
|
+
"typescript": "^6.0.2",
|
|
32
|
+
"vite": "^8.0.8",
|
|
33
|
+
"vitest": "^4.1.4"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"vite": ">=4.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/generator.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { EnvEntry } from './parser'
|
|
2
|
+
import type { InferredType } from './inferrer'
|
|
3
|
+
|
|
4
|
+
export interface GenerateOptions {
|
|
5
|
+
schema: 'zod' | 'valibot' | false
|
|
6
|
+
/** 是否扩展 Vite 的 ImportMetaEnv */
|
|
7
|
+
augmentImportMeta: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface EntryWithType {
|
|
11
|
+
entry: EnvEntry
|
|
12
|
+
inferred: InferredType
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── env.d.ts ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function generateDts(items: EntryWithType[], options: GenerateOptions): string {
|
|
18
|
+
const lines: string[] = []
|
|
19
|
+
|
|
20
|
+
lines.push(`// Auto-generated by vite-plugin-typed-env. DO NOT EDIT.`)
|
|
21
|
+
lines.push(`// Re-run vite to regenerate this file.`)
|
|
22
|
+
lines.push(``)
|
|
23
|
+
|
|
24
|
+
if (options.augmentImportMeta) {
|
|
25
|
+
lines.push(`/// <reference types="vite/client" />`)
|
|
26
|
+
lines.push(``)
|
|
27
|
+
lines.push(`interface ImportMetaEnv {`)
|
|
28
|
+
for (const { entry, inferred } of items) {
|
|
29
|
+
if (entry.annotations.description) {
|
|
30
|
+
lines.push(` /** ${entry.annotations.description} */`)
|
|
31
|
+
}
|
|
32
|
+
const opt = inferred.isOptional ? '?' : ''
|
|
33
|
+
lines.push(` readonly ${entry.key}${opt}: ${inferred.tsType}`)
|
|
34
|
+
}
|
|
35
|
+
lines.push(`}`)
|
|
36
|
+
lines.push(``)
|
|
37
|
+
lines.push(`interface ImportMeta {`)
|
|
38
|
+
lines.push(` readonly env: ImportMetaEnv`)
|
|
39
|
+
lines.push(`}`)
|
|
40
|
+
} else {
|
|
41
|
+
lines.push(`export interface Env {`)
|
|
42
|
+
for (const { entry, inferred } of items) {
|
|
43
|
+
if (entry.annotations.description) {
|
|
44
|
+
lines.push(` /** ${entry.annotations.description} */`)
|
|
45
|
+
}
|
|
46
|
+
const opt = inferred.isOptional ? '?' : ''
|
|
47
|
+
lines.push(` ${entry.key}${opt}: ${inferred.tsType}`)
|
|
48
|
+
}
|
|
49
|
+
lines.push(`}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return lines.join('\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── env.schema.ts ───────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export function generateZodSchema(items: EntryWithType[]): string {
|
|
58
|
+
const lines: string[] = []
|
|
59
|
+
|
|
60
|
+
lines.push(`// Auto-generated by vite-plugin-typed-env. DO NOT EDIT.`)
|
|
61
|
+
lines.push(`import { z } from 'zod'`)
|
|
62
|
+
lines.push(``)
|
|
63
|
+
lines.push(`export const envSchema = z.object({`)
|
|
64
|
+
|
|
65
|
+
for (const { entry, inferred } of items) {
|
|
66
|
+
if (entry.annotations.description) {
|
|
67
|
+
lines.push(` // ${entry.annotations.description}`)
|
|
68
|
+
}
|
|
69
|
+
lines.push(` ${entry.key}: ${inferred.zodSchema},`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push(`})`)
|
|
73
|
+
lines.push(``)
|
|
74
|
+
lines.push(`export type Env = z.infer<typeof envSchema>`)
|
|
75
|
+
|
|
76
|
+
return lines.join('\n')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── env.ts (runtime loader) ─────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function generateLoader(items: EntryWithType[], options: GenerateOptions): string {
|
|
82
|
+
const lines: string[] = []
|
|
83
|
+
|
|
84
|
+
lines.push(`// Auto-generated by vite-plugin-typed-env. DO NOT EDIT.`)
|
|
85
|
+
lines.push(``)
|
|
86
|
+
|
|
87
|
+
if (options.schema === 'zod') {
|
|
88
|
+
lines.push(`import { envSchema } from './env.schema'`)
|
|
89
|
+
lines.push(``)
|
|
90
|
+
lines.push(`const _parsed = envSchema.safeParse(import.meta.env)`)
|
|
91
|
+
lines.push(``)
|
|
92
|
+
lines.push(`if (!_parsed.success) {`)
|
|
93
|
+
lines.push(` const errors = _parsed.error.flatten().fieldErrors`)
|
|
94
|
+
lines.push(` const msg = Object.entries(errors)`)
|
|
95
|
+
lines.push(` .map(([k, v]) => \` \${k}: \${(v as string[]).join(', ')}\`)`)
|
|
96
|
+
lines.push(` .join('\\n')`)
|
|
97
|
+
lines.push(` throw new Error(\`[env-ts] Invalid environment variables:\\n\${msg}\`)`)
|
|
98
|
+
lines.push(`}`)
|
|
99
|
+
lines.push(``)
|
|
100
|
+
lines.push(`export const env = _parsed.data`)
|
|
101
|
+
lines.push(`export default env`)
|
|
102
|
+
} else {
|
|
103
|
+
// 无 schema 时,直接做简单的必填检查
|
|
104
|
+
const required = items.filter(({ inferred }) => !inferred.isOptional)
|
|
105
|
+
lines.push(`const _raw = import.meta.env`)
|
|
106
|
+
lines.push(``)
|
|
107
|
+
if (required.length > 0) {
|
|
108
|
+
lines.push(`const _required = [${required.map(({ entry }) => `'${entry.key}'`).join(', ')}] as const`)
|
|
109
|
+
lines.push(`for (const key of _required) {`)
|
|
110
|
+
lines.push(` if (!_raw[key]) throw new Error(\`[env-ts] Missing required env var: \${key}\`)`)
|
|
111
|
+
lines.push(`}`)
|
|
112
|
+
lines.push(``)
|
|
113
|
+
}
|
|
114
|
+
lines.push(`export const env = _raw as import('./env').Env`)
|
|
115
|
+
lines.push(`export default env`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return lines.join('\n')
|
|
119
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { Plugin, ResolvedConfig } from 'vite'
|
|
4
|
+
import { parseEnvFile, type EnvEntry } from './parser'
|
|
5
|
+
import { inferType } from './inferrer'
|
|
6
|
+
import { generateDts, generateZodSchema, generateLoader } from './generator'
|
|
7
|
+
|
|
8
|
+
export interface EnvTsOptions {
|
|
9
|
+
/**
|
|
10
|
+
* 生成 Zod schema 文件
|
|
11
|
+
* @default 'zod'
|
|
12
|
+
*/
|
|
13
|
+
schema?: 'zod' | false
|
|
14
|
+
/**
|
|
15
|
+
* 生成文件的输出目录(相对于项目根目录)
|
|
16
|
+
* @default 'src'
|
|
17
|
+
*/
|
|
18
|
+
output?: string
|
|
19
|
+
/**
|
|
20
|
+
* 是否扩展 Vite 的 ImportMetaEnv 类型
|
|
21
|
+
* 开启后 import.meta.env.YOUR_VAR 自动有类型
|
|
22
|
+
* @default true
|
|
23
|
+
*/
|
|
24
|
+
augmentImportMeta?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* 缺失必填变量时是否让构建失败
|
|
27
|
+
* @default true
|
|
28
|
+
*/
|
|
29
|
+
strict?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* 额外监听的 .env 文件(默认自动检测 .env, .env.local 等)
|
|
32
|
+
*/
|
|
33
|
+
envFiles?: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── 核心函数 ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export async function generateTypes(envDir: string, outputDir: string, options: Required<EnvTsOptions>): Promise<void> {
|
|
39
|
+
// 1. 找到所有 .env 文件(按优先级顺序读取,后面的覆盖前面的)
|
|
40
|
+
const envFileNames = [
|
|
41
|
+
'.env',
|
|
42
|
+
'.env.local',
|
|
43
|
+
`.env.${process.env.NODE_ENV ?? 'development'}`,
|
|
44
|
+
`.env.${process.env.NODE_ENV ?? 'development'}.local`,
|
|
45
|
+
...options.envFiles
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const entries = new Map<string, EnvEntry>()
|
|
49
|
+
|
|
50
|
+
for (const fileName of envFileNames) {
|
|
51
|
+
const filePath = path.join(envDir, fileName)
|
|
52
|
+
if (!fs.existsSync(filePath)) continue
|
|
53
|
+
|
|
54
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
55
|
+
const parsed = parseEnvFile(content)
|
|
56
|
+
|
|
57
|
+
// 后读的文件覆盖先读的(保留注释/annotation)
|
|
58
|
+
for (const entry of parsed) {
|
|
59
|
+
entries.set(entry.key, entry)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (entries.size === 0) {
|
|
64
|
+
console.warn('[env-ts] No .env files found or all are empty, skipping generation.')
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. 对每个变量做类型推断
|
|
69
|
+
const items = Array.from(entries.values()).map((entry) => ({
|
|
70
|
+
entry,
|
|
71
|
+
inferred: inferType(entry)
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
// strict 模式:检查必填变量在 process.env 里是否真实存在
|
|
75
|
+
if (options.strict) {
|
|
76
|
+
const missing = items
|
|
77
|
+
.filter(({ inferred }) => !inferred.isOptional && inferred.defaultValue === undefined)
|
|
78
|
+
.filter(({ entry }) => {
|
|
79
|
+
const v = process.env[entry.key]
|
|
80
|
+
return v === undefined || v === ''
|
|
81
|
+
})
|
|
82
|
+
.map(({ entry }) => entry.key)
|
|
83
|
+
|
|
84
|
+
if (missing.length > 0) {
|
|
85
|
+
// 开发时警告,构建时报错
|
|
86
|
+
const msg = `[env-ts] Missing required env variables: ${missing.join(', ')}`
|
|
87
|
+
if (process.env.NODE_ENV === 'production') {
|
|
88
|
+
throw new Error(msg)
|
|
89
|
+
} else {
|
|
90
|
+
console.warn(msg)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. 确保输出目录存在
|
|
96
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
97
|
+
|
|
98
|
+
const genOptions = {
|
|
99
|
+
schema: options.schema,
|
|
100
|
+
augmentImportMeta: options.augmentImportMeta
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 4. 生成 env.d.ts
|
|
104
|
+
const dtsContent = generateDts(items, genOptions)
|
|
105
|
+
writeIfChanged(path.join(outputDir, 'env.d.ts'), dtsContent)
|
|
106
|
+
|
|
107
|
+
// 5. 生成 env.schema.ts(可选)
|
|
108
|
+
if (options.schema === 'zod') {
|
|
109
|
+
const schemaContent = generateZodSchema(items)
|
|
110
|
+
writeIfChanged(path.join(outputDir, 'env.schema.ts'), schemaContent)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 6. 生成 env.ts(运行时 loader)
|
|
114
|
+
const loaderContent = generateLoader(items, genOptions)
|
|
115
|
+
writeIfChanged(path.join(outputDir, 'env.ts'), loaderContent)
|
|
116
|
+
|
|
117
|
+
console.log(`[env-ts] Generated ${items.length} env types → ${path.relative(process.cwd(), outputDir)}/`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 只在内容变化时写文件,避免触发不必要的热更新
|
|
121
|
+
function writeIfChanged(filePath: string, content: string): void {
|
|
122
|
+
if (fs.existsSync(filePath)) {
|
|
123
|
+
const existing = fs.readFileSync(filePath, 'utf-8')
|
|
124
|
+
if (existing === content) return
|
|
125
|
+
}
|
|
126
|
+
fs.writeFileSync(filePath, content, 'utf-8')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Vite 插件 ────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export default function envTs(userOptions: EnvTsOptions = {}): Plugin {
|
|
132
|
+
const options: Required<EnvTsOptions> = {
|
|
133
|
+
schema: 'zod',
|
|
134
|
+
output: 'src',
|
|
135
|
+
augmentImportMeta: true,
|
|
136
|
+
strict: true,
|
|
137
|
+
envFiles: [],
|
|
138
|
+
...userOptions
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let config: ResolvedConfig
|
|
142
|
+
let outputDir: string
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name: 'vite-plugin-typed-env',
|
|
146
|
+
enforce: 'pre', // 在其他插件之前运行,确保类型文件先生成
|
|
147
|
+
|
|
148
|
+
configResolved(resolvedConfig) {
|
|
149
|
+
config = resolvedConfig
|
|
150
|
+
outputDir = path.resolve(config.root, options.output)
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async buildStart() {
|
|
154
|
+
const envDir = config.envDir === false ? config.root : config.envDir
|
|
155
|
+
await generateTypes(envDir, outputDir, options)
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async handleHotUpdate({ file, server }) {
|
|
159
|
+
if (!file) return
|
|
160
|
+
|
|
161
|
+
const fileName = path.basename(file)
|
|
162
|
+
const isEnvFile = fileName.startsWith('.env') || options.envFiles.includes(file)
|
|
163
|
+
|
|
164
|
+
if (!isEnvFile) return
|
|
165
|
+
|
|
166
|
+
console.log(`[env-ts] Detected change in ${fileName}, regenerating...`)
|
|
167
|
+
const envDir = config.envDir === false ? config.root : config.envDir
|
|
168
|
+
await generateTypes(envDir, outputDir, options)
|
|
169
|
+
|
|
170
|
+
// 通知 client 有文件更新(触发 TS 语言服务刷新)
|
|
171
|
+
server.hot.send({ type: 'full-reload' })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/inferrer.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { EnvEntry } from './parser'
|
|
2
|
+
|
|
3
|
+
export interface InferredType {
|
|
4
|
+
tsType: string // TypeScript 类型字符串
|
|
5
|
+
zodSchema: string // Zod schema 字符串
|
|
6
|
+
isOptional: boolean
|
|
7
|
+
defaultValue?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 判断是否是布尔值
|
|
11
|
+
function isBoolean(v: string): boolean {
|
|
12
|
+
return ['true', 'false', '1', '0', 'yes', 'no'].includes(v.toLowerCase())
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 判断是否是纯数字
|
|
16
|
+
function isNumber(v: string): boolean {
|
|
17
|
+
return v !== '' && !isNaN(Number(v))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 判断是否是 URL
|
|
21
|
+
function isUrl(v: string): boolean {
|
|
22
|
+
try {
|
|
23
|
+
new URL(v)
|
|
24
|
+
return v.startsWith('http://') || v.startsWith('https://') || v.includes('://') // postgres://, redis:// 等
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 判断是否是逗号分隔的数字列表
|
|
31
|
+
function isNumberArray(v: string): boolean {
|
|
32
|
+
if (!v.includes(',')) return false
|
|
33
|
+
return v.split(',').every((s) => isNumber(s.trim()))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 判断是否是逗号分隔的字符串列表
|
|
37
|
+
function isStringArray(v: string): boolean {
|
|
38
|
+
return v.includes(',') && v.split(',').length > 1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 解析 @type 注释指令
|
|
42
|
+
function inferFromAnnotation(ann: string): Pick<InferredType, 'tsType' | 'zodSchema'> | null {
|
|
43
|
+
// @type: enum(a, b, c)
|
|
44
|
+
const enumMatch = ann.match(/^enum\((.+)\)$/)
|
|
45
|
+
if (enumMatch) {
|
|
46
|
+
const values = enumMatch[1].split(',').map((s) => s.trim())
|
|
47
|
+
const tsLiterals = values.map((v) => `'${v}'`).join(' | ')
|
|
48
|
+
const zodValues = values.map((v) => `'${v}'`).join(', ')
|
|
49
|
+
return {
|
|
50
|
+
tsType: tsLiterals,
|
|
51
|
+
zodSchema: `z.enum([${zodValues}])`
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// @type: number[]
|
|
56
|
+
if (ann === 'number[]') {
|
|
57
|
+
return {
|
|
58
|
+
tsType: 'number[]',
|
|
59
|
+
zodSchema: `z.string().transform(v => v.split(',').map(Number))`
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// @type: string[]
|
|
64
|
+
if (ann === 'string[]') {
|
|
65
|
+
return {
|
|
66
|
+
tsType: 'string[]',
|
|
67
|
+
zodSchema: `z.string().transform(v => v.split(','))`
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// @type: url
|
|
72
|
+
if (ann === 'url') {
|
|
73
|
+
return { tsType: 'string', zodSchema: `z.string().url()` }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// @type: number
|
|
77
|
+
if (ann === 'number') {
|
|
78
|
+
return { tsType: 'number', zodSchema: `z.coerce.number()` }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// @type: boolean
|
|
82
|
+
if (ann === 'boolean') {
|
|
83
|
+
return {
|
|
84
|
+
tsType: 'boolean',
|
|
85
|
+
zodSchema: `z.enum(['true','false','1','0']).transform(v => v === 'true' || v === '1')`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// @type: port
|
|
90
|
+
if (ann === 'port') {
|
|
91
|
+
return { tsType: 'number', zodSchema: `z.coerce.number().int().min(1).max(65535)` }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// @type: email
|
|
95
|
+
if (ann === 'email') {
|
|
96
|
+
return { tsType: 'string', zodSchema: `z.string().email()` }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 从值自动推断类型
|
|
103
|
+
function inferFromValue(value: string): Pick<InferredType, 'tsType' | 'zodSchema'> {
|
|
104
|
+
if (value === '') {
|
|
105
|
+
return { tsType: 'string', zodSchema: 'z.string()' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (isBoolean(value)) {
|
|
109
|
+
return {
|
|
110
|
+
tsType: 'boolean',
|
|
111
|
+
zodSchema: `z.enum(['true','false','1','0','yes','no']).transform(v => ['true','1','yes'].includes(v.toLowerCase()))`
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isNumber(value)) {
|
|
116
|
+
// 区分整数和浮点
|
|
117
|
+
const schema = Number.isInteger(Number(value)) ? 'z.coerce.number().int()' : 'z.coerce.number()'
|
|
118
|
+
return { tsType: 'number', zodSchema: schema }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isNumberArray(value)) {
|
|
122
|
+
return {
|
|
123
|
+
tsType: 'number[]',
|
|
124
|
+
zodSchema: `z.string().transform(v => v.split(',').map(Number))`
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (isUrl(value)) {
|
|
129
|
+
return { tsType: 'string', zodSchema: 'z.string().url()' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isStringArray(value)) {
|
|
133
|
+
return {
|
|
134
|
+
tsType: 'string[]',
|
|
135
|
+
zodSchema: `z.string().transform(v => v.split(',').map(s => s.trim()))`
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { tsType: 'string', zodSchema: 'z.string().min(1)' }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function inferType(entry: EnvEntry): InferredType {
|
|
143
|
+
const { value, annotations } = entry
|
|
144
|
+
const isOptional = annotations.optional === true || value === ''
|
|
145
|
+
|
|
146
|
+
// 优先使用 @type 注释
|
|
147
|
+
const fromAnnotation = annotations.type ? inferFromAnnotation(annotations.type) : null
|
|
148
|
+
|
|
149
|
+
const base = fromAnnotation ?? inferFromValue(value)
|
|
150
|
+
|
|
151
|
+
let zodSchema = base.zodSchema
|
|
152
|
+
// 处理 optional 和 default
|
|
153
|
+
if (annotations.default !== undefined) {
|
|
154
|
+
zodSchema = `${zodSchema}.default('${annotations.default}')`
|
|
155
|
+
} else if (isOptional) {
|
|
156
|
+
zodSchema = `${zodSchema}.optional()`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
tsType: base.tsType,
|
|
161
|
+
zodSchema,
|
|
162
|
+
isOptional,
|
|
163
|
+
defaultValue: annotations.default
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface EnvEntry {
|
|
2
|
+
key: string
|
|
3
|
+
value: string
|
|
4
|
+
annotations: Annotations
|
|
5
|
+
comment: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Annotations {
|
|
9
|
+
type?: string // @type: url | enum(a,b,c) | number[] ...
|
|
10
|
+
optional?: boolean // @optional
|
|
11
|
+
default?: string // @default: foo
|
|
12
|
+
description?: string // @desc: some description
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ANNOTATION_RE = /^#\s*@(\w+)(?::\s*(.+))?$/
|
|
16
|
+
|
|
17
|
+
function parseAnnotations(lines: string[]): Annotations {
|
|
18
|
+
const ann: Annotations = {}
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const m = line.match(ANNOTATION_RE)
|
|
21
|
+
if (!m) continue
|
|
22
|
+
const [, key, value] = m
|
|
23
|
+
if (key === 'optional') ann.optional = true
|
|
24
|
+
else if (key === 'type' && value) ann.type = value.trim()
|
|
25
|
+
else if (key === 'default' && value) ann.default = value.trim()
|
|
26
|
+
else if (key === 'desc' && value) ann.description = value.trim()
|
|
27
|
+
}
|
|
28
|
+
return ann
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseEnvFile(content: string): EnvEntry[] {
|
|
32
|
+
const lines = content.split('\n')
|
|
33
|
+
const entries: EnvEntry[] = []
|
|
34
|
+
const pendingComments: string[] = []
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
const raw = lines[i].trim()
|
|
38
|
+
|
|
39
|
+
if (raw === '') {
|
|
40
|
+
pendingComments.length = 0
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (raw.startsWith('#')) {
|
|
45
|
+
pendingComments.push(raw)
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const eqIdx = raw.indexOf('=')
|
|
50
|
+
if (eqIdx === -1) continue
|
|
51
|
+
|
|
52
|
+
const key = raw.slice(0, eqIdx).trim()
|
|
53
|
+
let value = raw.slice(eqIdx + 1).trim()
|
|
54
|
+
|
|
55
|
+
// 去除引号
|
|
56
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
57
|
+
value = value.slice(1, -1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const annotations = parseAnnotations(pendingComments)
|
|
61
|
+
const description = pendingComments
|
|
62
|
+
.filter((l) => !ANNOTATION_RE.test(l))
|
|
63
|
+
.map((l) => l.replace(/^#\s*/, ''))
|
|
64
|
+
.join(' ')
|
|
65
|
+
.trim()
|
|
66
|
+
|
|
67
|
+
entries.push({ key, value, annotations: { ...annotations, description: description || undefined }, comment: '' })
|
|
68
|
+
pendingComments.length = 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseEnvFile } from '../src/parser.js'
|
|
3
|
+
import { inferType } from '../src/inferrer.js'
|
|
4
|
+
import { generateDts, generateZodSchema } from '../src/generator.js'
|
|
5
|
+
|
|
6
|
+
// ─── Parser ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe('parseEnvFile', () => {
|
|
9
|
+
it('parses basic key=value', () => {
|
|
10
|
+
const result = parseEnvFile('PORT=3000\nHOST=localhost')
|
|
11
|
+
expect(result).toHaveLength(2)
|
|
12
|
+
expect(result[0]).toMatchObject({ key: 'PORT', value: '3000' })
|
|
13
|
+
expect(result[1]).toMatchObject({ key: 'HOST', value: 'localhost' })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('strips quotes', () => {
|
|
17
|
+
const result = parseEnvFile(`DB_URL="postgres://localhost/mydb"`)
|
|
18
|
+
expect(result[0].value).toBe('postgres://localhost/mydb')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('parses @type annotation', () => {
|
|
22
|
+
const result = parseEnvFile(`# @type: enum(info, warn, error)\nLOG_LEVEL=info`)
|
|
23
|
+
expect(result[0].annotations.type).toBe('enum(info, warn, error)')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('parses @optional annotation', () => {
|
|
27
|
+
const result = parseEnvFile(`# @optional\nSENTRY_DSN=`)
|
|
28
|
+
expect(result[0].annotations.optional).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('parses @default annotation', () => {
|
|
32
|
+
const result = parseEnvFile(`# @default: 3000\nPORT=`)
|
|
33
|
+
expect(result[0].annotations.default).toBe('3000')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('ignores empty lines and resets comment buffer', () => {
|
|
37
|
+
const result = parseEnvFile(`# @optional\n\nPORT=3000`)
|
|
38
|
+
expect(result[0].annotations.optional).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('parses description comment', () => {
|
|
42
|
+
const result = parseEnvFile(`# Database connection string\nDATABASE_URL=postgres://localhost/db`)
|
|
43
|
+
expect(result[0].annotations.description).toBe('Database connection string')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ─── Inferrer ────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe('inferType', () => {
|
|
50
|
+
const make = (value: string, annType?: string, optional?: boolean) =>
|
|
51
|
+
inferType({
|
|
52
|
+
key: 'TEST',
|
|
53
|
+
value,
|
|
54
|
+
comment: '',
|
|
55
|
+
annotations: { type: annType, optional }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('infers number from numeric string', () => {
|
|
59
|
+
const r = make('3000')
|
|
60
|
+
expect(r.tsType).toBe('number')
|
|
61
|
+
expect(r.zodSchema).toContain('z.coerce.number()')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('infers boolean from true/false', () => {
|
|
65
|
+
const r = make('true')
|
|
66
|
+
expect(r.tsType).toBe('boolean')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('infers string for plain string', () => {
|
|
70
|
+
const r = make('hello')
|
|
71
|
+
expect(r.tsType).toBe('string')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('infers url type for http URLs', () => {
|
|
75
|
+
const r = make('https://api.example.com')
|
|
76
|
+
expect(r.zodSchema).toContain('.url()')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('infers number[] for comma-separated numbers', () => {
|
|
80
|
+
const r = make('1,2,3')
|
|
81
|
+
expect(r.tsType).toBe('number[]')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('infers string[] for comma-separated strings', () => {
|
|
85
|
+
const r = make('a,b,c')
|
|
86
|
+
expect(r.tsType).toBe('string[]')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('respects @type: enum annotation', () => {
|
|
90
|
+
const r = make('info', 'enum(info, warn, error)')
|
|
91
|
+
expect(r.tsType).toBe("'info' | 'warn' | 'error'")
|
|
92
|
+
expect(r.zodSchema).toContain("z.enum(['info', 'warn', 'error'])")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('respects @type: url annotation', () => {
|
|
96
|
+
const r = make('not-a-url', 'url')
|
|
97
|
+
expect(r.zodSchema).toContain('.url()')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('marks empty value as optional', () => {
|
|
101
|
+
const r = make('')
|
|
102
|
+
expect(r.isOptional).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('marks @optional annotated entry as optional', () => {
|
|
106
|
+
const r = make('value', undefined, true)
|
|
107
|
+
expect(r.isOptional).toBe(true)
|
|
108
|
+
expect(r.zodSchema).toContain('.optional()')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ─── Generator ───────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe('generateDts', () => {
|
|
115
|
+
const items = [
|
|
116
|
+
{
|
|
117
|
+
entry: { key: 'PORT', value: '3000', comment: '', annotations: { description: 'Server port' } },
|
|
118
|
+
inferred: { tsType: 'number', zodSchema: 'z.coerce.number()', isOptional: false }
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
entry: { key: 'SENTRY_DSN', value: '', comment: '', annotations: { optional: true } },
|
|
122
|
+
inferred: { tsType: 'string', zodSchema: 'z.string().optional()', isOptional: true }
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
it('generates ImportMetaEnv augmentation', () => {
|
|
127
|
+
const output = generateDts(items, { schema: 'zod', augmentImportMeta: true })
|
|
128
|
+
expect(output).toContain('interface ImportMetaEnv')
|
|
129
|
+
expect(output).toContain('readonly PORT: number')
|
|
130
|
+
expect(output).toContain('readonly SENTRY_DSN?: string')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('includes JSDoc from description', () => {
|
|
134
|
+
const output = generateDts(items, { schema: 'zod', augmentImportMeta: true })
|
|
135
|
+
expect(output).toContain('/** Server port */')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('generateZodSchema', () => {
|
|
140
|
+
const items = [
|
|
141
|
+
{
|
|
142
|
+
entry: { key: 'DATABASE_URL', value: 'postgres://localhost/db', comment: '', annotations: {} },
|
|
143
|
+
inferred: { tsType: 'string', zodSchema: 'z.string().url()', isOptional: false }
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
it('generates valid zod schema', () => {
|
|
148
|
+
const output = generateZodSchema(items)
|
|
149
|
+
expect(output).toContain("import { z } from 'zod'")
|
|
150
|
+
expect(output).toContain('export const envSchema = z.object({')
|
|
151
|
+
expect(output).toContain('DATABASE_URL: z.string().url()')
|
|
152
|
+
expect(output).toContain('export type Env = z.infer<typeof envSchema>')
|
|
153
|
+
})
|
|
154
|
+
})
|
package/tsconfig.json
ADDED