vite-plugin-typed-env 0.1.0 → 0.1.2
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/README.md +6 -0
- package/dist/index.cjs +367 -0
- package/dist/index.d.cts +35 -0
- package/dist/index.d.mts +35 -0
- package/dist/index.mjs +338 -0
- package/package.json +15 -3
- package/.claude/settings.local.json +0 -8
- package/.prettierrc.cjs +0 -14
- package/src/generator.ts +0 -119
- package/src/index.ts +0 -174
- package/src/inferrer.ts +0 -165
- package/src/parser.ts +0 -72
- package/tests/index.test.ts +0 -154
- package/tsconfig.json +0 -12
- package/tsdown.config.ts +0 -9
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
//#region src/parser.ts
|
|
4
|
+
const ANNOTATION_RE = /^#\s*@(\w+)(?::\s*(.+))?$/;
|
|
5
|
+
function parseAnnotations(lines) {
|
|
6
|
+
const ann = {};
|
|
7
|
+
for (const line of lines) {
|
|
8
|
+
const m = line.match(ANNOTATION_RE);
|
|
9
|
+
if (!m) continue;
|
|
10
|
+
const [, key, value] = m;
|
|
11
|
+
if (key === "optional") ann.optional = true;
|
|
12
|
+
else if (key === "type" && value) ann.type = value.trim();
|
|
13
|
+
else if (key === "default" && value) ann.default = value.trim();
|
|
14
|
+
else if (key === "desc" && value) ann.description = value.trim();
|
|
15
|
+
}
|
|
16
|
+
return ann;
|
|
17
|
+
}
|
|
18
|
+
function parseEnvFile(content) {
|
|
19
|
+
const lines = content.split("\n");
|
|
20
|
+
const entries = [];
|
|
21
|
+
const pendingComments = [];
|
|
22
|
+
for (let i = 0; i < lines.length; i++) {
|
|
23
|
+
const raw = lines[i].trim();
|
|
24
|
+
if (raw === "") {
|
|
25
|
+
pendingComments.length = 0;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (raw.startsWith("#")) {
|
|
29
|
+
pendingComments.push(raw);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const eqIdx = raw.indexOf("=");
|
|
33
|
+
if (eqIdx === -1) continue;
|
|
34
|
+
const key = raw.slice(0, eqIdx).trim();
|
|
35
|
+
let value = raw.slice(eqIdx + 1).trim();
|
|
36
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
37
|
+
const annotations = parseAnnotations(pendingComments);
|
|
38
|
+
const description = pendingComments.filter((l) => !ANNOTATION_RE.test(l)).map((l) => l.replace(/^#\s*/, "")).join(" ").trim();
|
|
39
|
+
entries.push({
|
|
40
|
+
key,
|
|
41
|
+
value,
|
|
42
|
+
annotations: {
|
|
43
|
+
...annotations,
|
|
44
|
+
description: description || void 0
|
|
45
|
+
},
|
|
46
|
+
comment: ""
|
|
47
|
+
});
|
|
48
|
+
pendingComments.length = 0;
|
|
49
|
+
}
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/inferrer.ts
|
|
54
|
+
function isBoolean(v) {
|
|
55
|
+
return [
|
|
56
|
+
"true",
|
|
57
|
+
"false",
|
|
58
|
+
"1",
|
|
59
|
+
"0",
|
|
60
|
+
"yes",
|
|
61
|
+
"no"
|
|
62
|
+
].includes(v.toLowerCase());
|
|
63
|
+
}
|
|
64
|
+
function isNumber(v) {
|
|
65
|
+
return v !== "" && !isNaN(Number(v));
|
|
66
|
+
}
|
|
67
|
+
function isUrl(v) {
|
|
68
|
+
try {
|
|
69
|
+
new URL(v);
|
|
70
|
+
return v.startsWith("http://") || v.startsWith("https://") || v.includes("://");
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function isNumberArray(v) {
|
|
76
|
+
if (!v.includes(",")) return false;
|
|
77
|
+
return v.split(",").every((s) => isNumber(s.trim()));
|
|
78
|
+
}
|
|
79
|
+
function isStringArray(v) {
|
|
80
|
+
return v.includes(",") && v.split(",").length > 1;
|
|
81
|
+
}
|
|
82
|
+
function inferFromAnnotation(ann) {
|
|
83
|
+
const enumMatch = ann.match(/^enum\((.+)\)$/);
|
|
84
|
+
if (enumMatch) {
|
|
85
|
+
const values = enumMatch[1].split(",").map((s) => s.trim());
|
|
86
|
+
return {
|
|
87
|
+
tsType: values.map((v) => `'${v}'`).join(" | "),
|
|
88
|
+
zodSchema: `z.enum([${values.map((v) => `'${v}'`).join(", ")}])`
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (ann === "number[]") return {
|
|
92
|
+
tsType: "number[]",
|
|
93
|
+
zodSchema: `z.string().transform(v => v.split(',').map(Number))`
|
|
94
|
+
};
|
|
95
|
+
if (ann === "string[]") return {
|
|
96
|
+
tsType: "string[]",
|
|
97
|
+
zodSchema: `z.string().transform(v => v.split(','))`
|
|
98
|
+
};
|
|
99
|
+
if (ann === "url") return {
|
|
100
|
+
tsType: "string",
|
|
101
|
+
zodSchema: `z.string().url()`
|
|
102
|
+
};
|
|
103
|
+
if (ann === "number") return {
|
|
104
|
+
tsType: "number",
|
|
105
|
+
zodSchema: `z.coerce.number()`
|
|
106
|
+
};
|
|
107
|
+
if (ann === "boolean") return {
|
|
108
|
+
tsType: "boolean",
|
|
109
|
+
zodSchema: `z.enum(['true','false','1','0']).transform(v => v === 'true' || v === '1')`
|
|
110
|
+
};
|
|
111
|
+
if (ann === "port") return {
|
|
112
|
+
tsType: "number",
|
|
113
|
+
zodSchema: `z.coerce.number().int().min(1).max(65535)`
|
|
114
|
+
};
|
|
115
|
+
if (ann === "email") return {
|
|
116
|
+
tsType: "string",
|
|
117
|
+
zodSchema: `z.string().email()`
|
|
118
|
+
};
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function inferFromValue(value) {
|
|
122
|
+
if (value === "") return {
|
|
123
|
+
tsType: "string",
|
|
124
|
+
zodSchema: "z.string()"
|
|
125
|
+
};
|
|
126
|
+
if (isBoolean(value)) return {
|
|
127
|
+
tsType: "boolean",
|
|
128
|
+
zodSchema: `z.enum(['true','false','1','0','yes','no']).transform(v => ['true','1','yes'].includes(v.toLowerCase()))`
|
|
129
|
+
};
|
|
130
|
+
if (isNumber(value)) return {
|
|
131
|
+
tsType: "number",
|
|
132
|
+
zodSchema: Number.isInteger(Number(value)) ? "z.coerce.number().int()" : "z.coerce.number()"
|
|
133
|
+
};
|
|
134
|
+
if (isNumberArray(value)) return {
|
|
135
|
+
tsType: "number[]",
|
|
136
|
+
zodSchema: `z.string().transform(v => v.split(',').map(Number))`
|
|
137
|
+
};
|
|
138
|
+
if (isUrl(value)) return {
|
|
139
|
+
tsType: "string",
|
|
140
|
+
zodSchema: "z.string().url()"
|
|
141
|
+
};
|
|
142
|
+
if (isStringArray(value)) return {
|
|
143
|
+
tsType: "string[]",
|
|
144
|
+
zodSchema: `z.string().transform(v => v.split(',').map(s => s.trim()))`
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
tsType: "string",
|
|
148
|
+
zodSchema: "z.string().min(1)"
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function inferType(entry) {
|
|
152
|
+
const { value, annotations } = entry;
|
|
153
|
+
const isOptional = annotations.optional === true || value === "";
|
|
154
|
+
const base = (annotations.type ? inferFromAnnotation(annotations.type) : null) ?? inferFromValue(value);
|
|
155
|
+
let zodSchema = base.zodSchema;
|
|
156
|
+
if (annotations.default !== void 0) zodSchema = `${zodSchema}.default('${annotations.default}')`;
|
|
157
|
+
else if (isOptional) zodSchema = `${zodSchema}.optional()`;
|
|
158
|
+
return {
|
|
159
|
+
tsType: base.tsType,
|
|
160
|
+
zodSchema,
|
|
161
|
+
isOptional,
|
|
162
|
+
defaultValue: annotations.default
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/generator.ts
|
|
167
|
+
function generateDts(items, options) {
|
|
168
|
+
const lines = [];
|
|
169
|
+
lines.push(`// Auto-generated by vite-plugin-typed-env. DO NOT EDIT.`);
|
|
170
|
+
lines.push(`// Re-run vite to regenerate this file.`);
|
|
171
|
+
lines.push(``);
|
|
172
|
+
if (options.augmentImportMeta) {
|
|
173
|
+
lines.push(`/// <reference types="vite/client" />`);
|
|
174
|
+
lines.push(``);
|
|
175
|
+
lines.push(`interface ImportMetaEnv {`);
|
|
176
|
+
for (const { entry, inferred } of items) {
|
|
177
|
+
if (entry.annotations.description) lines.push(` /** ${entry.annotations.description} */`);
|
|
178
|
+
const opt = inferred.isOptional ? "?" : "";
|
|
179
|
+
lines.push(` readonly ${entry.key}${opt}: ${inferred.tsType}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push(`}`);
|
|
182
|
+
lines.push(``);
|
|
183
|
+
lines.push(`interface ImportMeta {`);
|
|
184
|
+
lines.push(` readonly env: ImportMetaEnv`);
|
|
185
|
+
lines.push(`}`);
|
|
186
|
+
} else {
|
|
187
|
+
lines.push(`export interface Env {`);
|
|
188
|
+
for (const { entry, inferred } of items) {
|
|
189
|
+
if (entry.annotations.description) lines.push(` /** ${entry.annotations.description} */`);
|
|
190
|
+
const opt = inferred.isOptional ? "?" : "";
|
|
191
|
+
lines.push(` ${entry.key}${opt}: ${inferred.tsType}`);
|
|
192
|
+
}
|
|
193
|
+
lines.push(`}`);
|
|
194
|
+
}
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|
|
197
|
+
function generateZodSchema(items) {
|
|
198
|
+
const lines = [];
|
|
199
|
+
lines.push(`// Auto-generated by vite-plugin-typed-env. DO NOT EDIT.`);
|
|
200
|
+
lines.push(`import { z } from 'zod'`);
|
|
201
|
+
lines.push(``);
|
|
202
|
+
lines.push(`export const envSchema = z.object({`);
|
|
203
|
+
for (const { entry, inferred } of items) {
|
|
204
|
+
if (entry.annotations.description) lines.push(` // ${entry.annotations.description}`);
|
|
205
|
+
lines.push(` ${entry.key}: ${inferred.zodSchema},`);
|
|
206
|
+
}
|
|
207
|
+
lines.push(`})`);
|
|
208
|
+
lines.push(``);
|
|
209
|
+
lines.push(`export type Env = z.infer<typeof envSchema>`);
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
212
|
+
function generateLoader(items, options) {
|
|
213
|
+
const lines = [];
|
|
214
|
+
lines.push(`// Auto-generated by vite-plugin-typed-env. DO NOT EDIT.`);
|
|
215
|
+
lines.push(``);
|
|
216
|
+
if (options.schema === "zod") {
|
|
217
|
+
lines.push(`import { envSchema } from './env.schema'`);
|
|
218
|
+
lines.push(``);
|
|
219
|
+
lines.push(`const _parsed = envSchema.safeParse(import.meta.env)`);
|
|
220
|
+
lines.push(``);
|
|
221
|
+
lines.push(`if (!_parsed.success) {`);
|
|
222
|
+
lines.push(` const errors = _parsed.error.flatten().fieldErrors`);
|
|
223
|
+
lines.push(` const msg = Object.entries(errors)`);
|
|
224
|
+
lines.push(` .map(([k, v]) => \` \${k}: \${(v as string[]).join(', ')}\`)`);
|
|
225
|
+
lines.push(` .join('\\n')`);
|
|
226
|
+
lines.push(` throw new Error(\`[env-ts] Invalid environment variables:\\n\${msg}\`)`);
|
|
227
|
+
lines.push(`}`);
|
|
228
|
+
lines.push(``);
|
|
229
|
+
lines.push(`export const env = _parsed.data`);
|
|
230
|
+
lines.push(`export default env`);
|
|
231
|
+
} else {
|
|
232
|
+
const required = items.filter(({ inferred }) => !inferred.isOptional);
|
|
233
|
+
lines.push(`const _raw = import.meta.env`);
|
|
234
|
+
lines.push(``);
|
|
235
|
+
if (required.length > 0) {
|
|
236
|
+
lines.push(`const _required = [${required.map(({ entry }) => `'${entry.key}'`).join(", ")}] as const`);
|
|
237
|
+
lines.push(`for (const key of _required) {`);
|
|
238
|
+
lines.push(` if (!_raw[key]) throw new Error(\`[env-ts] Missing required env var: \${key}\`)`);
|
|
239
|
+
lines.push(`}`);
|
|
240
|
+
lines.push(``);
|
|
241
|
+
}
|
|
242
|
+
lines.push(`export const env = _raw as import('./env').Env`);
|
|
243
|
+
lines.push(`export default env`);
|
|
244
|
+
}
|
|
245
|
+
return lines.join("\n");
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/index.ts
|
|
249
|
+
async function generateTypes(envDir, outputDir, options) {
|
|
250
|
+
const envFileNames = [
|
|
251
|
+
".env",
|
|
252
|
+
".env.local",
|
|
253
|
+
`.env.${process.env.NODE_ENV ?? "development"}`,
|
|
254
|
+
`.env.${process.env.NODE_ENV ?? "development"}.local`,
|
|
255
|
+
...options.envFiles
|
|
256
|
+
];
|
|
257
|
+
const entries = /* @__PURE__ */ new Map();
|
|
258
|
+
for (const fileName of envFileNames) {
|
|
259
|
+
const filePath = path.join(envDir, fileName);
|
|
260
|
+
if (!fs.existsSync(filePath)) continue;
|
|
261
|
+
const parsed = parseEnvFile(fs.readFileSync(filePath, "utf-8"));
|
|
262
|
+
for (const entry of parsed) entries.set(entry.key, entry);
|
|
263
|
+
}
|
|
264
|
+
if (entries.size === 0) {
|
|
265
|
+
console.warn("[env-ts] No .env files found or all are empty, skipping generation.");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const items = Array.from(entries.values()).map((entry) => ({
|
|
269
|
+
entry,
|
|
270
|
+
inferred: inferType(entry)
|
|
271
|
+
}));
|
|
272
|
+
if (options.strict) {
|
|
273
|
+
const missing = items.filter(({ inferred }) => !inferred.isOptional && inferred.defaultValue === void 0).filter(({ entry }) => {
|
|
274
|
+
const v = process.env[entry.key];
|
|
275
|
+
return v === void 0 || v === "";
|
|
276
|
+
}).map(({ entry }) => entry.key);
|
|
277
|
+
if (missing.length > 0) {
|
|
278
|
+
const msg = `[env-ts] Missing required env variables: ${missing.join(", ")}`;
|
|
279
|
+
if (process.env.NODE_ENV === "production") throw new Error(msg);
|
|
280
|
+
else console.warn(msg);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
284
|
+
const genOptions = {
|
|
285
|
+
schema: options.schema,
|
|
286
|
+
augmentImportMeta: options.augmentImportMeta
|
|
287
|
+
};
|
|
288
|
+
const dtsContent = generateDts(items, genOptions);
|
|
289
|
+
writeIfChanged(path.join(outputDir, "env.d.ts"), dtsContent);
|
|
290
|
+
if (options.schema === "zod") {
|
|
291
|
+
const schemaContent = generateZodSchema(items);
|
|
292
|
+
writeIfChanged(path.join(outputDir, "env.schema.ts"), schemaContent);
|
|
293
|
+
}
|
|
294
|
+
const loaderContent = generateLoader(items, genOptions);
|
|
295
|
+
writeIfChanged(path.join(outputDir, "env.ts"), loaderContent);
|
|
296
|
+
console.log(`[env-ts] Generated ${items.length} env types → ${path.relative(process.cwd(), outputDir)}/`);
|
|
297
|
+
}
|
|
298
|
+
function writeIfChanged(filePath, content) {
|
|
299
|
+
if (fs.existsSync(filePath)) {
|
|
300
|
+
if (fs.readFileSync(filePath, "utf-8") === content) return;
|
|
301
|
+
}
|
|
302
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
303
|
+
}
|
|
304
|
+
function envTs(userOptions = {}) {
|
|
305
|
+
const options = {
|
|
306
|
+
schema: "zod",
|
|
307
|
+
output: "src",
|
|
308
|
+
augmentImportMeta: true,
|
|
309
|
+
strict: true,
|
|
310
|
+
envFiles: [],
|
|
311
|
+
...userOptions
|
|
312
|
+
};
|
|
313
|
+
let config;
|
|
314
|
+
let outputDir;
|
|
315
|
+
return {
|
|
316
|
+
name: "vite-plugin-typed-env",
|
|
317
|
+
enforce: "pre",
|
|
318
|
+
configResolved(resolvedConfig) {
|
|
319
|
+
config = resolvedConfig;
|
|
320
|
+
outputDir = path.resolve(config.root, options.output);
|
|
321
|
+
},
|
|
322
|
+
async buildStart() {
|
|
323
|
+
await generateTypes(config.envDir === false ? config.root : config.envDir, outputDir, options);
|
|
324
|
+
},
|
|
325
|
+
async handleHotUpdate({ file, server }) {
|
|
326
|
+
if (!file) return;
|
|
327
|
+
const fileName = path.basename(file);
|
|
328
|
+
if (!(fileName.startsWith(".env") || options.envFiles.includes(file))) return;
|
|
329
|
+
console.log(`[env-ts] Detected change in ${fileName}, regenerating...`);
|
|
330
|
+
await generateTypes(config.envDir === false ? config.root : config.envDir, outputDir, options);
|
|
331
|
+
server.hot.send({ type: "full-reload" });
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
export { envTs as default, generateTypes };
|
|
337
|
+
|
|
338
|
+
//# sourceMappingURL=index.mjs.map
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-typed-env",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.cjs",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
7
|
-
"types": "./dist/index.d.
|
|
7
|
+
"types": "./dist/index.d.cts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"import": {
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/*.cjs",
|
|
22
|
+
"dist/*.mjs",
|
|
23
|
+
"dist/*.d.cts",
|
|
24
|
+
"dist/*.d.mts"
|
|
25
|
+
],
|
|
20
26
|
"scripts": {
|
|
21
27
|
"build": "tsdown",
|
|
22
28
|
"typecheck": "tsc --noEmit",
|
|
@@ -33,6 +39,12 @@
|
|
|
33
39
|
"vitest": "^4.1.4"
|
|
34
40
|
},
|
|
35
41
|
"peerDependencies": {
|
|
36
|
-
"vite": ">=4.0.0"
|
|
42
|
+
"vite": ">=4.0.0",
|
|
43
|
+
"zod": ">=3.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"zod": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
37
49
|
}
|
|
38
50
|
}
|
package/.prettierrc.cjs
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
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/src/generator.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
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
|
-
}
|