Metadata
The --- block at the top of a document, read by syntax.md's rule: one key: value per line,
where a value is null, a boolean, a number, a quoted string, a […] list, or otherwise a
string as written. A list may wrap onto the indented lines after its key, the way a formatter
writes a long one. Keys are flat, and a . in one is an ordinary character, as YAML
reads it. The rule warns on the mistakes writers make, not on every corner of YAML. A value
YAML would read as another type (no, 1.10, 1e3) or as structure (a leading * or {) is
a warning, and its key is skipped, since any guess could be wrong. A value holding : or #
is a warning too, but markz keeps it as written. Skipping it would lose a title like
Issue #42, which YAML cuts short. A line that isn't key: value skips its key, and the rest
of the block is still read.
import { type Builder, type MetadataObject, type MetadataScalar } from "./ast";
/** Reads the lines between `start` and `end` (the fences excluded). */
export function parseMetadata(
source: string,
start: number,
end: number,
b: Pick<Builder, "warn">,
): MetadataObject {
const value: Record<string, MetadataScalar | MetadataScalar[]> = {};
// The key the previous line set, so a line that continues its value can skip it.
let last: string | null = null;
for (let at = start; at < end;) {
let lineEnd = at;
while (lineEnd < end && source[lineEnd] !== "\n" && source[lineEnd] !== "\r") lineEnd++;
const line = source.slice(at, lineEnd);
// A blank line keeps the key: YAML allows one before a value's next line.
if (line.trim() !== "" && line[0] !== "#") {
const match = /^([A-Za-z_][\w.-]*):(?:[ \t]+|$)/.exec(line);
if (!match) {
// Not a key line: part of the value before it (`tags:` over ` - a`), so that goes too.
if (last !== null) delete value[last];
b.warn("metadata-line", at, lineEnd);
} else {
const key = match[1]!;
// Setting `__proto__` on an object changes its prototype instead of adding a key.
if (key === "__proto__") {
b.warn("metadata-line", at, lineEnd, "`__proto__` is not a metadata key");
} else if (Object.hasOwn(value, key)) {
b.warn("metadata-duplicate-key", at, lineEnd);
} else {
let text = line.slice(match[0].length).trim();
// A long list wraps, as oxfmt writes it: `nav:`, then ` [`, an item a line, ` ]`.
if (text === "" || (text.startsWith("[") && !text.endsWith("]"))) {
const wrapped = indented(source, lineEnd, end);
if (wrapped && `${text}${wrapped.text}`.startsWith("[")) {
text = `${text} ${wrapped.text}`.trim();
lineEnd = wrapped.end;
}
}
const result = parseValue(text);
if (typeof result === "string") b.warn("metadata-value", at, lineEnd, result);
else {
value[key] = result.value;
if (result.warn) b.warn("metadata-value", at, lineEnd, result.warn);
}
}
}
last = match ? match[1]! : null;
}
at = lineEnd;
if (source[at] === "\r") at++;
if (source[at] === "\n") at++;
}
return value;
}
/** The indented lines after `from`, joined, and where the last one ends; `null` when there are none. */
function indented(source: string, from: number, end: number): { text: string; end: number } | null {
const parts: string[] = [];
let last = from;
let at = from;
for (;;) {
if (source[at] === "\r") at++;
if (source[at] === "\n") at++;
if (at >= end || (source[at] !== " " && source[at] !== "\t")) break;
let lineEnd = at;
while (lineEnd < end && source[lineEnd] !== "\n" && source[lineEnd] !== "\r") lineEnd++;
const part = source.slice(at, lineEnd).trim();
if (part === "") break;
parts.push(part);
last = lineEnd;
at = lineEnd;
}
return parts.length ? { text: parts.join(" "), end: last } : null;
}
/** A value, kept with a message or not; or the message when it is rejected. */
type Parsed<T> = { value: T; warn?: string } | string;
function parseValue(text: string): Parsed<MetadataScalar | MetadataScalar[]> {
if (!text.startsWith("[")) return parseScalar(text);
if (!text.endsWith("]")) return "a list must close with `]`";
const inner = text.slice(1, -1).trim();
if (inner === "") return { value: [] };
const items: MetadataScalar[] = [];
let warn: string | undefined;
const parts = splitList(inner);
// A comma after the last item, which a formatter adds to a wrapped list.
if (parts.length > 1 && parts.at(-1)?.trim() === "") parts.pop();
for (const item of parts) {
if (item === null) return "unbalanced quotes in a list";
const trimmed = item.trim();
if (trimmed === "") return "empty list item";
if (/^[[{]/.test(trimmed) || (!/^["']/.test(trimmed) && /[[\]{}]/.test(trimmed))) {
return "nested lists and maps are not supported";
}
const scalar = parseScalar(trimmed);
if (typeof scalar === "string") return scalar;
items.push(scalar.value);
warn ??= scalar.warn;
}
return { value: items, warn };
}
function parseScalar(text: string): Parsed<MetadataScalar> {
if (text === "" || text === "null") return { value: null };
if (text === "true") return { value: true };
if (text === "false") return { value: false };
// A number that keeps every digit it was written with.
if (/^-?(?:0|[1-9]\d*)(?:\.\d*[1-9])?$/.test(text)) return { value: Number(text) };
if (text[0] === '"') {
if (!/^"(?:[^"\\]|\\.)*"$/.test(text)) return "a double-quoted value must close at the end";
try {
return { value: JSON.parse(text) as string };
} catch {
return "not a valid JSON string";
}
}
if (text[0] === "'") {
if (!/^'(?:[^']|'')*'$/.test(text)) return "a single-quoted value must close at the end";
return { value: text.slice(1, -1).replaceAll("''", "'") };
}
if (/^(?:true|false|null|yes|no|on|off|~)$/i.test(text)) {
return `\`${text}\` reads as true, false or null in YAML: quote it, or write \`true\`, \`false\` or \`null\``;
}
// Every number form YAML 1.2 reads, so a hash like `1e3456` isn't quietly Infinity elsewhere.
if (
/^[-+]?(?:(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?|\.inf|\.nan)$|^0x[\da-f]+$|^0o[0-7]+$/i.test(text)
) {
const n = Number(text);
return `\`${text}\` reads as a number in YAML: quote it${Number.isNaN(n) ? "" : `, or write it as \`${n}\``}`;
}
if (/^[{&*!|>%@`,#\]}]|^[-?:](?:[ \t]|$)/.test(text)) {
return `\`${text[0]}\` at the start of a value is YAML syntax: quote the value`;
}
if (/:(?:[ \t]|$)/.test(text)) {
return { value: text, warn: "`: ` inside a value is YAML syntax: quote the value" };
}
if (/[ \t]#/.test(text)) {
return {
value: text,
warn: "` #` starts a comment in YAML, which drops the rest: quote the value",
};
}
return { value: text };
}
/** Splits on commas outside quotes; `null` for an unclosed quote. */
function splitList(text: string): (string | null)[] {
const items: (string | null)[] = [];
let quote = "";
let from = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quote) {
if (c === "\\" && quote === '"') i++;
else if (c === quote) quote = "";
} else if ((c === '"' || c === "'") && text.slice(from, i).trim() === "") quote = c;
else if (c === ",") {
items.push(text.slice(from, i));
from = i + 1;
}
}
items.push(quote ? null : text.slice(from));
return items;
}