svelte-streamdown 2.4.0 → 2.4.2

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.
@@ -1,901 +1,650 @@
1
- const linkImagePattern = /(!?\[)([^\]]*?)$/;
2
- const boldPattern = /(\*\*)([^]*?)$/;
3
- const italicPattern = /(__)([^_]*?)$/;
4
- const boldItalicPattern = /(\*\*\*)([^*]*?)$/;
5
- const singleAsteriskPattern = /(\*)([^*]*?)$/;
6
- const singleUnderscorePattern = /(_)([^_]*?)$/;
7
- const inlineCodePattern = /(`)([^`]*?)$/;
8
- const strikethroughPattern = /(~~)([^~]*?)$/;
9
- const subPattern = /(~)([^~]*?)$/;
10
- const supPattern = /(\^)([^\^]*?)$/;
11
- const incompleteLinkUrlPattern = /(!?\[[^\]]*\]\()([^)]*?)$/;
12
- // Helper function to find the end of the line containing a specific position
13
- const findEndOfLineContaining = (text, position) => {
14
- let endPos = position;
15
- // Move forward to find the end of the line
16
- while (endPos < text.length && text[endPos] !== '\n') {
17
- endPos++;
18
- }
19
- return endPos;
20
- };
21
- // Helper function to check if we have a complete code block
22
- const hasCompleteCodeBlock = (text) => {
23
- const tripleBackticks = (text.match(/```/g) || []).length;
24
- return tripleBackticks > 0 && tripleBackticks % 2 === 0 && text.includes('\n');
25
- };
26
- // Handles incomplete links and images by preserving them with a special marker
27
- const handleIncompleteLinksAndImages = (text) => {
28
- // Check for incomplete link URLs like "[text](incomplete-url"
29
- const urlMatch = text.match(incompleteLinkUrlPattern);
30
- if (urlMatch) {
31
- const isImage = urlMatch[1].startsWith('!');
32
- const url = urlMatch[2];
33
- // Only handle if there's actually some URL content
34
- if (url.length > 0) {
35
- // Check if the URL is actually incomplete
36
- const isIncomplete = isUrlIncomplete(url);
37
- if (isIncomplete) {
38
- // For incomplete URLs, replace with incomplete marker
39
- const marker = isImage ? 'streamdown:incomplete-image' : 'streamdown:incomplete-link';
40
- return text.replace(urlMatch[2], marker) + ')';
41
- }
42
- else {
43
- // URL is complete, just add closing parenthesis
44
- return `${text})`;
45
- }
46
- }
47
- else {
48
- // If URL is empty (just "[text]("), complete it with incomplete marker
49
- const marker = isImage ? 'streamdown:incomplete-image' : 'streamdown:incomplete-link';
50
- return `${text}${marker})`;
51
- }
52
- }
53
- // Check for incomplete link text like "[incomplete-text" (but not "[text](")
54
- const linkMatch = text.match(linkImagePattern);
55
- if (linkMatch && !text.includes('](')) {
56
- const isImage = linkMatch[1].startsWith('!');
57
- // For incomplete link/image text, complete with incomplete marker
58
- const marker = isImage ? 'streamdown:incomplete-image' : 'streamdown:incomplete-link';
59
- return `${text}](${marker})`;
60
- }
61
- return text;
62
- };
63
- // Helper function to determine if a URL is incomplete or lacks proper domain extension
64
- const isUrlIncomplete = (url) => {
65
- // If URL is clearly incomplete (too short, no protocol, etc.)
66
- if (!url || url.length < 4) {
67
- return true;
68
- }
69
- // If URL starts with protocol but is very short
70
- if ((url.startsWith('http://') && url.length < 12) ||
71
- (url.startsWith('https://') && url.length < 13)) {
72
- return true;
73
- }
74
- // If URL has protocol, extract the domain part
75
- let domain = url;
76
- if (url.startsWith('http://')) {
77
- domain = url.substring(7);
78
- }
79
- else if (url.startsWith('https://')) {
80
- domain = url.substring(8);
81
- }
82
- // Remove path, query, and fragment parts to get just the domain
83
- domain = domain.split('/')[0].split('?')[0].split('#')[0];
84
- // Check if domain has a proper extension
85
- const domainParts = domain.split('.');
86
- if (domainParts.length < 2) {
87
- return true; // No extension at all
88
- }
89
- const extension = domainParts[domainParts.length - 1];
90
- // Check if extension looks valid (at least 2 characters, only letters)
91
- if (extension.length < 2 || !/^[a-zA-Z]+$/.test(extension)) {
92
- return true;
93
- }
94
- // If we get here, the URL looks reasonably complete
95
- return false;
96
- };
97
- // Completes incomplete bold formatting (**)
98
- const handleIncompleteBold = (text) => {
99
- // Don't process if inside a complete code block
100
- if (hasCompleteCodeBlock(text)) {
101
- return text;
102
- }
103
- // IMPORTANT SAFEGUARD: Don't modify text that contains complete italic patterns
104
- // This prevents accidentally converting *italic* to something else
105
- const completeItalicPattern = /\*[^*\n]+\*/g;
106
- const completeItalicMatches = text.match(completeItalicPattern);
107
- const boldMatch = text.match(boldPattern);
108
- if (boldMatch) {
109
- // Find the position of the last ** marker
110
- const lastDoubleAsteriskIndex = text.lastIndexOf('**');
111
- const contentAfterMarker = text.substring(lastDoubleAsteriskIndex + 2);
112
- // Don't close if there's no meaningful content after the opening markers
113
- // Check if content is only whitespace or other emphasis markers
114
- if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
115
- return text;
116
- }
117
- // Check if this is a standalone horizontal rule (asterisks on a line by themselves)
118
- const matchIndex = lastDoubleAsteriskIndex;
119
- const lineStart = text.lastIndexOf('\n', matchIndex) + 1;
120
- const lineEnd = text.indexOf('\n', matchIndex);
121
- const lineContent = text.substring(lineStart, lineEnd === -1 ? text.length : lineEnd);
122
- const trimmedLine = lineContent.trim();
123
- // If the line contains only asterisks (2 or more), it's a horizontal rule
124
- if (/^\*+$/.test(trimmedLine) && trimmedLine.length >= 2) {
1
+ class IncompleteMarkdownParser {
2
+ plugins = [];
3
+ state = {
4
+ currentLine: 0,
5
+ context: 'normal',
6
+ blockingContexts: new Set(),
7
+ lineContexts: []
8
+ };
9
+ setState = (state) => {
10
+ this.state = { ...this.state, ...state };
11
+ };
12
+ constructor(plugins = []) {
13
+ this.plugins = plugins;
14
+ }
15
+ // Main parsing methods
16
+ parse(text) {
17
+ if (!text || typeof text !== 'string') {
125
18
  return text;
126
19
  }
127
- // For streaming context, we allow multi-line completion
128
- // Remove the conservative multi-line restriction for better UX
129
- // ADDITIONAL SAFEGUARD: Check if the content after ** would interfere with existing italic
130
- if (contentAfterMarker.endsWith('*') && !contentAfterMarker.endsWith('**')) {
131
- // Before treating as incomplete closing marker, check if this would break italic formatting
132
- const potentiallyAffectedText = contentAfterMarker.slice(0, -1);
133
- if (completeItalicMatches && potentiallyAffectedText.includes('*')) {
134
- // Don't modify if it would interfere with existing italic patterns
135
- return text;
20
+ this.state = {
21
+ currentLine: 0,
22
+ context: 'normal',
23
+ blockingContexts: new Set(),
24
+ lineContexts: [],
25
+ fenceInfo: undefined
26
+ };
27
+ let result = text;
28
+ // Execute preprocess hooks for all plugins
29
+ for (const plugin of this.plugins) {
30
+ if (plugin.preprocess) {
31
+ try {
32
+ const preprocessResult = plugin.preprocess({
33
+ text: result,
34
+ state: this.state,
35
+ setState: this.setState
36
+ });
37
+ if (typeof preprocessResult === 'string') {
38
+ result = preprocessResult;
39
+ }
40
+ else {
41
+ result = preprocessResult.text;
42
+ this.setState(preprocessResult.state);
43
+ }
44
+ }
45
+ catch (error) {
46
+ console.error(`Plugin ${plugin.name} preprocess hook failed:`, error);
47
+ }
136
48
  }
137
- // The content ends with a single *, treat it as an incomplete closing marker
138
- // Remove the trailing * and add complete closing **
139
- const contentWithoutTrailingAsterisk = contentAfterMarker.slice(0, -1);
140
- return text.substring(0, lastDoubleAsteriskIndex + 2) + contentWithoutTrailingAsterisk + '**';
141
- }
142
- // Count all ** sequences - if odd, we have an unmatched opening **
143
- const doubleAsteriskMatches = text.match(/\*\*/g) || [];
144
- const doubleAsteriskCount = doubleAsteriskMatches.length;
145
- if (doubleAsteriskCount % 2 === 1) {
146
- // Find the end of the line containing the incomplete bold marker
147
- const endOfLine = findEndOfLineContaining(text, lastDoubleAsteriskIndex);
148
- return text.substring(0, endOfLine) + '**' + text.substring(endOfLine);
149
49
  }
150
- }
151
- return text;
152
- };
153
- // Completes incomplete italic formatting with double underscores (__)
154
- const handleIncompleteDoubleUnderscoreItalic = (text) => {
155
- const italicMatch = text.match(italicPattern);
156
- if (italicMatch) {
157
- // Don't close if there's no meaningful content after the opening markers
158
- // italicMatch[2] contains the content after __
159
- // Check if content is only whitespace or other emphasis markers
160
- const contentAfterMarker = italicMatch[2];
161
- if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
162
- return text;
163
- }
164
- // Check if this is a standalone horizontal rule (underscores on a line by themselves)
165
- const matchIndex = text.lastIndexOf('__');
166
- const lineStart = text.lastIndexOf('\n', matchIndex) + 1;
167
- const lineEnd = text.indexOf('\n', matchIndex);
168
- const lineContent = text.substring(lineStart, lineEnd === -1 ? text.length : lineEnd);
169
- const trimmedLine = lineContent.trim();
170
- // If the line contains only underscores (3 or more), it's a horizontal rule
171
- if (/^_+$/.test(trimmedLine) && trimmedLine.length >= 3) {
172
- return text;
173
- }
174
- // Check if the underscore marker is in a list item context
175
- // Find the position of the matched underscore marker
176
- const markerIndex = text.lastIndexOf(italicMatch[1]);
177
- const beforeMarker = text.substring(0, markerIndex);
178
- const lastNewlineBeforeMarker = beforeMarker.lastIndexOf('\n');
179
- const lineStart2 = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1;
180
- const lineBeforeMarker = text.substring(lineStart2, markerIndex);
181
- // Check if this line is a list item with just the underscore marker
182
- if (/^[\s]*[-*+][\s]+$/.test(lineBeforeMarker)) {
183
- // This is a list item with just emphasis markers
184
- // Check if content after marker spans multiple lines
185
- const hasNewlineInContent = contentAfterMarker.includes('\n');
186
- if (hasNewlineInContent) {
187
- // Don't complete if the content spans to another line
188
- return text;
50
+ // Split into lines for processing
51
+ const lines = result.split('\n');
52
+ const processedLines = [...lines];
53
+ // Process each line with each plugin
54
+ for (let i = 0; i < processedLines.length; i++) {
55
+ this.state.currentLine = i;
56
+ let line = processedLines[i];
57
+ for (const plugin of this.plugins) {
58
+ // Skip this plugin if current line is in a blocking context
59
+ const currentLineContext = this.state.lineContexts?.[i];
60
+ const shouldSkip = currentLineContext &&
61
+ (plugin.skipInBlockTypes || []).some((blockType) => currentLineContext[blockType]);
62
+ if (shouldSkip) {
63
+ continue;
64
+ }
65
+ try {
66
+ const match = plugin.pattern ? line.match(plugin.pattern) : line.match(/.*/);
67
+ if (match && plugin.handler) {
68
+ line = plugin.handler({
69
+ line,
70
+ text: line,
71
+ match,
72
+ state: this.state,
73
+ setState: this.setState
74
+ });
75
+ }
76
+ }
77
+ catch (error) {
78
+ console.error(`Plugin ${plugin.name} failed on line ${i}:`, error);
79
+ }
189
80
  }
81
+ processedLines[i] = line;
190
82
  }
191
- const underscorePairs = (text.match(/__/g) || []).length;
192
- if (underscorePairs % 2 === 1) {
193
- // Find the position of the last __ marker
194
- const lastDoubleUnderscoreIndex = text.lastIndexOf('__');
195
- // Find the end of the line containing the incomplete italic marker
196
- const endOfLine = findEndOfLineContaining(text, lastDoubleUnderscoreIndex);
197
- return text.substring(0, endOfLine) + '__' + text.substring(endOfLine);
198
- }
199
- }
200
- return text;
201
- };
202
- // Counts single asterisks that are not part of double asterisks, not escaped, and not list markers
203
- const countSingleAsterisks = (text) => {
204
- let count = 0;
205
- for (let i = 0; i < text.length; i++) {
206
- if (text[i] === '*') {
207
- const prevChar = i > 0 ? text[i - 1] : '';
208
- const nextChar = i < text.length - 1 ? text[i + 1] : '';
209
- // Skip if escaped with backslash
210
- if (prevChar === '\\') {
211
- continue;
212
- }
213
- // Check if this is a list marker (asterisk at start of line followed by space)
214
- // Look backwards to find the start of the current line
215
- let lineStartIndex = i;
216
- for (let j = i - 1; j >= 0; j--) {
217
- if (text[j] === '\n') {
218
- lineStartIndex = j + 1;
219
- break;
83
+ // Rebuild text from processed lines
84
+ result = processedLines.join('\n');
85
+ // Execute afterParse hooks for all plugins
86
+ for (const plugin of this.plugins) {
87
+ if (plugin.postprocess) {
88
+ try {
89
+ result = plugin.postprocess({ text: result, state: this.state, setState: this.setState });
220
90
  }
221
- if (j === 0) {
222
- lineStartIndex = 0;
223
- break;
91
+ catch (error) {
92
+ console.error(`Plugin ${plugin.name} afterParse hook failed:`, error);
224
93
  }
225
94
  }
226
- // Check if this asterisk is at the beginning of a line (with optional whitespace)
227
- const beforeAsterisk = text.substring(lineStartIndex, i);
228
- if (beforeAsterisk.trim() === '' && (nextChar === ' ' || nextChar === '\t')) {
229
- // This is likely a list marker, don't count it
230
- continue;
231
- }
232
- if (prevChar !== '*' && nextChar !== '*') {
233
- count++;
234
- }
235
95
  }
96
+ return result;
97
+ }
98
+ // Create default plugins that replicate the original handler functions
99
+ static createDefaultPlugins() {
100
+ return [
101
+ // Block-level plugin that manages blocking contexts
102
+ {
103
+ name: 'contextManager',
104
+ preprocess: ({ text }) => {
105
+ // Pre-scan the entire text to establish blocking contexts
106
+ const lines = text.split('\n');
107
+ let inCodeBlock = false;
108
+ let inMathBlock = false;
109
+ // Track which lines are in which contexts for state management
110
+ const lineContexts = [];
111
+ for (let i = 0; i < lines.length; i++) {
112
+ const line = lines[i];
113
+ // Check for block boundaries
114
+ if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) {
115
+ inCodeBlock = !inCodeBlock;
116
+ }
117
+ if (line.trim().startsWith('$$') && !line.trim().includes('$$', 2)) {
118
+ inMathBlock = !inMathBlock;
119
+ }
120
+ lineContexts[i] = { code: inCodeBlock, math: inMathBlock };
121
+ }
122
+ // Set the final blocking contexts (for postprocessing)
123
+ const finalContexts = new Set();
124
+ if (inCodeBlock)
125
+ finalContexts.add('code');
126
+ if (inMathBlock)
127
+ finalContexts.add('math');
128
+ // Return both the text and the updated state
129
+ return {
130
+ text: text, // Don't modify text in preprocess
131
+ state: {
132
+ blockingContexts: finalContexts,
133
+ lineContexts
134
+ }
135
+ };
136
+ },
137
+ postprocess: ({ text, state }) => {
138
+ // Complete incomplete blocks at end of input
139
+ if (state.blockingContexts.has('code')) {
140
+ return text + '\n```';
141
+ }
142
+ if (state.blockingContexts.has('math')) {
143
+ return text + '\n$$';
144
+ }
145
+ return text;
146
+ }
147
+ },
148
+ {
149
+ name: 'boldItalic',
150
+ pattern: /\*\*\*/,
151
+ skipInBlockTypes: ['code', 'math'],
152
+ handler: ({ line }) => {
153
+ if (line.trim() === '***') {
154
+ return line;
155
+ }
156
+ const tripleAsterisks = (line.match(/\*\*\*/g) || []).length;
157
+ if (tripleAsterisks % 2 === 1) {
158
+ const lastTripleAsteriskIndex = line.lastIndexOf('***');
159
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTripleAsteriskIndex);
160
+ return line.substring(0, endOfCellOrLine) + '***' + line.substring(endOfCellOrLine);
161
+ }
162
+ return line;
163
+ }
164
+ },
165
+ {
166
+ name: 'bold',
167
+ pattern: /\*\*/,
168
+ skipInBlockTypes: ['code', 'math'],
169
+ handler: ({ line }) => {
170
+ if (line.trim() === '***') {
171
+ return line;
172
+ }
173
+ const doubleAsteriskMatches = (line.match(/\*\*/g) || []).length;
174
+ if (doubleAsteriskMatches % 2 === 1) {
175
+ const lastDoubleAsteriskIndex = line.lastIndexOf('**');
176
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleAsteriskIndex);
177
+ return line.substring(0, endOfCellOrLine) + '**' + line.substring(endOfCellOrLine);
178
+ }
179
+ return line;
180
+ }
181
+ },
182
+ {
183
+ name: 'doubleUnderscoreItalic',
184
+ pattern: /__/,
185
+ skipInBlockTypes: ['code', 'math'],
186
+ handler: ({ line }) => {
187
+ if (line.trim() === '___') {
188
+ return line;
189
+ }
190
+ const underscorePairs = (line.match(/__/g) || []).length;
191
+ if (underscorePairs % 2 === 1) {
192
+ const lastDoubleUnderscoreIndex = line.lastIndexOf('__');
193
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleUnderscoreIndex);
194
+ return line.substring(0, endOfCellOrLine) + '__' + line.substring(endOfCellOrLine);
195
+ }
196
+ return line;
197
+ }
198
+ },
199
+ {
200
+ name: 'strikethrough',
201
+ pattern: /~~/,
202
+ skipInBlockTypes: ['code', 'math'],
203
+ handler: ({ line }) => {
204
+ const tildePairs = (line.match(/~~/g) || []).length;
205
+ if (tildePairs % 2 === 1) {
206
+ const lastDoubleTildeIndex = line.lastIndexOf('~~');
207
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleTildeIndex);
208
+ // Only complete if there's content after the tildes
209
+ const contentAfterTildes = line.substring(lastDoubleTildeIndex + 2, endOfCellOrLine);
210
+ if (contentAfterTildes.trim().length > 0) {
211
+ return line.substring(0, endOfCellOrLine) + '~~' + line.substring(endOfCellOrLine);
212
+ }
213
+ }
214
+ return line;
215
+ }
216
+ },
217
+ {
218
+ name: 'singleAsteriskItalic',
219
+ pattern: /[\s\S]*/,
220
+ skipInBlockTypes: ['code', 'math'],
221
+ handler: ({ line }) => {
222
+ if (line.trim() === '***') {
223
+ return line;
224
+ }
225
+ // Inline countSingleAsterisks logic
226
+ let singleAsterisks = 0;
227
+ for (let i = 0; i < line.length; i++) {
228
+ if (line[i] === '*') {
229
+ const prevChar = i > 0 ? line[i - 1] : '';
230
+ const nextChar = i < line.length - 1 ? line[i + 1] : '';
231
+ let lineStartIndex = i;
232
+ for (let j = i - 1; j >= 0; j--) {
233
+ if (line[j] === '\n') {
234
+ lineStartIndex = j + 1;
235
+ break;
236
+ }
237
+ if (j === 0) {
238
+ lineStartIndex = 0;
239
+ break;
240
+ }
241
+ }
242
+ const beforeAsterisk = line.substring(lineStartIndex, i);
243
+ if (beforeAsterisk.trim() === '' && (nextChar === ' ' || nextChar === '\t')) {
244
+ continue;
245
+ }
246
+ if (prevChar !== '*' && nextChar !== '*') {
247
+ singleAsterisks++;
248
+ }
249
+ }
250
+ }
251
+ if (singleAsterisks % 2 === 1) {
252
+ // Inline findFirstSingleAsterisk logic
253
+ let firstSingleAsteriskIndex = -1;
254
+ for (let i = 0; i < line.length; i++) {
255
+ if (line[i] === '*' && line[i - 1] !== '*' && line[i + 1] !== '*') {
256
+ const prevChar = i > 0 ? line[i - 1] : '';
257
+ const nextChar = i < line.length - 1 ? line[i + 1] : '';
258
+ if (/\w/.test(prevChar) && /\w/.test(nextChar))
259
+ continue;
260
+ if (/\w/.test(prevChar) && !/\s/.test(prevChar))
261
+ continue;
262
+ firstSingleAsteriskIndex = i;
263
+ break;
264
+ }
265
+ }
266
+ if (firstSingleAsteriskIndex !== -1) {
267
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, firstSingleAsteriskIndex);
268
+ return line.substring(0, endOfCellOrLine) + '*' + line.substring(endOfCellOrLine);
269
+ }
270
+ }
271
+ return line;
272
+ }
273
+ },
274
+ {
275
+ name: 'inlineCode',
276
+ skipInBlockTypes: ['code', 'math'],
277
+ pattern: /`/,
278
+ handler: ({ line }) => {
279
+ // Inline countSingleBackticks logic
280
+ let singleBacktickCount = 0;
281
+ for (let i = 0; i < line.length; i++) {
282
+ if (line[i] === '`') {
283
+ const isTripleStart = line.substring(i, i + 3) === '```';
284
+ const isTripleMiddle = i > 0 && line.substring(i - 1, i + 2) === '```';
285
+ const isTripleEnd = i > 1 && line.substring(i - 2, i + 1) === '```';
286
+ const isPartOfTriple = isTripleStart || isTripleMiddle || isTripleEnd;
287
+ if (!isPartOfTriple) {
288
+ singleBacktickCount++;
289
+ }
290
+ }
291
+ }
292
+ // Inline hasCompleteCodeBlock logic
293
+ const tripleBackticks = (line.match(/```/g) || []).length;
294
+ const hasCompleteBlock = tripleBackticks > 0 && tripleBackticks % 2 === 0 && line.includes('\n');
295
+ if (singleBacktickCount % 2 === 1 && !hasCompleteBlock) {
296
+ const lastBacktickIndex = line.lastIndexOf('`');
297
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastBacktickIndex);
298
+ // Only complete if there's content after the backtick and it doesn't contain table delimiters
299
+ const contentAfterBacktick = line.substring(lastBacktickIndex + 1, endOfCellOrLine);
300
+ if (contentAfterBacktick.trim().length > 0 && !contentAfterBacktick.includes('|')) {
301
+ return line.substring(0, endOfCellOrLine) + '`' + line.substring(endOfCellOrLine);
302
+ }
303
+ }
304
+ return line;
305
+ }
306
+ },
307
+ {
308
+ name: 'singleUnderscoreItalic',
309
+ pattern: /[\s\S]*/,
310
+ skipInBlockTypes: ['code', 'math'],
311
+ handler: ({ line }) => {
312
+ // Inline countSingleUnderscores logic
313
+ let singleUnderscores = 0;
314
+ for (let i = 0; i < line.length; i++) {
315
+ if (line[i] === '_') {
316
+ const prevChar = i > 0 ? line[i - 1] : '';
317
+ const nextChar = i < line.length - 1 ? line[i + 1] : '';
318
+ if (prevChar === '\\')
319
+ continue;
320
+ if (isWithinMathBlock(line, i))
321
+ continue;
322
+ if (prevChar &&
323
+ nextChar &&
324
+ /[\p{L}\p{N}_]/u.test(prevChar) &&
325
+ /[\p{L}\p{N}_]/u.test(nextChar)) {
326
+ continue;
327
+ }
328
+ if (prevChar !== '_' && nextChar !== '_') {
329
+ singleUnderscores++;
330
+ }
331
+ }
332
+ }
333
+ if (singleUnderscores % 2 === 1) {
334
+ // Inline findFirstSingleUnderscore logic
335
+ let firstSingleUnderscoreIndex = -1;
336
+ for (let i = 0; i < line.length; i++) {
337
+ if (line[i] === '_' &&
338
+ line[i - 1] !== '_' &&
339
+ line[i + 1] !== '_' &&
340
+ line[i - 1] !== '\\' &&
341
+ !isWithinMathBlock(line, i)) {
342
+ const prevChar = i > 0 ? line[i - 1] : '';
343
+ const nextChar = i < line.length - 1 ? line[i + 1] : '';
344
+ if (prevChar &&
345
+ nextChar &&
346
+ /[\p{L}\p{N}_]/u.test(prevChar) &&
347
+ /[\p{L}\p{N}_]/u.test(nextChar)) {
348
+ continue;
349
+ }
350
+ firstSingleUnderscoreIndex = i;
351
+ break;
352
+ }
353
+ }
354
+ if (firstSingleUnderscoreIndex !== -1) {
355
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, firstSingleUnderscoreIndex);
356
+ return line.substring(0, endOfCellOrLine) + '_' + line.substring(endOfCellOrLine);
357
+ }
358
+ }
359
+ return line;
360
+ }
361
+ },
362
+ {
363
+ name: 'subscript',
364
+ pattern: /~/,
365
+ skipInBlockTypes: ['code', 'math'],
366
+ handler: ({ line }) => {
367
+ // Inline countSingleTildes logic
368
+ let singleTildes = 0;
369
+ for (let i = 0; i < line.length; i++) {
370
+ if (line[i] === '~') {
371
+ const prevChar = i > 0 ? line[i - 1] : '';
372
+ const nextChar = i < line.length - 1 ? line[i + 1] : '';
373
+ if (prevChar === '\\')
374
+ continue;
375
+ if (prevChar !== '~' && nextChar !== '~')
376
+ singleTildes++;
377
+ }
378
+ }
379
+ if (singleTildes % 2 === 1) {
380
+ const lastTildeIndex = line.lastIndexOf('~');
381
+ if (lastTildeIndex !== -1 && !isWithinMathBlock(line, lastTildeIndex)) {
382
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTildeIndex);
383
+ // Only complete if there's content after the tilde
384
+ const contentAfterTilde = line.substring(lastTildeIndex + 1, endOfCellOrLine);
385
+ if (contentAfterTilde.trim().length > 0) {
386
+ return line.substring(0, endOfCellOrLine) + '~' + line.substring(endOfCellOrLine);
387
+ }
388
+ }
389
+ }
390
+ return line;
391
+ }
392
+ },
393
+ {
394
+ name: 'footnoteRef',
395
+ pattern: /\[\^[^\]\s,]*/,
396
+ skipInBlockTypes: ['code', 'math'],
397
+ handler: ({ line }) => {
398
+ if (!line.includes(']')) {
399
+ return line.replace(/\[\^[^\]\s,]*/, '[^streamdown:footnote]');
400
+ }
401
+ return line;
402
+ }
403
+ },
404
+ {
405
+ name: 'superscript',
406
+ pattern: /\^/,
407
+ skipInBlockTypes: ['code', 'math'],
408
+ handler: ({ line }) => {
409
+ // Inline countSingleCarets logic
410
+ let singleCarets = 0;
411
+ for (let i = 0; i < line.length; i++) {
412
+ if (line[i] === '^') {
413
+ const prevChar = i > 0 ? line[i - 1] : '';
414
+ if (prevChar === '\\')
415
+ continue;
416
+ if (!isWithinFootnoteRef(line, i))
417
+ singleCarets++;
418
+ }
419
+ }
420
+ if (singleCarets % 2 === 1) {
421
+ const lastCaretIndex = line.lastIndexOf('^');
422
+ if (lastCaretIndex !== -1 &&
423
+ !isWithinMathBlock(line, lastCaretIndex) &&
424
+ !isWithinFootnoteRef(line, lastCaretIndex)) {
425
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastCaretIndex);
426
+ // Only complete if there's content after the caret
427
+ const contentAfterCaret = line.substring(lastCaretIndex + 1, endOfCellOrLine);
428
+ if (contentAfterCaret.trim().length > 0) {
429
+ return line.substring(0, endOfCellOrLine) + '^' + line.substring(endOfCellOrLine);
430
+ }
431
+ }
432
+ }
433
+ return line;
434
+ }
435
+ },
436
+ {
437
+ name: 'inlineMath',
438
+ pattern: /\$/,
439
+ skipInBlockTypes: ['code', 'math'],
440
+ handler: ({ line }) => {
441
+ // Inline countSingleDollarSigns logic
442
+ let singleDollars = 0;
443
+ for (let i = 0; i < line.length; i++) {
444
+ if (line[i] === '$') {
445
+ const prevChar = i > 0 ? line[i - 1] : '';
446
+ const nextChar = i < line.length - 1 ? line[i + 1] : '';
447
+ if (prevChar === '\\')
448
+ continue;
449
+ if (prevChar === '$' || nextChar === '$')
450
+ continue;
451
+ if (nextChar && /\d/.test(nextChar))
452
+ continue;
453
+ singleDollars++;
454
+ }
455
+ }
456
+ if (singleDollars % 2 === 1) {
457
+ let lastDollarIndex = -1;
458
+ for (let i = line.length - 1; i >= 0; i--) {
459
+ if (line[i] === '$') {
460
+ const prevChar = i > 0 ? line[i - 1] : '';
461
+ const nextChar = i < line.length - 1 ? line[i + 1] : '';
462
+ if (prevChar !== '\\' &&
463
+ prevChar !== '$' &&
464
+ nextChar !== '$' &&
465
+ nextChar !== '' &&
466
+ !/\d/.test(nextChar)) {
467
+ lastDollarIndex = i;
468
+ break;
469
+ }
470
+ }
471
+ }
472
+ if (lastDollarIndex !== -1) {
473
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDollarIndex);
474
+ return line.substring(0, endOfCellOrLine) + '$' + line.substring(endOfCellOrLine);
475
+ }
476
+ }
477
+ return line;
478
+ }
479
+ },
480
+ {
481
+ name: 'blockMath',
482
+ pattern: /\$\$/,
483
+ skipInBlockTypes: ['code', 'math'],
484
+ handler: ({ line }) => {
485
+ const dollarPairs = (line.match(/\$\$/g) || []).length;
486
+ if (dollarPairs % 2 === 0)
487
+ return line;
488
+ const firstDollarIndex = line.indexOf('$$');
489
+ // Only complete if there's content after $$ on the same line (no newline immediately after)
490
+ const hasNewlineAfterStart = line.indexOf('\n', firstDollarIndex) !== -1;
491
+ if (!hasNewlineAfterStart) {
492
+ // Single line case: $$content → $$content$$
493
+ return line + '$$';
494
+ }
495
+ // Multi-line cases are handled by contextManager
496
+ return line;
497
+ }
498
+ },
499
+ {
500
+ name: 'descriptionList',
501
+ pattern: /^(\s*):/,
502
+ skipInBlockTypes: ['code', 'math'],
503
+ handler: ({ line }) => {
504
+ // Check if this is a description list item that needs completion
505
+ const colonMatch = line.match(/^(\s*):(.+)$/);
506
+ if (colonMatch) {
507
+ const [, indent, content] = colonMatch;
508
+ // Only complete if the content doesn't already contain a colon
509
+ if (!content.includes(':')) {
510
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, line.length - 1);
511
+ return line.substring(0, endOfCellOrLine) + ':' + line.substring(endOfCellOrLine);
512
+ }
513
+ }
514
+ return line;
515
+ }
516
+ },
517
+ {
518
+ name: 'linksAndImages',
519
+ pattern: /(!?\[.*)$/,
520
+ skipInBlockTypes: ['code', 'math'],
521
+ handler: ({ line }) => {
522
+ // Check for incomplete links with URLs: [text](url
523
+ const urlMatch = line.match(/(!?\[[^\]]*\]\()([^)]*?)$/);
524
+ if (urlMatch) {
525
+ const url = urlMatch[2];
526
+ if (url.length > 0) {
527
+ // Inline isUrlIncomplete logic
528
+ let isIncomplete = true;
529
+ if (url && url.length >= 4) {
530
+ if ((url.startsWith('http://') && url.length >= 12) ||
531
+ (url.startsWith('https://') && url.length >= 13)) {
532
+ let domain = url;
533
+ if (url.startsWith('http://'))
534
+ domain = url.substring(7);
535
+ else if (url.startsWith('https://'))
536
+ domain = url.substring(8);
537
+ domain = domain.split('/')[0].split('?')[0].split('#')[0];
538
+ const domainParts = domain.split('.');
539
+ if (domainParts.length >= 2) {
540
+ const extension = domainParts[domainParts.length - 1];
541
+ if (extension.length >= 2 && /^[a-zA-Z]+$/.test(extension)) {
542
+ isIncomplete = false;
543
+ }
544
+ }
545
+ }
546
+ }
547
+ if (isIncomplete) {
548
+ const marker = urlMatch[1].startsWith('!')
549
+ ? 'streamdown:incomplete-image'
550
+ : 'streamdown:incomplete-link';
551
+ return line.replace(url, marker) + ')';
552
+ }
553
+ else {
554
+ return line + ')';
555
+ }
556
+ }
557
+ else {
558
+ const marker = urlMatch[1].startsWith('!')
559
+ ? 'streamdown:incomplete-image'
560
+ : 'streamdown:incomplete-link';
561
+ return line + marker + ')';
562
+ }
563
+ }
564
+ // Check for incomplete links without URLs: [text
565
+ const linkMatch = line.match(/(!?\[)([^\]]*?)$/);
566
+ if (linkMatch && !line.includes('](')) {
567
+ const [, openBracket, linkTextWithPossibleBoundary] = linkMatch;
568
+ // Find the position of the opening bracket
569
+ const bracketIndex = line.lastIndexOf(openBracket);
570
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, bracketIndex);
571
+ // Extract the clean link text (remove any trailing | or whitespace)
572
+ const linkText = linkTextWithPossibleBoundary.replace(/[\s|]+$/, '');
573
+ const marker = openBracket.startsWith('!')
574
+ ? 'streamdown:incomplete-image'
575
+ : 'streamdown:incomplete-link';
576
+ // Replace from bracket to end of cell/line, including boundary if it's |
577
+ const includeBoundary = endOfCellOrLine < line.length && line[endOfCellOrLine] === '|';
578
+ const incompleteEnd = includeBoundary ? endOfCellOrLine + 1 : endOfCellOrLine;
579
+ const incompletePart = line.substring(bracketIndex, incompleteEnd);
580
+ const completedPart = openBracket + linkText + '](' + marker + ')' + (includeBoundary ? '|' : '');
581
+ return line.replace(incompletePart, completedPart);
582
+ }
583
+ return line;
584
+ }
585
+ }
586
+ ];
236
587
  }
