Files
PaperClipAI/packages/shared/src/frontmatter.ts
T
3abe9e2134 build(deps): bump zod from 3.25.76 to 4.4.3 (#11719)
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.4.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/colinhacks/zod/releases">zod's
releases</a>.</em></p>
<blockquote>
<h2>v4.4.3</h2>
<h2>Commits:</h2>
<ul>
<li>4c2fa95ce3f3390fbc522324e406b4e9e89b88f9 docs: use Zernio primary
wordmark for gold sponsor logo</li>
<li>2aeec83eb135e3a83756e973ef44845fc5a455d2 docs: prune lapsed gold
sponsors and rebalance logo sizing</li>
<li>7391be88ac1ee5cd02057f5ccc012a1f5df4efd0 docs: prune lapsed
silver/bronze sponsors and add active ones</li>
<li>2c703322a21b4e2b12f33f49ea8430c451a68b4f docs: normalize bronze
sponsor logos to github avatar pattern</li>
<li>9195250cab0e7950efe39c3926d6c203b4b0a170 docs: remove Mintlify from
bronze sponsors (churned)</li>
<li>b8dffe9e62f17e6571e6249d05cc5102b54d94e4 docs: remove Numeric and
Speakeasy (2+ missed monthly cycles)</li>
<li>1cab69383fcdeae2a366d5e2a2fc4d8fc765d168 fix(v4): restore catch
handling for absent object keys (<a
href="https://redirect.github.com/colinhacks/zod/issues/5937">#5937</a>)
(<a
href="https://redirect.github.com/colinhacks/zod/issues/5939">#5939</a>)</li>
<li>c2be4f819064eed62c7c350a2d399b5faecd15f8 fix(v4): generalize
optin/fallback to transform; restore preprocess on absent keys (<a
href="https://redirect.github.com/colinhacks/zod/issues/5941">#5941</a>)</li>
<li>f3c9ec03ba7a28ae72d25cc295f38674bee0f559 4.4.3</li>
<li>1fb56a5c18c27102dbc92260a4007c7732a0ccca docs: document release
procedure in AGENTS.md</li>
</ul>
<h2>v4.4.2</h2>
<h2>Commits:</h2>
<ul>
<li>0c62df0ea19fd05abdf90473e9eef7eea530fab2 Clean up docs navigation
and stale labels (<a
href="https://redirect.github.com/colinhacks/zod/issues/5901">#5901</a>)</li>
<li>20cc794895cc8604fe0c87d83a5d1c3f89fad0ac chore: add security policy
and refresh tooling deps</li>
<li>6fbe07b0177efdd1bf1c0b05160e70d7a0702337 fix(docs): heading anchor
links now include the hash so it doesnt scoll all the way up, follows
navbar logic (<a
href="https://redirect.github.com/colinhacks/zod/issues/5791">#5791</a>)</li>
<li>4bbed1b1c73eca4ce9e59b1189ed236aa6c8b5bd Tighten discriminated union
option typing</li>
<li>bbac3e567e7fccfaaf7cdc97f1ce30c295e2c908 Update PR guidance for
agents</li>
<li>cf0dc942a32805c292fff59ade20a7ace980735a Merge remote-tracking
branch 'origin/main' into fix-discriminated-union-key-constraint</li>
<li>292c894a5fd2aa42e527900b83d8d7a3009a709c docs: add Zernio gold
sponsor</li>
<li>1fc9f311c28dcf80d0bb5a36b177086cbc3d8eca docs: document codec
inversion</li>
<li>1373c85da9aeff704a9762d27bc58699618aefb7 docs: remove AI disclosure
guidance</li>
<li>e20d02b473c08e3a4e557bc610b1b5fac079b649 chore: ignore triage
notes</li>
<li>e58ea4d91b1dfe8194b73508203213cbc7e9c936 docs: test Zod Mini tab
code heights</li>
<li>905761a5d127e8d5dd2ebb3bc88c75cb0b8149ff docs: document preprocess
input type narrowing</li>
<li>bf64bac850d4dee2b7dde7e64909d5d796d32043 chore: tighten test
guidance in AGENTS.md</li>
<li>8ec4e73f4c4693b6361ad591be40fb41eb8a9f95 chore: update play.ts
scratch</li>
<li>02c2baf7d0d615872fa4528a8020603b71211702 Make z.preprocess defer
optionality to inner schema (<a
href="https://redirect.github.com/colinhacks/zod/issues/5929">#5929</a>)</li>
<li>88015df8e25c44fb5385eb3ef28935119cd5edea fix(docs): drop deprecated
<code>baseUrl</code> from tsconfig</li>
<li>c59d4474e3b4cad1b323462186cf607178ce8267 4.4.2</li>
</ul>
<h2>v4.4.1</h2>
<h2>Commits:</h2>
<ul>
<li>481f7be4238c83ed58183f921b2646f340a91c6a ci: gate release publishing
on full test workflow</li>
<li>95ccab423aec720b2523c3a64cdc7e3204537cc7 test(v3): restore optional
undefined expectations</li>
<li>cede2c63739a5823d6aa5093d291e9a111da943d fix(v4): reject tuple holes
before required defaults (<a
href="https://redirect.github.com/colinhacks/zod/issues/5900">#5900</a>)</li>
<li>edd0bf0f5ada4a8dc581c259407d7bbad0a71ea7 release: 4.4.1</li>
<li>180d83d1dbe6a59260710cc8637a3dea2281ee56 docs: remove Jazz featured
sponsor</li>
</ul>
<h2>v4.4.0</h2>
<h2>4.4.0</h2>
<p>This is a minor release with a wide set of correctness and soundness
fixes. Some fixes intentionally make Zod stricter, so code that depended
on previously accepted invalid or ambiguous inputs may need small
updates.</p>
<h2>Potentially breaking bug fixes</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/colinhacks/zod/commit/1fb56a5c18c27102dbc92260a4007c7732a0ccca"><code>1fb56a5</code></a>
docs: document release procedure in AGENTS.md</li>
<li><a
href="https://github.com/colinhacks/zod/commit/f3c9ec03ba7a28ae72d25cc295f38674bee0f559"><code>f3c9ec0</code></a>
4.4.3</li>
<li><a
href="https://github.com/colinhacks/zod/commit/c2be4f819064eed62c7c350a2d399b5faecd15f8"><code>c2be4f8</code></a>
fix(v4): generalize optin/fallback to transform; restore preprocess on
absent...</li>
<li><a
href="https://github.com/colinhacks/zod/commit/1cab69383fcdeae2a366d5e2a2fc4d8fc765d168"><code>1cab693</code></a>
fix(v4): restore catch handling for absent object keys (<a
href="https://redirect.github.com/colinhacks/zod/issues/5937">#5937</a>)
(<a
href="https://redirect.github.com/colinhacks/zod/issues/5939">#5939</a>)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/b8dffe9e62f17e6571e6249d05cc5102b54d94e4"><code>b8dffe9</code></a>
docs: remove Numeric and Speakeasy (2+ missed monthly cycles)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/9195250cab0e7950efe39c3926d6c203b4b0a170"><code>9195250</code></a>
docs: remove Mintlify from bronze sponsors (churned)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/2c703322a21b4e2b12f33f49ea8430c451a68b4f"><code>2c70332</code></a>
docs: normalize bronze sponsor logos to github avatar pattern</li>
<li><a
href="https://github.com/colinhacks/zod/commit/7391be88ac1ee5cd02057f5ccc012a1f5df4efd0"><code>7391be8</code></a>
docs: prune lapsed silver/bronze sponsors and add active ones</li>
<li><a
href="https://github.com/colinhacks/zod/commit/2aeec83eb135e3a83756e973ef44845fc5a455d2"><code>2aeec83</code></a>
docs: prune lapsed gold sponsors and rebalance logo sizing</li>
<li><a
href="https://github.com/colinhacks/zod/commit/4c2fa95ce3f3390fbc522324e406b4e9e89b88f9"><code>4c2fa95</code></a>
docs: use Zernio primary wordmark for gold sponsor logo</li>
<li>Additional commits viewable in <a
href="https://github.com/colinhacks/zod/compare/v3.25.76...v4.4.3">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for zod since your current version.</p>
</details>
<br />

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Priya Raman <priya@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-21 00:04:14 -07:00

649 lines
20 KiB
TypeScript

import { z } from "zod";
export interface MarkdownDoc {
frontmatter: Record<string, unknown>;
body: string;
hasFrontmatter: boolean;
}
export interface FrontmatterBlock {
frontmatterText: string;
body: string;
hasFrontmatter: boolean;
}
export type FrontmatterRoundTripIssueKind =
| "anchor"
| "alias"
| "comment"
| "quoted_key"
| "tag";
export interface FrontmatterRoundTripIssue {
kind: FrontmatterRoundTripIssueKind;
line: number;
column: number;
message: string;
}
const SKILL_FRONTMATTER_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const SUPPORTED_FRONTMATTER_KEY_RE = /^[A-Za-z0-9_. -]+$/;
type SerializableFrontmatterValue =
| null
| string
| number
| boolean
| SerializableFrontmatterValue[]
| { [key: string]: SerializableFrontmatterValue };
const skillMetadataValueSchema: z.ZodType<unknown> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(skillMetadataValueSchema),
z.record(z.string(), skillMetadataValueSchema),
])
);
export const skillFrontmatterSchema = z.object({
name: z.string().regex(SKILL_FRONTMATTER_SLUG_RE, "Expected a lowercase URL slug."),
description: z.string().min(1),
"allowed-tools": z.array(z.string()).optional(),
metadata: z.record(z.string(), skillMetadataValueSchema).optional(),
}).passthrough();
export const skillFrontmatterKnownKeys = [
"name",
"description",
"allowed-tools",
"metadata",
] as const;
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function asBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
export function asStringArray(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value)) return null;
const out: string[] = [];
for (const item of value) {
const text = asString(item);
if (!text) return null;
out.push(text);
}
return out;
}
export function splitFrontmatterBlock(raw: string): FrontmatterBlock {
if (!raw.startsWith("---\n")) {
return { frontmatterText: "", body: raw, hasFrontmatter: false };
}
const closing = raw.indexOf("\n---\n", 3);
if (closing < 0) {
return { frontmatterText: "", body: raw, hasFrontmatter: false };
}
return {
frontmatterText: raw.slice(4, closing),
body: raw.slice(closing + 5),
hasFrontmatter: true,
};
}
export function stringifyFrontmatter(value: Record<string, unknown>): string {
return stringifyYamlRecord(assertSerializableRecord(value), 0).join("\n");
}
export function getSkillFrontmatterUnknownKeys(value: Record<string, unknown>) {
const known = new Set<string>(skillFrontmatterKnownKeys);
return Object.keys(value).filter((key) => !known.has(key));
}
export function detectFrontmatterRoundTripIssues(rawYaml: string): FrontmatterRoundTripIssue[] {
const issues: FrontmatterRoundTripIssue[] = [];
const lines = rawYaml.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
const contentStart = line.search(/\S/u);
if (contentStart < 0) continue;
const content = line.slice(contentStart);
if (content.startsWith("#")) {
issues.push({
kind: "comment",
line: index + 1,
column: contentStart + 1,
message: "Comments are not preserved by the frontmatter field serializer.",
});
continue;
}
const inlineComment = findRoundTripPattern(line, /(^|\s)#/u);
if (inlineComment >= 0) {
issues.push({
kind: "comment",
line: index + 1,
column: inlineComment + 1,
message: "Inline comments are not preserved by the frontmatter field serializer.",
});
}
const quotedKey = /^\s*(?:-\s*)?(["']).+?\1\s*:/u.exec(line);
if (quotedKey) {
issues.push({
kind: "quoted_key",
line: index + 1,
column: line.indexOf(quotedKey[1]!) + 1,
message: "Quoted YAML keys cannot be round-tripped by the frontmatter parser.",
});
}
const anchor = findRoundTripPattern(line, /(^|[\s,[{])&[A-Za-z0-9_-]+/u);
if (anchor >= 0) {
issues.push({
kind: "anchor",
line: index + 1,
column: anchor + 1,
message: "YAML anchors cannot be round-tripped by the frontmatter parser.",
});
}
const alias = findRoundTripPattern(line, /(^|[\s,[{])\*[A-Za-z0-9_-]+/u);
if (alias >= 0) {
issues.push({
kind: "alias",
line: index + 1,
column: alias + 1,
message: "YAML aliases cannot be round-tripped by the frontmatter parser.",
});
}
const tag = findRoundTripPattern(line, /(^|\s)![A-Za-z!][^\s]*/u);
if (tag >= 0) {
issues.push({
kind: "tag",
line: index + 1,
column: tag + 1,
message: "YAML tags cannot be round-tripped by the frontmatter parser.",
});
}
}
return issues;
}
/**
* Recombine a split block into a full markdown document. This is the exact
* inverse of {@link splitFrontmatterBlock}: `join(split(x)) === x` for every
* input, so opening a file and saving it untouched is byte-identical. The body
* is passed through verbatim — never re-parsed or re-serialized.
*/
export function joinFrontmatterBlock(block: FrontmatterBlock): string {
if (!block.hasFrontmatter) return block.body;
return `---\n${block.frontmatterText}\n---\n${block.body}`;
}
/**
* Parse the raw YAML of a frontmatter block (the text between the `---` fences,
* as returned by {@link splitFrontmatterBlock}) into a plain object. Lenient:
* unparseable input yields `{}` rather than throwing.
*/
export function parseFrontmatterFields(frontmatterText: string): Record<string, unknown> {
return parseYamlFrontmatter(frontmatterText);
}
export interface FrontmatterAnalysis {
/** The parsed field object (best-effort; `{}` when nothing parses). */
parsed: Record<string, unknown>;
/**
* True when the field editor is safe to use: the raw YAML both parses and
* re-serializes byte-for-byte. When false, editing must stay in raw-YAML mode
* so bytes the serializer can't reproduce (comments, anchors, folded scalars,
* custom ordering, quoting) are never silently rewritten.
*/
canRoundTrip: boolean;
/** Structural features that make the block non-round-trippable, if any. */
issues: FrontmatterRoundTripIssue[];
}
/**
* Decide whether a frontmatter block can be edited through the structured
* field form. Fields mode is only offered when re-serializing the parsed object
* reproduces the original block exactly — this is the load-bearing round-trip
* safety gate for the Skill Studio FrontmatterPanel (PAP-13145 Option B).
*/
export function analyzeFrontmatterBlock(frontmatterText: string): FrontmatterAnalysis {
const issues = detectFrontmatterRoundTripIssues(frontmatterText);
const parsed = parseYamlFrontmatter(frontmatterText);
let canRoundTrip = issues.length === 0;
if (canRoundTrip) {
try {
canRoundTrip = stringifyFrontmatter(parsed) === frontmatterText;
} catch {
canRoundTrip = false;
}
}
return { parsed, canRoundTrip, issues };
}
export function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
const normalized = raw.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const frontmatterRaw = normalized.slice(4, closing);
const body = normalized.slice(closing + 5).trim();
return {
frontmatter: parseYamlFrontmatter(frontmatterRaw),
body,
hasFrontmatter: true,
};
}
function assertSerializableRecord(value: Record<string, unknown>) {
const out: Record<string, SerializableFrontmatterValue> = {};
for (const [key, entryValue] of Object.entries(value)) {
if (entryValue === undefined) continue;
out[key] = assertSerializableValue(entryValue);
}
return out;
}
function assertSerializableValue(value: unknown): SerializableFrontmatterValue {
if (
value === null
|| typeof value === "string"
|| typeof value === "boolean"
) {
return value;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError("Frontmatter numbers must be finite.");
}
return value;
}
if (Array.isArray(value)) {
return value
.filter((entry) => entry !== undefined)
.map((entry) => assertSerializableValue(entry));
}
if (isPlainRecord(value)) {
return assertSerializableRecord(value);
}
throw new TypeError(`Unsupported frontmatter value type: ${typeof value}`);
}
function stringifyYamlRecord(record: Record<string, SerializableFrontmatterValue>, indentLevel: number): string[] {
const lines: string[] = [];
for (const [key, value] of Object.entries(record)) {
assertYamlKey(key);
lines.push(...stringifyYamlProperty(key, value, indentLevel));
}
return lines;
}
function stringifyYamlProperty(key: string, value: SerializableFrontmatterValue, indentLevel: number): string[] {
const indent = " ".repeat(indentLevel);
if (Array.isArray(value)) {
if (value.length === 0) return [`${indent}${key}: []`];
return [`${indent}${key}:`, ...stringifyYamlArray(value, indentLevel + 2)];
}
if (isSerializableRecord(value)) {
const entries = Object.entries(value);
if (entries.length === 0) return [`${indent}${key}: {}`];
return [`${indent}${key}:`, ...stringifyYamlRecord(value, indentLevel + 2)];
}
if (typeof value === "string" && value.includes("\n")) {
return stringifyBlockScalarProperty(key, value, indentLevel);
}
return [`${indent}${key}: ${stringifyYamlScalar(value)}`];
}
function stringifyYamlArray(values: SerializableFrontmatterValue[], indentLevel: number): string[] {
const indent = " ".repeat(indentLevel);
const lines: string[] = [];
for (const value of values) {
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${indent}- []`);
} else {
lines.push(`${indent}-`);
lines.push(...stringifyYamlArray(value, indentLevel + 2));
}
continue;
}
if (isSerializableRecord(value)) {
const entries = Object.entries(value);
if (entries.length === 0) {
lines.push(`${indent}- {}`);
} else {
lines.push(`${indent}-`);
lines.push(...stringifyYamlRecord(value, indentLevel + 2));
}
continue;
}
if (typeof value === "string" && value.includes("\n")) {
lines.push(...stringifyBlockScalarArrayItem(value, indentLevel));
continue;
}
lines.push(`${indent}- ${stringifyYamlScalar(value)}`);
}
return lines;
}
function stringifyBlockScalarProperty(key: string, value: string, indentLevel: number) {
const indent = " ".repeat(indentLevel);
return [
`${indent}${key}: ${blockScalarIndicator(value)}`,
...indentBlockScalarValue(value, indentLevel + 2),
];
}
function stringifyBlockScalarArrayItem(value: string, indentLevel: number) {
const indent = " ".repeat(indentLevel);
return [
`${indent}- ${blockScalarIndicator(value)}`,
...indentBlockScalarValue(value, indentLevel + 2),
];
}
function blockScalarIndicator(value: string) {
if (!value.endsWith("\n")) return "|-";
if (value.endsWith("\n\n")) return "|+";
return "|";
}
function indentBlockScalarValue(value: string, indentLevel: number) {
const indent = " ".repeat(indentLevel);
return value.split("\n").map((line) => `${indent}${line}`);
}
function stringifyYamlScalar(value: Exclude<SerializableFrontmatterValue, SerializableFrontmatterValue[] | Record<string, SerializableFrontmatterValue>>) {
if (value === null) return "null";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") return String(value);
if (isPlainYamlScalar(value)) return value;
return JSON.stringify(value);
}
function isPlainYamlScalar(value: string) {
if (value.length === 0) return false;
if (value.trim() !== value) return false;
if (value === "null" || value === "~" || value === "true" || value === "false") return false;
if (value === "[]" || value === "{}") return false;
if (/^-?\d+(\.\d+)?$/u.test(value)) return false;
if (/["'[\]{}#,>&*!|@`]/u.test(value)) return false;
if (value.includes(":")) return false;
return true;
}
function assertYamlKey(key: string) {
if (!SUPPORTED_FRONTMATTER_KEY_RE.test(key) || key.includes(":")) {
throw new TypeError(`Unsupported frontmatter key: ${key}`);
}
}
function isSerializableRecord(value: SerializableFrontmatterValue): value is Record<string, SerializableFrontmatterValue> {
return isPlainRecord(value);
}
function findRoundTripPattern(line: string, pattern: RegExp) {
const match = pattern.exec(line);
if (!match) return -1;
return match.index + (match[1]?.length ?? 0);
}
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
const prepared = prepareYamlLines(raw);
const firstContentIndex = prepared.findIndex((line) => !line.isBlank && !line.isComment);
if (firstContentIndex < 0) return {};
const parsed = parseYamlBlock(prepared, firstContentIndex, prepared[firstContentIndex]!.indent);
return isPlainRecord(parsed.value) ? parsed.value : {};
}
function prepareYamlLines(raw: string) {
return raw
.split("\n")
.map((line) => ({
indent: line.match(/^ */)?.[0].length ?? 0,
raw: line,
content: line.trim(),
isBlank: line.trim().length === 0,
isComment: line.trim().startsWith("#"),
}));
}
function parseYamlBlock(
lines: Array<{ indent: number; raw: string; content: string; isBlank: boolean; isComment: boolean }>,
startIndex: number,
indentLevel: number,
): { value: unknown; nextIndex: number } {
let index = startIndex;
while (index < lines.length && (lines[index]!.isBlank || lines[index]!.isComment)) {
index += 1;
}
if (index >= lines.length || lines[index]!.indent < indentLevel) {
return { value: {}, nextIndex: index };
}
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
if (isArray) {
const values: unknown[] = [];
while (index < lines.length) {
const line = lines[index]!;
if (line.isBlank || line.isComment) {
index += 1;
continue;
}
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
const remainder = line.content.slice(1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
values.push(nested.value);
index = nested.nextIndex;
continue;
}
if (isYamlBlockScalarIndicator(remainder)) {
const block = parseYamlBlockScalar(lines, index, indentLevel, remainder);
values.push(block.value);
index = block.nextIndex;
continue;
}
const inlineObjectSeparator = remainder.indexOf(":");
if (
inlineObjectSeparator > 0
&& !remainder.startsWith("\"")
&& !remainder.startsWith("{")
&& !remainder.startsWith("[")
) {
const key = remainder.slice(0, inlineObjectSeparator).trim();
const rawValue = remainder.slice(inlineObjectSeparator + 1).trim();
const nextObject: Record<string, unknown> = {
[key]: parseYamlScalar(rawValue),
};
if (index < lines.length && lines[index]!.indent > indentLevel) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
if (isPlainRecord(nested.value)) {
Object.assign(nextObject, nested.value);
}
index = nested.nextIndex;
}
values.push(nextObject);
continue;
}
values.push(parseYamlScalar(remainder));
}
return { value: values, nextIndex: index };
}
const record: Record<string, unknown> = {};
while (index < lines.length) {
const line = lines[index]!;
if (line.isBlank || line.isComment) {
index += 1;
continue;
}
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel) {
index += 1;
continue;
}
const separatorIndex = line.content.indexOf(":");
if (separatorIndex <= 0) {
index += 1;
continue;
}
const key = line.content.slice(0, separatorIndex).trim();
const remainder = line.content.slice(separatorIndex + 1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
record[key] = nested.value;
index = nested.nextIndex;
continue;
}
if (isYamlBlockScalarIndicator(remainder)) {
const block = parseYamlBlockScalar(lines, index, indentLevel, remainder);
record[key] = block.value;
index = block.nextIndex;
continue;
}
record[key] = parseYamlScalar(remainder);
}
return { value: record, nextIndex: index };
}
function isYamlBlockScalarIndicator(rawValue: string) {
return /^[>|][+-]?$/.test(rawValue.trim());
}
function parseYamlBlockScalar(
lines: Array<{ indent: number; raw: string; content: string; isBlank: boolean; isComment: boolean }>,
startIndex: number,
parentIndent: number,
indicator: string,
): { value: string; nextIndex: number } {
const trimmedIndicator = indicator.trim();
const style = trimmedIndicator[0];
const chomp = trimmedIndicator.endsWith("+")
? "+"
: trimmedIndicator.endsWith("-")
? "-"
: "";
let index = startIndex;
const collected: Array<{ indent: number; raw: string; isBlank: boolean }> = [];
while (index < lines.length) {
const line = lines[index]!;
if (!line.isBlank && line.indent <= parentIndent) break;
collected.push({ indent: line.indent, raw: line.raw, isBlank: line.isBlank });
index += 1;
}
const contentLines = collected.filter((line) => !line.isBlank);
if (contentLines.length === 0) return { value: "", nextIndex: index };
const blockIndent = Math.min(...contentLines.map((line) => line.indent));
const normalizedLines = collected.map((line) => (
line.isBlank ? "" : line.raw.slice(Math.min(blockIndent, line.raw.length))
));
const baseValue = style === "|"
? normalizedLines.join("\n")
: foldYamlBlockScalarLines(normalizedLines);
return {
value: applyYamlBlockChomp(baseValue, chomp),
nextIndex: index,
};
}
function foldYamlBlockScalarLines(lines: string[]) {
let value = "";
let pendingBlankLines = 0;
for (const line of lines) {
if (line === "") {
pendingBlankLines += 1;
continue;
}
if (value.length === 0) {
value = `${"\n".repeat(pendingBlankLines)}${line}`;
} else if (pendingBlankLines > 0) {
value += `${"\n".repeat(pendingBlankLines + 1)}${line}`;
} else {
value += ` ${line}`;
}
pendingBlankLines = 0;
}
if (pendingBlankLines > 0 && value.length > 0) {
value += "\n".repeat(pendingBlankLines);
}
return value;
}
function applyYamlBlockChomp(value: string, chomp: "" | "+" | "-") {
if (chomp === "+") return value;
if (chomp === "-") return value.replace(/\n+$/u, "");
if (value.length === 0) return value;
return value.replace(/\n+$/u, "") + "\n";
}
function parseYamlScalar(rawValue: string): unknown {
const trimmed = rawValue.trim();
if (trimmed === "") return "";
if (trimmed === "null" || trimmed === "~") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "[]") return [];
if (trimmed === "{}") return {};
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (
trimmed.startsWith("\"") ||
trimmed.startsWith("[") ||
trimmed.startsWith("{")
) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}