@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.
@@ -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'