frontmatterParser.ts
utils/frontmatterParser.ts
No strong subsystem tag
371
Lines
12404
Bytes
10
Exports
3
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 371 lines, 3 detected imports, and 10 detected exports.
Important relationships
Detected exports
FrontmatterDataParsedMarkdownFRONTMATTER_REGEXparseFrontmattersplitPathInFrontmatterparsePositiveIntFromFrontmattercoerceDescriptionToStringparseBooleanFrontmatterFrontmatterShellparseShellFrontmatter
Keywords
frontmatterparsedmatchreturnsskillyamlcontentonlyvaluesmarkdown
Detected imports
./debug.js./settings/types.js./yaml.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* 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
}