UNPKG

@raven-js/fledge

Version:

From nestling to flight-ready - Build & bundle tool for modern JavaScript apps

307 lines (264 loc) 8.06 kB
/** * @author Anonyfox <max@anonyfox.com> * @license MIT * @see {@link https://ravenjs.dev} * @see {@link https://github.com/Anonyfox/ravenjs} * @see {@link https://anonyfox.com} */ /** * @file Robust .env file parsing with comprehensive quirk handling. * * Handles comments, quotes, escapes, multiline values, exports, inline comments, * variable validation, and all common .env file edge cases. */ /** * Parse .env file content into environment variables object * @param {string} content - Raw .env file content * @returns {Record<string, string>} Parsed environment variables * * @example Basic usage * ```javascript * const result = parseEnvFile(` * NODE_ENV=production * API_KEY="secret123" * PORT=3000 * `); * // { NODE_ENV: "production", API_KEY: "secret123", PORT: "3000" } * ``` * * @example Advanced features * ```javascript * const result = parseEnvFile(` * # Database configuration * DB_HOST=localhost * DB_PASS="password with spaces" * DB_QUERY='SELECT * FROM "users"' * export NODE_ENV=development * MULTILINE="line1 * line2" * EMPTY_VAR= * EQUALS_IN_VALUE=key=value=more * `); * ``` */ export function parseEnvFile(content) { if (typeof content !== "string") { throw new Error("Environment file content must be a string"); } /** @type {Record<string, string>} */ const variables = {}; const lines = normalizeLineEndings(content).split("\n"); let i = 0; while (i < lines.length) { const result = parseLine(lines, i); if (result.variable) { const { key, value } = result.variable; if (isValidVariableName(key)) { variables[key] = value; } } i = result.nextIndex; } return variables; } /** * Normalize different line ending formats to \n * @param {string} content - File content with mixed line endings * @returns {string} Content with normalized line endings */ function normalizeLineEndings(content) { return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } /** * Parse a single line or multiline value starting at given index * @param {string[]} lines - Array of file lines * @param {number} startIndex - Starting line index * @returns {{ variable?: { key: string, value: string } | null, nextIndex: number }} Parse result */ function parseLine(lines, startIndex) { const line = lines[startIndex]; if (!line) { return { variable: null, nextIndex: startIndex + 1 }; } const trimmed = line.trim(); // Skip empty lines and comments if (!trimmed || trimmed.startsWith("#")) { return { nextIndex: startIndex + 1 }; } // Handle export statements const cleanLine = trimmed.startsWith("export ") ? trimmed.slice(7) : trimmed; // Find first equals sign (key=value) const equalIndex = cleanLine.indexOf("="); if (equalIndex === -1) { return { nextIndex: startIndex + 1 }; } const key = cleanLine.slice(0, equalIndex).trim(); const rawValue = cleanLine.slice(equalIndex + 1); // Parse value, handling quotes and multiline const valueResult = parseValue(rawValue, lines, startIndex); return { variable: { key, value: valueResult.value }, nextIndex: valueResult.nextIndex, }; } /** * Parse environment variable value with quote and multiline handling * @param {string} rawValue - Raw value part after equals sign * @param {string[]} lines - All file lines for multiline support * @param {number} currentIndex - Current line index * @returns {{ value: string, nextIndex: number }} Parsed value and next line index */ function parseValue(rawValue, lines, currentIndex) { const trimmedValue = rawValue.trim(); // Handle empty values if (!trimmedValue) { return { value: "", nextIndex: currentIndex + 1 }; } // Remove inline comments (not inside quotes) const valueWithoutComments = removeInlineComments(trimmedValue); // Handle quoted values (including multiline) - check if starts with quote if (startsWithQuote(valueWithoutComments)) { return parseQuotedValue(valueWithoutComments, lines, currentIndex); } // Handle unquoted values return { value: valueWithoutComments, nextIndex: currentIndex + 1 }; } /** * Remove inline comments while preserving comments inside quotes * @param {string} value - Value to process * @returns {string} Value with inline comments removed */ function removeInlineComments(value) { let inQuotes = false; let quoteChar = ""; let escaped = false; for (let i = 0; i < value.length; i++) { const char = value[i]; if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (!inQuotes && (char === '"' || char === "'")) { inQuotes = true; quoteChar = char; continue; } if (inQuotes && char === quoteChar) { inQuotes = false; quoteChar = ""; continue; } if (!inQuotes && char === "#") { return value.slice(0, i).trim(); } } return value; } /** * Check if value starts with a quote character * @param {string} value - Value to check * @returns {boolean} True if starts with quote */ function startsWithQuote(value) { if (value.length === 0) return false; const first = value[0]; return first === '"' || first === "'"; } /** * Parse quoted value with escape handling and multiline support * @param {string} quotedValue - Quoted value to parse * @param {string[]} lines - All file lines for multiline support * @param {number} currentIndex - Current line index * @returns {{ value: string, nextIndex: number }} Parsed value and next line index */ function parseQuotedValue(quotedValue, lines, currentIndex) { const quoteChar = quotedValue[0]; if (!quoteChar) { return { value: "", nextIndex: currentIndex + 1 }; } let content = quotedValue.slice(1); // Remove opening quote let lineIndex = currentIndex; // Check if quote is properly closed on same line if (content.endsWith(quoteChar) && !isEscaped(content, content.length - 1)) { // Single line quoted value content = content.slice(0, -1); // Remove closing quote return { value: unescapeString(content, quoteChar), nextIndex: lineIndex + 1, }; } // Multiline quoted value - start with the content from first line const fullContent = [content]; lineIndex++; // Continue reading lines until we find the closing quote while (lineIndex < lines.length) { const line = lines[lineIndex]; if (!line) { lineIndex++; continue; } // Check if this line ends the quote if (line.endsWith(quoteChar) && !isEscaped(line, line.length - 1)) { // Add line without the closing quote fullContent.push(line.slice(0, -1)); lineIndex++; // Move to next line after the closing quote break; } else { // Add the full line and continue fullContent.push(line); lineIndex++; } } const multilineContent = fullContent.join("\n"); return { value: unescapeString(multilineContent, quoteChar), nextIndex: lineIndex, }; } /** * Check if character at position is escaped * @param {string} str - String to check * @param {number} position - Character position * @returns {boolean} True if character is escaped */ function isEscaped(str, position) { let backslashCount = 0; let i = position - 1; while (i >= 0 && str[i] === "\\") { backslashCount++; i--; } return backslashCount % 2 === 1; } /** * Unescape string content based on quote type * @param {string} content - Escaped string content * @param {string} quoteChar - Quote character used (for context) * @returns {string} Unescaped string */ function unescapeString(content, quoteChar) { return content .replace(/\\n/g, "\n") .replace(/\\r/g, "\r") .replace(/\\t/g, "\t") .replace(/\\\\/g, "\\") .replace(new RegExp(`\\\\${quoteChar}`, "g"), quoteChar); } /** * Validate environment variable name * @param {string} name - Variable name to validate * @returns {boolean} True if valid variable name */ function isValidVariableName(name) { if (!name || typeof name !== "string") { return false; } // Must start with letter or underscore // Can contain letters, digits, underscores // Cannot start with digit return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); }