UNPKG

@brianlovin/notion-skills

Version:

Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.

128 lines 4.92 kB
/** * Single helper for splitting + reading + editing skill frontmatter. * * Several call sites (audit, add, list, migrate) had their own * variants — some regex-based, some yaml-based, with subtly * different fallback behavior. Consolidating here means: * - one yaml dependency point (we already use the `yaml` lib * elsewhere; no new dep) * - consistent handling of multi-line descriptions, BOM, edge * cases like missing closing `---` * - one place to update if the spec changes */ import { Document, parseDocument, isMap, parse as parseYaml } from "yaml"; const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; /** * Strip BOM, locate the frontmatter block, parse it as YAML, return * a structured view. Tolerant: malformed YAML or missing frontmatter * → returns `hasFrontmatter: false`, body = full original text. */ export function parseFrontmatter(text) { const stripped = text.replace(/^/, ""); const match = stripped.match(FRONTMATTER_RE); if (!match) { return { frontmatter: {}, body: stripped, hasFrontmatter: false }; } let parsed; try { parsed = parseYaml(match[1] ?? ""); } catch { return { frontmatter: {}, body: match[2] ?? "", hasFrontmatter: false }; } const frontmatter = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; return { frontmatter, body: match[2] ?? "", hasFrontmatter: true }; } /** * Read a single field from the frontmatter as a string. Returns * empty string when the field is missing or non-string. Useful for * cheap checks like `description` length, where you don't need to * walk a whole structure. */ export function readFrontmatterString(text, key) { const { frontmatter } = parseFrontmatter(text); const value = frontmatter[key]; return typeof value === "string" ? value : ""; } /** * Read a frontmatter field as a list of strings. Accepts: * - block-form: `key:\n - a\n - b` * - flow-form: `key: [a, b]` * - inline csv: `key: a, b, c` * Non-string values are dropped. Missing key → empty array. */ export function readFrontmatterList(text, key) { const { frontmatter } = parseFrontmatter(text); const value = frontmatter[key]; if (Array.isArray(value)) { return value.filter((v) => typeof v === "string"); } if (typeof value === "string") { // Inline CSV form (`key: a, b, c`) round-tripped through yaml as // a string. Split on commas and trim. return value .split(/\s*,\s*/) .map((s) => s.trim()) .filter(Boolean); } return []; } /** * Inject `metadata.<key> = <value>` into a SKILL.md's frontmatter * non-destructively. Preserves the rest of the frontmatter's * formatting (quoting, key order, comments) by editing through * yaml's `Document` API rather than splicing strings. * * Cases handled: * - No frontmatter at all: synthesise a minimal block with the * metadata.<key> set. The skill is still broken at the spec * level (no name/description) but at least preserves whatever * body the caller wrote. * - Malformed frontmatter / non-map root: return text unchanged. * Better to leave the skill alone than rewrite something that * might be hand-edited weirdly. * - Metadata block missing entirely: add it with just the new key. * - Metadata block exists, key absent: add the key inside. * - Key (or its case-insensitive equivalent) already present and * `preserveExisting` is true: return text unchanged. The user's * value wins. */ export function injectMetadataKey(text, key, value, options = {}) { const preserveExisting = options.preserveExisting ?? true; const stripped = text.replace(/^/, ""); const match = stripped.match(FRONTMATTER_RE); if (!match) { return `---\nmetadata:\n ${key}: ${JSON.stringify(value)}\n---\n\n${stripped}`; } const [, fmText, body] = match; let doc; try { doc = parseDocument(fmText ?? ""); } catch { return text; } if (!doc.contents || !isMap(doc.contents)) return text; let metadata = doc.get("metadata"); if (!metadata || !isMap(metadata)) { metadata = new Document().createNode({}); doc.set("metadata", metadata); } if (preserveExisting) { // Respect any pre-existing key (case-insensitive — covers users // who hand-wrote `origin` lowercase even though we now ship // `Origin`). const lower = key.toLowerCase(); for (const item of metadata.items) { const k = String(item.key); if (k === key || k.toLowerCase() === lower) return text; } } metadata.set(key, value); return `---\n${doc.toString().trimEnd()}\n---\n${body ?? ""}`; } //# sourceMappingURL=frontmatter.js.map