@tiptap/core 3.6.7 → 3.7.0
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.
- package/dist/index.cjs +3653 -3143
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +736 -63
- package/dist/index.d.ts +736 -63
- package/dist/index.js +3681 -3181
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/Extendable.ts +44 -0
- package/src/ExtensionManager.ts +9 -0
- package/src/commands/insertContent.ts +15 -13
- package/src/commands/insertContentAt.ts +28 -26
- package/src/commands/setContent.ts +20 -18
- package/src/index.ts +3 -0
- package/src/pasteRules/nodePasteRule.ts +1 -1
- package/src/types.ts +156 -0
- package/src/utilities/index.ts +2 -0
- package/src/utilities/markdown/attributeUtils.ts +130 -0
- package/src/utilities/markdown/createAtomBlockMarkdownSpec.ts +141 -0
- package/src/utilities/markdown/createBlockMarkdownSpec.ts +225 -0
- package/src/utilities/markdown/createInlineMarkdownSpec.ts +236 -0
- package/src/utilities/markdown/index.ts +13 -0
- package/src/utilities/markdown/parseIndentedBlocks.ts +193 -0
- package/src/utilities/markdown/renderNestedMarkdownContent.ts +94 -0
- package/dist/jsx-runtime/jsx-runtime.d.cts +0 -23
- package/dist/jsx-runtime/jsx-runtime.d.ts +0 -23
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
JSONContent,
|
|
3
|
+
MarkdownParseHelpers,
|
|
4
|
+
MarkdownParseResult,
|
|
5
|
+
MarkdownToken,
|
|
6
|
+
MarkdownTokenizer,
|
|
7
|
+
} from '../../types.js'
|
|
8
|
+
import {
|
|
9
|
+
parseAttributes as defaultParseAttributes,
|
|
10
|
+
serializeAttributes as defaultSerializeAttributes,
|
|
11
|
+
} from './attributeUtils.js'
|
|
12
|
+
|
|
13
|
+
export interface AtomBlockMarkdownSpecOptions {
|
|
14
|
+
/** The Tiptap node name this spec is for */
|
|
15
|
+
nodeName: string
|
|
16
|
+
/** The markdown syntax name (defaults to nodeName if not provided) */
|
|
17
|
+
name?: string
|
|
18
|
+
/** Function to parse attributes from token attribute string */
|
|
19
|
+
parseAttributes?: (attrString: string) => Record<string, any>
|
|
20
|
+
/** Function to serialize attributes back to string for rendering */
|
|
21
|
+
serializeAttributes?: (attrs: Record<string, any>) => string
|
|
22
|
+
/** Default attributes to apply when parsing */
|
|
23
|
+
defaultAttributes?: Record<string, any>
|
|
24
|
+
/** Required attributes that must be present for successful parsing */
|
|
25
|
+
requiredAttributes?: string[]
|
|
26
|
+
/** Attributes that are allowed to be rendered back to markdown (whitelist) */
|
|
27
|
+
allowedAttributes?: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates a complete markdown spec for atomic block nodes using Pandoc syntax.
|
|
32
|
+
*
|
|
33
|
+
* The generated spec handles:
|
|
34
|
+
* - Parsing self-closing blocks with `:::blockName {attributes}`
|
|
35
|
+
* - Extracting and parsing attributes
|
|
36
|
+
* - Validating required attributes
|
|
37
|
+
* - Rendering blocks back to markdown
|
|
38
|
+
*
|
|
39
|
+
* @param options - Configuration for the atomic block markdown spec
|
|
40
|
+
* @returns Complete markdown specification object
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* const youtubeSpec = createAtomBlockMarkdownSpec({
|
|
45
|
+
* nodeName: 'youtube',
|
|
46
|
+
* requiredAttributes: ['src'],
|
|
47
|
+
* defaultAttributes: { start: 0 },
|
|
48
|
+
* allowedAttributes: ['src', 'start', 'width', 'height'] // Only these get rendered to markdown
|
|
49
|
+
* })
|
|
50
|
+
*
|
|
51
|
+
* // Usage in extension:
|
|
52
|
+
* export const Youtube = Node.create({
|
|
53
|
+
* // ... other config
|
|
54
|
+
* markdown: youtubeSpec
|
|
55
|
+
* })
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function createAtomBlockMarkdownSpec(options: AtomBlockMarkdownSpecOptions): {
|
|
59
|
+
parseMarkdown: (token: MarkdownToken, h: MarkdownParseHelpers) => MarkdownParseResult
|
|
60
|
+
markdownTokenizer: MarkdownTokenizer
|
|
61
|
+
renderMarkdown: (node: JSONContent) => string
|
|
62
|
+
} {
|
|
63
|
+
const {
|
|
64
|
+
nodeName,
|
|
65
|
+
name: markdownName,
|
|
66
|
+
parseAttributes = defaultParseAttributes,
|
|
67
|
+
serializeAttributes = defaultSerializeAttributes,
|
|
68
|
+
defaultAttributes = {},
|
|
69
|
+
requiredAttributes = [],
|
|
70
|
+
allowedAttributes,
|
|
71
|
+
} = options
|
|
72
|
+
|
|
73
|
+
// Use markdownName for syntax, fallback to nodeName
|
|
74
|
+
const blockName = markdownName || nodeName
|
|
75
|
+
|
|
76
|
+
// Helper function to filter attributes based on allowlist
|
|
77
|
+
const filterAttributes = (attrs: Record<string, any>) => {
|
|
78
|
+
if (!allowedAttributes) {
|
|
79
|
+
return attrs
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const filtered: Record<string, any> = {}
|
|
83
|
+
allowedAttributes.forEach(key => {
|
|
84
|
+
if (key in attrs) {
|
|
85
|
+
filtered[key] = attrs[key]
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
return filtered
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
parseMarkdown: (token: MarkdownToken, h: MarkdownParseHelpers) => {
|
|
93
|
+
const attrs = { ...defaultAttributes, ...token.attributes }
|
|
94
|
+
return h.createNode(nodeName, attrs, [])
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
markdownTokenizer: {
|
|
98
|
+
name: nodeName,
|
|
99
|
+
level: 'block' as const,
|
|
100
|
+
start(src: string) {
|
|
101
|
+
const regex = new RegExp(`^:::${blockName}(?:\\s|$)`, 'm')
|
|
102
|
+
const index = src.match(regex)?.index
|
|
103
|
+
return index !== undefined ? index : -1
|
|
104
|
+
},
|
|
105
|
+
tokenize(src, _tokens, _lexer) {
|
|
106
|
+
// Use non-global regex to match from the start of the string
|
|
107
|
+
// Include optional newline to ensure we consume the entire line
|
|
108
|
+
const regex = new RegExp(`^:::${blockName}(?:\\s+\\{([^}]*)\\})?\\s*:::(?:\\n|$)`)
|
|
109
|
+
const match = src.match(regex)
|
|
110
|
+
|
|
111
|
+
if (!match) {
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parse attributes if present
|
|
116
|
+
const attrString = match[1] || ''
|
|
117
|
+
const attributes = parseAttributes(attrString)
|
|
118
|
+
|
|
119
|
+
// Validate required attributes
|
|
120
|
+
const missingRequired = requiredAttributes.find(required => !(required in attributes))
|
|
121
|
+
if (missingRequired) {
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
type: nodeName,
|
|
127
|
+
raw: match[0],
|
|
128
|
+
attributes,
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
renderMarkdown: node => {
|
|
134
|
+
const filteredAttrs = filterAttributes(node.attrs || {})
|
|
135
|
+
const attrs = serializeAttributes(filteredAttrs)
|
|
136
|
+
const attrString = attrs ? ` {${attrs}}` : ''
|
|
137
|
+
|
|
138
|
+
return `:::${blockName}${attrString} :::`
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
JSONContent,
|
|
3
|
+
MarkdownParseHelpers,
|
|
4
|
+
MarkdownParseResult,
|
|
5
|
+
MarkdownRendererHelpers,
|
|
6
|
+
MarkdownToken,
|
|
7
|
+
MarkdownTokenizer,
|
|
8
|
+
} from '../../types.js'
|
|
9
|
+
import {
|
|
10
|
+
parseAttributes as defaultParseAttributes,
|
|
11
|
+
serializeAttributes as defaultSerializeAttributes,
|
|
12
|
+
} from './attributeUtils.js'
|
|
13
|
+
|
|
14
|
+
export interface BlockMarkdownSpecOptions {
|
|
15
|
+
/** The Tiptap node name this spec is for */
|
|
16
|
+
nodeName: string
|
|
17
|
+
/** The markdown syntax name (defaults to nodeName if not provided) */
|
|
18
|
+
name?: string
|
|
19
|
+
/** Function to extract content from the node for serialization */
|
|
20
|
+
getContent?: (token: MarkdownToken) => string
|
|
21
|
+
/** Function to parse attributes from the attribute string */
|
|
22
|
+
parseAttributes?: (attrString: string) => Record<string, any>
|
|
23
|
+
/** Function to serialize attributes to string */
|
|
24
|
+
serializeAttributes?: (attrs: Record<string, any>) => string
|
|
25
|
+
/** Default attributes to apply when parsing */
|
|
26
|
+
defaultAttributes?: Record<string, any>
|
|
27
|
+
/** Content type: 'block' allows paragraphs/lists/etc, 'inline' only allows bold/italic/links/etc */
|
|
28
|
+
content?: 'block' | 'inline'
|
|
29
|
+
/** Allowlist of attributes to include in markdown (if not provided, all attributes are included) */
|
|
30
|
+
allowedAttributes?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a complete markdown spec for block-level nodes using Pandoc syntax.
|
|
35
|
+
*
|
|
36
|
+
* The generated spec handles:
|
|
37
|
+
* - Parsing blocks with `:::blockName {attributes}` syntax
|
|
38
|
+
* - Extracting and parsing attributes
|
|
39
|
+
* - Rendering blocks back to markdown with proper formatting
|
|
40
|
+
* - Nested content support
|
|
41
|
+
*
|
|
42
|
+
* @param options - Configuration for the block markdown spec
|
|
43
|
+
* @returns Complete markdown specification object
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* const calloutSpec = createBlockMarkdownSpec({
|
|
48
|
+
* nodeName: 'callout',
|
|
49
|
+
* defaultAttributes: { type: 'info' },
|
|
50
|
+
* allowedAttributes: ['type', 'title'] // Only these get rendered to markdown
|
|
51
|
+
* })
|
|
52
|
+
*
|
|
53
|
+
* // Usage in extension:
|
|
54
|
+
* export const Callout = Node.create({
|
|
55
|
+
* // ... other config
|
|
56
|
+
* markdown: calloutSpec
|
|
57
|
+
* })
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function createBlockMarkdownSpec(options: BlockMarkdownSpecOptions): {
|
|
61
|
+
parseMarkdown: (token: MarkdownToken, h: MarkdownParseHelpers) => MarkdownParseResult
|
|
62
|
+
markdownTokenizer: MarkdownTokenizer
|
|
63
|
+
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => string
|
|
64
|
+
} {
|
|
65
|
+
const {
|
|
66
|
+
nodeName,
|
|
67
|
+
name: markdownName,
|
|
68
|
+
getContent,
|
|
69
|
+
parseAttributes = defaultParseAttributes,
|
|
70
|
+
serializeAttributes = defaultSerializeAttributes,
|
|
71
|
+
defaultAttributes = {},
|
|
72
|
+
content = 'block',
|
|
73
|
+
allowedAttributes,
|
|
74
|
+
} = options
|
|
75
|
+
|
|
76
|
+
// Use markdownName for syntax, fallback to nodeName
|
|
77
|
+
const blockName = markdownName || nodeName
|
|
78
|
+
|
|
79
|
+
// Helper function to filter attributes based on allowlist
|
|
80
|
+
const filterAttributes = (attrs: Record<string, any>) => {
|
|
81
|
+
if (!allowedAttributes) {
|
|
82
|
+
return attrs
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const filtered: Record<string, any> = {}
|
|
86
|
+
allowedAttributes.forEach(key => {
|
|
87
|
+
if (key in attrs) {
|
|
88
|
+
filtered[key] = attrs[key]
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
return filtered
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
parseMarkdown: (token, h) => {
|
|
96
|
+
let nodeContent: JSONContent[]
|
|
97
|
+
|
|
98
|
+
if (getContent) {
|
|
99
|
+
const contentResult = getContent(token)
|
|
100
|
+
// If getContent returns a string, wrap it in a text node
|
|
101
|
+
nodeContent = typeof contentResult === 'string' ? [{ type: 'text', text: contentResult }] : contentResult
|
|
102
|
+
} else if (content === 'block') {
|
|
103
|
+
nodeContent = h.parseChildren(token.tokens || [])
|
|
104
|
+
} else {
|
|
105
|
+
nodeContent = h.parseInline(token.tokens || [])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const attrs = { ...defaultAttributes, ...token.attributes }
|
|
109
|
+
|
|
110
|
+
return h.createNode(nodeName, attrs, nodeContent)
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
markdownTokenizer: {
|
|
114
|
+
name: nodeName,
|
|
115
|
+
level: 'block' as const,
|
|
116
|
+
start(src) {
|
|
117
|
+
const regex = new RegExp(`^:::${blockName}`, 'm')
|
|
118
|
+
const index = src.match(regex)?.index
|
|
119
|
+
return index !== undefined ? index : -1
|
|
120
|
+
},
|
|
121
|
+
tokenize(src, _tokens, lexer) {
|
|
122
|
+
// Match the opening tag with optional attributes
|
|
123
|
+
const openingRegex = new RegExp(`^:::${blockName}(?:\\s+\\{([^}]*)\\})?\\s*\\n`)
|
|
124
|
+
const openingMatch = src.match(openingRegex)
|
|
125
|
+
|
|
126
|
+
if (!openingMatch) {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const [openingTag, attrString = ''] = openingMatch
|
|
131
|
+
const attributes = parseAttributes(attrString)
|
|
132
|
+
|
|
133
|
+
// Find the matching closing tag by tracking nesting level
|
|
134
|
+
let level = 1
|
|
135
|
+
const position = openingTag.length
|
|
136
|
+
let matchedContent = ''
|
|
137
|
+
|
|
138
|
+
// Pattern to match any block opening (:::word) or closing (:::)
|
|
139
|
+
const blockPattern = /^:::([\w-]*)(\s.*)?/gm
|
|
140
|
+
const remaining = src.slice(position)
|
|
141
|
+
|
|
142
|
+
blockPattern.lastIndex = 0
|
|
143
|
+
|
|
144
|
+
// run until no more matches are found
|
|
145
|
+
for (;;) {
|
|
146
|
+
const match = blockPattern.exec(remaining)
|
|
147
|
+
if (match === null) {
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
const matchPos = match.index
|
|
151
|
+
const blockType = match[1] // Empty string for closing tag, block name for opening
|
|
152
|
+
|
|
153
|
+
if (match[2]?.endsWith(':::')) {
|
|
154
|
+
// this is an atom ::: node, we skip it
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (blockType) {
|
|
159
|
+
// Opening tag found - increase level
|
|
160
|
+
level += 1
|
|
161
|
+
} else {
|
|
162
|
+
// Closing tag found - decrease level
|
|
163
|
+
level -= 1
|
|
164
|
+
|
|
165
|
+
if (level === 0) {
|
|
166
|
+
// Found our matching closing tag
|
|
167
|
+
// Don't trim yet - keep newlines for tokenizer regex matching
|
|
168
|
+
const rawContent = remaining.slice(0, matchPos)
|
|
169
|
+
matchedContent = rawContent.trim()
|
|
170
|
+
const fullMatch = src.slice(0, position + matchPos + match[0].length)
|
|
171
|
+
|
|
172
|
+
// Tokenize the content
|
|
173
|
+
let contentTokens: MarkdownToken[] = []
|
|
174
|
+
if (matchedContent) {
|
|
175
|
+
if (content === 'block') {
|
|
176
|
+
// Use rawContent for tokenization to preserve line boundaries for regex matching
|
|
177
|
+
contentTokens = lexer.blockTokens(rawContent)
|
|
178
|
+
|
|
179
|
+
// Parse inline tokens for any token that has text content but no tokens
|
|
180
|
+
contentTokens.forEach(token => {
|
|
181
|
+
if (token.text && (!token.tokens || token.tokens.length === 0)) {
|
|
182
|
+
token.tokens = lexer.inlineTokens(token.text)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Clean up empty trailing paragraphs
|
|
187
|
+
while (contentTokens.length > 0) {
|
|
188
|
+
const lastToken = contentTokens[contentTokens.length - 1]
|
|
189
|
+
if (lastToken.type === 'paragraph' && (!lastToken.text || lastToken.text.trim() === '')) {
|
|
190
|
+
contentTokens.pop()
|
|
191
|
+
} else {
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
contentTokens = lexer.inlineTokens(matchedContent)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
type: nodeName,
|
|
202
|
+
raw: fullMatch,
|
|
203
|
+
attributes,
|
|
204
|
+
content: matchedContent,
|
|
205
|
+
tokens: contentTokens,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// No matching closing tag found
|
|
212
|
+
return undefined
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
renderMarkdown: (node, h) => {
|
|
217
|
+
const filteredAttrs = filterAttributes(node.attrs || {})
|
|
218
|
+
const attrs = serializeAttributes(filteredAttrs)
|
|
219
|
+
const attrString = attrs ? ` {${attrs}}` : ''
|
|
220
|
+
const renderedContent = h.renderChildren(node.content || [], '\n\n')
|
|
221
|
+
|
|
222
|
+
return `:::${blockName}${attrString}\n\n${renderedContent}\n\n:::`
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
JSONContent,
|
|
3
|
+
MarkdownParseHelpers,
|
|
4
|
+
MarkdownParseResult,
|
|
5
|
+
MarkdownToken,
|
|
6
|
+
MarkdownTokenizer,
|
|
7
|
+
} from '../../types.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse shortcode attributes like 'id="madonna" handle="john" name="John Doe"'
|
|
11
|
+
* Requires all values to be quoted with either single or double quotes
|
|
12
|
+
*/
|
|
13
|
+
function parseShortcodeAttributes(attrString: string): Record<string, any> {
|
|
14
|
+
if (!attrString.trim()) {
|
|
15
|
+
return {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const attributes: Record<string, any> = {}
|
|
19
|
+
// Match key=value pairs, only accepting quoted values
|
|
20
|
+
const regex = /(\w+)=(?:"([^"]*)"|'([^']*)')/g
|
|
21
|
+
let match = regex.exec(attrString)
|
|
22
|
+
|
|
23
|
+
while (match !== null) {
|
|
24
|
+
const [, key, doubleQuoted, singleQuoted] = match
|
|
25
|
+
attributes[key] = doubleQuoted || singleQuoted
|
|
26
|
+
match = regex.exec(attrString)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return attributes
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Serialize attributes back to shortcode format
|
|
34
|
+
* Always quotes all values with double quotes
|
|
35
|
+
*/
|
|
36
|
+
function serializeShortcodeAttributes(attrs: Record<string, any>): string {
|
|
37
|
+
return Object.entries(attrs)
|
|
38
|
+
.filter(([, value]) => value !== undefined && value !== null)
|
|
39
|
+
.map(([key, value]) => `${key}="${value}"`)
|
|
40
|
+
.join(' ')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface InlineMarkdownSpecOptions {
|
|
44
|
+
/** The Tiptap node name this spec is for */
|
|
45
|
+
nodeName: string
|
|
46
|
+
/** The shortcode name (defaults to nodeName if not provided) */
|
|
47
|
+
name?: string
|
|
48
|
+
/** Function to extract content from the node for serialization */
|
|
49
|
+
getContent?: (node: any) => string
|
|
50
|
+
/** Function to parse attributes from the attribute string */
|
|
51
|
+
parseAttributes?: (attrString: string) => Record<string, any>
|
|
52
|
+
/** Function to serialize attributes to string */
|
|
53
|
+
serializeAttributes?: (attrs: Record<string, any>) => string
|
|
54
|
+
/** Default attributes to apply when parsing */
|
|
55
|
+
defaultAttributes?: Record<string, any>
|
|
56
|
+
/** Whether this is a self-closing shortcode (no content, like [emoji name=party]) */
|
|
57
|
+
selfClosing?: boolean
|
|
58
|
+
/** Allowlist of attributes to include in markdown (if not provided, all attributes are included) */
|
|
59
|
+
allowedAttributes?: string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a complete markdown spec for inline nodes using attribute syntax.
|
|
64
|
+
*
|
|
65
|
+
* The generated spec handles:
|
|
66
|
+
* - Parsing shortcode syntax with `[nodeName attributes]content[/nodeName]` format
|
|
67
|
+
* - Self-closing shortcodes like `[emoji name=party_popper]`
|
|
68
|
+
* - Extracting and parsing attributes from the opening tag
|
|
69
|
+
* - Rendering inline elements back to shortcode markdown
|
|
70
|
+
* - Supporting both content-based and self-closing inline elements
|
|
71
|
+
*
|
|
72
|
+
* @param options - Configuration for the inline markdown spec
|
|
73
|
+
* @returns Complete markdown specification object
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* // Self-closing mention: [mention id="madonna" label="Madonna"]
|
|
78
|
+
* const mentionSpec = createInlineMarkdownSpec({
|
|
79
|
+
* nodeName: 'mention',
|
|
80
|
+
* selfClosing: true,
|
|
81
|
+
* defaultAttributes: { type: 'user' },
|
|
82
|
+
* allowedAttributes: ['id', 'label'] // Only these get rendered to markdown
|
|
83
|
+
* })
|
|
84
|
+
*
|
|
85
|
+
* // Self-closing emoji: [emoji name="party_popper"]
|
|
86
|
+
* const emojiSpec = createInlineMarkdownSpec({
|
|
87
|
+
* nodeName: 'emoji',
|
|
88
|
+
* selfClosing: true,
|
|
89
|
+
* allowedAttributes: ['name']
|
|
90
|
+
* })
|
|
91
|
+
*
|
|
92
|
+
* // With content: [highlight color="yellow"]text[/highlight]
|
|
93
|
+
* const highlightSpec = createInlineMarkdownSpec({
|
|
94
|
+
* nodeName: 'highlight',
|
|
95
|
+
* selfClosing: false,
|
|
96
|
+
* allowedAttributes: ['color', 'style']
|
|
97
|
+
* })
|
|
98
|
+
*
|
|
99
|
+
* // Usage in extension:
|
|
100
|
+
* export const Mention = Node.create({
|
|
101
|
+
* name: 'mention', // Must match nodeName
|
|
102
|
+
* // ... other config
|
|
103
|
+
* markdown: mentionSpec
|
|
104
|
+
* })
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export function createInlineMarkdownSpec(options: InlineMarkdownSpecOptions): {
|
|
108
|
+
parseMarkdown: (token: MarkdownToken, h: MarkdownParseHelpers) => MarkdownParseResult
|
|
109
|
+
markdownTokenizer: MarkdownTokenizer
|
|
110
|
+
renderMarkdown: (node: JSONContent) => string
|
|
111
|
+
} {
|
|
112
|
+
const {
|
|
113
|
+
nodeName,
|
|
114
|
+
name: shortcodeName,
|
|
115
|
+
getContent,
|
|
116
|
+
parseAttributes = parseShortcodeAttributes,
|
|
117
|
+
serializeAttributes = serializeShortcodeAttributes,
|
|
118
|
+
defaultAttributes = {},
|
|
119
|
+
selfClosing = false,
|
|
120
|
+
allowedAttributes,
|
|
121
|
+
} = options
|
|
122
|
+
|
|
123
|
+
// Use shortcodeName for markdown syntax, fallback to nodeName
|
|
124
|
+
const shortcode = shortcodeName || nodeName
|
|
125
|
+
|
|
126
|
+
// Helper function to filter attributes based on allowlist
|
|
127
|
+
const filterAttributes = (attrs: Record<string, any>) => {
|
|
128
|
+
if (!allowedAttributes) {
|
|
129
|
+
return attrs
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const filtered: Record<string, any> = {}
|
|
133
|
+
allowedAttributes.forEach(key => {
|
|
134
|
+
if (key in attrs) {
|
|
135
|
+
filtered[key] = attrs[key]
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
return filtered
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Escape special regex characters in shortcode name
|
|
142
|
+
const escapedShortcode = shortcode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
parseMarkdown: (token: MarkdownToken, h: MarkdownParseHelpers) => {
|
|
146
|
+
const attrs = { ...defaultAttributes, ...token.attributes }
|
|
147
|
+
|
|
148
|
+
if (selfClosing) {
|
|
149
|
+
// Self-closing nodes like mentions are atomic - no content
|
|
150
|
+
return h.createNode(nodeName, attrs)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Nodes with content
|
|
154
|
+
const content = getContent ? getContent(token) : token.content || ''
|
|
155
|
+
if (content) {
|
|
156
|
+
// For inline content, create text nodes using the proper helper
|
|
157
|
+
return h.createNode(nodeName, attrs, [h.createTextNode(content)])
|
|
158
|
+
}
|
|
159
|
+
return h.createNode(nodeName, attrs, [])
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
markdownTokenizer: {
|
|
163
|
+
name: nodeName,
|
|
164
|
+
level: 'inline' as const,
|
|
165
|
+
start(src: string) {
|
|
166
|
+
// Create a non-global version for finding the start position
|
|
167
|
+
const startPattern = selfClosing
|
|
168
|
+
? new RegExp(`\\[${escapedShortcode}\\s*[^\\]]*\\]`)
|
|
169
|
+
: new RegExp(`\\[${escapedShortcode}\\s*[^\\]]*\\][\\s\\S]*?\\[\\/${escapedShortcode}\\]`)
|
|
170
|
+
|
|
171
|
+
const match = src.match(startPattern)
|
|
172
|
+
const index = match?.index
|
|
173
|
+
return index !== undefined ? index : -1
|
|
174
|
+
},
|
|
175
|
+
tokenize(src, _tokens, _lexer) {
|
|
176
|
+
// Use non-global regex to match from the start of the string
|
|
177
|
+
const tokenPattern = selfClosing
|
|
178
|
+
? new RegExp(`^\\[${escapedShortcode}\\s*([^\\]]*)\\]`)
|
|
179
|
+
: new RegExp(`^\\[${escapedShortcode}\\s*([^\\]]*)\\]([\\s\\S]*?)\\[\\/${escapedShortcode}\\]`)
|
|
180
|
+
|
|
181
|
+
const match = src.match(tokenPattern)
|
|
182
|
+
|
|
183
|
+
if (!match) {
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let content = ''
|
|
188
|
+
let attrString = ''
|
|
189
|
+
|
|
190
|
+
if (selfClosing) {
|
|
191
|
+
// Self-closing: [shortcode attr="value"]
|
|
192
|
+
const [, attrs] = match
|
|
193
|
+
attrString = attrs
|
|
194
|
+
} else {
|
|
195
|
+
// With content: [shortcode attr="value"]content[/shortcode]
|
|
196
|
+
const [, attrs, contentMatch] = match
|
|
197
|
+
attrString = attrs
|
|
198
|
+
content = contentMatch || ''
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Parse attributes from the attribute string
|
|
202
|
+
const attributes = parseAttributes(attrString.trim())
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
type: nodeName,
|
|
206
|
+
raw: match[0],
|
|
207
|
+
content: content.trim(),
|
|
208
|
+
attributes,
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
renderMarkdown: (node: JSONContent) => {
|
|
214
|
+
let content = ''
|
|
215
|
+
if (getContent) {
|
|
216
|
+
content = getContent(node)
|
|
217
|
+
} else if (node.content && node.content.length > 0) {
|
|
218
|
+
// Extract text from content array for inline nodes
|
|
219
|
+
content = node.content
|
|
220
|
+
.filter((child: any) => child.type === 'text')
|
|
221
|
+
.map((child: any) => child.text)
|
|
222
|
+
.join('')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const filteredAttrs = filterAttributes(node.attrs || {})
|
|
226
|
+
const attrs = serializeAttributes(filteredAttrs)
|
|
227
|
+
const attrString = attrs ? ` ${attrs}` : ''
|
|
228
|
+
|
|
229
|
+
if (selfClosing) {
|
|
230
|
+
return `[${shortcode}${attrString}]`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return `[${shortcode}${attrString}]${content}[/${shortcode}]`
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Markdown utilities for creating standardized markdown specs.
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for creating complete markdown specifications
|
|
5
|
+
* for different types of nodes using unified syntax patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from './attributeUtils.js'
|
|
9
|
+
export * from './createAtomBlockMarkdownSpec.js'
|
|
10
|
+
export * from './createBlockMarkdownSpec.js'
|
|
11
|
+
export * from './createInlineMarkdownSpec.js'
|
|
12
|
+
export * from './parseIndentedBlocks.js'
|
|
13
|
+
export * from './renderNestedMarkdownContent.js'
|