// src/lib/text-tools/yaml.ts — from tinytinytools.com // Served verbatim from the working tree. Method: https://tinytinytools.com/guides /** * A YAML subset parser, written for the browser rather than pulled in as a dependency. * * YAML 1.2 is a large specification — anchors, aliases, merge keys, tags, complex mapping * keys, multiple documents, and a type-resolution system that has caused real production * incidents. Implementing all of it correctly is a library's job, not a page's. What this * covers is the shape of YAML people actually write in config files: nested mappings, * sequences, quoted and plain scalars, comments, block scalars, and flow collections. * * Everything outside that subset is *reported*, never guessed at. A file using an anchor * comes back with a message naming the line, because silently dropping a key would be the * worst possible behavior for a config file someone is about to deploy. * * One deliberate choice worth stating: this resolves booleans the YAML 1.2 way, where only * `true` and `false` are booleans. YAML 1.1 also treated `yes`, `no`, `on`, and `off` as * booleans, which is the origin of the "Norway problem" — the country code NO parsed as * boolean false. Unquoted `no` is kept as the string "no" here, and flagged so nobody is * surprised by a parser elsewhere that decides differently. */ export type YamlValue = string | number | boolean | null | YamlValue[] | { [key: string]: YamlValue }; export type YamlIssueKind = | 'anchor-or-alias' | 'merge-key' | 'tag' | 'multiple-documents' | 'tab-indent' | 'ambiguous-boolean' | 'duplicate-key' | 'complex-key'; export interface YamlIssue { readonly line: number; readonly kind: YamlIssueKind; readonly detail: string; } export type YamlParseResult = | { readonly ok: true; readonly value: YamlValue; readonly issues: readonly YamlIssue[] } | { readonly ok: false; readonly error: string; readonly line?: number }; export const YAML_MAX_INPUT = 500_000; /** * A value that is only an anchor or a tag, such as `&defaults` or `!!map`. It labels the * node rather than being the node, so the indented block beneath it is still the value. */ const LABEL_ONLY = /^(?:&[A-Za-z0-9_-]+|!!?[A-Za-z0-9_:.-]*)(?:\s+(?:&[A-Za-z0-9_-]+|!!?[A-Za-z0-9_:.-]*))*$/; /** YAML 1.1 booleans that YAML 1.2 demoted to strings. Reported, not converted. */ const AMBIGUOUS_BOOLEANS = new Set(['yes', 'no', 'on', 'off', 'y', 'n']); interface Line { readonly raw: string; readonly indent: number; readonly content: string; readonly number: number; } /** Strips an unquoted trailing comment. A # inside quotes is content, not a comment. */ function stripComment(value: string): string { let quote: string | null = null; for (let index = 0; index < value.length; index += 1) { const char = value[index]; if (quote) { if (char === '\\' && quote === '"') { index += 1; continue; } if (char === quote) quote = null; continue; } if (char === '"' || char === "'") { quote = char; continue; } // A # only opens a comment at the start of the line or after whitespace. if (char === '#' && (index === 0 || /\s/.test(value[index - 1]))) return value.slice(0, index); } return value; } function parseScalar(raw: string, line: number, issues: YamlIssue[]): YamlValue { const value = raw.trim(); if (value === '' || value === '~' || value === 'null' || value === 'Null' || value === 'NULL') return null; if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { return value .slice(1, -1) .replace(/\\n/g, '\n') .replace(/\\t/g, '\t') .replace(/\\r/g, '\r') .replace(/\\"/g, '"') .replace(/\\\\/g, '\\'); } if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) { // Single quotes are literal; the only escape is '' for one quote. return value.slice(1, -1).replace(/''/g, "'"); } if (value === 'true' || value === 'True' || value === 'TRUE') return true; if (value === 'false' || value === 'False' || value === 'FALSE') return false; if (AMBIGUOUS_BOOLEANS.has(value.toLowerCase())) { issues.push({ line, kind: 'ambiguous-boolean', detail: `"${value}" is a string here, following YAML 1.2. A YAML 1.1 parser would read it as a boolean. Quote it to be unambiguous.`, }); return value; } if (/^-?(?:0|[1-9]\d*)$/.test(value)) { const asNumber = Number(value); if (Number.isSafeInteger(asNumber)) return asNumber; return value; } if (/^-?(?:0|[1-9]\d*)\.\d+(?:[eE][+-]?\d+)?$/.test(value)) return Number(value); return value; } /** Parses `[a, b]` and `{a: 1, b: 2}`, including nesting, without recursing into blocks. */ function parseFlow(raw: string, line: number, issues: YamlIssue[]): YamlValue { const text = raw.trim(); let index = 0; const skipSpace = () => { while (index < text.length && /\s/.test(text[index])) index += 1; }; const readToken = (stops: string): string => { let quote: string | null = null; const start = index; while (index < text.length) { const char = text[index]; if (quote) { if (char === '\\' && quote === '"') index += 1; else if (char === quote) quote = null; index += 1; continue; } if (char === '"' || char === "'") { quote = char; index += 1; continue; } if (stops.includes(char)) break; index += 1; } return text.slice(start, index); }; const parseValue = (): YamlValue => { skipSpace(); if (text[index] === '[') { index += 1; const list: YamlValue[] = []; skipSpace(); if (text[index] === ']') { index += 1; return list; } for (;;) { list.push(parseValue()); skipSpace(); if (text[index] === ',') { index += 1; continue; } if (text[index] === ']') { index += 1; break; } break; } return list; } if (text[index] === '{') { index += 1; const map: Record = {}; skipSpace(); if (text[index] === '}') { index += 1; return map; } for (;;) { skipSpace(); const key = readToken(':,}').trim(); if (text[index] === ':') index += 1; map[String(parseScalar(key, line, issues))] = parseValue(); skipSpace(); if (text[index] === ',') { index += 1; continue; } if (text[index] === '}') { index += 1; break; } break; } return map; } return parseScalar(readToken(',]}'), line, issues); }; return parseValue(); } const isFlow = (value: string): boolean => { const trimmed = value.trim(); return (trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('{') && trimmed.endsWith('}')); }; /** Splits `key: value` at the first colon that is not inside quotes or a flow collection. */ function splitKey(content: string): { key: string; rest: string } | null { let quote: string | null = null; let depth = 0; for (let index = 0; index < content.length; index += 1) { const char = content[index]; if (quote) { if (char === '\\' && quote === '"') { index += 1; continue; } if (char === quote) quote = null; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (char === '[' || char === '{') depth += 1; if (char === ']' || char === '}') depth -= 1; if (char === ':' && depth === 0) { const next = content[index + 1]; if (next === undefined || next === ' ' || next === '\t') { return { key: content.slice(0, index).trim(), rest: content.slice(index + 1).trim() }; } } } return null; } export function parseYaml(text: string): YamlParseResult { if (text.length > YAML_MAX_INPUT) { return { ok: false, error: `Input above ${YAML_MAX_INPUT.toLocaleString('en-US')} characters is not parsed in the page.` }; } if (!text.trim()) return { ok: false, error: 'Paste some YAML to convert.' }; const issues: YamlIssue[] = []; const rawLines = text.replace(/\r\n?/g, '\n').split('\n'); // Document markers first: only the first document is converted, and saying so beats // returning one document's worth of data as though it were the whole file. const markers = rawLines .map((line, index) => ({ line, index })) .filter(item => /^---\s*$/.test(item.line) || /^---\s+\S/.test(item.line)) .map(item => item.index); // A marker on the first line opens document one; any later marker separates documents, // which means everything before it was already a document in its own right. const opensFirst = markers.length > 0 && markers[0] === 0; const separators = opensFirst ? markers.slice(1) : markers; const documentCount = separators.length + 1; let start = opensFirst ? 1 : 0; const end = separators.length > 0 ? separators[0] : rawLines.length; if (documentCount > 1) { issues.push({ line: separators[0] + 1, kind: 'multiple-documents', detail: `This file contains ${documentCount} YAML documents. Only the first was converted; JSON has no multi-document equivalent.`, }); } const scoped = rawLines.slice(start, end).map((raw, offset) => ({ raw, number: start + offset + 1 })); const lines: Line[] = []; for (const item of scoped) { if (/^\s*$/.test(item.raw)) continue; const withoutComment = stripComment(item.raw); if (/^\s*$/.test(withoutComment)) continue; if (/^\t/.test(item.raw) || /^ *\t/.test(item.raw)) { issues.push({ line: item.number, kind: 'tab-indent', detail: 'A tab was used for indentation. YAML forbids tabs for indentation; use spaces.' }); } if (/^\s*\.\.\.\s*$/.test(withoutComment)) continue; const indent = withoutComment.length - withoutComment.trimStart().length; lines.push({ raw: item.raw, indent, content: withoutComment.trim(), number: item.number }); } if (lines.length === 0) return { ok: false, error: 'No YAML content was found — the input is only comments or blank lines.' }; let cursor = 0; const noteUnsupported = (content: string, line: number): void => { if (/(^|\s)[&*][A-Za-z0-9_-]+/.test(content)) { issues.push({ line, kind: 'anchor-or-alias', detail: 'Anchors and aliases (& and *) are not resolved; the text was read as an ordinary value.' }); } if (/^<<\s*:/.test(content)) { issues.push({ line, kind: 'merge-key', detail: 'A merge key (<<) is not expanded, so the merged mapping was not inlined.' }); } if (/(^|\s)!!?[A-Za-z]/.test(content)) { issues.push({ line, kind: 'tag', detail: 'A type tag (! or !!) is ignored; the value was resolved by its plain form.' }); } if (/^\?\s/.test(content)) { issues.push({ line, kind: 'complex-key', detail: 'A complex mapping key (?) is not supported and was skipped.' }); } }; /** Consumes the raw lines of a `|` or `>` block scalar at the given parent indent. */ const readBlockScalar = (header: string, parentIndent: number): string => { const literal = header.trimStart().startsWith('|'); const chomp = header.includes('-') ? 'strip' : header.includes('+') ? 'keep' : 'clip'; const collected: string[] = []; let blockIndent: number | null = null; // Block scalars keep blank lines, so this walks the original text rather than `lines`. const startNumber = cursor < lines.length ? lines[cursor].number : rawLines.length + 1; let pointer = startNumber - 1; while (pointer < rawLines.length) { const raw = rawLines[pointer]; if (/^\s*$/.test(raw)) { collected.push(''); pointer += 1; continue; } const indent = raw.length - raw.trimStart().length; if (indent <= parentIndent) break; if (blockIndent === null) blockIndent = indent; collected.push(raw.slice(Math.min(blockIndent, indent))); pointer += 1; } // Advance the line cursor past everything the block consumed. while (cursor < lines.length && lines[cursor].number <= pointer) cursor += 1; while (collected.length > 0 && collected[collected.length - 1] === '') collected.pop(); let value = literal ? collected.join('\n') : collected.reduce((acc, current, index) => { if (index === 0) return current; if (current === '' || acc.endsWith('\n')) return `${acc}\n${current}`; return `${acc} ${current}`; }, ''); if (chomp === 'clip') value += '\n'; if (chomp === 'keep') value += '\n'; return value; }; const parseBlock = (indent: number): YamlValue => { if (cursor >= lines.length) return null; const first = lines[cursor]; if (first.content.startsWith('- ') || first.content === '-') return parseSequence(indent); return parseMapping(indent); }; const parseSequence = (indent: number): YamlValue[] => { const list: YamlValue[] = []; while (cursor < lines.length) { const line = lines[cursor]; if (line.indent < indent) break; if (line.indent > indent) break; if (!line.content.startsWith('- ') && line.content !== '-') break; noteUnsupported(line.content, line.number); const rest = line.content === '-' ? '' : line.content.slice(2).trim(); cursor += 1; if (rest === '' || LABEL_ONLY.test(rest)) { const next = lines[cursor]; list.push(next && next.indent > indent ? parseBlock(next.indent) : null); continue; } if (rest === '|' || rest === '>' || /^[|>][-+]?$/.test(rest)) { list.push(readBlockScalar(rest, indent)); continue; } if (isFlow(rest)) { list.push(parseFlow(rest, line.number, issues)); continue; } const pair = splitKey(rest); if (pair) { // "- key: value" opens a mapping whose keys align with the first key's column. const itemIndent = indent + 2; const map: Record = {}; assignPair(map, pair, line.number, itemIndent); while (cursor < lines.length && lines[cursor].indent >= itemIndent && !lines[cursor].content.startsWith('- ')) { const inner = lines[cursor]; const innerPair = splitKey(inner.content); if (!innerPair) break; noteUnsupported(inner.content, inner.number); cursor += 1; assignPair(map, innerPair, inner.number, inner.indent); } list.push(map); continue; } list.push(parseScalar(rest, line.number, issues)); } return list; }; function assignPair( target: Record, pair: { key: string; rest: string }, lineNumber: number, keyIndent: number, ): void { const key = String(parseScalar(pair.key, lineNumber, issues)); if (Object.prototype.hasOwnProperty.call(target, key)) { issues.push({ line: lineNumber, kind: 'duplicate-key', detail: `The key "${key}" appears more than once; the later value replaced the earlier one.` }); } if (pair.rest === '' || LABEL_ONLY.test(pair.rest)) { const next = lines[cursor]; target[key] = next && next.indent > keyIndent ? parseBlock(next.indent) : null; return; } if (/^[|>][-+]?$/.test(pair.rest)) { target[key] = readBlockScalar(pair.rest, keyIndent); return; } if (isFlow(pair.rest)) { target[key] = parseFlow(pair.rest, lineNumber, issues); return; } target[key] = parseScalar(pair.rest, lineNumber, issues); } function parseMapping(indent: number): YamlValue { const map: Record = {}; while (cursor < lines.length) { const line = lines[cursor]; if (line.indent !== indent) break; if (line.content.startsWith('- ')) break; const pair = splitKey(line.content); if (!pair) break; noteUnsupported(line.content, line.number); cursor += 1; assignPair(map, pair, line.number, indent); } return map; } const value = parseBlock(lines[0].indent); if (cursor < lines.length) { return { ok: false, error: `Line ${lines[cursor].number} is indented in a way this parser could not place: "${lines[cursor].content.slice(0, 60)}"`, line: lines[cursor].number, }; } return { ok: true, value, issues }; } export type YamlToJsonResult = | { readonly ok: true; readonly json: string; readonly issues: readonly YamlIssue[] } | { readonly ok: false; readonly error: string; readonly line?: number }; export function yamlToJson(text: string, indent = 2): YamlToJsonResult { const parsed = parseYaml(text); if (!parsed.ok) return parsed; return { ok: true, json: JSON.stringify(parsed.value, null, indent), issues: parsed.issues }; }