Files
oh-my-pi/packages/utils/src/frontmatter.ts
T
roboomp 86824c94e9 fix(ttsr): registered rules with inline regex flags and malformed scope
Rules whose condition led with a PCRE-style inline flag group (e.g.
`(?i)`) never registered: `new RegExp("(?i)...")` throws in Bun/JS, so
the condition failed to compile and `TtsrManager.addRule` dropped the
rule as having zero usable conditions.

- Add `compileRuleCondition` in capability/rule.ts translating a leading
  `(?i)`/`(?m)`/`(?s)` group into native RegExp flags; wire it into the
  TtsrManager and both ttsr-cli compile sites.
- Strip surrounding quotes from scope tokens so a malformed
  `scope: "text","thinking"` recovers to canonical `text`/`thinking`.
- Reparse each value in parseFrontmatter's YAML fallback so one bad line
  can't leave sibling values wrapped in literal quotes.

Fixes #4796
2026-07-14 19:01:47 +00:00

176 lines
5.8 KiB
TypeScript

import { YAML } from "bun";
import { truncate } from "./format";
import * as logger from "./logger";
function stripHtmlComments(content: string): string {
return content.replace(/<!--[\s\S]*?-->/g, "");
}
/** Convert kebab-case to camelCase (e.g. "thinking-level" -> "thinkingLevel") */
function kebabToCamel(key: string): string {
if (!key.includes("-")) return key;
return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
/** Recursively normalize object keys from kebab-case to camelCase */
function normalizeKeys<T>(obj: T): T {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) {
let changed = false;
const out: unknown[] = new Array(obj.length);
for (let i = 0; i < obj.length; i++) {
const v = obj[i];
const nv = normalizeKeys(v);
out[i] = nv;
if (nv !== v) changed = true;
}
return (changed ? (out as unknown) : obj) as T;
}
let changed = false;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const nk = key.includes("-") ? kebabToCamel(key) : key;
const nv = normalizeKeys(value);
result[nk] = nv;
if (nk !== key || nv !== value) changed = true;
}
return (changed ? result : obj) as T;
}
const PLAIN_SCALAR_KEY_VALUE = /^(\s*[A-Za-z_][\w-]*:\s+)(\S.*?)(\s*)$/;
const FLOW_OR_EXPLICIT_VALUE_START = new Set(['"', "'", "[", "{", "|", ">", "!", "&", "*", "#"]);
function quoteAmbiguousPlainScalars(metadata: string): string | undefined {
let changed = false;
const lines = metadata.split("\n").map(line => {
const match = line.match(PLAIN_SCALAR_KEY_VALUE);
if (!match) return line;
const [, prefix, rawValue, suffix] = match;
const value = rawValue.trimEnd();
if (!value.includes(": ")) return line;
if (FLOW_OR_EXPLICIT_VALUE_START.has(value[0])) return line;
changed = true;
return `${prefix}${JSON.stringify(value)}${suffix}`;
});
return changed ? lines.join("\n") : undefined;
}
function parseYamlRecord(metadata: string): Record<string, unknown> | null {
const loaded = YAML.parse(metadata.replaceAll("\t", " "));
if (loaded === null || loaded === undefined) return null;
if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
return loaded as Record<string, unknown>;
}
export class FrontmatterError extends Error {
constructor(
error: Error,
readonly source?: unknown,
) {
super(`Failed to parse YAML frontmatter (${source}): ${error.message}`, { cause: error });
this.name = "FrontmatterError";
}
toString(): string {
// Format the error with stack and detail, including the error message, stack, and source if present
const details: string[] = [this.message];
if (this.source !== undefined) {
details.push(`Source: ${JSON.stringify(this.source)}`);
}
if (this.cause && typeof this.cause === "object" && "stack" in this.cause && this.cause.stack) {
details.push(`Stack:\n${this.cause.stack}`);
} else if (this.stack) {
details.push(`Stack:\n${this.stack}`);
}
return details.join("\n\n");
}
}
export interface FrontmatterOptions {
/** Source of the content (alias: source) */
location?: unknown;
/** Source of the content (alias for location) */
source?: unknown;
/** Fallback frontmatter values */
fallback?: Record<string, unknown>;
/** Normalize the content */
normalize?: boolean;
/** Level of error handling */
level?: "off" | "warn" | "fatal";
}
/**
* Parse YAML frontmatter from markdown content
* Returns { frontmatter, body } where body has frontmatter stripped
*/
export function parseFrontmatter(
content: string,
options?: FrontmatterOptions,
): { frontmatter: Record<string, unknown>; body: string } {
const { location, source, fallback, normalize = true, level = "warn" } = options ?? {};
const loc = location ?? source;
const frontmatter: Record<string, unknown> = { ...fallback };
const normalized = normalize ? stripHtmlComments(content.replace(/\r\n?/g, "\n")) : content;
if (!normalized.startsWith("---")) {
return { frontmatter, body: normalized };
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
return { frontmatter, body: normalized };
}
const metadata = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
try {
const loaded = parseYamlRecord(metadata);
return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body };
} catch (error) {
const quotedMetadata = quoteAmbiguousPlainScalars(metadata);
if (quotedMetadata) {
try {
const loaded = parseYamlRecord(quotedMetadata);
return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body };
} catch {
// Fall through to the existing warning + simple key/value fallback.
}
}
const err = new FrontmatterError(
error instanceof Error ? error : new Error(`YAML: ${error}`),
loc ?? `Inline '${truncate(content, 64)}'`,
);
if (level === "warn" || level === "fatal") {
logger.warn("Failed to parse YAML frontmatter", { err: err.toString() });
}
if (level === "fatal") {
throw err;
}
// Simple key: value fallback. Reparse each value on its own so one
// malformed line (e.g. `scope: "text","thinking"`) can't leave sibling
// values wrapped in literal quotes; values that don't parse as YAML fall
// back to the raw trimmed string (issue #4796).
for (const line of metadata.split("\n")) {
const match = line.match(/^([\w-]+):\s*(.*)$/);
if (!match) continue;
const raw = match[2].trim();
let value: unknown = raw;
if (raw.length > 0) {
try {
const parsed = YAML.parse(raw);
if (parsed !== null && typeof parsed !== "object") value = parsed;
else if (Array.isArray(parsed)) value = parsed;
} catch {
// keep the raw string
}
}
frontmatter[match[1]] = value;
}
return { frontmatter: normalizeKeys(frontmatter) as Record<string, unknown>, body };
}
}