Merge branch 'main' into main
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user