Particular.Tech
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-parser

Source

lib/env-parser.ts
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))]
}