From 9cb0de37a5d20549d8ffc4d3ac0e5be20587e7e6 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 27 Apr 2026 14:31:51 +0200 Subject: [PATCH] fix: parse crlf frontmatter --- src/utils/frontmatter.test.ts | 10 +++ src/utils/frontmatter.ts | 72 ++++++++++++------- src/utils/rawEditorUtils.test.ts | 4 ++ src/utils/rawEditorUtils.ts | 2 +- ...raw-editor-frontmatter-propagation.spec.ts | 47 ++++++------ 5 files changed, 86 insertions(+), 49 deletions(-) diff --git a/src/utils/frontmatter.test.ts b/src/utils/frontmatter.test.ts index ba49c8bb..0b63d497 100644 --- a/src/utils/frontmatter.test.ts +++ b/src/utils/frontmatter.test.ts @@ -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') }) diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index eca5ceb2..91acd886 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -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) diff --git a/src/utils/rawEditorUtils.test.ts b/src/utils/rawEditorUtils.test.ts index d6bce65f..e2527fb8 100644 --- a/src/utils/rawEditorUtils.test.ts +++ b/src/utils/rawEditorUtils.test.ts @@ -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') diff --git a/src/utils/rawEditorUtils.ts b/src/utils/rawEditorUtils.ts index 9ec6dcb6..90a62239 100644 --- a/src/utils/rawEditorUtils.ts +++ b/src/utils/rawEditorUtils.ts @@ -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' diff --git a/tests/smoke/raw-editor-frontmatter-propagation.spec.ts b/tests/smoke/raw-editor-frontmatter-propagation.spec.ts index aeb8ad3e..d75d6348 100644 --- a/tests/smoke/raw-editor-frontmatter-propagation.spec.ts +++ b/tests/smoke/raw-editor-frontmatter-propagation.spec.ts @@ -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 { } async function openRawEditor(page: Page): Promise { - 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' }),