svelte-streamdown 2.4.1 → 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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
if (
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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++;
|
|
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++;
|
|
316
|
-
inInlineMath = false;
|
|
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;
|
|
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
|
|
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
|