237
- return count;
238
- };
239
- // Completes incomplete italic formatting with single asterisks (*)
240
- const handleIncompleteSingleAsteriskItalic = (text) => {
241
- // Don't process if inside a complete code block
242
- if (hasCompleteCodeBlock(text)) {
588
+ }
589
+ // Legacy function for backward compatibility
590
+ const defaultPlugins = IncompleteMarkdownParser.createDefaultPlugins();
591
+ const defaultParser = new IncompleteMarkdownParser(defaultPlugins);
592
+ export const parseIncompleteMarkdown = (text) => {
593
+ if (!text || typeof text !== 'string') {
243
594
  return text;
244
595
  }
245
- // IMPORTANT SAFEGUARD: Check if we already have complete italic formatting patterns
246
- // If text contains complete *word* patterns, don't modify them
247
- const completeItalicPattern = /\*[^*\n]+\*/g;
248
- const completeMatches = text.match(completeItalicPattern);
249
- if (completeMatches) {
250
- // Count asterisks in complete matches
251
- const asterisksInCompleteMatches = completeMatches.join('').split('*').length - 1;
252
- const totalAsterisks = (text.match(/\*/g) || []).length;
253
- // If most asterisks are already in complete italic patterns, don't process
254
- if (asterisksInCompleteMatches >= totalAsterisks) {
255
- return text;
256
- }
257
- }
258
- const singleAsteriskMatch = text.match(singleAsteriskPattern);
259
- if (singleAsteriskMatch) {
260
- // Find the first single asterisk position (not part of **)
261
- let firstSingleAsteriskIndex = -1;
262
- for (let i = 0; i < text.length; i++) {
263
- if (text[i] === '*' && text[i - 1] !== '*' && text[i + 1] !== '*') {
264
- firstSingleAsteriskIndex = i;
265
- break;
266
- }
267
- }
268
- if (firstSingleAsteriskIndex === -1) {
269
- return text;
270
- }
271
- // Get content after the first single asterisk
272
- const contentAfterFirstAsterisk = text.substring(firstSingleAsteriskIndex + 1);
273
- // Check if there's meaningful content after the asterisk
274
- // Don't close if content is only whitespace or emphasis markers
275
- if (!contentAfterFirstAsterisk || /^[\s_~*`]*$/.test(contentAfterFirstAsterisk)) {
276
- return text;
277
- }
278
- // Additional check: be more conservative about single asterisks
279
- // Only complete if the asterisk appears to be intended for formatting
280
- const prevChar = firstSingleAsteriskIndex > 0 ? text[firstSingleAsteriskIndex - 1] : '';
281
- const nextChar = firstSingleAsteriskIndex < text.length - 1 ? text[firstSingleAsteriskIndex + 1] : '';
282
- // If asterisk is surrounded by word characters, it's likely literal (e.g., test*var)
283
- if (/\w/.test(prevChar) && /\w/.test(nextChar)) {
284
- return text;
285
- }
286
- // If asterisk is at the end of a word/phrase, be more cautious
287
- // Only complete if there's clear whitespace before it (typical italic pattern)
288
- if (/\w/.test(prevChar) && !/\s/.test(prevChar)) {
289
- return text;
290
- }
291
- const singleAsterisks = countSingleAsterisks(text);
292
- if (singleAsterisks % 2 === 1) {
293
- // Find the end of the line containing the incomplete italic marker
294
- const endOfLine = findEndOfLineContaining(text, firstSingleAsteriskIndex);
295
- return text.substring(0, endOfLine) + '*' + text.substring(endOfLine);
296
- }
596
+ return defaultParser.parse(text);
597
+ };
598
+ // Utility functions
599
+ const findEndOfCellOrLineContaining = (text, position) => {
600
+ let endPos = position;
601
+ while (endPos < text.length && text[endPos] !== '\n' && text[endPos] !== '|') {
602
+ endPos++;
297
603
  }
298
- return text;
604
+ return endPos;
299
605
  };
300
- // Check if a position is within a math block (between $ or $$)
301
606
  const isWithinMathBlock = (text, position) => {
302
- // Count dollar signs before this position
303
607
  let inInlineMath = false;
304
608
  let inBlockMath = false;
305
609
  for (let i = 0; i < text.length && i < position; i++) {
306
- // Skip escaped dollar signs
307
610
  if (text[i] === '\\' && text[i + 1] === '$') {
308
- i++; // Skip the next character
611
+ i++;
309
612
  continue;
310
613
  }
311
614
  if (text[i] === '$') {
312
- // Check for block math ($$)
313
615
  if (text[i + 1] === '$') {
314
616
  inBlockMath = !inBlockMath;
315
- i++; // Skip the second $
316
- inInlineMath = false; // Block math takes precedence
617
+ i++;
618
+ inInlineMath = false;
317
619
  }
318
620
  else if (!inBlockMath) {
319
- // Only toggle inline math if not in block math
320
621
  inInlineMath = !inInlineMath;
321
622
  }
322
623
  }
323
624
  }
324
625
  return inInlineMath || inBlockMath;
325
626
  };
326
- // Check if a position is within a footnote reference pattern [^label]
327
627
  const isWithinFootnoteRef = (text, position) => {
328
- // Look backwards from position to find if we're inside [^...]
329
628
  let openBracketPos = -1;
330
629
  let caretPos = -1;
331
630
  for (let i = position; i >= 0; i--) {
332
- if (text[i] === ']') {
333
- // Found closing bracket before our position, not in footnote
631
+ if (text[i] === ']')
334
632
  return false;
335
- }
336
- if (text[i] === '^' && caretPos === -1) {
633
+ if (text[i] === '^' && caretPos === -1)
337
634
  caretPos = i;
338
- }
339
635
  if (text[i] === '[') {
340
636
  openBracketPos = i;
341
637
  break;
342
638
  }
343
639
  }
344
- // Check if we have the pattern [^ and our position is after the caret
345
640
  if (openBracketPos !== -1 && caretPos === openBracketPos + 1 && position >= caretPos) {
346
- // Look forward to see if there's a closing bracket
347
641
  for (let i = position + 1; i < text.length; i++) {
348
- if (text[i] === ']') {
349
- return true; // We're inside [^...] pattern
350
- }
351
- if (text[i] === '[' || text[i] === '\n') {
352
- break; // Invalid pattern
353
- }
354
- }
355
- }
356
- return false;
357
- };
358
- // Counts single underscores that are not part of double underscores, not escaped, and not in math blocks
359
- const countSingleUnderscores = (text) => {
360
- let count = 0;
361
- for (let i = 0; i < text.length; i++) {
362
- if (text[i] === '_') {
363
- const prevChar = i > 0 ? text[i - 1] : '';
364
- const nextChar = i < text.length - 1 ? text[i + 1] : '';
365
- // Skip if escaped with backslash
366
- if (prevChar === '\\') {
367
- continue;
368
- }
369
- // Skip if within math block
370
- if (isWithinMathBlock(text, i)) {
371
- continue;
372
- }
373
- // Skip if underscore is word-internal (between word characters)
374
- if (prevChar &&
375
- nextChar &&
376
- /[\p{L}\p{N}_]/u.test(prevChar) &&
377
- /[\p{L}\p{N}_]/u.test(nextChar)) {
378
- continue;
379
- }
380
- if (prevChar !== '_' && nextChar !== '_') {
381
- count++;
382
- }
383
- }
384
- }
385
- return count;
386
- };
387
- // Completes incomplete italic formatting with single underscores (_)
388
- const handleIncompleteSingleUnderscoreItalic = (text) => {
389
- // Don't process if inside a complete code block
390
- if (hasCompleteCodeBlock(text)) {
391
- return text;
392
- }
393
- const singleUnderscoreMatch = text.match(singleUnderscorePattern);
394
- if (singleUnderscoreMatch) {
395
- // Find the first single underscore position (not part of __ and not word-internal)
396
- let firstSingleUnderscoreIndex = -1;
397
- for (let i = 0; i < text.length; i++) {
398
- if (text[i] === '_' &&
399
- text[i - 1] !== '_' &&
400
- text[i + 1] !== '_' &&
401
- text[i - 1] !== '\\' &&
402
- !isWithinMathBlock(text, i)) {
403
- // Check if underscore is word-internal (between word characters)
404
- const prevChar = i > 0 ? text[i - 1] : '';
405
- const nextChar = i < text.length - 1 ? text[i + 1] : '';
406
- if (prevChar &&
407
- nextChar &&
408
- /[\p{L}\p{N}_]/u.test(prevChar) &&
409
- /[\p{L}\p{N}_]/u.test(nextChar)) {
410
- continue;
411
- }
412
- firstSingleUnderscoreIndex = i;
642
+ if (text[i] === ']')
643
+ return true;
644
+ if (text[i] === '[' || text[i] === '\n')
413
645
  break;
414
- }
415
- }
416
- if (firstSingleUnderscoreIndex === -1) {
417
- return text;
418
- }
419
- // Get content after the first single underscore
420
- const contentAfterFirstUnderscore = text.substring(firstSingleUnderscoreIndex + 1);
421
- // Check if there's meaningful content after the underscore
422
- // Don't close if content is only whitespace or emphasis markers
423
- if (!contentAfterFirstUnderscore || /^[\s_~*`]*$/.test(contentAfterFirstUnderscore)) {
424
- return text;
425
- }
426
- const singleUnderscores = countSingleUnderscores(text);
427
- if (singleUnderscores % 2 === 1) {
428
- // Find the end of the line containing the incomplete underscore italic marker
429
- const endOfLine = findEndOfLineContaining(text, firstSingleUnderscoreIndex);
430
- return text.substring(0, endOfLine) + '_' + text.substring(endOfLine);
431
646
  }
432
647
  }
433
- return text;
434
- };
435
- // Checks if a backtick at position i is part of a triple backtick sequence
436
- const isPartOfTripleBacktick = (text, i) => {
437
- const isTripleStart = text.substring(i, i + 3) === '```';
438
- const isTripleMiddle = i > 0 && text.substring(i - 1, i + 2) === '```';
439
- const isTripleEnd = i > 1 && text.substring(i - 2, i + 1) === '```';
440
- return isTripleStart || isTripleMiddle || isTripleEnd;
441
- };
442
- // Counts single backticks that are not part of triple backticks
443
- const countSingleBackticks = (text) => {
444
- let count = 0;
445
- for (let i = 0; i < text.length; i++) {
446
- if (text[i] === '`' && !isPartOfTripleBacktick(text, i)) {
447
- count++;
448
- }
449
- }
450
- return count;
451
- };
452
- // Completes incomplete inline code formatting (`)
453
- // Avoids completing if inside an incomplete code block
454
- const handleIncompleteInlineCode = (text) => {
455
- // Check if we have inline triple backticks (starts with ``` and should end with ```)
456
- // This pattern should ONLY match truly inline code (no newlines)
457
- // Examples: ```code``` or ```python code```
458
- const inlineTripleBacktickMatch = text.match(/^```[^`\n]*```?$/);
459
- if (inlineTripleBacktickMatch && !text.includes('\n')) {
460
- // Check if it ends with exactly 2 backticks (incomplete)
461
- if (text.endsWith('``') && !text.endsWith('```')) {
462
- return `${text}\``;
463
- }
464
- // Already complete inline triple backticks
465
- return text;
466
- }
467
- // Check if we're inside a multi-line code block (complete or incomplete)
468
- const allTripleBackticks = (text.match(/```/g) || []).length;
469
- const insideIncompleteCodeBlock = allTripleBackticks % 2 === 1;
470
- // Don't modify text if we have complete multi-line code blocks (even pairs of ```)
471
- if (allTripleBackticks > 0 && allTripleBackticks % 2 === 0 && text.includes('\n')) {
472
- // We have complete multi-line code blocks, don't add any backticks
473
- return text;
474
- }
475
- // Special case: if text ends with ```\n (triple backticks followed by newline)
476
- // This is actually a complete code block, not incomplete
477
- if (text.endsWith('```\n') || text.endsWith('```')) {
478
- // Count all triple backticks - if even, it's complete
479
- if (allTripleBackticks % 2 === 0) {
480
- return text;
481
- }
482
- }
483
- const inlineCodeMatch = text.match(inlineCodePattern);
484
- if (inlineCodeMatch && !insideIncompleteCodeBlock) {
485
- // Don't close if there's no meaningful content after the opening marker
486
- // inlineCodeMatch[2] contains the content after `
487
- // Check if content is only whitespace or other emphasis markers
488
- const contentAfterMarker = inlineCodeMatch[2];
489
- if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
490
- return text;
491
- }
492
- const singleBacktickCount = countSingleBackticks(text);
493
- if (singleBacktickCount % 2 === 1) {
494
- // Find the position of the last backtick
495
- const lastBacktickIndex = text.lastIndexOf('`');
496
- // Find the end of the line containing the incomplete code marker
497
- const endOfLine = findEndOfLineContaining(text, lastBacktickIndex);
498
- return text.substring(0, endOfLine) + '`' + text.substring(endOfLine);
499
- }
500
- }
501
- return text;
502
- };
503
- // Completes incomplete strikethrough formatting (~~)
504
- const handleIncompleteStrikethrough = (text) => {
505
- const strikethroughMatch = text.match(strikethroughPattern);
506
- if (strikethroughMatch) {
507
- // Don't close if there's no meaningful content after the opening markers
508
- // strikethroughMatch[2] contains the content after ~~
509
- // Check if content is only whitespace or other emphasis markers
510
- const contentAfterMarker = strikethroughMatch[2];
511
- if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
512
- return text;
513
- }
514
- const tildePairs = (text.match(/~~/g) || []).length;
515
- if (tildePairs % 2 === 1) {
516
- // Find the position of the last ~~ marker
517
- const lastDoubleTildeIndex = text.lastIndexOf('~~');
518
- // Find the end of the line containing the incomplete strikethrough marker
519
- const endOfLine = findEndOfLineContaining(text, lastDoubleTildeIndex);
520
- return text.substring(0, endOfLine) + '~~' + text.substring(endOfLine);
521
- }
522
- }
523
- return text;
524
- };
525
- // Counts single tildes that are not part of double tildes and not escaped
526
- const countSingleTildes = (text) => {
527
- let count = 0;
528
- for (let i = 0; i < text.length; i++) {
529
- if (text[i] === '~') {
530
- const prevChar = i > 0 ? text[i - 1] : '';
531
- const nextChar = i < text.length - 1 ? text[i + 1] : '';
532
- // Skip if escaped with backslash
533
- if (prevChar === '\\') {
534
- continue;
535
- }
536
- // Skip if part of double tilde
537
- if (prevChar !== '~' && nextChar !== '~') {
538
- count++;
539
- }
540
- }
541
- }
542
- return count;
543
- };
544
- // Completes incomplete subscript formatting (~)
545
- const handleIncompleteSub = (text) => {
546
- // Don't process if inside a complete code block
547
- if (hasCompleteCodeBlock(text)) {
548
- return text;
549
- }
550
- const singleTildes = countSingleTildes(text);
551
- if (singleTildes % 2 === 1) {
552
- // Find the last unmatched tilde
553
- const lastTildeIndex = text.lastIndexOf('~');
554
- if (lastTildeIndex !== -1) {
555
- // Check if the tilde is within a math block - if so, don't process
556
- if (isWithinMathBlock(text, lastTildeIndex)) {
557
- return text;
558
- }
559
- const afterTilde = text.substring(lastTildeIndex + 1);
560
- // Don't close if there's no meaningful content after the opening marker
561
- if (!afterTilde || /^[\s_~*`^]*$/.test(afterTilde)) {
562
- return text;
563
- }
564
- // Find the end of the line containing the incomplete subscript marker
565
- const endOfLine = findEndOfLineContaining(text, lastTildeIndex);
566
- return text.substring(0, endOfLine) + '~' + text.substring(endOfLine);
567
- }
568
- }
569
- return text;
570
- };
571
- // Counts single carets that are not escaped and not in footnote references
572
- const countSingleCarets = (text) => {
573
- let count = 0;
574
- for (let i = 0; i < text.length; i++) {
575
- if (text[i] === '^') {
576
- const prevChar = i > 0 ? text[i - 1] : '';
577
- // Skip if escaped with backslash
578
- if (prevChar === '\\') {
579
- continue;
580
- }
581
- // Skip if within footnote reference
582
- if (isWithinFootnoteRef(text, i)) {
583
- continue;
584
- }
585
- count++;
586
- }
587
- }
588
- return count;
589
- };
590
- // Completes incomplete superscript formatting (^)
591
- const handleIncompleteSup = (text) => {
592
- // Don't process if inside a complete code block
593
- if (hasCompleteCodeBlock(text)) {
594
- return text;
595
- }
596
- const singleCarets = countSingleCarets(text);
597
- if (singleCarets % 2 === 1) {
598
- // Find the last unmatched caret
599
- const lastCaretIndex = text.lastIndexOf('^');
600
- if (lastCaretIndex !== -1) {
601
- // Check if the caret is within a math block - if so, don't process
602
- if (isWithinMathBlock(text, lastCaretIndex)) {
603
- return text;
604
- }
605
- // Check if the caret is within a footnote reference - if so, don't process
606
- if (isWithinFootnoteRef(text, lastCaretIndex)) {
607
- return text;
608
- }
609
- const afterCaret = text.substring(lastCaretIndex + 1);
610
- // Don't close if there's no meaningful content after the opening marker
611
- if (!afterCaret || /^[\s_~*`^]*$/.test(afterCaret)) {
612
- return text;
613
- }
614
- // Find the end of the line containing the incomplete superscript marker
615
- const endOfLine = findEndOfLineContaining(text, lastCaretIndex);
616
- return text.substring(0, endOfLine) + '^' + text.substring(endOfLine);
617
- }
618
- }
619
- return text;
620
- };
621
- // Counts single dollar signs that are not part of double dollar signs, not escaped, and not currency
622
- const countSingleDollarSigns = (text) => {
623
- let count = 0;
624
- for (let i = 0; i < text.length; i++) {
625
- if (text[i] === '$') {
626
- const prevChar = i > 0 ? text[i - 1] : '';
627
- const nextChar = i < text.length - 1 ? text[i + 1] : '';
628
- // Skip if escaped with backslash
629
- if (prevChar === '\\') {
630
- continue;
631
- }
632
- // Skip if part of double dollar
633
- if (prevChar === '$' || nextChar === '$') {
634
- continue;
635
- }
636
- // Skip if this looks like currency (digit immediately after $)
637
- if (nextChar && /\d/.test(nextChar)) {
638
- continue;
639
- }
640
- count++;
641
- }
642
- }
643
- return count;
644
- };
645
- // Completes incomplete inline math formatting ($)
646
- const handleIncompleteInlineMath = (text) => {
647
- // Don't process if inside a complete code block
648
- if (hasCompleteCodeBlock(text)) {
649
- return text;
650
- }
651
- // Count single dollar signs (excluding currency patterns)
652
- const singleDollars = countSingleDollarSigns(text);
653
- // If we have an odd number of single dollars, we need to complete
654
- if (singleDollars % 2 === 1) {
655
- // Find the last unmatched dollar sign
656
- let lastDollarIndex = -1;
657
- for (let i = text.length - 1; i >= 0; i--) {
658
- if (text[i] === '$') {
659
- const prevChar = i > 0 ? text[i - 1] : '';
660
- const nextChar = i < text.length - 1 ? text[i + 1] : '';
661
- // Skip if escaped or part of double dollar
662
- if (prevChar === '\\' || prevChar === '$' || nextChar === '$') {
663
- continue;
664
- }
665
- // Skip if this looks like currency
666
- if (nextChar && /\d/.test(nextChar)) {
667
- continue;
668
- }
669
- lastDollarIndex = i;
670
- break;
671
- }
672
- }
673
- if (lastDollarIndex !== -1) {
674
- const afterDollar = text.substring(lastDollarIndex + 1);
675
- // Don't complete if there's no meaningful content after the dollar
676
- if (!afterDollar || /^[\s$]*$/.test(afterDollar)) {
677
- return text;
678
- }
679
- // Check if content after dollar looks like math (contains letters, spaces, symbols)
680
- // but not pure numbers (which would be currency)
681
- if (/^\d+(\.\d{2})?\s*$/.test(afterDollar.trim())) {
682
- // This looks like currency, don't complete
683
- return text;
684
- }
685
- // Find the end of the line containing the incomplete inline math marker
686
- const endOfLine = findEndOfLineContaining(text, lastDollarIndex);
687
- return text.substring(0, endOfLine) + '$' + text.substring(endOfLine);
688
- }
689
- }
690
- return text;
691
- };
692
- // Completes incomplete block KaTeX formatting ($$)
693
- const handleIncompleteBlockKatex = (text) => {
694
- // Count all $$ pairs in the text
695
- const dollarPairs = (text.match(/\$\$/g) || []).length;
696
- // If we have an even number of $$, the block is complete
697
- if (dollarPairs % 2 === 0) {
698
- return text;
699
- }
700
- // If we have an odd number, add closing $$
701
- // Check if this looks like a multi-line math block (contains newlines after opening $$)
702
- const firstDollarIndex = text.indexOf('$$');
703
- const hasNewlineAfterStart = firstDollarIndex !== -1 && text.indexOf('\n', firstDollarIndex) !== -1;
704
- // For multi-line blocks, add newline before closing $$ if not present
705
- if (hasNewlineAfterStart && !text.endsWith('\n')) {
706
- return `${text}\n$$`;
707
- }
708
- // For inline blocks or when already ending with newline, just add $$
709
- return `${text}$$`;
710
- };
711
- // Counts triple asterisks that are not part of quadruple or more asterisks
712
- const countTripleAsterisks = (text) => {
713
- let count = 0;
714
- for (let i = 0; i < text.length; i++) {
715
- if (text[i] === '*') {
716
- // Count consecutive asterisks
717
- let asteriskCount = 0;
718
- while (i < text.length && text[i] === '*') {
719
- asteriskCount++;
720
- i++;
721
- }
722
- i--; // Adjust back one position since the outer loop will increment
723
- // Each group of exactly 3 asterisks counts as one triple asterisk marker
724
- if (asteriskCount >= 3) {
725
- count += Math.floor(asteriskCount / 3);
726
- }
727
- }
728
- }
729
- return count;
730
- };
731
- // Completes incomplete bold-italic formatting (***)
732
- const handleIncompleteBoldItalic = (text) => {
733
- // Don't process if inside a complete code block
734
- if (hasCompleteCodeBlock(text)) {
735
- return text;
736
- }
737
- // Don't process if text is only asterisks and has 4 or more consecutive asterisks
738
- // This prevents cases like **** from being treated as incomplete ***
739
- if (/^\*{4,}$/.test(text)) {
740
- return text;
741
- }
742
- const boldItalicMatch = text.match(boldItalicPattern);
743
- if (boldItalicMatch) {
744
- // Don't close if there's no meaningful content after the opening markers
745
- // boldItalicMatch[2] contains the content after ***
746
- // Check if content is only whitespace or other emphasis markers
747
- const contentAfterMarker = boldItalicMatch[2];
748
- if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
749
- return text;
750
- }
751
- // Check if this is a standalone horizontal rule (*** on a line by itself)
752
- const matchIndex = text.lastIndexOf('***');
753
- const lineStart = text.lastIndexOf('\n', matchIndex) + 1;
754
- const lineEnd = text.indexOf('\n', matchIndex);
755
- const lineContent = text.substring(lineStart, lineEnd === -1 ? text.length : lineEnd);
756
- const trimmedLine = lineContent.trim();
757
- // If the line contains only *** (possibly with whitespace), it's a horizontal rule
758
- if (trimmedLine === '***') {
759
- return text;
760
- }
761
- const tripleAsteriskCount = countTripleAsterisks(text);
762
- if (tripleAsteriskCount % 2 === 1) {
763
- // Find the position of the last *** marker
764
- const lastTripleAsteriskIndex = text.lastIndexOf('***');
765
- // Find the end of the line containing the incomplete bold-italic marker
766
- const endOfLine = findEndOfLineContaining(text, lastTripleAsteriskIndex);
767
- return text.substring(0, endOfLine) + '***' + text.substring(endOfLine);
768
- }
769
- }
770
- return text;
771
- };
772
- // Handles incomplete code blocks by leaving them unchanged
773
- const handleIncompleteCodeBlock = (text) => {
774
- // Check if we have an incomplete code block (odd number of triple backticks with newlines)
775
- const tripleBackticks = (text.match(/```/g) || []).length;
776
- // If we have an odd number of triple backticks and the text contains newlines,
777
- // this is likely an incomplete code block - leave it unchanged
778
- if (tripleBackticks % 2 === 1 && text.includes('\n')) {
779
- return text;
780
- }
781
- return text;
782
- };
783
- // Handles incomplete footnote references
784
- const handleIncompleteFootnotes = (text) => {
785
- // Check for incomplete footnote references like [^ or [^label
786
- // Match [^ followed by optional simple label, but ensure no ] follows immediately
787
- // and no complex characters that would indicate this isn't a footnote
788
- const footnotePattern = /\[\^([a-zA-Z0-9_-]*)(?![^\]\s,])/g;
789
- let result = text;
790
- let match;
791
- while ((match = footnotePattern.exec(text)) !== null) {
792
- const fullMatch = match[0];
793
- const label = match[1];
794
- // Check if this is at the end of a line or followed by simple separators
795
- const matchIndex = match.index;
796
- const afterMatch = text.substring(matchIndex + fullMatch.length);
797
- // If followed by end of string, newline, space, or comma, it's likely a footnote
798
- if (afterMatch === '' ||
799
- /^\s/.test(afterMatch) ||
800
- afterMatch.startsWith('\n') ||
801
- afterMatch.startsWith(',')) {
802
- const marker = 'streamdown-footnote';
803
- const replacement = `[^${marker}]`;
804
- result = result.replace(fullMatch, replacement);
805
- break; // Only process the first match to avoid conflicts
806
- }
807
- }
808
- return result;
809
- };
810
- // Completes incomplete description list items (:term -> :term:)
811
- // Also processes incomplete inline formatting within the description content
812
- const handleIncompleteDescriptionList = (text) => {
813
- const lines = text.split('\n');
814
- let inFence = false;
815
- let fenceChar = '';
816
- let fenceIndent = '';
817
- for (let i = 0; i < lines.length; i++) {
818
- const line = lines[i];
819
- // Check for fence open/close
820
- const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
821
- if (fenceMatch) {
822
- const indent = fenceMatch[1];
823
- const fence = fenceMatch[2];
824
- const lang = fenceMatch[3];
825
- if (!inFence) {
826
- // Opening fence
827
- inFence = true;
828
- fenceChar = fence.substring(0, 1); // Store just the first character
829
- fenceIndent = indent;
830
- continue;
831
- }
832
- else if (fence.startsWith(fenceChar) && indent === fenceIndent) {
833
- // Closing fence - match the fence character type and indentation
834
- inFence = false;
835
- fenceChar = '';
836
- fenceIndent = '';
837
- continue;
838
- }
839
- }
840
- // Skip processing if inside a fence
841
- if (inFence) {
842
- continue;
843
- }
844
- // Detect description-list lines with /^\s*:[^:]+$/
845
- const descriptionMatch = line.match(/^(\s*:[^:]+)$/);
846
- if (descriptionMatch) {
847
- const fullMatch = descriptionMatch[1];
848
- const colonIndex = fullMatch.indexOf(':');
849
- const beforeColon = fullMatch.substring(0, colonIndex + 1);
850
- const content = fullMatch.substring(colonIndex + 1);
851
- // Apply formatters in this order
852
- let processedContent = content;
853
- processedContent = handleIncompleteBoldItalic(processedContent); // ***
854
- processedContent = handleIncompleteBold(processedContent); // **
855
- processedContent = handleIncompleteDoubleUnderscoreItalic(processedContent); // __
856
- processedContent = handleIncompleteStrikethrough(processedContent); // ~~
857
- processedContent = handleIncompleteInlineCode(processedContent); // `
858
- processedContent = handleIncompleteSingleAsteriskItalic(processedContent); // *
859
- processedContent = handleIncompleteSingleUnderscoreItalic(processedContent); // _
860
- processedContent = handleIncompleteSub(processedContent); // ~
861
- processedContent = handleIncompleteSup(processedContent); // ^
862
- processedContent = handleIncompleteBlockKatex(processedContent);
863
- processedContent = handleIncompleteInlineMath(processedContent);
864
- processedContent = handleIncompleteLinksAndImages(processedContent);
865
- // Set the line to beforeColon + processedContent + ':'
866
- lines[i] = beforeColon + processedContent + ':';
867
- }
868
- }
869
- return lines.join('\n');
870
- };
871
- // Parses markdown text and removes incomplete tokens to prevent partial rendering
872
- export const parseIncompleteMarkdown = (text) => {
873
- if (!text || typeof text !== 'string') {
874
- return text;
875
- }
876
- let result = text;
877
- // Handle incomplete code blocks FIRST - this prevents other formatters
878
- // from processing content that should be treated as literal code
879
- result = handleIncompleteCodeBlock(result);
880
- // Handle incomplete footnotes FIRST (before any other processing to avoid conflicts)
881
- result = handleIncompleteFootnotes(result);
882
- // Handle incomplete description lists (block-level elements)
883
- result = handleIncompleteDescriptionList(result);
884
- // Handle various formatting completions ONLY if not inside a code block
885
- // Handle double patterns before single patterns for proper priority
886
- result = handleIncompleteBoldItalic(result); // ***
887
- result = handleIncompleteBold(result); // **
888
- result = handleIncompleteDoubleUnderscoreItalic(result); // __
889
- result = handleIncompleteStrikethrough(result); // ~~
890
- result = handleIncompleteInlineCode(result); // `
891
- result = handleIncompleteSingleAsteriskItalic(result); // *
892
- result = handleIncompleteSingleUnderscoreItalic(result); // _
893
- result = handleIncompleteSub(result); // ~
894
- result = handleIncompleteSup(result); // ^
895
- // Handle KaTeX formatting
896
- result = handleIncompleteBlockKatex(result);
897
- result = handleIncompleteInlineMath(result);
898
- // Handle incomplete links and images LAST
899
- result = handleIncompleteLinksAndImages(result);
900
- return result;
648
+ return false;
901
649
  };
650
+ // Export the class and interfaces