Utilitieslib
env-parser
Parse, serialize, and merge .env files.
{
"entries": [
{
"key": "DATABASE_URL",
"value": "postgres://app:secret@db/main"
},
{
"key": "API_KEY",
"value": "pt_live_8f2a#c91d"
},
{
"key": "PORT",
"value": "3000"
}
],
"duplicates": [],
"errors": [
{
"line": 5,
"message": "Invalid key \"1BROKEN\""
}
]
}Installation
npx shadcn@latest add @particular/env-parserSource
export type EnvVar = { key: string; value: string }
export type DotenvParseResult = {
entries: EnvVar[]
/** Keys defined more than once; the last definition wins. */
duplicates: string[]
errors: { line: number; message: string }[]
}
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
export function isValidEnvKey(key: string) {
return KEY_PATTERN.test(key)
}
const QUOTES = ['"', "'", "`"]
/**
* Parses `.env` text the way Node's `util.parseEnv` (and `node --env-file`, `dotenv`) does: comments,
* `export` prefixes, and single, double, or backtick quotes that may span lines. A quoted value ends at
* the next matching quote; there are no escapes except `\n` inside double quotes. An unquoted value
* ends at the first `#`. Unterminated quotes are reported instead of guessed at.
*/
export function parseDotenv(text: string): DotenvParseResult {
const lines = text.replace(/\r\n?/g, "\n").split("\n")
const values = new Map<string, string>()
const seen = new Set<string>()
const duplicates = new Set<string>()
const errors: DotenvParseResult["errors"] = []
for (let index = 0; index < lines.length; index++) {
const lineNumber = index + 1
const line = lines[index].trim()
if (line === "" || line.startsWith("#")) continue
const assignment = lines[index].trimStart().replace(/^export\s+/, "")
const equals = assignment.indexOf("=")
if (equals === -1) {
errors.push({ line: lineNumber, message: "Expected KEY=value" })
continue
}
const key = assignment.slice(0, equals).trim()
if (!isValidEnvKey(key)) {
errors.push({ line: lineNumber, message: `Invalid key "${key}"` })
continue
}
let raw = assignment.slice(equals + 1).trimStart()
let value: string
const quote = QUOTES.find((candidate) => raw.startsWith(candidate))
if (quote) {
let body = raw.slice(1)
let closing = body.indexOf(quote)
let end = index
while (closing === -1 && end + 1 < lines.length) {
end++
body += `\n${lines[end]}`
closing = body.indexOf(quote)
}
if (closing === -1) {
errors.push({ line: lineNumber, message: `Unterminated quote for ${key}` })
continue
}
index = end
value = body.slice(0, closing)
if (quote === '"') value = value.replace(/\\n/g, "\n")
} else {
const comment = raw.indexOf("#")
if (comment !== -1) raw = raw.slice(0, comment)
value = raw.trim()
}
if (seen.has(key)) duplicates.add(key)
seen.add(key)
values.delete(key)
values.set(key, value)
}
return {
entries: [...values].map(([key, value]) => ({ key, value })),
duplicates: [...duplicates],
errors,
}
}
function quoteValue(value: string): string | null {
if (value === "" || /^[\w./:@+-]+$/.test(value)) return value
if (!value.includes("'")) return `'${value}'`
if (!value.includes('"') && !value.includes("\\n")) return `"${value}"`
if (!value.includes("`")) return `\`${value}\``
return null
}
/**
* Writes entries so `parseDotenv` and Node's `util.parseEnv` read back the same values. Throws for a
* value that contains all three quote characters plus a literal backslash-n, which no `.env` quoting can carry.
*/
export function serializeDotenv(entries: EnvVar[]): string {
const unrepresentable: string[] = []
const lines = entries.map(({ key, value }) => {
const quoted = quoteValue(value)
if (quoted === null) unrepresentable.push(key)
return `${key}=${quoted ?? ""}`
})
if (unrepresentable.length > 0) throw new Error(`Cannot write these values to .env: ${unrepresentable.join(", ")}`)
return lines.join("\n")
}
/** Imported entries replace existing keys in place; new keys are appended. */
export function mergeEnvVars(current: EnvVar[], incoming: EnvVar[]): EnvVar[] {
const incomingByKey = new Map(incoming.map((entry) => [entry.key, entry.value]))
const merged = current.map((entry) =>
incomingByKey.has(entry.key) ? { key: entry.key, value: incomingByKey.get(entry.key) ?? "" } : entry
)
const existing = new Set(current.map((entry) => entry.key))
return [...merged, ...incoming.filter((entry) => !existing.has(entry.key))]
}