svelte-streamdown 2.0.4 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Elements/Code.svelte +16 -14
- package/dist/Elements/Element.svelte +6 -0
- package/dist/Elements/Mermaid.svelte +95 -96
- package/dist/Streamdown.d.ts +9 -0
- package/dist/Streamdown.svelte +13 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/theme.js +6 -6
- package/dist/utils/hightlighter.svelte.d.ts +0 -1
- package/dist/utils/hightlighter.svelte.js +7 -5
- package/dist/utils/parse-incomplete-markdown.js +24 -1
- package/package.json +1 -1
|
@@ -47,21 +47,23 @@
|
|
|
47
47
|
<div class={streamdown.theme.code.base} data-language={language}>
|
|
48
48
|
<div class={streamdown.theme.code.header} data-code-block-header data-language={language}>
|
|
49
49
|
<span class={streamdown.theme.code.language}>{language}</span>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
{#if streamdown.controls.code}
|
|
51
|
+
<div class="flex items-center gap-2">
|
|
52
|
+
<!-- Download button snippet -->
|
|
53
|
+
<button
|
|
54
|
+
class={streamdown.theme.code.button}
|
|
55
|
+
onclick={downloadCode}
|
|
56
|
+
title="Download file"
|
|
57
|
+
type="button"
|
|
58
|
+
>
|
|
59
|
+
{@render downloadIcon()}
|
|
60
|
+
</button>
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
<button class={streamdown.theme.code.button} onclick={copy.copy} type="button">
|
|
63
|
+
{@render copyIcon()}
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
{/if}
|
|
65
67
|
</div>
|
|
66
68
|
<div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
|
|
67
69
|
<div>
|
|
@@ -203,6 +203,12 @@
|
|
|
203
203
|
<Alert {token} {children} />
|
|
204
204
|
{:else if token.type === 'footnoteRef'}
|
|
205
205
|
<FootnoteRef {token} />
|
|
206
|
+
{:else if token.type === 'html'}
|
|
207
|
+
{#if streamdown.renderHtml}
|
|
208
|
+
{@const content =
|
|
209
|
+
typeof streamdown.renderHtml === 'function' ? streamdown.renderHtml(token) : token.raw}
|
|
210
|
+
{@html content}
|
|
211
|
+
{/if}
|
|
206
212
|
{:else}
|
|
207
213
|
<!-- For tokens we don't handle specifically, render children or fallback -->
|
|
208
214
|
{@render children?.()}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { onMount } from 'svelte';
|
|
2
|
+
import { flushSync, onMount, tick } from 'svelte';
|
|
3
3
|
import { useStreamdown } from '../Streamdown.js';
|
|
4
4
|
import Slot from './Slot.svelte';
|
|
5
5
|
import type { Tokens } from 'marked';
|
|
@@ -178,9 +178,7 @@
|
|
|
178
178
|
const mergedConfig = { ...defaultConfig };
|
|
179
179
|
mermaid.initialize(mergedConfig);
|
|
180
180
|
|
|
181
|
-
// Use a stable ID based on chart content hash and timestamp to ensure uniqueness
|
|
182
181
|
const chartHash = code.split('').reduce((acc, char) => {
|
|
183
|
-
// biome-ignore lint/suspicious/noBitwiseOperators: "Required for Mermaid"
|
|
184
182
|
return ((acc << 5) - acc + char.charCodeAt(0)) | 0;
|
|
185
183
|
}, 0);
|
|
186
184
|
|
|
@@ -196,7 +194,6 @@
|
|
|
196
194
|
svgTarget.setAttribute(attribute.name, attribute.value);
|
|
197
195
|
});
|
|
198
196
|
svgTarget.innerHTML = svg.innerHTML;
|
|
199
|
-
// After rendering, fit the SVG within its parent container
|
|
200
197
|
|
|
201
198
|
panzoom.zoomToFit();
|
|
202
199
|
panzoom.zoomToFit();
|
|
@@ -216,101 +213,103 @@
|
|
|
216
213
|
{@attach insider.attach}
|
|
217
214
|
data-expanded={'false'}
|
|
218
215
|
>
|
|
219
|
-
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<svg
|
|
227
|
-
class={streamdown.theme.mermaid.icon}
|
|
228
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
229
|
-
viewBox="0 0 24 24"
|
|
230
|
-
fill="none"
|
|
231
|
-
stroke="currentColor"
|
|
232
|
-
stroke-width="2"
|
|
233
|
-
stroke-linecap="round"
|
|
234
|
-
stroke-linejoin="round"
|
|
235
|
-
><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path
|
|
236
|
-
d="M21 17v2a2 2 0 0 1-2 2h-2"
|
|
237
|
-
/><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><rect
|
|
238
|
-
width="10"
|
|
239
|
-
height="8"
|
|
240
|
-
x="7"
|
|
241
|
-
y="8"
|
|
242
|
-
rx="1"
|
|
243
|
-
/></svg
|
|
216
|
+
{#if streamdown.controls.mermaid}
|
|
217
|
+
<div class={streamdown.theme.mermaid.buttons}>
|
|
218
|
+
<button
|
|
219
|
+
class={streamdown.theme.mermaid.button}
|
|
220
|
+
aria-label="Zoom to fit"
|
|
221
|
+
onclick={() => panzoom.zoomToFit()}
|
|
222
|
+
data-panzoom-ignore
|
|
244
223
|
>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
224
|
+
<svg
|
|
225
|
+
class={streamdown.theme.mermaid.icon}
|
|
226
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
227
|
+
viewBox="0 0 24 24"
|
|
228
|
+
fill="none"
|
|
229
|
+
stroke="currentColor"
|
|
230
|
+
stroke-width="2"
|
|
231
|
+
stroke-linecap="round"
|
|
232
|
+
stroke-linejoin="round"
|
|
233
|
+
><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path
|
|
234
|
+
d="M21 17v2a2 2 0 0 1-2 2h-2"
|
|
235
|
+
/><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><rect
|
|
236
|
+
width="10"
|
|
237
|
+
height="8"
|
|
238
|
+
x="7"
|
|
239
|
+
y="8"
|
|
240
|
+
rx="1"
|
|
241
|
+
/></svg
|
|
242
|
+
>
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
class={streamdown.theme.mermaid.button}
|
|
246
|
+
aria-label="Zoom in"
|
|
247
|
+
onclick={() => panzoom.zoomIn()}
|
|
248
|
+
data-panzoom-ignore
|
|
267
249
|
>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path
|
|
307
|
-
d="M21 8V3h-5"
|
|
308
|
-
/><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path
|
|
309
|
-
d="M9 9 3 3"
|
|
310
|
-
/></svg
|
|
250
|
+
<svg
|
|
251
|
+
class={streamdown.theme.mermaid.icon}
|
|
252
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
253
|
+
viewBox="0 0 24 24"
|
|
254
|
+
fill="none"
|
|
255
|
+
stroke="currentColor"
|
|
256
|
+
stroke-width="2"
|
|
257
|
+
stroke-linecap="round"
|
|
258
|
+
stroke-linejoin="round"
|
|
259
|
+
><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
|
|
260
|
+
x1="11"
|
|
261
|
+
x2="11"
|
|
262
|
+
y1="8"
|
|
263
|
+
y2="14"
|
|
264
|
+
/><line x1="8" x2="14" y1="11" y2="11" /></svg
|
|
265
|
+
>
|
|
266
|
+
</button>
|
|
267
|
+
<button
|
|
268
|
+
class={streamdown.theme.mermaid.button}
|
|
269
|
+
aria-label="Zoom out"
|
|
270
|
+
onclick={() => panzoom.zoomOut()}
|
|
271
|
+
data-panzoom-ignore
|
|
272
|
+
><svg
|
|
273
|
+
class={streamdown.theme.mermaid.icon}
|
|
274
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
275
|
+
viewBox="0 0 24 24"
|
|
276
|
+
fill="none"
|
|
277
|
+
stroke="currentColor"
|
|
278
|
+
stroke-width="2"
|
|
279
|
+
stroke-linecap="round"
|
|
280
|
+
stroke-linejoin="round"
|
|
281
|
+
><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
|
|
282
|
+
x1="8"
|
|
283
|
+
x2="14"
|
|
284
|
+
y1="11"
|
|
285
|
+
y2="11"
|
|
286
|
+
/></svg
|
|
287
|
+
></button
|
|
311
288
|
>
|
|
312
|
-
|
|
313
|
-
|
|
289
|
+
<button
|
|
290
|
+
class={streamdown.theme.mermaid.button}
|
|
291
|
+
aria-label="Toggle expand"
|
|
292
|
+
onclick={() => panzoom.toggleExpand()}
|
|
293
|
+
data-panzoom-ignore
|
|
294
|
+
>
|
|
295
|
+
<svg
|
|
296
|
+
class={streamdown.theme.mermaid.icon}
|
|
297
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
298
|
+
viewBox="0 0 24 24"
|
|
299
|
+
fill="none"
|
|
300
|
+
stroke="currentColor"
|
|
301
|
+
stroke-width="2"
|
|
302
|
+
stroke-linecap="round"
|
|
303
|
+
stroke-linejoin="round"
|
|
304
|
+
><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path
|
|
305
|
+
d="M21 8V3h-5"
|
|
306
|
+
/><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path
|
|
307
|
+
d="M9 9 3 3"
|
|
308
|
+
/></svg
|
|
309
|
+
>
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
{/if}
|
|
314
313
|
<svg {@attach panzoom.attach} data-mermaid-svg></svg>
|
|
315
314
|
</div>
|
|
316
315
|
{:else}
|
package/dist/Streamdown.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets
|
|
|
7
7
|
snippets: Snippets;
|
|
8
8
|
shikiTheme: BundledTheme;
|
|
9
9
|
theme: Theme;
|
|
10
|
+
controls: {
|
|
11
|
+
code: boolean;
|
|
12
|
+
mermaid: boolean;
|
|
13
|
+
};
|
|
10
14
|
}
|
|
11
15
|
export declare class StreamdownContext {
|
|
12
16
|
footnotes: {
|
|
@@ -86,5 +90,10 @@ export type StreamdownProps = {
|
|
|
86
90
|
important?: string;
|
|
87
91
|
};
|
|
88
92
|
};
|
|
93
|
+
controls?: {
|
|
94
|
+
code?: boolean;
|
|
95
|
+
mermaid?: boolean;
|
|
96
|
+
};
|
|
97
|
+
renderHtml?: boolean | ((token: Tokens.HTML | Tokens.Tag) => string);
|
|
89
98
|
} & Partial<Snippets>;
|
|
90
99
|
export {};
|
package/dist/Streamdown.svelte
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
baseTheme,
|
|
22
22
|
mergeTheme: shouldMergeTheme = true,
|
|
23
23
|
streamdown = $bindable(),
|
|
24
|
+
renderHtml,
|
|
25
|
+
controls,
|
|
24
26
|
...snippets
|
|
25
27
|
}: StreamdownProps = $props();
|
|
26
28
|
|
|
@@ -60,11 +62,22 @@
|
|
|
60
62
|
get katexConfig() {
|
|
61
63
|
return katexConfig;
|
|
62
64
|
},
|
|
65
|
+
get renderHtml() {
|
|
66
|
+
return renderHtml;
|
|
67
|
+
},
|
|
63
68
|
get translations() {
|
|
64
69
|
return translations;
|
|
65
70
|
},
|
|
66
71
|
get shikiPreloadThemes() {
|
|
67
72
|
return shikiPreloadThemes;
|
|
73
|
+
},
|
|
74
|
+
get controls() {
|
|
75
|
+
const codeControls = controls?.code ?? true;
|
|
76
|
+
const mermaidControls = controls?.mermaid ?? true;
|
|
77
|
+
return {
|
|
78
|
+
code: codeControls,
|
|
79
|
+
mermaid: mermaidControls
|
|
80
|
+
};
|
|
68
81
|
}
|
|
69
82
|
});
|
|
70
83
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { default as Streamdown } from './Streamdown.svelte';
|
|
2
|
-
export { useStreamdown } from './Streamdown.js';
|
|
3
|
-
export
|
|
4
|
-
export {
|
|
2
|
+
export { useStreamdown, type StreamdownProps } from './Streamdown.js';
|
|
3
|
+
export { theme, shadcnTheme, mergeTheme, type Theme } from './theme.js';
|
|
4
|
+
export { lex, parseBlocks, type StreamdownToken } from './marked/index.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { default as Streamdown } from './Streamdown.svelte';
|
|
2
2
|
export { useStreamdown } from './Streamdown.js';
|
|
3
|
-
export
|
|
4
|
-
export {
|
|
3
|
+
export { theme, shadcnTheme, mergeTheme } from './theme.js';
|
|
4
|
+
export { lex, parseBlocks } from './marked/index.js';
|
package/dist/theme.js
CHANGED
|
@@ -3,7 +3,7 @@ import { twMerge } from 'tailwind-merge';
|
|
|
3
3
|
export const cn = (...inputs) => twMerge(clsx(inputs));
|
|
4
4
|
export const theme = {
|
|
5
5
|
link: {
|
|
6
|
-
base: 'text-blue-600 font-medium underline',
|
|
6
|
+
base: 'text-blue-600 font-medium underline wrap-anywhere hover:text-blue-600/80',
|
|
7
7
|
blocked: 'text-gray-500'
|
|
8
8
|
},
|
|
9
9
|
h1: {
|
|
@@ -39,8 +39,8 @@ export const theme = {
|
|
|
39
39
|
},
|
|
40
40
|
code: {
|
|
41
41
|
base: 'my-4 w-full overflow-hidden rounded-xl border border-gray-200 flex flex-col',
|
|
42
|
-
container: ' relative overflow-visible bg-gray-100
|
|
43
|
-
header: 'flex items-center justify-between bg-gray-100/80 p-2
|
|
42
|
+
container: ' relative overflow-visible bg-gray-100 p-2 font-mono text-sm ',
|
|
43
|
+
header: 'flex items-center justify-between bg-gray-100/80 p-2 text-gray-600 text-xs',
|
|
44
44
|
button: 'cursor-pointer size-6 p-1 text-gray-600 transition-all hover:text-gray-900 rounded hover:bg-gray-100',
|
|
45
45
|
language: 'ml-1 font-mono lowercase',
|
|
46
46
|
skeleton: 'rounded-md font-mono text-transparent bg-gray-200 scale-y-90 animate-pulse whitespace-nowrap inline-block',
|
|
@@ -130,7 +130,7 @@ export const theme = {
|
|
|
130
130
|
};
|
|
131
131
|
export const shadcnTheme = {
|
|
132
132
|
link: {
|
|
133
|
-
base: 'text-primary font-medium underline hover:text-primary/80',
|
|
133
|
+
base: 'text-primary wrap-anywhere font-medium underline hover:text-primary/80',
|
|
134
134
|
blocked: 'text-muted-foreground'
|
|
135
135
|
},
|
|
136
136
|
h1: {
|
|
@@ -166,8 +166,8 @@ export const shadcnTheme = {
|
|
|
166
166
|
},
|
|
167
167
|
code: {
|
|
168
168
|
base: 'my-4 w-full overflow-hidden rounded-lg border border-border flex flex-col',
|
|
169
|
-
container: 'relative overflow-visible bg-muted
|
|
170
|
-
header: 'flex items-center justify-between bg-muted/80
|
|
169
|
+
container: 'relative overflow-visible bg-muted p-2 font-mono text-sm',
|
|
170
|
+
header: 'flex items-center justify-between bg-muted/80 px-2 py-1 text-muted-foreground text-xs',
|
|
171
171
|
button: 'cursor-pointer size-6 p-1 text-muted-foreground transition-all hover:text-foreground rounded hover:bg-muted',
|
|
172
172
|
language: 'ml-1 font-mono lowercase',
|
|
173
173
|
skeleton: 'rounded-md font-mono text-transparent bg-border/80 scale-y-90 w-fit animate-pulse whitespace-nowrap inline-block',
|
|
@@ -3,7 +3,6 @@ import { SvelteSet } from 'svelte/reactivity';
|
|
|
3
3
|
export declare const loadShiki: () => Promise<[any, import("shiki").CreateHighlighterFactory<BundledLanguage, BundledTheme>]>;
|
|
4
4
|
declare class HighlighterManager {
|
|
5
5
|
initialized: boolean;
|
|
6
|
-
private highlighter;
|
|
7
6
|
private highlighters;
|
|
8
7
|
private createHighlighter;
|
|
9
8
|
private engine;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {} from 'shiki';
|
|
1
|
+
import { bundledLanguages } from 'shiki';
|
|
2
2
|
import { untrack } from 'svelte';
|
|
3
3
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
4
|
+
const isLanguageSupported = (language) => {
|
|
5
|
+
return Object.hasOwn(bundledLanguages, language);
|
|
6
|
+
};
|
|
4
7
|
// Remove background styles from <pre> tags (inline style)
|
|
5
8
|
const removePreBackground = (html) => {
|
|
6
9
|
return html.replace(/<pre[^>]*style="[^"]*background[^";]*;?[^"]*"[^>]*>/g, (match) => match.replace(/style="[^"]*background[^";]*;?[^"]*"/, ''));
|
|
@@ -13,7 +16,6 @@ export const loadShiki = async () => {
|
|
|
13
16
|
};
|
|
14
17
|
class HighlighterManager {
|
|
15
18
|
initialized = $state(false);
|
|
16
|
-
highlighter = null;
|
|
17
19
|
highlighters = new SvelteMap();
|
|
18
20
|
createHighlighter = null;
|
|
19
21
|
engine = null;
|
|
@@ -47,7 +49,7 @@ class HighlighterManager {
|
|
|
47
49
|
}
|
|
48
50
|
const highlighter = await this.createHighlighter?.({
|
|
49
51
|
themes: [theme],
|
|
50
|
-
langs: [language],
|
|
52
|
+
langs: isLanguageSupported(language) ? [language] : ['text'],
|
|
51
53
|
engine: this.engine
|
|
52
54
|
});
|
|
53
55
|
this.highlighters.set(`${theme}:${language}`, highlighter);
|
|
@@ -66,7 +68,7 @@ class HighlighterManager {
|
|
|
66
68
|
if (!this.highlighters.has(`${theme}:${language}`)) {
|
|
67
69
|
const highlighter = await this.createHighlighter({
|
|
68
70
|
themes: [theme],
|
|
69
|
-
langs: [language],
|
|
71
|
+
langs: isLanguageSupported(language) ? [language] : ['text'],
|
|
70
72
|
engine: this.engine
|
|
71
73
|
});
|
|
72
74
|
this.highlighters.set(`${theme}:${language}`, highlighter);
|
|
@@ -83,7 +85,7 @@ class HighlighterManager {
|
|
|
83
85
|
return '';
|
|
84
86
|
}
|
|
85
87
|
let html = highlighter.codeToHtml(code, {
|
|
86
|
-
lang: language,
|
|
88
|
+
lang: isLanguageSupported(language) ? language : 'text',
|
|
87
89
|
theme: theme
|
|
88
90
|
});
|
|
89
91
|
// Remove background and add custom class if needed
|
|
@@ -244,6 +244,13 @@ const countSingleUnderscores = (text) => {
|
|
|
244
244
|
if (isWithinMathBlock(text, index)) {
|
|
245
245
|
return acc;
|
|
246
246
|
}
|
|
247
|
+
// Skip if underscore is word-internal (between word characters)
|
|
248
|
+
if (prevChar &&
|
|
249
|
+
nextChar &&
|
|
250
|
+
/[\p{L}\p{N}_]/u.test(prevChar) &&
|
|
251
|
+
/[\p{L}\p{N}_]/u.test(nextChar)) {
|
|
252
|
+
return acc;
|
|
253
|
+
}
|
|
247
254
|
if (prevChar !== '_' && nextChar !== '_') {
|
|
248
255
|
return acc + 1;
|
|
249
256
|
}
|
|
@@ -259,13 +266,23 @@ const handleIncompleteSingleUnderscoreItalic = (text) => {
|
|
|
259
266
|
}
|
|
260
267
|
const singleUnderscoreMatch = text.match(singleUnderscorePattern);
|
|
261
268
|
if (singleUnderscoreMatch) {
|
|
262
|
-
// Find the first single underscore position (not part of __)
|
|
269
|
+
// Find the first single underscore position (not part of __ and not word-internal)
|
|
263
270
|
let firstSingleUnderscoreIndex = -1;
|
|
264
271
|
for (let i = 0; i < text.length; i++) {
|
|
265
272
|
if (text[i] === '_' &&
|
|
266
273
|
text[i - 1] !== '_' &&
|
|
267
274
|
text[i + 1] !== '_' &&
|
|
275
|
+
text[i - 1] !== '\\' &&
|
|
268
276
|
!isWithinMathBlock(text, i)) {
|
|
277
|
+
// Check if underscore is word-internal (between word characters)
|
|
278
|
+
const prevChar = i > 0 ? text[i - 1] : '';
|
|
279
|
+
const nextChar = i < text.length - 1 ? text[i + 1] : '';
|
|
280
|
+
if (prevChar &&
|
|
281
|
+
nextChar &&
|
|
282
|
+
/[\p{L}\p{N}_]/u.test(prevChar) &&
|
|
283
|
+
/[\p{L}\p{N}_]/u.test(nextChar)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
269
286
|
firstSingleUnderscoreIndex = i;
|
|
270
287
|
break;
|
|
271
288
|
}
|
|
@@ -282,6 +299,12 @@ const handleIncompleteSingleUnderscoreItalic = (text) => {
|
|
|
282
299
|
}
|
|
283
300
|
const singleUnderscores = countSingleUnderscores(text);
|
|
284
301
|
if (singleUnderscores % 2 === 1) {
|
|
302
|
+
// If text ends with newline(s), insert underscore before them
|
|
303
|
+
const trailingNewlineMatch = text.match(/\n+$/);
|
|
304
|
+
if (trailingNewlineMatch) {
|
|
305
|
+
const textBeforeNewlines = text.slice(0, -trailingNewlineMatch[0].length);
|
|
306
|
+
return `${textBeforeNewlines}_${trailingNewlineMatch[0]}`;
|
|
307
|
+
}
|
|
285
308
|
return `${text}_`;
|
|
286
309
|
}
|
|
287
310
|
}
|