Skip to content

Commit 71fbe96

Browse files
fix(yaml): resolve empty props blocks instead of throwing (#324)
Co-authored-by: Farnabaz <farnabaz@gmail.com>
1 parent 3de0854 commit 71fbe96

6 files changed

Lines changed: 207 additions & 7 deletions

File tree

packages/comark/src/internal/frontmatter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function parseFrontmatter(content: string) {
1919
const hasCarriageReturn = content[idx - 1] === CR
2020
frontmatter = content.slice(4, idx - (hasCarriageReturn ? 1 : 0))
2121
if (frontmatter) {
22-
data = parseYaml(frontmatter)
22+
data = parseYaml(frontmatter) ?? {}
2323
content = content.slice(idx + 4 + (hasCarriageReturn ? 1 : 0))
2424
}
2525
}

packages/comark/src/internal/yaml.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
import { dump, JSON_SCHEMA, load, type DumpOptions } from 'js-yaml'
1+
import { dump, JSON_SCHEMA, loadAll, YAMLException, type DumpOptions } from 'js-yaml'
22

33
/**
4-
* Parse YAML content
4+
* Parse YAML content.
5+
*
56
* @param content - The content to parse
6-
* @returns The parsed data
7+
* @returns The parsed data, or `undefined` when the content has no YAML document
78
*/
8-
export function parseYaml(content: string): Record<string, unknown> {
9-
return load(content, { schema: JSON_SCHEMA }) as Record<string, unknown>
9+
export function parseYaml(content: string): Record<string, unknown> | undefined {
10+
const documents = loadAll(content, { schema: JSON_SCHEMA }) as Record<string, unknown>[]
11+
// Preserve `load()`'s single-document guard rather than silently taking the first.
12+
if (documents.length > 1) {
13+
throw new YAMLException('expected a single document in the stream, but found more')
14+
}
15+
return documents[0]
1016
}
1117

1218
/**
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parse } from '../src/index'
3+
import type { ComarkNode } from 'comark'
4+
5+
// Helper to check if a node is an element with a specific tag
6+
function isElement(node: ComarkNode, tag: string): boolean {
7+
return Array.isArray(node) && node[0] === tag
8+
}
9+
10+
// Helper to get the attrs object of an element
11+
function getAttrs(node: ComarkNode): Record<string, unknown> {
12+
return Array.isArray(node) ? ((node[1] as Record<string, unknown>) ?? {}) : {}
13+
}
14+
15+
describe('empty component props block (#319)', () => {
16+
it('parses an empty props block without throwing', async () => {
17+
const result = await parse('::page-section\n---\n---\n#title\nHello\n::')
18+
const section = result.nodes[0] as ComarkNode
19+
20+
expect(isElement(section, 'page-section')).toBe(true)
21+
// No YAML-derived keys should be present on the props object
22+
const attrs = getAttrs(section)
23+
const yamlKeys = Object.keys(attrs).filter((key) => key !== '$')
24+
expect(yamlKeys).toHaveLength(0)
25+
})
26+
27+
it('preserves slot content when the props block is empty', async () => {
28+
const result = await parse('::page-section\n---\n---\n#title\nHello\n::')
29+
const md = JSON.stringify(result.nodes)
30+
expect(md).toContain('Hello')
31+
})
32+
33+
it('parses a whitespace-only props block without throwing', async () => {
34+
const result = await parse('::hero\n---\n \n---\ncontent\n::')
35+
const hero = result.nodes[0] as ComarkNode
36+
37+
expect(isElement(hero, 'hero')).toBe(true)
38+
expect(JSON.stringify(result.nodes)).toContain('content')
39+
})
40+
41+
it('parses a comment-only props block without throwing', async () => {
42+
const result = await parse('::hero\n---\n# todo\n---\ncontent\n::')
43+
const hero = result.nodes[0] as ComarkNode
44+
45+
expect(isElement(hero, 'hero')).toBe(true)
46+
const attrs = getAttrs(hero)
47+
const yamlKeys = Object.keys(attrs).filter((key) => key !== '$')
48+
expect(yamlKeys).toHaveLength(0)
49+
})
50+
51+
it('still applies props from a non-empty props block', async () => {
52+
const result = await parse('::hero\n---\ntitle: x\n---\n#title\nHi\n::')
53+
const hero = result.nodes[0] as ComarkNode
54+
55+
expect(isElement(hero, 'hero')).toBe(true)
56+
expect(getAttrs(hero).title).toBe('x')
57+
})
58+
59+
it('round-trips a multi-key, typed props block', async () => {
60+
const result = await parse('::hero\n---\ncount: 3\nlabel: hello\n---\ncontent\n::')
61+
const hero = result.nodes[0] as ComarkNode
62+
63+
expect(isElement(hero, 'hero')).toBe(true)
64+
// Non-string attribute values are JSON-stringified onto the element (see syntax.ts)
65+
expect(getAttrs(hero).count).toBe('3')
66+
expect(getAttrs(hero).label).toBe('hello')
67+
})
68+
69+
it('parses document-level comment-only frontmatter without throwing', async () => {
70+
const result = await parse('---\n# comment only\n---\n\n# Heading')
71+
72+
const hasHeading = result.nodes.some((node) => isElement(node, 'h1'))
73+
expect(hasHeading).toBe(true)
74+
})
75+
76+
it('rejects a malformed (non-empty, invalid) props block', async () => {
77+
await expect(parse('::hero\n---\nfoo: [1,2\n---\ncontent\n::')).rejects.toThrow()
78+
})
79+
})

packages/comark/test/frontmatter.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,33 @@ Content`
107107
expect(result.content).toBe(input)
108108
})
109109

110+
it('should handle comment-only frontmatter without throwing', () => {
111+
const input = `---
112+
# no metadata yet
113+
---
114+
115+
Content`
116+
const result = parseFrontmatter(input)
117+
expect(result.data).toEqual({})
118+
expect(result.content.trim()).toBe('Content')
119+
})
120+
121+
it('should handle whitespace-only frontmatter without throwing', () => {
122+
const input = '---\n \n---\n\nContent'
123+
const result = parseFrontmatter(input)
124+
expect(result.data).toEqual({})
125+
expect(result.content.trim()).toBe('Content')
126+
})
127+
128+
it('should still throw for malformed YAML in frontmatter', () => {
129+
const input = `---
130+
foo: [1,2
131+
---
132+
133+
Content`
134+
expect(() => parseFrontmatter(input)).toThrow()
135+
})
136+
110137
it('should handle frontmatter with special characters', () => {
111138
const input = `---
112139
title: "Hello: World"

packages/comark/test/yaml.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parseYaml } from '../src/internal/yaml'
3+
4+
describe('parseYaml', () => {
5+
describe('empty documents resolve to undefined', () => {
6+
it('returns undefined for an empty string', () => {
7+
expect(parseYaml('')).toBeUndefined()
8+
})
9+
10+
it('returns undefined for spaces only', () => {
11+
expect(parseYaml(' ')).toBeUndefined()
12+
})
13+
14+
it('returns undefined for a tab', () => {
15+
expect(parseYaml('\t')).toBeUndefined()
16+
})
17+
18+
it('returns undefined for mixed whitespace', () => {
19+
expect(parseYaml('\t\n ')).toBeUndefined()
20+
})
21+
22+
it('returns undefined for newlines only', () => {
23+
expect(parseYaml('\n\n')).toBeUndefined()
24+
})
25+
26+
it('returns undefined for a single comment line', () => {
27+
expect(parseYaml('# only a comment')).toBeUndefined()
28+
})
29+
30+
it('returns undefined for multiple comment lines', () => {
31+
expect(parseYaml('# first comment\n# second comment\n')).toBeUndefined()
32+
})
33+
})
34+
35+
describe('valid YAML parses normally', () => {
36+
it('parses a flat mapping', () => {
37+
expect(parseYaml('a: 1\nb: two')).toEqual({ a: 1, b: 'two' })
38+
})
39+
40+
it('parses nested objects and arrays', () => {
41+
const yaml = `title: Nested
42+
meta:
43+
description: A description
44+
keywords:
45+
- one
46+
- two`
47+
expect(parseYaml(yaml)).toEqual({
48+
title: 'Nested',
49+
meta: {
50+
description: 'A description',
51+
keywords: ['one', 'two'],
52+
},
53+
})
54+
})
55+
})
56+
57+
describe('falsy scalar documents are preserved, not coerced', () => {
58+
it('returns the number 0 for a bare "0" document', () => {
59+
expect(parseYaml('0')).toBe(0)
60+
})
61+
62+
it('returns false for a bare "false" document', () => {
63+
expect(parseYaml('false')).toBe(false)
64+
})
65+
66+
it('returns null for a bare "null" document', () => {
67+
expect(parseYaml('null')).toBeNull()
68+
})
69+
})
70+
71+
describe('malformed YAML still throws', () => {
72+
it('throws on an unterminated flow sequence', () => {
73+
expect(() => parseYaml('foo: [1,2')).toThrow()
74+
})
75+
76+
it('throws on a structural indentation error', () => {
77+
expect(() =>
78+
parseYaml(`foo:
79+
bar: 1
80+
baz: 2`)
81+
).toThrow()
82+
})
83+
84+
it('throws when the input contains more than one document', () => {
85+
expect(() => parseYaml('x: 1\n---\ny: 2')).toThrow()
86+
})
87+
})
88+
})

test/bundle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => {
6767
"@comark/react": "47.8k (68 files)",
6868
"@comark/svelte": "47.6k (76 files)",
6969
"@comark/vue": "63.3k (68 files)",
70-
"comark": "374k (136 files)",
70+
"comark": "375k (136 files)",
7171
}
7272
`)
7373
})

0 commit comments

Comments
 (0)