UNPKG

@brianlovin/notion-skills

Version:

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

580 lines 21.2 kB
import { stringify as yamlStringify } from "yaml"; import { readMultiSelect, readRichText, readSelect, readTitle, } from "./notion.js"; import { isSpecCategoryName, parseFromChildPage, } from "./skill-files.js"; // ---------- slugify ---------- export function slugify(input) { return input .toLowerCase() .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9-_ ]+/g, "") .trim() .replace(/[\s_]+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 64) .replace(/-+$/g, "") || "untitled"; } import { SCHEMA, SELECT_DEFAULT, SPEC_DEFAULTS } from "./schema.js"; /** * Build a SKILL.md document from a SkillProperties value plus body. * Emits frontmatter keys in SCHEMA order (stable for round-trips). * Skips keys that are unset or match the spec default. */ export function buildSkillMarkdown(opts) { const fm = {}; for (const prop of SCHEMA) { const key = prop.frontmatterKey; const value = opts.properties[key]; if (value === undefined) continue; // Tags are a Notion-only filter — used by `list --tag`, `install // --tag`, etc. They aren't part of the Skills spec, so they don't // belong in SKILL.md frontmatter. We still read them from Notion // and persist them on the row; we just don't round-trip to disk. if (prop.taxonomyOnly) continue; if (prop.metricOnly) continue; // Skip select values that mean "use spec default" if (prop.kind === "select") { if (value === "" || value === SELECT_DEFAULT) continue; // For booleans (disable-model-invocation, user-invocable), also skip // when the selected value matches the spec default — keeps SKILL.md // tidy by only emitting non-default state. const specDefault = SPEC_DEFAULTS[key]; if (specDefault !== undefined && value === specDefault) continue; } if (prop.kind === "rich_text" || prop.kind === "list_text") { if (value === "" || (Array.isArray(value) && value.length === 0)) continue; } if (prop.kind === "multi_select") { if (!Array.isArray(value) || value.length === 0) continue; } fm[key] = value; } // Spec's official extension point: any non-SCHEMA Notion column the // user added gets surfaced under `metadata`. Emit only when non-empty // so single-column skills stay tidy. if (opts.properties.metadata && Object.keys(opts.properties.metadata).length > 0) { fm.metadata = opts.properties.metadata; } const fmText = yamlStringify(fm).trimEnd(); const body = opts.body.trim(); return `---\n${fmText}\n---\n\n${body}\n`; } export async function convertPageToSkill(client, page) { if (page.archived || page.in_trash) return { ok: false, reason: "archived" }; const title = readTitle(page.properties); if (!title) return { ok: false, reason: "missing title" }; const description = readRichText(page.properties, "Description"); if (!description) return { ok: false, reason: `missing "Description" property` }; const { body, files } = await fetchPageContent(client, page); const properties = readSkillPropertiesFromPage(page, slugify(title), description); return { ok: true, skill: { properties, body, pageId: page.id, lastEditedTime: page.last_edited_time, files, }, }; } function readChildPageTitle(block) { const inner = block.child_page; const t = inner?.title?.trim(); return t && t.length > 0 ? t : null; } /** * Fetch a page's body markdown plus every sibling file expressed as a * top-level child page. Pure read — no validation. Used by: * - convertPageToSkill (after validating description) * - list's drift slow-path (which only needs body + files for hashing) * * Nested child_pages (inside columns, toggles, etc.) are NOT supported * in v1: they're treated as inline content, not sibling files. */ export async function fetchPageContent(client, page) { const blocks = await fetchBlockTree(client, page.id); const childPageBlocks = blocks.filter((b) => b.type === "child_page"); const bodyBlocks = blocks.filter((b) => b.type !== "child_page"); const body = renderBlocks(bodyBlocks, 0); const files = []; for (const cp of childPageBlocks) { const childTitle = readChildPageTitle(cp); if (!childTitle) continue; if (isSpecCategoryName(childTitle)) { // Spec category wrapper (scripts / references / assets): // recurse into its children and surface them with the category // prefix in the file path. const grandchildren = await fetchBlockTree(client, cp.id); for (const gc of grandchildren) { if (gc.type !== "child_page") continue; const gcTitle = readChildPageTitle(gc); if (!gcTitle) continue; const gcBlocks = await fetchBlockTree(client, gc.id); const gcBody = renderBlocks(gcBlocks, 0); files.push(parseFromChildPage(`${childTitle}/${gcTitle}`, gcBody)); } continue; } // Flat root-level file (legacy / non-spec-dir path). const childBlocks = await fetchBlockTree(client, cp.id); const childBody = renderBlocks(childBlocks, 0); files.push(parseFromChildPage(childTitle, childBody)); } return { body, files }; } /** * Read every spec-mapped property off a Notion page into a SkillProperties. * * Defaults: * - Empty rich_text / list_text → undefined * - Select == "default" or unset → undefined * - Otherwise the cell's value passes through * * The slug + description are passed in from the caller so we don't reread * them; everything else is read fresh. */ function readSkillPropertiesFromPage(page, name, description) { const props = { name, description }; const richText = (notionName) => { const v = readRichText(page.properties, notionName); return v ? v : undefined; }; const listFromText = (notionName, splitOn) => { const v = readRichText(page.properties, notionName); if (!v) return undefined; const items = v.split(splitOn).map((s) => s.trim()).filter(Boolean); return items.length ? items : undefined; }; /** * Tool patterns like `Bash(git *)` contain whitespace inside parentheses, * so naive whitespace splitting wrecks them. Parse with paren-depth * awareness instead — splits only on whitespace at depth 0. */ const toolsList = (notionName) => { const v = readRichText(page.properties, notionName); if (!v) return undefined; return splitToolsRespectingParens(v); }; const select = (notionName) => { const v = readSelect(page.properties, notionName); if (!v || v === "default") return undefined; return v; }; const multiSelect = (notionName) => { const v = readMultiSelect(page.properties, notionName); return v.length > 0 ? v : undefined; }; // core (spec) props.license = richText("License"); props.compatibility = richText("Compatibility"); props["allowed-tools"] = toolsList("Allowed Tools"); // claude props.when_to_use = richText("When To Use"); props["argument-hint"] = richText("Argument Hint"); props.arguments = listFromText("Arguments", /\s+/); props.paths = listFromText("Paths", /\s*,\s*/); props["disable-model-invocation"] = select("Disable Model Invocation"); props["user-invocable"] = select("User Invocable"); props.model = select("Model"); props.effort = select("Effort"); props.context = select("Context"); props.agent = select("Agent"); props.shell = select("Shell"); // notion-side props.tags = multiSelect("Tags"); // metadata: anything NOT in SCHEMA and not a derived/auto property // gets surfaced as `metadata.<column-name>` in SKILL.md frontmatter. // This is the spec's official extension point — users add Notion // columns at will, and they round-trip without further code changes. props.metadata = readMetadataFromPage(page.properties); return props; } /** Notion property types that are derived/auto-managed and shouldn't * round-trip as user metadata. */ const NON_USER_METADATA_TYPES = new Set([ "created_time", "created_by", "last_edited_time", "last_edited_by", "formula", "rollup", "relation", "unique_id", "people", "files", "verification", "button", ]); function readMetadataFromPage(props) { const schemaNames = new Set(SCHEMA.map((p) => p.notionName)); const out = {}; for (const [name, prop] of Object.entries(props)) { if (schemaNames.has(name)) continue; if (NON_USER_METADATA_TYPES.has(prop.type)) continue; const value = readMetadataValue(prop); if (value === undefined) continue; out[name] = value; } return Object.keys(out).length > 0 ? out : undefined; } function readMetadataValue(prop) { switch (prop.type) { case "rich_text": { const text = (prop.rich_text ?? []) .map((rt) => rt.plain_text ?? "") .join(""); return text || undefined; } case "title": { const text = (prop.title ?? []) .map((rt) => rt.plain_text ?? "") .join(""); return text || undefined; } case "select": { const sel = prop.select; return sel?.name; } case "multi_select": { const items = (prop.multi_select ?? []) .map((opt) => opt.name) .filter((s) => !!s); return items.length > 0 ? items : undefined; } case "number": { return typeof prop.number === "number" ? prop.number : undefined; } case "checkbox": { return prop.checkbox === true; } case "url": case "email": case "phone_number": { const v = prop[prop.type]; return typeof v === "string" && v.length > 0 ? v : undefined; } case "date": { const date = prop.date; if (!date?.start) return undefined; return date.end ? `${date.start}/${date.end}` : date.start; } default: return undefined; } } /** * Split a string on whitespace, ignoring whitespace inside parens. * `Read Edit Bash(git *)` → `["Read", "Edit", "Bash(git *)"]`. * Used for the `allowed-tools` frontmatter field, where tool patterns * may contain spaces inside parenthesised argument matchers. */ export function splitToolsRespectingParens(s) { const out = []; let i = 0; while (i < s.length) { while (i < s.length && /\s/.test(s[i])) i++; if (i >= s.length) break; const start = i; let depth = 0; while (i < s.length && (depth > 0 || !/\s/.test(s[i]))) { const ch = s[i]; if (ch === "(") depth++; else if (ch === ")") depth = Math.max(0, depth - 1); i++; } out.push(s.slice(start, i)); } return out; } export async function fetchBlockTree(client, blockId, depth = 0, visited = new Set()) { if (visited.has(blockId)) return []; visited.add(blockId); const verbose = process.env.NOTION_SKILLS_DEBUG === "1"; if (verbose) { console.error(`${" ".repeat(depth)}↳ fetching children of ${blockId}`); } const blocks = await client.getBlockChildren(blockId); const result = []; for (const block of blocks) { const node = block; if (block.has_children && shouldRecurseInto(block.type) && depth < 10) { node.children = await fetchBlockTree(client, block.id, depth + 1, visited); } result.push(node); } return result; } function shouldRecurseInto(type) { // Skip child_page / child_database — they reference distinct content, // not nested blocks of the current page. if (type === "child_page" || type === "child_database") return false; return true; } // ---------- block rendering ---------- /** * Render a tree of Notion blocks (with attached children) to markdown. * Exposed for tests; sync uses convertPageToSkill which calls this internally. */ export function blocksToMarkdown(blocks) { return renderBlocks(blocks, 0); } function renderBlocks(blocks, indent) { const out = []; let i = 0; while (i < blocks.length) { const block = blocks[i]; // Group consecutive list items of the same type for proper numbering if (block.type === "numbered_list_item" || block.type === "bulleted_list_item") { const groupType = block.type; const group = []; while (i < blocks.length && blocks[i].type === groupType) { group.push(blocks[i]); i++; } out.push(renderList(group, groupType, indent)); continue; } out.push(renderBlock(block, indent)); i++; } return out.filter((s) => s !== "").join("\n\n"); } function renderBlock(block, indent) { const pad = " ".repeat(indent); const t = block.type; const data = block[t] ?? {}; switch (t) { case "paragraph": return pad + renderRichText(data.rich_text ?? []); case "heading_1": return pad + "# " + renderRichText(data.rich_text ?? []); case "heading_2": return pad + "## " + renderRichText(data.rich_text ?? []); case "heading_3": return pad + "### " + renderRichText(data.rich_text ?? []); case "quote": return prefixLines(renderRichText(data.rich_text ?? []), pad + "> "); case "callout": { const icon = data.icon?.emoji ? data.icon.emoji + " " : ""; const text = renderRichText(data.rich_text ?? []); return prefixLines(icon + text, pad + "> "); } case "divider": return pad + "---"; case "code": { const lang = data.language ?? ""; const text = (data.rich_text ?? []) .map((r) => r.plain_text) .join(""); return pad + "```" + lang + "\n" + text + "\n" + pad + "```"; } case "to_do": { const checked = data.checked ? "x" : " "; const inner = renderRichText(data.rich_text ?? []); const head = `${pad}- [${checked}] ${inner}`; const children = block.children?.length ? "\n" + renderBlocks(block.children, indent + 2) : ""; return head + children; } case "toggle": { const summary = renderRichText(data.rich_text ?? []); const inner = block.children?.length ? renderBlocks(block.children, 0) : ""; return (pad + "<details>\n" + pad + `<summary>${escapeHtml(summary)}</summary>\n\n` + indentText(inner, pad) + "\n" + pad + "</details>"); } case "bookmark": case "embed": case "link_preview": { const url = data.url ?? ""; return pad + (url ? `[${url}](${url})` : ""); } case "image": { const url = (data.file?.url ?? data.external?.url ?? ""); const caption = renderRichText(data.caption ?? []) || "image"; return pad + (url ? `![${caption}](${url})` : ""); } case "video": case "audio": case "file": case "pdf": { const url = (data.file?.url ?? data.external?.url ?? ""); const caption = renderRichText(data.caption ?? []) || t; return pad + (url ? `[${caption}](${url})` : ""); } case "table": { const rows = (block.children ?? []).filter((b) => b.type === "table_row"); return renderTable(rows, indent); } case "column_list": case "column": case "synced_block": return block.children?.length ? renderBlocks(block.children, indent) : ""; case "child_page": return pad + `*(child page: ${data.title ?? ""})*`; case "child_database": return pad + `*(child database: ${data.title ?? ""})*`; case "equation": return pad + "$$" + (data.expression ?? "") + "$$"; case "table_of_contents": case "breadcrumb": return ""; case "unsupported": return pad + "*(unsupported block)*"; default: { const rich = data.rich_text; if (Array.isArray(rich)) return pad + renderRichText(rich); return ""; } } } function renderList(group, type, indent) { const pad = " ".repeat(indent); return group .map((item, idx) => { const data = item[type] ?? {}; const marker = type === "numbered_list_item" ? `${idx + 1}.` : "-"; const head = `${pad}${marker} ${renderRichText(data.rich_text ?? [])}`; const children = item.children?.length ? "\n" + renderBlocks(item.children, indent + (type === "numbered_list_item" ? 3 : 2)) : ""; return head + children; }) .join("\n"); } function renderTable(rows, indent) { if (rows.length === 0) return ""; const pad = " ".repeat(indent); const cellsFor = (row) => { const data = row.table_row; const cells = data?.cells ?? []; return cells.map((c) => renderRichText(c).replace(/\|/g, "\\|")); }; const lines = []; const header = cellsFor(rows[0]); lines.push(pad + "| " + header.join(" | ") + " |"); lines.push(pad + "| " + header.map(() => "---").join(" | ") + " |"); for (let i = 1; i < rows.length; i++) { lines.push(pad + "| " + cellsFor(rows[i]).join(" | ") + " |"); } return lines.join("\n"); } // ---------- rich text ---------- /** * Notion returns rich text as an array of runs, each carrying its own * annotations. A naive per-run render emits opening/closing markers * around every run, so a phrase like "**bold** **`code`** **bold**" becomes * "**bold ****`code`**** bold**" — four-asterisk noise. We merge spanning * annotations (bold, italic, strikethrough) across consecutive runs and * keep per-run wrappers for things that don't span (code, href). */ function renderRichText(rt) { if (rt.length === 0) return ""; const out = []; const state = { bold: false, italic: false, strikethrough: false }; for (const run of rt) { const a = (run.annotations ?? {}); const want = { bold: !!a.bold, italic: !!a.italic, strikethrough: !!a.strikethrough, }; if (state.strikethrough && !want.strikethrough) { out.push("~~"); state.strikethrough = false; } if (state.italic && !want.italic) { out.push("*"); state.italic = false; } if (state.bold && !want.bold) { out.push("**"); state.bold = false; } if (!state.bold && want.bold) { out.push("**"); state.bold = true; } if (!state.italic && want.italic) { out.push("*"); state.italic = true; } if (!state.strikethrough && want.strikethrough) { out.push("~~"); state.strikethrough = true; } let text = run.plain_text; if (a.code) text = "`" + text + "`"; if (run.href) text = `[${text}](${run.href})`; out.push(text); } if (state.strikethrough) out.push("~~"); if (state.italic) out.push("*"); if (state.bold) out.push("**"); return out.join(""); } // ---------- helpers ---------- function prefixLines(text, prefix) { return text .split("\n") .map((line) => prefix + line) .join("\n"); } function indentText(text, prefix) { if (!text) return ""; return text .split("\n") .map((line) => (line.length === 0 ? line : prefix + line)) .join("\n"); } function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;")); } //# sourceMappingURL=convert.js.map