π File detail
utils/frontmatterParser.ts
π§© .tsπ 371 linesπΎ 12,404 bytesπ text
β Back to All Filesπ― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes FrontmatterData, ParsedMarkdown, FRONTMATTER_REGEX, parseFrontmatter, and splitPathInFrontmatter (and more) β mainly functions, hooks, or classes. It composes internal code from debug, settings, and yaml (relative imports). What the file header says: Frontmatter parser for markdown files Extracts and parses YAML frontmatter between --- delimiters.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
Frontmatter parser for markdown files Extracts and parses YAML frontmatter between --- delimiters
π€ Exports (heuristic)
FrontmatterDataParsedMarkdownFRONTMATTER_REGEXparseFrontmattersplitPathInFrontmatterparsePositiveIntFromFrontmattercoerceDescriptionToStringparseBooleanFrontmatterFrontmatterShellparseShellFrontmatter
π₯οΈ Source preview
/**
* Frontmatter parser for markdown files
* Extracts and parses YAML frontmatter between --- delimiters
*/
import { logForDebugging } from './debug.js'
import type { HooksSettings } from './settings/types.js'
import { parseYaml } from './yaml.js'
export type FrontmatterData = {
// YAML can return null for keys with no value (e.g., "key:" with nothing after)
'allowed-tools'?: string | string[] | null
description?: string | null
// Memory type: 'user', 'feedback', 'project', or 'reference'
// Only applicable to memory files; narrowed via parseMemoryType() in src/memdir/memoryTypes.ts
type?: string | null
'argument-hint'?: string | null
when_to_use?: string | null
version?: string | null
// Only applicable to slash commands -- a string similar to a boolean env var
// to determine whether to make them visible to the SlashCommand tool.
'hide-from-slash-command-tool'?: string | null
// Model alias or name (e.g., 'haiku', 'sonnet', 'opus', or specific model names)
// Use 'inherit' for commands to use the parent model
model?: string | null
// Comma-separated list of skill names to preload (only applicable to agents)
skills?: string | null
// Whether users can invoke this skill by typing /skill-name
// 'true' = user can type /skill-name to invoke
// 'false' = only model can invoke via Skill tool
// Default depends on source: commands/ defaults to true, skills/ defaults to false
'user-invocable'?: string | null
// Hooks to register when this skill is invoked
// Keys are hook events (PreToolUse, PostToolUse, Stop, etc.)
// Values are arrays of matcher configurations with hooks
// Validated by HooksSchema in loadSkillsDir.ts
hooks?: HooksSettings | null
// Effort level for agents (e.g., 'low', 'medium', 'high', 'max', or an integer)
// Controls the thinking effort used by the agent's model
effort?: string | null
// Execution context for skills: 'inline' (default) or 'fork' (run as sub-agent)
// 'inline' = skill content expands into the current conversation
// 'fork' = skill runs in a sub-agent with separate context and token budget
context?: 'inline' | 'fork' | null
// Agent type to use when forked (e.g., 'Bash', 'general-purpose')
// Only applicable when context is 'fork'
agent?: string | null
// Glob patterns for file paths this skill applies to. Accepts either a
// comma-separated string or a YAML list of strings.
// When set, the skill is only activated when the model touches matching files
// Uses the same format as CLAUDE.md paths frontmatter
paths?: string | string[] | null
// Shell to use for !`cmd` and ```! blocks in skill/command .md content.
// 'bash' (default) or 'powershell'. File-scoped β applies to all !-blocks.
// Never consults settings.defaultShell: skills are portable across platforms,
// so the author picks the shell, not the reader. See docs/design/ps-shell-selection.md Β§5.3.
shell?: string | null
[key: string]: unknown
}
export type ParsedMarkdown = {
frontmatter: FrontmatterData
content: string
}
// Characters that require quoting in YAML values (when unquoted)
// - { } are flow mapping indicators
// - * is anchor/alias indicator
// - [ ] are flow sequence indicators
// - ': ' (colon followed by space) is key indicator β causes 'Nested mappings
// are not allowed in compact mappings' when it appears mid-value. Match the
// pattern rather than bare ':' so '12:34' times and 'https://' URLs stay unquoted.
// - # is comment indicator
// - & is anchor indicator
// - ! is tag indicator
// - | > are block scalar indicators (only at start)
// - % is directive indicator (only at start)
// - @ ` are reserved
const YAML_SPECIAL_CHARS = /[{}[\]*&#!|>%@`]|: /
/**
* Pre-processes frontmatter text to quote values that contain special YAML characters.
* This allows glob patterns like **\/*.{ts,tsx} to be parsed correctly.
*/
function quoteProblematicValues(frontmatterText: string): string {
const lines = frontmatterText.split('\n')
const result: string[] = []
for (const line of lines) {
// Match simple key: value lines (not indented, not list items, not block scalars)
const match = line.match(/^([a-zA-Z_-]+):\s+(.+)$/)
if (match) {
const [, key, value] = match
if (!key || !value) {
result.push(line)
continue
}
// Skip if already quoted
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
result.push(line)
continue
}
// Quote if contains special YAML characters
if (YAML_SPECIAL_CHARS.test(value)) {
// Use double quotes and escape any existing double quotes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
result.push(`${key}: "${escaped}"`)
continue
}
}
result.push(line)
}
return result.join('\n')
}
export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/
/**
* Parses markdown content to extract frontmatter and content
* @param markdown The raw markdown content
* @returns Object containing parsed frontmatter and content without frontmatter
*/
export function parseFrontmatter(
markdown: string,
sourcePath?: string,
): ParsedMarkdown {
const match = markdown.match(FRONTMATTER_REGEX)
if (!match) {
// No frontmatter found
return {
frontmatter: {},
content: markdown,
}
}
const frontmatterText = match[1] || ''
const content = markdown.slice(match[0].length)
let frontmatter: FrontmatterData = {}
try {
const parsed = parseYaml(frontmatterText) as FrontmatterData | null
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
frontmatter = parsed
}
} catch {
// YAML parsing failed - try again after quoting problematic values
try {
const quotedText = quoteProblematicValues(frontmatterText)
const parsed = parseYaml(quotedText) as FrontmatterData | null
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
frontmatter = parsed
}
} catch (retryError) {
// Still failed - log for debugging so users can diagnose broken frontmatter
const location = sourcePath ? ` in ${sourcePath}` : ''
logForDebugging(
`Failed to parse YAML frontmatter${location}: ${retryError instanceof Error ? retryError.message : retryError}`,
{ level: 'warn' },
)
}
}
return {
frontmatter,
content,
}
}
/**
* Splits a comma-separated string and expands brace patterns.
* Commas inside braces are not treated as separators.
* Also accepts a YAML list (string array) for ergonomic frontmatter.
* @param input - Comma-separated string, or array of strings, with optional brace patterns
* @returns Array of expanded strings
* @example
* splitPathInFrontmatter("a, b") // returns ["a", "b"]
* splitPathInFrontmatter("a, src/*.{ts,tsx}") // returns ["a", "src/*.ts", "src/*.tsx"]
* splitPathInFrontmatter("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"]
* splitPathInFrontmatter(["a", "src/*.{ts,tsx}"]) // returns ["a", "src/*.ts", "src/*.tsx"]
*/
export function splitPathInFrontmatter(input: string | string[]): string[] {
if (Array.isArray(input)) {
return input.flatMap(splitPathInFrontmatter)
}
if (typeof input !== 'string') {
return []
}
// Split by comma while respecting braces
const parts: string[] = []
let current = ''
let braceDepth = 0
for (let i = 0; i < input.length; i++) {
const char = input[i]
if (char === '{') {
braceDepth++
current += char
} else if (char === '}') {
braceDepth--
current += char
} else if (char === ',' && braceDepth === 0) {
// Split here - we're at a comma outside of braces
const trimmed = current.trim()
if (trimmed) {
parts.push(trimmed)
}
current = ''
} else {
current += char
}
}
// Add the last part
const trimmed = current.trim()
if (trimmed) {
parts.push(trimmed)
}
// Expand brace patterns in each part
return parts
.filter(p => p.length > 0)
.flatMap(pattern => expandBraces(pattern))
}
/**
* Expands brace patterns in a glob string.
* @example
* expandBraces("src/*.{ts,tsx}") // returns ["src/*.ts", "src/*.tsx"]
* expandBraces("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"]
*/
function expandBraces(pattern: string): string[] {
// Find the first brace group
const braceMatch = pattern.match(/^([^{]*)\{([^}]+)\}(.*)$/)
if (!braceMatch) {
// No braces found, return pattern as-is
return [pattern]
}
const prefix = braceMatch[1] || ''
const alternatives = braceMatch[2] || ''
const suffix = braceMatch[3] || ''
// Split alternatives by comma and expand each one
const parts = alternatives.split(',').map(alt => alt.trim())
// Recursively expand remaining braces in suffix
const expanded: string[] = []
for (const part of parts) {
const combined = prefix + part + suffix
// Recursively handle additional brace groups
const furtherExpanded = expandBraces(combined)
expanded.push(...furtherExpanded)
}
return expanded
}
/**
* Parses a positive integer value from frontmatter.
* Handles both number and string representations.
*
* @param value The raw value from frontmatter (could be number, string, or undefined)
* @returns The parsed positive integer, or undefined if invalid or not provided
*/
export function parsePositiveIntFromFrontmatter(
value: unknown,
): number | undefined {
if (value === undefined || value === null) {
return undefined
}
const parsed = typeof value === 'number' ? value : parseInt(String(value), 10)
if (Number.isInteger(parsed) && parsed > 0) {
return parsed
}
return undefined
}
/**
* Validate and coerce a description value from frontmatter.
*
* Strings are returned as-is (trimmed). Primitive values (numbers, booleans)
* are coerced to strings via String(). Non-scalar values (arrays, objects)
* are invalid and are logged then omitted. Null, undefined, and
* empty/whitespace-only strings return null so callers can fall back to
* a default.
*
* @param value - The raw frontmatter description value
* @param componentName - The skill/command/agent/style name for log messages
* @param pluginName - The plugin name, if this came from a plugin
*/
export function coerceDescriptionToString(
value: unknown,
componentName?: string,
pluginName?: string,
): string | null {
if (value == null) {
return null
}
if (typeof value === 'string') {
return value.trim() || null
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
// Non-scalar descriptions (arrays, objects) are invalid β log and omit
const source = pluginName
? `${pluginName}:${componentName}`
: (componentName ?? 'unknown')
logForDebugging(`Description invalid for ${source} - omitting`, {
level: 'warn',
})
return null
}
/**
* Parse a boolean frontmatter value.
* Only returns true for literal true or "true" string.
*/
export function parseBooleanFrontmatter(value: unknown): boolean {
return value === true || value === 'true'
}
/**
* Shell values accepted in `shell:` frontmatter for .md `!`-block execution.
*/
export type FrontmatterShell = 'bash' | 'powershell'
const FRONTMATTER_SHELLS: readonly FrontmatterShell[] = ['bash', 'powershell']
/**
* Parse and validate the `shell:` frontmatter field.
*
* Returns undefined for absent/null/empty (caller defaults to bash).
* Logs a warning and returns undefined for unrecognized values β we fall
* back to bash rather than failing the skill load, matching how `effort`
* and other fields degrade.
*/
export function parseShellFrontmatter(
value: unknown,
source: string,
): FrontmatterShell | undefined {
if (value == null) {
return undefined
}
const normalized = String(value).trim().toLowerCase()
if (normalized === '') {
return undefined
}
if ((FRONTMATTER_SHELLS as readonly string[]).includes(normalized)) {
return normalized as FrontmatterShell
}
logForDebugging(
`Frontmatter 'shell: ${value}' in ${source} is not recognized. Valid values: ${FRONTMATTER_SHELLS.join(', ')}. Falling back to bash.`,
{ level: 'warn' },
)
return undefined
}