@tiptap/core 3.6.7 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @fileoverview Utility for parsing indented markdown blocks with hierarchical nesting.
3
+ *
4
+ * This utility handles the complex logic of parsing markdown blocks that can contain
5
+ * nested content based on indentation levels, maintaining proper hierarchical structure
6
+ * for lists, task lists, and other indented block types.
7
+ */
8
+
9
+ export interface ParsedBlock {
10
+ type: string
11
+ raw: string
12
+ mainContent: string
13
+ indentLevel: number
14
+ nestedContent?: string
15
+ nestedTokens?: any[]
16
+ [key: string]: any
17
+ }
18
+
19
+ export interface BlockParserConfig {
20
+ /** Regex pattern to match block items */
21
+ itemPattern: RegExp
22
+ /** Function to extract data from regex match */
23
+ extractItemData: (match: RegExpMatchArray) => {
24
+ mainContent: string
25
+ indentLevel: number
26
+ [key: string]: any
27
+ }
28
+ /** Function to create the final token */
29
+ createToken: (data: any, nestedTokens?: any[]) => ParsedBlock
30
+ /** Base indentation to remove from nested content (default: 2 spaces) */
31
+ baseIndentSize?: number
32
+ /**
33
+ * Custom parser for nested content. If provided, this will be called instead
34
+ * of the default lexer.blockTokens() for parsing nested content.
35
+ * This allows recursive parsing of the same block type.
36
+ */
37
+ customNestedParser?: (dedentedContent: string) => any[] | undefined
38
+ }
39
+
40
+ /**
41
+ * Parses markdown text into hierarchical indented blocks with proper nesting.
42
+ *
43
+ * This utility handles:
44
+ * - Line-by-line parsing with pattern matching
45
+ * - Hierarchical nesting based on indentation levels
46
+ * - Nested content collection and parsing
47
+ * - Empty line handling
48
+ * - Content dedenting for nested blocks
49
+ *
50
+ * The key difference from flat parsing is that this maintains the hierarchical
51
+ * structure where nested items become `nestedTokens` of their parent items,
52
+ * rather than being flattened into a single array.
53
+ *
54
+ * @param src - The markdown source text to parse
55
+ * @param config - Configuration object defining how to parse and create tokens
56
+ * @param lexer - Markdown lexer for parsing nested content
57
+ * @returns Parsed result with hierarchical items, or undefined if no matches
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const result = parseIndentedBlocks(src, {
62
+ * itemPattern: /^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,
63
+ * extractItemData: (match) => ({
64
+ * indentLevel: match[1].length,
65
+ * mainContent: match[4],
66
+ * checked: match[3].toLowerCase() === 'x'
67
+ * }),
68
+ * createToken: (data, nestedTokens) => ({
69
+ * type: 'taskItem',
70
+ * checked: data.checked,
71
+ * text: data.mainContent,
72
+ * nestedTokens
73
+ * })
74
+ * }, lexer)
75
+ * ```
76
+ */
77
+ export function parseIndentedBlocks(
78
+ src: string,
79
+ config: BlockParserConfig,
80
+ lexer: {
81
+ inlineTokens: (src: string) => any[]
82
+ blockTokens: (src: string) => any[]
83
+ },
84
+ ):
85
+ | {
86
+ items: ParsedBlock[]
87
+ raw: string
88
+ }
89
+ | undefined {
90
+ const lines = src.split('\n')
91
+ const items: ParsedBlock[] = []
92
+ let totalRaw = ''
93
+ let i = 0
94
+ const baseIndentSize = config.baseIndentSize || 2
95
+
96
+ while (i < lines.length) {
97
+ const currentLine = lines[i]
98
+ const itemMatch = currentLine.match(config.itemPattern)
99
+
100
+ if (!itemMatch) {
101
+ // Not a matching item - stop if we have items, otherwise this isn't our block type
102
+ if (items.length > 0) {
103
+ break
104
+ } else if (currentLine.trim() === '') {
105
+ i += 1
106
+ continue
107
+ } else {
108
+ return undefined
109
+ }
110
+ }
111
+
112
+ const itemData = config.extractItemData(itemMatch)
113
+ const { indentLevel, mainContent } = itemData
114
+ totalRaw = `${totalRaw}${currentLine}\n`
115
+
116
+ // Collect content for this item (including nested items)
117
+ const itemContent = [mainContent] // Start with the main text
118
+ i += 1
119
+
120
+ // Look ahead for nested content (indented more than current item)
121
+ while (i < lines.length) {
122
+ const nextLine = lines[i]
123
+
124
+ if (nextLine.trim() === '') {
125
+ // Empty line - might be end of nested content
126
+ const nextNonEmptyIndex = lines.slice(i + 1).findIndex(l => l.trim() !== '')
127
+ if (nextNonEmptyIndex === -1) {
128
+ // No more content
129
+ break
130
+ }
131
+
132
+ const nextNonEmpty = lines[i + 1 + nextNonEmptyIndex]
133
+ const nextIndent = nextNonEmpty.match(/^(\s*)/)?.[1]?.length || 0
134
+
135
+ if (nextIndent > indentLevel) {
136
+ // Nested content continues after empty line
137
+ itemContent.push(nextLine)
138
+ totalRaw = `${totalRaw}${nextLine}\n`
139
+ i += 1
140
+ continue
141
+ } else {
142
+ // End of nested content
143
+ break
144
+ }
145
+ }
146
+
147
+ const nextIndent = nextLine.match(/^(\s*)/)?.[1]?.length || 0
148
+
149
+ if (nextIndent > indentLevel) {
150
+ // This is nested content for the current item
151
+ itemContent.push(nextLine)
152
+ totalRaw = `${totalRaw}${nextLine}\n`
153
+ i += 1
154
+ } else {
155
+ // Same or less indentation - this belongs to parent level
156
+ break
157
+ }
158
+ }
159
+
160
+ // Parse nested content if present
161
+ let nestedTokens: any[] | undefined
162
+ const nestedContent = itemContent.slice(1)
163
+
164
+ if (nestedContent.length > 0) {
165
+ // Remove the base indentation from nested content
166
+ const dedentedNested = nestedContent
167
+ .map(nestedLine => nestedLine.slice(indentLevel + baseIndentSize)) // Remove base indent + 2 spaces
168
+ .join('\n')
169
+
170
+ if (dedentedNested.trim()) {
171
+ // Use custom nested parser if provided, otherwise fall back to default
172
+ if (config.customNestedParser) {
173
+ nestedTokens = config.customNestedParser(dedentedNested)
174
+ } else {
175
+ nestedTokens = lexer.blockTokens(dedentedNested)
176
+ }
177
+ }
178
+ }
179
+
180
+ // Create the token using the provided factory function
181
+ const token = config.createToken(itemData, nestedTokens)
182
+ items.push(token)
183
+ }
184
+
185
+ if (items.length === 0) {
186
+ return undefined
187
+ }
188
+
189
+ return {
190
+ items,
191
+ raw: totalRaw.trim(),
192
+ }
193
+ }
@@ -0,0 +1,94 @@
1
+ import type { JSONContent } from '@tiptap/core'
2
+
3
+ /**
4
+ * @fileoverview Utility functions for rendering nested content in markdown.
5
+ *
6
+ * This module provides reusable utilities for extensions that need to render
7
+ * content with a prefix on the main line and properly indented nested content.
8
+ */
9
+
10
+ /**
11
+ * Utility function for rendering content with a main line prefix and nested indented content.
12
+ *
13
+ * This function handles the common pattern of rendering content with:
14
+ * 1. A main line with a prefix (like "- " for lists, "> " for blockquotes, etc.)
15
+ * 2. Nested content that gets indented properly
16
+ *
17
+ * @param node - The ProseMirror node representing the content
18
+ * @param h - The markdown renderer helper
19
+ * @param prefixOrGenerator - Either a string prefix or a function that generates the prefix from context
20
+ * @param ctx - Optional context object (used when prefixOrGenerator is a function)
21
+ * @returns The rendered markdown string
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * // For a bullet list item with static prefix
26
+ * return renderNestedMarkdownContent(node, h, '- ')
27
+ *
28
+ * // For a task item with static prefix
29
+ * const prefix = `- [${node.attrs?.checked ? 'x' : ' '}] `
30
+ * return renderNestedMarkdownContent(node, h, prefix)
31
+ *
32
+ * // For a blockquote with static prefix
33
+ * return renderNestedMarkdownContent(node, h, '> ')
34
+ *
35
+ * // For content with dynamic prefix based on context
36
+ * return renderNestedMarkdownContent(node, h, ctx => {
37
+ * if (ctx.parentType === 'orderedList') {
38
+ * return `${ctx.index + 1}. `
39
+ * }
40
+ * return '- '
41
+ * }, ctx)
42
+ *
43
+ * // Custom extension example
44
+ * const CustomContainer = Node.create({
45
+ * name: 'customContainer',
46
+ * // ... other config
47
+ * markdown: {
48
+ * render: (node, h) => {
49
+ * const type = node.attrs?.type || 'info'
50
+ * return renderNestedMarkdownContent(node, h, `[${type}] `)
51
+ * }
52
+ * }
53
+ * })
54
+ * ```
55
+ */
56
+ export function renderNestedMarkdownContent(
57
+ node: JSONContent,
58
+ h: {
59
+ renderChildren: (nodes: JSONContent[]) => string
60
+ indent: (text: string) => string
61
+ },
62
+ prefixOrGenerator: string | ((ctx: any) => string),
63
+ ctx?: any,
64
+ ): string {
65
+ if (!node || !Array.isArray(node.content)) {
66
+ return ''
67
+ }
68
+
69
+ // Determine the prefix based on the input
70
+ const prefix = typeof prefixOrGenerator === 'function' ? prefixOrGenerator(ctx) : prefixOrGenerator
71
+
72
+ const [content, ...children] = node.content
73
+
74
+ // Render the main content (typically a paragraph)
75
+ const mainContent = h.renderChildren([content])
76
+ const output = [`${prefix}${mainContent}`]
77
+
78
+ // Handle nested children with proper indentation
79
+ if (children && children.length > 0) {
80
+ children.forEach(child => {
81
+ const childContent = h.renderChildren([child])
82
+ if (childContent) {
83
+ // Split the child content by lines and indent each line
84
+ const indentedChild = childContent
85
+ .split('\n')
86
+ .map(line => (line ? h.indent(line) : ''))
87
+ .join('\n')
88
+ output.push(indentedChild)
89
+ }
90
+ })
91
+ }
92
+
93
+ return output.join('\n')
94
+ }