|
|
|
|
@@ -4,28 +4,36 @@ export interface ParsedFrontmatter {
|
|
|
|
|
[key: string]: FrontmatterValue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unquote(s: string): string {
|
|
|
|
|
type MarkdownContent = string
|
|
|
|
|
type FrontmatterBody = string
|
|
|
|
|
type FrontmatterLine = string
|
|
|
|
|
type FrontmatterKey = string
|
|
|
|
|
type FrontmatterText = string
|
|
|
|
|
|
|
|
|
|
const FRONTMATTER_CLOSE_DELIMITER = /(?:^|\r?\n)---(?:\r?\n|$)/
|
|
|
|
|
|
|
|
|
|
function unquote(s: FrontmatterText): FrontmatterText {
|
|
|
|
|
return s.replace(/^["']|["']$/g, '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collapseList(items: string[]): FrontmatterValue {
|
|
|
|
|
function collapseList(items: FrontmatterText[]): FrontmatterValue {
|
|
|
|
|
return items.length === 1 ? items[0] : items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isBlockScalar(value: string): boolean {
|
|
|
|
|
function isBlockScalar(value: FrontmatterText): boolean {
|
|
|
|
|
return value === '' || value === '|' || value === '>'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isInlineArrayLiteral(value: string): boolean {
|
|
|
|
|
function isInlineArrayLiteral(value: FrontmatterText): boolean {
|
|
|
|
|
return value.startsWith('[') && value.endsWith(']') && !value.startsWith('[[')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseInlineArray(value: string): FrontmatterValue {
|
|
|
|
|
function parseInlineArray(value: FrontmatterText): FrontmatterValue {
|
|
|
|
|
const items = value.slice(1, -1).split(',').map(s => unquote(s.trim()))
|
|
|
|
|
return collapseList(items)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseScalar(value: string): FrontmatterValue {
|
|
|
|
|
function parseScalar(value: FrontmatterText): FrontmatterValue {
|
|
|
|
|
const clean = unquote(value)
|
|
|
|
|
const lower = clean.toLowerCase()
|
|
|
|
|
if (lower === 'true' || lower === 'yes') return true
|
|
|
|
|
@@ -36,30 +44,40 @@ function parseScalar(value: string): FrontmatterValue {
|
|
|
|
|
|
|
|
|
|
export type FrontmatterState = 'valid' | 'empty' | 'none' | 'invalid'
|
|
|
|
|
|
|
|
|
|
function frontmatterContentStart(content: MarkdownContent): number | null {
|
|
|
|
|
if (content.startsWith('---\r\n')) return 5
|
|
|
|
|
if (content.startsWith('---\n')) return 4
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractFrontmatterBody(content: MarkdownContent | null): FrontmatterBody | null {
|
|
|
|
|
if (!content) return null
|
|
|
|
|
const start = frontmatterContentStart(content)
|
|
|
|
|
if (start === null) return null
|
|
|
|
|
const rest = content.slice(start)
|
|
|
|
|
const close = rest.match(FRONTMATTER_CLOSE_DELIMITER)
|
|
|
|
|
if (!close || close.index === undefined) return null
|
|
|
|
|
return rest.slice(0, close.index)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Detect whether content has valid, empty, missing, or invalid frontmatter. */
|
|
|
|
|
export function detectFrontmatterState(content: string | null): FrontmatterState {
|
|
|
|
|
export function detectFrontmatterState(content: MarkdownContent | null): FrontmatterState {
|
|
|
|
|
if (!content) return 'none'
|
|
|
|
|
const match = content.match(/^---\n([\s\S]*?)---/)
|
|
|
|
|
if (!match) return 'none'
|
|
|
|
|
const body = match[1].trim()
|
|
|
|
|
const frontmatterBody = extractFrontmatterBody(content)
|
|
|
|
|
if (frontmatterBody === null) return 'none'
|
|
|
|
|
const body = frontmatterBody.trim()
|
|
|
|
|
if (!body) return 'empty'
|
|
|
|
|
// Valid frontmatter needs at least one line starting with a word character followed by colon
|
|
|
|
|
const hasValidLine = body.split('\n').some(line => /^[A-Za-z][\w -]*:/.test(line))
|
|
|
|
|
const hasValidLine = body.split(/\r?\n/).some(line => /^[A-Za-z][\w -]*:/.test(line))
|
|
|
|
|
return hasValidLine ? 'valid' : 'invalid'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractFrontmatterBody(content: string | null): string | null {
|
|
|
|
|
if (!content) return null
|
|
|
|
|
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
|
|
|
|
return match ? match[1] : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseListItem(line: string): string | null {
|
|
|
|
|
function parseListItem(line: FrontmatterLine): FrontmatterText | null {
|
|
|
|
|
const match = line.match(/^ {2}- (.*)$/)
|
|
|
|
|
return match ? unquote(match[1]) : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseKeyValueLine(line: string): { key: string, value: string } | null {
|
|
|
|
|
function parseKeyValueLine(line: FrontmatterLine): { key: FrontmatterKey, value: FrontmatterText } | null {
|
|
|
|
|
const match = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
|
|
|
|
|
if (!match) return null
|
|
|
|
|
return {
|
|
|
|
|
@@ -68,7 +86,7 @@ function parseKeyValueLine(line: string): { key: string, value: string } | null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseFrontmatterValue(value: string): FrontmatterValue | undefined {
|
|
|
|
|
function parseFrontmatterValue(value: FrontmatterText): FrontmatterValue | undefined {
|
|
|
|
|
if (isBlockScalar(value)) return undefined
|
|
|
|
|
if (isInlineArrayLiteral(value)) return parseInlineArray(value)
|
|
|
|
|
return parseScalar(value)
|
|
|
|
|
@@ -76,9 +94,9 @@ function parseFrontmatterValue(value: string): FrontmatterValue | undefined {
|
|
|
|
|
|
|
|
|
|
function flushList(
|
|
|
|
|
result: ParsedFrontmatter,
|
|
|
|
|
currentKey: string | null,
|
|
|
|
|
currentList: string[],
|
|
|
|
|
): string[] {
|
|
|
|
|
currentKey: FrontmatterKey | null,
|
|
|
|
|
currentList: FrontmatterText[],
|
|
|
|
|
): FrontmatterText[] {
|
|
|
|
|
if (currentKey && currentList.length > 0) {
|
|
|
|
|
result[currentKey] = collapseList(currentList)
|
|
|
|
|
}
|
|
|
|
|
@@ -86,15 +104,15 @@ function flushList(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Parse YAML frontmatter from content */
|
|
|
|
|
export function parseFrontmatter(content: string | null): ParsedFrontmatter {
|
|
|
|
|
export function parseFrontmatter(content: MarkdownContent | null): ParsedFrontmatter {
|
|
|
|
|
const frontmatterBody = extractFrontmatterBody(content)
|
|
|
|
|
if (frontmatterBody === null) return {}
|
|
|
|
|
|
|
|
|
|
const result: ParsedFrontmatter = {}
|
|
|
|
|
let currentKey: string | null = null
|
|
|
|
|
let currentList: string[] = []
|
|
|
|
|
let currentKey: FrontmatterKey | null = null
|
|
|
|
|
let currentList: FrontmatterText[] = []
|
|
|
|
|
|
|
|
|
|
for (const line of frontmatterBody.split('\n')) {
|
|
|
|
|
for (const line of frontmatterBody.split(/\r?\n/)) {
|
|
|
|
|
const listItem = parseListItem(line)
|
|
|
|
|
if (listItem !== null && currentKey) {
|
|
|
|
|
currentList.push(listItem)
|
|
|
|
|
|