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'

View File

@@ -1,30 +1,34 @@
import { test, expect, type Page } from '@playwright/test'
import path from 'path'
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { triggerShortcutCommand } from './testBridge'
const PROJECT_NOTE_PATH = path.join('project', 'alpha-project.md')
const PROJECT_NOTE_CONTENT = `---
Is A: Project
Status: Done
Owner:
- "[[Note B]]"
Related to:
- "[[Note B]]"
- "[[Note C]]"
---
# Alpha Project
This is a test project that references other notes.
## Notes
See [[Note B]] for details and [[Note C]] for additional context.
`
const PROJECT_NOTE_CONTENT = [
'---',
'Is A: Project',
'Status: Done',
'Owner:',
' - "[[Note B]]"',
'Related to:',
' - "[[Note B]]"',
' - "[[Note C]]"',
'---',
'',
'# Alpha Project',
'',
'This is a test project that references other notes.',
'',
'## Notes',
'',
'See [[Note B]] for details and [[Note C]] for additional context.',
'',
].join('\r\n')
let tempVaultDir: string
@@ -47,7 +51,7 @@ async function showStatusChipInInbox(page: Page): Promise<void> {
}
async function openRawEditor(page: Page): Promise<void> {
await page.keyboard.press('Control+Backslash')
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
}
@@ -91,7 +95,7 @@ test.describe('Raw editor frontmatter propagation', () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('raw frontmatter edits immediately update inspector relationships and note-list chips @smoke', async ({ page }) => {
test('CRLF raw frontmatter updates inspector relationships and note-list chips @smoke', async ({ page }) => {
const notePath = path.join(tempVaultDir, PROJECT_NOTE_PATH)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
@@ -104,7 +108,8 @@ test.describe('Raw editor frontmatter propagation', () => {
await openRawEditor(page)
await replaceRawEditorContent(page, PROJECT_NOTE_CONTENT)
await page.waitForTimeout(800)
await page.keyboard.press('Control+Shift+i')
await expect(page.getByTestId('raw-editor-yaml-error')).toHaveCount(0)
await triggerShortcutCommand(page, APP_COMMAND_IDS.viewToggleProperties)
await expect(
page.locator('[data-testid="editable-property"]').filter({ hasText: 'Owner' }),