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/src/inferrer.ts
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
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
|
-
}
|
package/tests/index.test.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
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
DELETED