Skip to content

Commit 69c5e0c

Browse files
committed
feat: auto unwrap single child paragraphs
1 parent 9ca24be commit 69c5e0c

8 files changed

Lines changed: 685 additions & 23 deletions

File tree

src/index.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import type { MDCRoot } from './types/tree'
1+
import type { MDCRoot, MDCElement } from './types/tree'
2+
import type { ParseOptions } from './types'
23
import MarkdownIt from 'markdown-it'
34
import pluginMdc from 'markdown-it-mdc'
45
import { parseFrontMatter } from 'remark-mdc'
56
import { convertMarkdownItTokensToMDC } from './utils/parse'
7+
import { applyAutoUnwrap } from './utils/auto-unwrap'
68
import { generateToc } from './utils/table-of-contents'
79

810
export interface ParseResult {
@@ -15,13 +17,18 @@ export interface ParseResult {
1517
// Re-export auto-close utilities
1618
export { autoCloseMarkdown, detectUnclosedSyntax } from './auto-close'
1719

20+
// Re-export parse utilities
21+
export { applyAutoUnwrap } from './utils/auto-unwrap'
22+
1823
// Re-export types
19-
export type { MDCNode, MDCRoot } from './types/tree'
24+
export type { MDCNode, MDCRoot, MDCElement, MDCText, MDCComment } from './types/tree'
25+
export type { ParseOptions } from './types'
2026

2127
/**
2228
* Parse MDC content from a string
2329
*
2430
* @param source - The markdown/MDC content as a string
31+
* @param options - Parser options
2532
* @returns ParseResult - Object containing body, excerpt, data, and toc
2633
*
2734
* @example
@@ -45,9 +52,13 @@ export type { MDCNode, MDCRoot } from './types/tree'
4552
* console.log(result.body) // MDC AST
4653
* console.log(result.data) // { title: 'Hello World' }
4754
* console.log(result.toc) // Table of contents
55+
*
56+
* // Disable auto-unwrap
57+
* const result2 = parse(content, { autoUnwrap: false })
4858
* ```
4959
*/
50-
export function parse(source: string): ParseResult {
60+
export function parse(source: string, options: ParseOptions = {}): ParseResult {
61+
const { autoUnwrap = true } = options
5162
const { content, data } = parseFrontMatter(source)
5263

5364
// Enable tables, GFM features
@@ -64,7 +75,17 @@ export function parse(source: string): ParseResult {
6475
const children = convertMarkdownItTokensToMDC(tokens)
6576

6677
// Filter out top-level text nodes
67-
const filteredChildren = children.filter(child => child.type !== 'text')
78+
let filteredChildren = children.filter(child => child.type !== 'text')
79+
80+
// Apply auto-unwrap to container components if enabled
81+
if (autoUnwrap) {
82+
filteredChildren = filteredChildren.map((child) => {
83+
if (child.type === 'element') {
84+
return applyAutoUnwrap(child as MDCElement)
85+
}
86+
return child
87+
})
88+
}
6889

6990
const body: MDCRoot = {
7091
type: 'root',
@@ -79,7 +100,18 @@ export function parse(source: string): ParseResult {
79100

80101
if (excerptIndex !== -1) {
81102
const excerptTokens = tokens.slice(0, excerptIndex)
82-
const excerptChildren = convertMarkdownItTokensToMDC(excerptTokens, new Set())
103+
let excerptChildren = convertMarkdownItTokensToMDC(excerptTokens as any, new Set())
104+
105+
// Apply auto-unwrap to excerpt as well
106+
if (autoUnwrap) {
107+
excerptChildren = excerptChildren.map((child) => {
108+
if (child.type === 'element') {
109+
return applyAutoUnwrap(child as MDCElement)
110+
}
111+
return child
112+
})
113+
}
114+
83115
excerpt = {
84116
type: 'root',
85117
children: excerptChildren.filter(child => child.type !== 'text'),

src/stream.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Readable } from 'node:stream'
2-
import type { MDCRoot } from './types/tree'
2+
import type { MDCRoot, MDCElement } from './types/tree'
3+
import type { ParseOptions } from './types'
34
import MarkdownIt from 'markdown-it'
45
import pluginMdc from 'markdown-it-mdc'
56
import { parseFrontMatter } from 'remark-mdc'
67
import { autoCloseMarkdown } from './auto-close'
78
import { convertMarkdownItTokensToMDC } from './utils/parse'
9+
import { applyAutoUnwrap } from './utils/auto-unwrap'
810
import { generateToc } from './utils/table-of-contents'
911

1012
export interface ParseResult {
@@ -92,7 +94,8 @@ async function streamToString(stream: Readable | ReadableStream<Uint8Array>): Pr
9294
/**
9395
* Internal parse function
9496
*/
95-
function parseContent(source: string): ParseResult {
97+
function parseContent(source: string, options: ParseOptions = {}): ParseResult {
98+
const { autoUnwrap = true } = options
9699
const { content, data } = parseFrontMatter(source)
97100
// Enable tables, GFM features
98101
const markdownIt = new MarkdownIt({
@@ -107,7 +110,17 @@ function parseContent(source: string): ParseResult {
107110
const children = convertMarkdownItTokensToMDC(tokens)
108111

109112
// Filter out top-level text nodes
110-
const filteredChildren = children.filter(child => child.type !== 'text')
113+
let filteredChildren = children.filter(child => child.type !== 'text')
114+
115+
// Apply auto-unwrap to container components if enabled
116+
if (autoUnwrap) {
117+
filteredChildren = filteredChildren.map((child) => {
118+
if (child.type === 'element') {
119+
return applyAutoUnwrap(child as MDCElement)
120+
}
121+
return child
122+
})
123+
}
111124

112125
const body: MDCRoot = {
113126
type: 'root',
@@ -122,7 +135,18 @@ function parseContent(source: string): ParseResult {
122135

123136
if (excerptIndex !== -1) {
124137
const excerptTokens = tokens.slice(0, excerptIndex)
125-
const excerptChildren = convertMarkdownItTokensToMDC(excerptTokens, new Set())
138+
let excerptChildren = convertMarkdownItTokensToMDC(excerptTokens as any, new Set())
139+
140+
// Apply auto-unwrap to excerpt as well
141+
if (autoUnwrap) {
142+
excerptChildren = excerptChildren.map((child) => {
143+
if (child.type === 'element') {
144+
return applyAutoUnwrap(child as MDCElement)
145+
}
146+
return child
147+
})
148+
}
149+
126150
excerpt = {
127151
type: 'root',
128152
children: excerptChildren.filter(child => child.type !== 'text'),
@@ -156,6 +180,7 @@ function parseContent(source: string): ParseResult {
156180
* Parse MDC content from a Node.js Readable stream or Web ReadableStream
157181
*
158182
* @param stream - A Node.js Readable stream or Web ReadableStream containing MDC content
183+
* @param options - Parser options
159184
* @returns Promise resolving to the parsed MDC structure
160185
*
161186
* @example
@@ -166,18 +191,22 @@ function parseContent(source: string): ParseResult {
166191
* const stream = createReadStream('content.md')
167192
* const result = await parseStream(stream)
168193
* console.log(result.body)
194+
*
195+
* // Disable auto-unwrap
196+
* const result2 = await parseStream(stream, { autoUnwrap: false })
169197
* ```
170198
*/
171-
export async function parseStream(stream: Readable | ReadableStream<Uint8Array>): Promise<ParseResult> {
199+
export async function parseStream(stream: Readable | ReadableStream<Uint8Array>, options?: ParseOptions): Promise<ParseResult> {
172200
const content = await streamToString(stream)
173-
return parseContent(content)
201+
return parseContent(content, options)
174202
}
175203

176204
/**
177205
* Parse MDC content incrementally from a stream,
178206
* yielding results as each chunk is received
179207
*
180208
* @param stream - A Node.js Readable stream or Web ReadableStream containing MDC content
209+
* @param options - Parser options
181210
* @yields IncrementalParseResult for each chunk received
182211
*
183212
* @example
@@ -191,10 +220,16 @@ export async function parseStream(stream: Readable | ReadableStream<Uint8Array>)
191220
* console.log('Current body:', result.body)
192221
* console.log('Complete:', result.isComplete)
193222
* }
223+
*
224+
* // Disable auto-unwrap
225+
* for await (const result of parseStreamIncremental(stream, { autoUnwrap: false })) {
226+
* // ...
227+
* }
194228
* ```
195229
*/
196230
export async function* parseStreamIncremental(
197231
stream: Readable | ReadableStream<Uint8Array>,
232+
options?: ParseOptions,
198233
): AsyncGenerator<IncrementalParseResult, void, unknown> {
199234
let accumulatedContent = ''
200235
let frontmatterParsed = false
@@ -230,7 +265,7 @@ export async function* parseStreamIncremental(
230265
const closedContent = autoCloseMarkdown(accumulatedContent)
231266

232267
// Parse the auto-closed content
233-
const result = parseContent(closedContent)
268+
const result = parseContent(closedContent, options)
234269

235270
yield {
236271
chunk: chunkStr,
@@ -242,7 +277,7 @@ export async function* parseStreamIncremental(
242277
}
243278

244279
// Final parse with complete content (no auto-close needed, content is complete)
245-
const finalResult = parseContent(accumulatedContent)
280+
const finalResult = parseContent(accumulatedContent, options)
246281
yield {
247282
chunk: '',
248283
body: finalResult.body,
@@ -263,7 +298,7 @@ export async function* parseStreamIncremental(
263298

264299
if (done) {
265300
// Final parse with complete content
266-
const finalResult = parseContent(accumulatedContent)
301+
const finalResult = parseContent(accumulatedContent, options)
267302
yield {
268303
chunk: '',
269304
body: finalResult.body,
@@ -289,7 +324,7 @@ export async function* parseStreamIncremental(
289324
const closedContent = autoCloseMarkdown(accumulatedContent)
290325

291326
// Parse the auto-closed content
292-
const result = parseContent(closedContent)
327+
const result = parseContent(closedContent, options)
293328

294329
yield {
295330
chunk: chunkStr,

src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export interface ParseOptions {
2+
/**
3+
* Whether to automatically unwrap single paragraphs in container components.
4+
* When enabled, if a container component (alert, card, callout, note, warning, tip, info)
5+
* has only a single paragraph child, the paragraph wrapper is removed and its children
6+
* become direct children of the container. This creates cleaner HTML output.
7+
*
8+
* @default true
9+
* @example
10+
* // With autoUnwrap: true (default)
11+
* // <alert><strong>Text</strong></alert>
12+
*
13+
* // With autoUnwrap: false
14+
* // <alert><p><strong>Text</strong></p></alert>
15+
*/
16+
autoUnwrap?: boolean
17+
}

src/utils/auto-unwrap.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { MDCElement } from '../types/tree'
2+
3+
// Node types that can be containers (for auto unwrap feature)
4+
const CONTAINER_NODE_TYPES = new Set([
5+
'alert',
6+
'card',
7+
'callout',
8+
'note',
9+
'warning',
10+
'tip',
11+
'info',
12+
])
13+
14+
// Node types that should not be unwrapped
15+
const NON_UNWRAPPABLE_TYPES = new Set([
16+
'pre',
17+
'code',
18+
'table',
19+
'thead',
20+
'tbody',
21+
'tr',
22+
'th',
23+
'td',
24+
'ul',
25+
'ol',
26+
'li',
27+
'blockquote',
28+
])
29+
30+
/**
31+
* Applies automatic unwrapping to container components.
32+
*
33+
* This utility removes unnecessary paragraph wrappers from container component children.
34+
* If a container has only a single paragraph child (and no other block elements),
35+
* the paragraph is unwrapped and its children are hoisted up to be direct children
36+
* of the container.
37+
*
38+
* @param node - The MDC element to process
39+
* @returns The node with auto-unwrapped children (if applicable)
40+
*
41+
* @example
42+
* // Before:
43+
* { tag: 'alert', children: [{ type: 'element', tag: 'p', children: [{ type: 'text', value: 'Text' }] }] }
44+
*
45+
* // After:
46+
* { tag: 'alert', children: [{ type: 'text', value: 'Text' }] }
47+
*/
48+
export function applyAutoUnwrap(node: MDCElement): MDCElement {
49+
// Only apply to container components
50+
if (!CONTAINER_NODE_TYPES.has(node.tag)) {
51+
return node
52+
}
53+
54+
// Don't unwrap if there are no children
55+
if (node.children.length === 0) {
56+
return node
57+
}
58+
59+
// Filter out empty text nodes for checking
60+
const nonEmptyChildren = node.children.filter(child =>
61+
child.type !== 'text' || (child.value && child.value.trim()),
62+
)
63+
64+
// Check if we have exactly one paragraph child (and possibly empty text nodes)
65+
const paragraphs = nonEmptyChildren.filter(
66+
child => child.type === 'element' && child.tag === 'p',
67+
)
68+
69+
// Also check for other non-unwrappable elements (lists, code blocks, etc.)
70+
const hasNonUnwrappableElements = nonEmptyChildren.some(
71+
child => child.type === 'element'
72+
&& child.tag !== 'p'
73+
&& (NON_UNWRAPPABLE_TYPES.has(child.tag) || child.tag === 'template'),
74+
)
75+
76+
// Only unwrap if:
77+
// 1. There's exactly one paragraph
78+
// 2. No other non-unwrappable elements (lists, code blocks, etc.)
79+
if (paragraphs.length === 1 && !hasNonUnwrappableElements) {
80+
const paragraph = paragraphs[0] as MDCElement
81+
// Unwrap: return the paragraph's children as the container's direct children
82+
return {
83+
...node,
84+
children: paragraph.children,
85+
}
86+
}
87+
88+
// Otherwise, keep the structure as-is
89+
return node
90+
}

0 commit comments

Comments
 (0)