fix: parse crlf frontmatter

This commit is contained in:
lucaronin
2026-04-27 14:31:51 +02:00
parent 324af6b271
commit 9cb0de37a5
5 changed files with 86 additions and 49 deletions

View File

@@ -76,6 +76,12 @@ describe('parseFrontmatter', () => {
expect(fm['Owner']).toBe('[[person/alice]]')
expect(fm['Belongs to']).toBe('[[project/alpha]]')
})
it('parses CRLF frontmatter from Windows-authored notes', () => {
const fm = parseFrontmatter('---\r\ntype: Note\r\nstatus: Active\r\n---\r\n# Title')
expect(fm['type']).toBe('Note')
expect(fm['status']).toBe('Active')
})
})
describe('detectFrontmatterState', () => {
@@ -99,6 +105,10 @@ describe('detectFrontmatterState', () => {
expect(detectFrontmatterState('---\ntitle: Hello\ntype: Note\n---\nBody')).toBe('valid')
})
it('returns "valid" for CRLF frontmatter', () => {
expect(detectFrontmatterState('---\r\ntitle: Hello\r\ntype: Note\r\n---\r\nBody')).toBe('valid')
})
it('returns "valid" for frontmatter with only a title', () => {
expect(detectFrontmatterState('---\ntitle: Test\n---\n')).toBe('valid')
})

View File

@@ -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)

View File

@@ -62,6 +62,10 @@ describe('detectYamlError', () => {
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
})
it('returns null for valid CRLF frontmatter', () => {
expect(detectYamlError('---\r\ntitle: My Note\r\n---\r\n\r\n# Title')).toBeNull()
})
it('returns error for unclosed frontmatter', () => {
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
expect(error).toContain('Unclosed frontmatter')

View File

@@ -114,7 +114,7 @@ export function getRawEditorDropdownPosition(
export function detectYamlError(content: string): string | null {
if (!content.startsWith('---')) return null
const rest = content.slice(3)
const closeIdx = rest.search(/\n---(\n|$)/)
const closeIdx = rest.search(/(?:^|\r?\n)---(?:\r?\n|$)/)
if (closeIdx === -1) return 'Unclosed frontmatter block — add a closing --- line'
const block = rest.slice(0, closeIdx)
if (/^\t/m.test(block)) return 'YAML frontmatter contains tab indentation — use spaces'