Skip to content

Commit aa8c235

Browse files
sandros94farnabaz
andauthored
fix(comark): only complete unclosed frontmatter while streaming (#269)
Co-authored-by: Farnabaz <farnabaz@gmail.com>
1 parent bde872f commit aa8c235

5 files changed

Lines changed: 117 additions & 17 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
## Input
2+
3+
```md
4+
---
5+
# Heading
6+
```
7+
8+
## AST
9+
10+
```json
11+
{
12+
"frontmatter": {},
13+
"meta": {},
14+
"nodes": [
15+
[
16+
"hr",
17+
{}
18+
],
19+
[
20+
"h1",
21+
{
22+
"id": "heading"
23+
},
24+
"Heading"
25+
]
26+
]
27+
}
28+
```
29+
30+
## HTML
31+
32+
```html
33+
<hr />
34+
<h1 id="heading">Heading</h1>
35+
```
36+
37+
## Markdown
38+
39+
```md
40+
---
41+
42+
# Heading
43+
```
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Input
2+
3+
```md
4+
---
5+
```
6+
7+
## AST
8+
9+
```json
10+
{
11+
"frontmatter": {},
12+
"meta": {},
13+
"nodes": [
14+
[
15+
"hr",
16+
{}
17+
]
18+
]
19+
}
20+
```
21+
22+
## HTML
23+
24+
```html
25+
<hr />
26+
```
27+
28+
## Markdown
29+
30+
```md
31+
---
32+
```

packages/comark/src/internal/parse/auto-close/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@ import { closeTables } from './table.ts'
1010
* Processes markdown in O(n) time by scanning character-by-character
1111
*
1212
* @param markdown - The markdown content to auto-close
13+
* @param options - `frontmatter` completes an unclosed leading frontmatter block.
1314
* @returns The markdown with unclosed syntax closed
1415
*/
15-
export function autoCloseMarkdown(markdown: string): string {
16+
export function autoCloseMarkdown(markdown: string, options: { frontmatter?: boolean } = {}): string {
1617
if (!markdown || markdown === '') return markdown
1718

1819
const lines = markdown.split('\n')
1920
const n = lines.length
2021

2122
// Single linear pass to collect document state
2223
let inFrontmatter = false
24+
// Whether the open frontmatter block has received any content. Empty
25+
// frontmatter is not valid (`parseFrontmatter` ignores it), so a lone `---`
26+
// is a thematic break, not an unclosed frontmatter block to complete.
27+
let frontmatterHasContent = false
2328
let inBlockMath = false
2429
let tableStart = -1
2530
// Tag name when inside a raw-text HTML element (`<style>`, `<script>`,
@@ -57,12 +62,13 @@ export function autoCloseMarkdown(markdown: string): string {
5762
}
5863

5964
// Frontmatter: only starts at document line 0
60-
if (idx === 0 && trimmed === '---') {
65+
if (idx === 0 && options.frontmatter && trimmed === '---') {
6166
inFrontmatter = true
6267
continue
6368
}
6469
if (inFrontmatter) {
6570
if (trimmed === '---') inFrontmatter = false
71+
else if (trimmed !== '') frontmatterHasContent = true
6672
continue
6773
}
6874

@@ -152,8 +158,8 @@ export function autoCloseMarkdown(markdown: string): string {
152158
result = closeTables(result)
153159
}
154160

155-
// Close unclosed frontmatter
156-
if (inFrontmatter) {
161+
// Complete an unclosed frontmatter block only when `frontmatter` is enabled,
162+
if (inFrontmatter && frontmatterHasContent) {
157163
const lastTrimmed = lines[lastIdx].trim()
158164
if (lastTrimmed === '-' || lastTrimmed === '--') {
159165
result += '-'.repeat(3 - lastTrimmed.length)

packages/comark/src/parse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export function createParse<const TPlugins extends readonly ComarkPlugin<any, an
110110
}
111111

112112
if (autoClose) {
113-
state.markdown = autoCloseMarkdown(state.markdown)
113+
state.markdown = autoCloseMarkdown(state.markdown, { frontmatter: opts.streaming })
114114
}
115115

116116
for (const plugin of plugins) {

packages/comark/test/auto-close.test.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -509,30 +509,41 @@ describe('frontmatter', () => {
509509
const expected = '---\ntitle: Test\n---'
510510
expect(autoCloseMarkdown(input)).toBe(expected)
511511
})
512-
it('should handle frontmatter partial', () => {
512+
// Partial frontmatter is only completed when `frontmatter: true` — for a
513+
// complete document a leading `---` with no closing delimiter is a thematic
514+
// break, so completing it would swallow the body (see the default cases below).
515+
it('should complete partial frontmatter when frontmatter option is enabled', () => {
513516
const input = '---\ntitle: Test'
514517
const expected = '---\ntitle: Test\n---'
515-
expect(autoCloseMarkdown(input)).toBe(expected)
518+
expect(autoCloseMarkdown(input, { frontmatter: true })).toBe(expected)
516519
})
517-
it('should handle frontmatter partial', () => {
520+
it('should complete a partial closing delimiter (-) when frontmatter option is enabled', () => {
518521
const input = '---\ntitle: Test\n-'
519522
const expected = '---\ntitle: Test\n---'
520-
expect(autoCloseMarkdown(input)).toBe(expected)
523+
expect(autoCloseMarkdown(input, { frontmatter: true })).toBe(expected)
521524
})
522-
it('should handle frontmatter partial', () => {
525+
it('should complete a partial closing delimiter (--) when frontmatter option is enabled', () => {
523526
const input = '---\ntitle: Test\n--'
524527
const expected = '---\ntitle: Test\n---'
525-
expect(autoCloseMarkdown(input)).toBe(expected)
528+
expect(autoCloseMarkdown(input, { frontmatter: true })).toBe(expected)
526529
})
527-
it('should handle frontmatter partial 2', () => {
530+
it('should complete partial frontmatter with an empty value when frontmatter option is enabled', () => {
528531
const input = '---\ntitle: '
529532
const expected = '---\ntitle: \n---'
530-
expect(autoCloseMarkdown(input)).toBe(expected)
533+
expect(autoCloseMarkdown(input, { frontmatter: true })).toBe(expected)
531534
})
532-
it('should handle frontmatter partial 3', () => {
535+
it('should complete partial frontmatter with a trailing newline when frontmatter option is enabled', () => {
533536
const input = '---\ntitle: Test\n'
534537
const expected = '---\ntitle: Test\n---'
535-
expect(autoCloseMarkdown(input)).toBe(expected)
538+
expect(autoCloseMarkdown(input, { frontmatter: true })).toBe(expected)
539+
})
540+
541+
it('should not complete unclosed frontmatter by default', () => {
542+
// Default (`frontmatter: false`): a leading `---` with content but no close
543+
// is a thematic break followed by body — completing it would swallow the body.
544+
const input = '---\ntitle: Test'
545+
expect(autoCloseMarkdown(input)).toBe(input)
546+
expect(autoCloseMarkdown('---\n# Heading')).toBe('---\n# Heading')
536547
})
537548

538549
it('should handle frontmatter with content after', () => {
@@ -553,9 +564,17 @@ describe('frontmatter', () => {
553564
expect(autoCloseMarkdown(input)).toBe(expected)
554565
})
555566

556-
it('should handle just opening ---', () => {
567+
it('should not complete a lone --- (thematic break, not frontmatter)', () => {
568+
// A lone `---` has no frontmatter content; completing it to `---\n---`
569+
// would parse as two thematic breaks (#268).
557570
const input = '---'
558-
const expected = '---\n---'
571+
const expected = '---'
572+
expect(autoCloseMarkdown(input)).toBe(expected)
573+
})
574+
575+
it('should not complete an empty --- opener', () => {
576+
const input = '---\n'
577+
const expected = '---\n'
559578
expect(autoCloseMarkdown(input)).toBe(expected)
560579
})
561580

0 commit comments

Comments
 (0)