yaml-config.mjs 2.75 KB
// lib/yaml-config.mjs — minimal YAML reader for config-vars.yaml.
//
// Scope is intentionally tiny: config-vars.yaml is exactly two levels deep
// (top-level section header `key:` → 2-space-indented `key: value` scalars).
// We deliberately support NO lists, NO flow style ([], {}), NO anchors and
// NO multiline — config-vars.yaml uses none of them, so a parser that handled
// them would be untested surface. Mirrors the zero-dependency, single-purpose
// spirit of the dotenv parser this replaces.
//
// Value rules (same philosophy as the old dotenv parser):
//  - a single layer of matching surrounding quotes ('…' or "…") is removed,
//    and anything after the closing quote (e.g. a trailing ` # comment`) is dropped
//  - an unquoted value is cut at the first ` #` (space-hash) inline comment
//  - a value that is empty or starts with `#` is treated as empty
//  - NO variable expansion: `$FOO`, `${FOO}`, backticks stay literal
//  - numbers stay strings (callers Number() them, as with dotenv)

/** Parse one `key:`-stripped raw value into its literal string. */
export function parseScalar(raw) {
  let s = String(raw).trim()
  if (s === '' || s[0] === '#') return ''
  const q = s[0]
  if (q === '"' || q === "'") {
    const end = s.indexOf(q, 1)
    if (end !== -1) return s.slice(1, end) // ignore anything after the closing quote
    // no closing quote → fall through and treat the (still-quoted) text literally
  }
  const hash = s.indexOf(' #')
  if (hash !== -1) s = s.slice(0, hash).trim()
  return s
}

/**
 * Parse config-vars.yaml text into a nested plain object.
 *
 * Top-level `section:` with no value opens a mapping; subsequent indented
 * `key: value` lines attach to it. A top-level `key: value` stays a root scalar.
 *
 * @param {string} text
 * @returns {Record<string, any>}
 */
export function parseYamlConfig(text) {
  const root = {}
  if (typeof text !== 'string') return root
  let section = null
  for (const rawLine of text.split('\n')) {
    const line = rawLine.replace(/\r$/, '') // tolerate CRLF
    const trimmed = line.trim()
    if (trimmed === '' || trimmed[0] === '#') continue // blank / full-line comment

    const colon = line.indexOf(':')
    if (colon === -1) continue // not a key: line; ignore

    const key = line.slice(0, colon).trim()
    if (key === '') continue
    const indent = line.length - line.replace(/^\s+/, '').length
    const value = parseScalar(line.slice(colon + 1))

    if (indent === 0) {
      if (value === '') {
        section = {}
        root[key] = section
      } else {
        root[key] = value
        section = null
      }
    } else if (section) {
      section[key] = value
    } else {
      root[key] = value // indented line with no open section — tolerate as root scalar
    }
  }
  return root
}