@humanspeak/svelte-markdown 1.0.1 → 1.0.3
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/LICENSE +1 -1
- package/README.md +162 -8
- package/dist/Parser.svelte +1 -1
- package/dist/SvelteMarkdown.svelte +68 -9
- package/dist/extensions/index.d.ts +12 -0
- package/dist/extensions/index.js +12 -0
- package/dist/extensions/mermaid/MermaidRenderer.svelte +79 -0
- package/dist/extensions/mermaid/MermaidRenderer.svelte.d.ts +8 -0
- package/dist/extensions/mermaid/index.d.ts +2 -0
- package/dist/extensions/mermaid/index.js +2 -0
- package/dist/extensions/mermaid/markedMermaid.d.ts +28 -0
- package/dist/extensions/mermaid/markedMermaid.js +49 -0
- package/dist/index.d.ts +2 -0
- package/dist/types.d.ts +24 -1
- package/dist/utils/createFilterUtilities.js +4 -2
- package/dist/utils/parse-and-cache.d.ts +19 -1
- package/dist/utils/parse-and-cache.js +49 -5
- package/package.json +26 -12
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ A powerful, customizable markdown renderer for Svelte with TypeScript support. B
|
|
|
25
25
|
- ♿ WCAG 2.1 accessibility compliance
|
|
26
26
|
- 🎯 GitHub-style slug generation for headers
|
|
27
27
|
- 🧪 Comprehensive test coverage (vitest and playwright)
|
|
28
|
-
-
|
|
28
|
+
- 🧩 First-class marked extensions support via `extensions` prop (e.g., KaTeX math, alerts)
|
|
29
29
|
- ⚡ Intelligent token caching (50-200x faster re-renders)
|
|
30
30
|
- 🖼️ Smart image lazy loading with fade-in animation
|
|
31
31
|
|
|
@@ -72,7 +72,8 @@ import type {
|
|
|
72
72
|
Renderers,
|
|
73
73
|
Token,
|
|
74
74
|
TokensList,
|
|
75
|
-
SvelteMarkdownOptions
|
|
75
|
+
SvelteMarkdownOptions,
|
|
76
|
+
MarkedExtension
|
|
76
77
|
} from '@humanspeak/svelte-markdown'
|
|
77
78
|
```
|
|
78
79
|
|
|
@@ -352,6 +353,158 @@ You can render arbitrary (non-standard) HTML tags like `<click>`, `<tooltip>`, o
|
|
|
352
353
|
|
|
353
354
|
Both approaches work for any tag name. Snippet overrides take precedence over component renderers when both are provided.
|
|
354
355
|
|
|
356
|
+
## Marked Extensions
|
|
357
|
+
|
|
358
|
+
Use third-party [marked extensions](https://marked.js.org/using_advanced#extensions) via the `extensions` prop. The component handles registering tokenizers internally — you just provide renderers for the custom token types.
|
|
359
|
+
|
|
360
|
+
### KaTeX Math Rendering
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
npm install marked-katex-extension katex
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**Component renderer approach:**
|
|
367
|
+
|
|
368
|
+
```svelte
|
|
369
|
+
<script lang="ts">
|
|
370
|
+
import SvelteMarkdown from '@humanspeak/svelte-markdown'
|
|
371
|
+
import type { RendererComponent, Renderers } from '@humanspeak/svelte-markdown'
|
|
372
|
+
import markedKatex from 'marked-katex-extension'
|
|
373
|
+
import KatexRenderer from './KatexRenderer.svelte'
|
|
374
|
+
|
|
375
|
+
interface KatexRenderers extends Renderers {
|
|
376
|
+
inlineKatex: RendererComponent
|
|
377
|
+
blockKatex: RendererComponent
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const renderers: Partial<KatexRenderers> = {
|
|
381
|
+
inlineKatex: KatexRenderer,
|
|
382
|
+
blockKatex: KatexRenderer
|
|
383
|
+
}
|
|
384
|
+
</script>
|
|
385
|
+
|
|
386
|
+
<svelte:head>
|
|
387
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.28/dist/katex.min.css" crossorigin="anonymous" />
|
|
388
|
+
</svelte:head>
|
|
389
|
+
|
|
390
|
+
<SvelteMarkdown
|
|
391
|
+
source="Euler's identity: $e^{{i\pi}} + 1 = 0$"
|
|
392
|
+
extensions={[markedKatex({ throwOnError: false })]}
|
|
393
|
+
{renderers}
|
|
394
|
+
/>
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Where `KatexRenderer.svelte` is:
|
|
398
|
+
|
|
399
|
+
```svelte
|
|
400
|
+
<script lang="ts">
|
|
401
|
+
import katex from 'katex'
|
|
402
|
+
|
|
403
|
+
interface Props {
|
|
404
|
+
text: string
|
|
405
|
+
displayMode?: boolean
|
|
406
|
+
}
|
|
407
|
+
const { text, displayMode = false }: Props = $props()
|
|
408
|
+
|
|
409
|
+
const html = $derived(katex.renderToString(text, { throwOnError: false, displayMode }))
|
|
410
|
+
</script>
|
|
411
|
+
|
|
412
|
+
{@html html}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**Snippet override approach** (no separate component file needed):
|
|
416
|
+
|
|
417
|
+
```svelte
|
|
418
|
+
<script lang="ts">
|
|
419
|
+
import SvelteMarkdown from '@humanspeak/svelte-markdown'
|
|
420
|
+
import katex from 'katex'
|
|
421
|
+
import markedKatex from 'marked-katex-extension'
|
|
422
|
+
</script>
|
|
423
|
+
|
|
424
|
+
<svelte:head>
|
|
425
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.28/dist/katex.min.css" crossorigin="anonymous" />
|
|
426
|
+
</svelte:head>
|
|
427
|
+
|
|
428
|
+
<SvelteMarkdown
|
|
429
|
+
source="Euler's identity: $e^{{i\pi}} + 1 = 0$"
|
|
430
|
+
extensions={[markedKatex({ throwOnError: false })]}
|
|
431
|
+
>
|
|
432
|
+
{#snippet inlineKatex(props)}
|
|
433
|
+
{@html katex.renderToString(props.text, { displayMode: false })}
|
|
434
|
+
{/snippet}
|
|
435
|
+
{#snippet blockKatex(props)}
|
|
436
|
+
{@html katex.renderToString(props.text, { displayMode: true })}
|
|
437
|
+
{/snippet}
|
|
438
|
+
</SvelteMarkdown>
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Mermaid Diagrams (Async Rendering)
|
|
442
|
+
|
|
443
|
+
The package includes built-in `markedMermaid` and `MermaidRenderer` helpers for Mermaid diagram support. Install mermaid as an optional peer dependency:
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
npm install mermaid
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Then use the built-in helpers — no boilerplate needed:
|
|
450
|
+
|
|
451
|
+
```svelte
|
|
452
|
+
<script lang="ts">
|
|
453
|
+
import SvelteMarkdown from '@humanspeak/svelte-markdown'
|
|
454
|
+
import type { RendererComponent, Renderers } from '@humanspeak/svelte-markdown'
|
|
455
|
+
import { markedMermaid, MermaidRenderer } from '@humanspeak/svelte-markdown/extensions'
|
|
456
|
+
|
|
457
|
+
// markdown containing fenced mermaid code blocks
|
|
458
|
+
let { source } = $props()
|
|
459
|
+
|
|
460
|
+
interface MermaidRenderers extends Renderers {
|
|
461
|
+
mermaid: RendererComponent
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const renderers: Partial<MermaidRenderers> = {
|
|
465
|
+
mermaid: MermaidRenderer
|
|
466
|
+
}
|
|
467
|
+
</script>
|
|
468
|
+
|
|
469
|
+
<SvelteMarkdown {source} extensions={[markedMermaid()]} {renderers} />
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`markedMermaid()` is a zero-dependency tokenizer that converts ` ```mermaid ` code blocks into custom tokens. `MermaidRenderer` lazy-loads mermaid in the browser, renders SVG asynchronously, and automatically re-renders when dark/light mode changes.
|
|
473
|
+
|
|
474
|
+
You can also use snippet overrides to wrap `MermaidRenderer` with custom markup:
|
|
475
|
+
|
|
476
|
+
```svelte
|
|
477
|
+
<SvelteMarkdown source={markdown} extensions={[markedMermaid()]}>
|
|
478
|
+
{#snippet mermaid(props)}
|
|
479
|
+
<div class="my-diagram-wrapper">
|
|
480
|
+
<MermaidRenderer text={props.text} />
|
|
481
|
+
</div>
|
|
482
|
+
{/snippet}
|
|
483
|
+
</SvelteMarkdown>
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Since Mermaid rendering is async, the snippet delegates to `MermaidRenderer` rather than calling `mermaid.render()` directly. This pattern works for any async extension — keep the async logic in a component and use the snippet for layout customization.
|
|
487
|
+
|
|
488
|
+
### How It Works
|
|
489
|
+
|
|
490
|
+
Marked extensions define custom token types with a `name` property (e.g., `inlineKatex`, `blockKatex`, `alert`). When you pass extensions via the `extensions` prop, SvelteMarkdown automatically extracts these token type names and makes them available as both **component renderer keys** and **snippet override names**.
|
|
491
|
+
|
|
492
|
+
To find the token type names for any extension, check its source or documentation for the `name` field in its `extensions` array:
|
|
493
|
+
|
|
494
|
+
```js
|
|
495
|
+
// Example: marked-katex-extension registers tokens named "inlineKatex" and "blockKatex"
|
|
496
|
+
// → use renderers={{ inlineKatex: ..., blockKatex: ... }}
|
|
497
|
+
// → or {#snippet inlineKatex(props)} and {#snippet blockKatex(props)}
|
|
498
|
+
|
|
499
|
+
// Example: a custom alert extension registers a token named "alert"
|
|
500
|
+
// → use renderers={{ alert: AlertComponent }}
|
|
501
|
+
// → or {#snippet alert(props)}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Each snippet/component receives the token's properties as props (e.g., `text`, `displayMode` for KaTeX; `text`, `level` for alerts).
|
|
505
|
+
|
|
506
|
+
See the [full documentation](https://markdown.svelte.page/docs/advanced/marked-extensions) and [interactive demo](https://markdown.svelte.page/examples/marked-extensions).
|
|
507
|
+
|
|
355
508
|
### TypeScript
|
|
356
509
|
|
|
357
510
|
All snippet prop types are exported for use in external components:
|
|
@@ -526,12 +679,13 @@ The component emits a `parsed` event when tokens are calculated:
|
|
|
526
679
|
|
|
527
680
|
## Props
|
|
528
681
|
|
|
529
|
-
| Prop
|
|
530
|
-
|
|
|
531
|
-
| source
|
|
532
|
-
| renderers
|
|
533
|
-
| options
|
|
534
|
-
| isInline
|
|
682
|
+
| Prop | Type | Description |
|
|
683
|
+
| ---------- | ----------------------- | ------------------------------------------------ |
|
|
684
|
+
| source | `string \| Token[]` | Markdown content or pre-parsed tokens |
|
|
685
|
+
| renderers | `Partial<Renderers>` | Custom component overrides |
|
|
686
|
+
| options | `SvelteMarkdownOptions` | Marked parser configuration |
|
|
687
|
+
| isInline | `boolean` | Toggle inline parsing mode |
|
|
688
|
+
| extensions | `MarkedExtension[]` | Third-party marked extensions (e.g., KaTeX math) |
|
|
535
689
|
|
|
536
690
|
## Security
|
|
537
691
|
|
package/dist/Parser.svelte
CHANGED
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
/>
|
|
96
96
|
{/each}
|
|
97
97
|
{/if}
|
|
98
|
-
{:else if type in renderers}
|
|
98
|
+
{:else if type in renderers || type in snippetOverrides}
|
|
99
99
|
{#if type === 'table'}
|
|
100
100
|
{#if renderers.table && renderers.tablerow && renderers.tablecell}
|
|
101
101
|
{@const tableSnippet = snippetOverrides[type]}
|
|
@@ -58,8 +58,9 @@
|
|
|
58
58
|
type Token,
|
|
59
59
|
type TokensList
|
|
60
60
|
} from './utils/markdown-parser.js'
|
|
61
|
-
import { parseAndCacheTokens } from './utils/parse-and-cache.js'
|
|
61
|
+
import { parseAndCacheTokens, parseAndCacheTokensAsync } from './utils/parse-and-cache.js'
|
|
62
62
|
import { rendererKeysInternal } from './utils/rendererKeys.js'
|
|
63
|
+
import { Marked } from 'marked'
|
|
63
64
|
|
|
64
65
|
const {
|
|
65
66
|
source = [],
|
|
@@ -67,15 +68,32 @@
|
|
|
67
68
|
options = {},
|
|
68
69
|
isInline = false,
|
|
69
70
|
parsed = () => {},
|
|
71
|
+
extensions = [],
|
|
70
72
|
...rest
|
|
71
73
|
}: SvelteMarkdownProps & {
|
|
72
74
|
[key: string]: unknown
|
|
73
75
|
} = $props()
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
// Extract custom token type names from the extensions array
|
|
78
|
+
const extensionTokenNames = $derived(
|
|
79
|
+
extensions.flatMap((ext) => ext.extensions?.map((e) => e.name) ?? [])
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Create a scoped Marked instance and extract its resolved defaults
|
|
83
|
+
const extensionDefaults = $derived(
|
|
84
|
+
extensions.length > 0 ? new Marked(...extensions).defaults : {}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const combinedOptions = $derived({ ...defaultOptions, ...extensionDefaults, ...options })
|
|
76
88
|
const slugger = new Slugger()
|
|
77
89
|
|
|
78
|
-
|
|
90
|
+
// Detect if any extension requires async processing
|
|
91
|
+
const hasAsyncExtension = $derived(extensions.some((ext) => ext.async === true))
|
|
92
|
+
|
|
93
|
+
// Synchronous token derivation (default fast path)
|
|
94
|
+
const syncTokens = $derived.by(() => {
|
|
95
|
+
if (hasAsyncExtension) return undefined
|
|
96
|
+
|
|
79
97
|
// Pre-parsed tokens - skip caching and parsing
|
|
80
98
|
if (Array.isArray(source)) {
|
|
81
99
|
return source as Token[]
|
|
@@ -90,6 +108,47 @@
|
|
|
90
108
|
return parseAndCacheTokens(source as string, combinedOptions, isInline)
|
|
91
109
|
}) satisfies Token[] | TokensList | undefined
|
|
92
110
|
|
|
111
|
+
// Async token state (used only when extensions require async walkTokens)
|
|
112
|
+
let asyncTokens = $state<Token[] | TokensList | undefined>(undefined)
|
|
113
|
+
let asyncRequestId = 0
|
|
114
|
+
|
|
115
|
+
$effect(() => {
|
|
116
|
+
if (!hasAsyncExtension) return
|
|
117
|
+
|
|
118
|
+
// Pre-parsed tokens - skip caching and parsing
|
|
119
|
+
if (Array.isArray(source)) {
|
|
120
|
+
asyncTokens = source as Token[]
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Empty string - return empty array
|
|
125
|
+
if (source === '') {
|
|
126
|
+
asyncTokens = []
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Async parse with caching
|
|
131
|
+
const currentSource = source as string
|
|
132
|
+
const currentOptions = combinedOptions
|
|
133
|
+
const currentInline = isInline
|
|
134
|
+
const requestId = ++asyncRequestId
|
|
135
|
+
parseAndCacheTokensAsync(currentSource, currentOptions, currentInline)
|
|
136
|
+
.then((result) => {
|
|
137
|
+
if (requestId === asyncRequestId) {
|
|
138
|
+
asyncTokens = result
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
.catch((error) => {
|
|
142
|
+
if (requestId === asyncRequestId) {
|
|
143
|
+
console.error('[svelte-markdown] async walkTokens failed:', error)
|
|
144
|
+
asyncTokens = []
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Unified tokens: prefer sync path, fall back to async
|
|
150
|
+
const tokens = $derived(hasAsyncExtension ? asyncTokens : syncTokens)
|
|
151
|
+
|
|
93
152
|
$effect(() => {
|
|
94
153
|
if (!tokens) return
|
|
95
154
|
parsed(tokens)
|
|
@@ -106,10 +165,13 @@
|
|
|
106
165
|
: defaultRenderers.html
|
|
107
166
|
})
|
|
108
167
|
|
|
109
|
-
//
|
|
168
|
+
// All renderer keys: built-in + extension token names
|
|
169
|
+
const allRendererKeys = $derived([...rendererKeysInternal, ...extensionTokenNames])
|
|
170
|
+
|
|
171
|
+
// Collect markdown snippet overrides (keys matching renderer names or extension token names)
|
|
110
172
|
const snippetOverrides = $derived(
|
|
111
173
|
Object.fromEntries(
|
|
112
|
-
|
|
174
|
+
allRendererKeys
|
|
113
175
|
.filter((key) => key in rest && rest[key] != null)
|
|
114
176
|
.map((key) => [key, rest[key]])
|
|
115
177
|
)
|
|
@@ -126,10 +188,7 @@
|
|
|
126
188
|
|
|
127
189
|
// Passthrough: everything that isn't a known snippet override
|
|
128
190
|
const snippetKeySet = $derived(
|
|
129
|
-
new Set([
|
|
130
|
-
...rendererKeysInternal,
|
|
131
|
-
...Object.keys(rest).filter((k) => k.startsWith('html_'))
|
|
132
|
-
])
|
|
191
|
+
new Set([...allRendererKeys, ...Object.keys(rest).filter((k) => k.startsWith('html_'))])
|
|
133
192
|
)
|
|
134
193
|
const passThroughProps = $derived(
|
|
135
194
|
Object.fromEntries(Object.entries(rest).filter(([key]) => !snippetKeySet.has(key)))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension helpers for `@humanspeak/svelte-markdown`.
|
|
3
|
+
*
|
|
4
|
+
* Import from `@humanspeak/svelte-markdown/extensions`:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { markedMermaid, MermaidRenderer } from '@humanspeak/svelte-markdown/extensions'
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* @module @humanspeak/svelte-markdown/extensions
|
|
11
|
+
*/
|
|
12
|
+
export { MermaidRenderer, markedMermaid } from './mermaid/index.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension helpers for `@humanspeak/svelte-markdown`.
|
|
3
|
+
*
|
|
4
|
+
* Import from `@humanspeak/svelte-markdown/extensions`:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { markedMermaid, MermaidRenderer } from '@humanspeak/svelte-markdown/extensions'
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* @module @humanspeak/svelte-markdown/extensions
|
|
11
|
+
*/
|
|
12
|
+
export { MermaidRenderer, markedMermaid } from './mermaid/index.js';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
text: string
|
|
6
|
+
lightTheme?: string
|
|
7
|
+
darkTheme?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { text, lightTheme = 'default', darkTheme = 'dark' }: Props = $props()
|
|
11
|
+
|
|
12
|
+
let svg = $state('')
|
|
13
|
+
let error = $state('')
|
|
14
|
+
let loading = $state(true)
|
|
15
|
+
let mermaidModule: typeof import('mermaid').default | null = $state(null)
|
|
16
|
+
let renderCounter = 0
|
|
17
|
+
|
|
18
|
+
async function renderDiagram(diagramText: string) {
|
|
19
|
+
if (!mermaidModule) return
|
|
20
|
+
const current = ++renderCounter
|
|
21
|
+
try {
|
|
22
|
+
error = ''
|
|
23
|
+
const isDark = document.documentElement.classList.contains('dark')
|
|
24
|
+
const theme = isDark ? darkTheme : lightTheme
|
|
25
|
+
const themed = `%%{init: {'theme': '${theme}'}}%%\n${diagramText}`
|
|
26
|
+
const id = `mermaid-${crypto.randomUUID()}`
|
|
27
|
+
const result = await mermaidModule.render(id, themed)
|
|
28
|
+
if (current !== renderCounter) return
|
|
29
|
+
svg = result.svg
|
|
30
|
+
} catch (e) {
|
|
31
|
+
if (current !== renderCounter) return
|
|
32
|
+
error = e instanceof Error ? e.message : String(e)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
$effect(() => {
|
|
37
|
+
if (mermaidModule) {
|
|
38
|
+
renderDiagram(text)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
onMount(() => {
|
|
43
|
+
let observer: MutationObserver
|
|
44
|
+
;(async () => {
|
|
45
|
+
try {
|
|
46
|
+
const mod = (await import('mermaid')).default
|
|
47
|
+
mod.initialize({ startOnLoad: false, securityLevel: 'strict' })
|
|
48
|
+
mermaidModule = mod
|
|
49
|
+
} catch (e) {
|
|
50
|
+
error = e instanceof Error ? e.message : String(e)
|
|
51
|
+
} finally {
|
|
52
|
+
loading = false
|
|
53
|
+
}
|
|
54
|
+
})()
|
|
55
|
+
|
|
56
|
+
observer = new MutationObserver((mutations) => {
|
|
57
|
+
for (const mutation of mutations) {
|
|
58
|
+
if (mutation.attributeName === 'class') {
|
|
59
|
+
renderDiagram(text)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
65
|
+
|
|
66
|
+
return () => observer.disconnect()
|
|
67
|
+
})
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
{#if loading}
|
|
71
|
+
<div class="mermaid-loading" data-testid="mermaid-loading">Loading diagram...</div>
|
|
72
|
+
{:else if error}
|
|
73
|
+
<div class="mermaid-error" data-testid="mermaid-error">Diagram error: {error}</div>
|
|
74
|
+
{:else}
|
|
75
|
+
<div class="mermaid-diagram" data-testid="mermaid-diagram">
|
|
76
|
+
<!-- trunk-ignore(eslint/svelte/no-at-html-tags) -->
|
|
77
|
+
{@html svg}
|
|
78
|
+
</div>
|
|
79
|
+
{/if}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MarkedExtension } from 'marked';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a marked extension that tokenizes fenced ` ```mermaid ` code blocks
|
|
4
|
+
* into custom `mermaid` tokens.
|
|
5
|
+
*
|
|
6
|
+
* The extension produces block-level tokens with `{ type: 'mermaid', raw, text }`
|
|
7
|
+
* where `text` is the trimmed diagram source. It has zero runtime dependencies —
|
|
8
|
+
* pair it with `MermaidRenderer` (or your own component) to render the diagrams.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```svelte
|
|
12
|
+
* <script lang="ts">
|
|
13
|
+
* import SvelteMarkdown from '@humanspeak/svelte-markdown'
|
|
14
|
+
* import { markedMermaid, MermaidRenderer } from '@humanspeak/svelte-markdown'
|
|
15
|
+
*
|
|
16
|
+
* const renderers = { mermaid: MermaidRenderer }
|
|
17
|
+
* </script>
|
|
18
|
+
*
|
|
19
|
+
* <SvelteMarkdown
|
|
20
|
+
* source={markdown}
|
|
21
|
+
* extensions={[markedMermaid()]}
|
|
22
|
+
* {renderers}
|
|
23
|
+
* />
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @returns A `MarkedExtension` with a single block-level `mermaid` tokenizer
|
|
27
|
+
*/
|
|
28
|
+
export declare function markedMermaid(): MarkedExtension;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a marked extension that tokenizes fenced ` ```mermaid ` code blocks
|
|
3
|
+
* into custom `mermaid` tokens.
|
|
4
|
+
*
|
|
5
|
+
* The extension produces block-level tokens with `{ type: 'mermaid', raw, text }`
|
|
6
|
+
* where `text` is the trimmed diagram source. It has zero runtime dependencies —
|
|
7
|
+
* pair it with `MermaidRenderer` (or your own component) to render the diagrams.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```svelte
|
|
11
|
+
* <script lang="ts">
|
|
12
|
+
* import SvelteMarkdown from '@humanspeak/svelte-markdown'
|
|
13
|
+
* import { markedMermaid, MermaidRenderer } from '@humanspeak/svelte-markdown'
|
|
14
|
+
*
|
|
15
|
+
* const renderers = { mermaid: MermaidRenderer }
|
|
16
|
+
* </script>
|
|
17
|
+
*
|
|
18
|
+
* <SvelteMarkdown
|
|
19
|
+
* source={markdown}
|
|
20
|
+
* extensions={[markedMermaid()]}
|
|
21
|
+
* {renderers}
|
|
22
|
+
* />
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @returns A `MarkedExtension` with a single block-level `mermaid` tokenizer
|
|
26
|
+
*/
|
|
27
|
+
export function markedMermaid() {
|
|
28
|
+
return {
|
|
29
|
+
extensions: [
|
|
30
|
+
{
|
|
31
|
+
name: 'mermaid',
|
|
32
|
+
level: 'block',
|
|
33
|
+
start(src) {
|
|
34
|
+
return src.match(/```mermaid/)?.index;
|
|
35
|
+
},
|
|
36
|
+
tokenizer(src) {
|
|
37
|
+
const match = src.match(/^```mermaid\n([\s\S]*?)```/);
|
|
38
|
+
if (match) {
|
|
39
|
+
return {
|
|
40
|
+
type: 'mermaid',
|
|
41
|
+
raw: match[0],
|
|
42
|
+
text: match[1].trim()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -59,5 +59,7 @@ export { htmlRendererKeysInternal as htmlRendererKeys, rendererKeysInternal as r
|
|
|
59
59
|
*/
|
|
60
60
|
export { MemoryCache } from './utils/cache.js';
|
|
61
61
|
export { TokenCache, tokenCache } from './utils/token-cache.js';
|
|
62
|
+
/** Re-exported `MarkedExtension` type for the `extensions` prop. */
|
|
63
|
+
export type { MarkedExtension } from 'marked';
|
|
62
64
|
/** Re-exported types for consumer convenience. */
|
|
63
65
|
export type { BlockquoteSnippetProps, BrSnippetProps, CodeSnippetProps, CodespanSnippetProps, DelSnippetProps, EmSnippetProps, EscapeSnippetProps, HeadingSnippetProps, HrSnippetProps, HtmlRenderers, HtmlSnippetOverrides, HtmlSnippetProps, ImageSnippetProps, LinkSnippetProps, ListItemSnippetProps, ListSnippetProps, ParagraphSnippetProps, RawTextSnippetProps, RendererComponent, Renderers, SnippetOverrides, StrongSnippetProps, SvelteMarkdownOptions, SvelteMarkdownProps, TableBodySnippetProps, TableCellSnippetProps, TableHeadSnippetProps, TableRowSnippetProps, TableSnippetProps, TextSnippetProps, Token, TokensList };
|
package/dist/types.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* @packageDocumentation
|
|
19
19
|
*/
|
|
20
|
-
import type { Token, TokensList } from 'marked';
|
|
20
|
+
import type { MarkedExtension, Token, TokensList } from 'marked';
|
|
21
21
|
import type { Snippet } from 'svelte';
|
|
22
22
|
import type { MarkedOptions, Renderers } from './utils/markdown-parser.js';
|
|
23
23
|
import type { HtmlKey } from './utils/rendererKeys.js';
|
|
@@ -152,6 +152,29 @@ export type SvelteMarkdownProps<T extends Renderers = Renderers> = {
|
|
|
152
152
|
* generation settings. Merged with {@link defaultOptions}.
|
|
153
153
|
*/
|
|
154
154
|
options?: Partial<SvelteMarkdownOptions>;
|
|
155
|
+
/**
|
|
156
|
+
* Array of marked extensions to apply when parsing.
|
|
157
|
+
*
|
|
158
|
+
* Internally creates a scoped `Marked` instance, extracts its resolved
|
|
159
|
+
* defaults, and merges them into the parser options so the lexer
|
|
160
|
+
* recognises any custom token types the extensions define.
|
|
161
|
+
*
|
|
162
|
+
* Extension token names are also used to collect snippet overrides,
|
|
163
|
+
* enabling both component-renderer and snippet-based rendering of
|
|
164
|
+
* custom tokens.
|
|
165
|
+
*
|
|
166
|
+
* @defaultValue `[]`
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```svelte
|
|
170
|
+
* <SvelteMarkdown
|
|
171
|
+
* source={markdown}
|
|
172
|
+
* extensions={[markedKatex({ throwOnError: false })]}
|
|
173
|
+
* renderers={{ inlineKatex: KatexRenderer, blockKatex: KatexRenderer }}
|
|
174
|
+
* />
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
extensions?: MarkedExtension[];
|
|
155
178
|
/**
|
|
156
179
|
* When `true`, the source is parsed with `Lexer.lexInline()` instead of
|
|
157
180
|
* `Lexer.lex()`, producing only inline tokens (no block elements).
|
|
@@ -45,13 +45,15 @@ export const createFilterUtilities = (keys, unsupportedComponent, defaultsMap) =
|
|
|
45
45
|
const hasKey = (key) => keys.includes(key);
|
|
46
46
|
/**
|
|
47
47
|
* Builds a map where every key is set to the unsupported component.
|
|
48
|
-
*
|
|
48
|
+
* Keys whose default value is null are preserved as null so that
|
|
49
|
+
* fallback chains (e.g. orderedlistitem || listitem) still work.
|
|
49
50
|
*/
|
|
50
51
|
const buildUnsupported = () => {
|
|
51
52
|
const result = {};
|
|
52
53
|
for (const key of keys) {
|
|
53
54
|
;
|
|
54
|
-
result[key] =
|
|
55
|
+
result[key] =
|
|
56
|
+
defaultsMap[key] === null ? null : unsupportedComponent;
|
|
55
57
|
}
|
|
56
58
|
return result;
|
|
57
59
|
};
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { SvelteMarkdownOptions } from '../types.js';
|
|
10
10
|
import type { Token, TokensList } from './markdown-parser.js';
|
|
11
11
|
/**
|
|
12
|
-
* Parses markdown source with caching.
|
|
12
|
+
* Parses markdown source with caching (synchronous path).
|
|
13
13
|
* Checks cache first, parses on miss, stores result, and returns tokens.
|
|
14
14
|
*
|
|
15
15
|
* @param source - Raw markdown string to parse
|
|
@@ -29,3 +29,21 @@ import type { Token, TokensList } from './markdown-parser.js';
|
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
export declare function parseAndCacheTokens(source: string, options: SvelteMarkdownOptions, isInline: boolean): Token[] | TokensList;
|
|
32
|
+
/**
|
|
33
|
+
* Parses markdown source with caching (async path).
|
|
34
|
+
* Uses Marked's recursive walkTokens with Promise.all to properly
|
|
35
|
+
* handle async walkTokens callbacks (e.g. marked-code-format).
|
|
36
|
+
*
|
|
37
|
+
* @param source - Raw markdown string to parse
|
|
38
|
+
* @param options - Svelte markdown parser options
|
|
39
|
+
* @param isInline - Whether to parse as inline markdown (no block elements)
|
|
40
|
+
* @returns Promise resolving to cleaned and cached token array
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* import { parseAndCacheTokensAsync } from './parse-and-cache.js'
|
|
45
|
+
*
|
|
46
|
+
* const tokens = await parseAndCacheTokensAsync('# Hello', opts, false)
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseAndCacheTokensAsync(source: string, options: SvelteMarkdownOptions, isInline: boolean): Promise<Token[] | TokensList>;
|
|
@@ -8,9 +8,17 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { tokenCache } from './token-cache.js';
|
|
10
10
|
import { shrinkHtmlTokens } from './token-cleanup.js';
|
|
11
|
-
import { Lexer } from 'marked';
|
|
11
|
+
import { Lexer, Marked } from 'marked';
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* Lex and clean tokens from markdown source. Shared by sync and async paths.
|
|
14
|
+
*/
|
|
15
|
+
function lexAndClean(source, options, isInline) {
|
|
16
|
+
const lexer = new Lexer(options);
|
|
17
|
+
const parsedTokens = isInline ? lexer.inlineTokens(source) : lexer.lex(source);
|
|
18
|
+
return shrinkHtmlTokens(parsedTokens);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parses markdown source with caching (synchronous path).
|
|
14
22
|
* Checks cache first, parses on miss, stores result, and returns tokens.
|
|
15
23
|
*
|
|
16
24
|
* @param source - Raw markdown string to parse
|
|
@@ -36,9 +44,7 @@ export function parseAndCacheTokens(source, options, isInline) {
|
|
|
36
44
|
return cached;
|
|
37
45
|
}
|
|
38
46
|
// Cache miss - parse and store
|
|
39
|
-
const
|
|
40
|
-
const parsedTokens = isInline ? lexer.inlineTokens(source) : lexer.lex(source);
|
|
41
|
-
const cleanedTokens = shrinkHtmlTokens(parsedTokens);
|
|
47
|
+
const cleanedTokens = lexAndClean(source, options, isInline);
|
|
42
48
|
if (typeof options.walkTokens === 'function') {
|
|
43
49
|
cleanedTokens.forEach(options.walkTokens);
|
|
44
50
|
}
|
|
@@ -46,3 +52,41 @@ export function parseAndCacheTokens(source, options, isInline) {
|
|
|
46
52
|
tokenCache.setTokens(source, options, cleanedTokens);
|
|
47
53
|
return cleanedTokens;
|
|
48
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Parses markdown source with caching (async path).
|
|
57
|
+
* Uses Marked's recursive walkTokens with Promise.all to properly
|
|
58
|
+
* handle async walkTokens callbacks (e.g. marked-code-format).
|
|
59
|
+
*
|
|
60
|
+
* @param source - Raw markdown string to parse
|
|
61
|
+
* @param options - Svelte markdown parser options
|
|
62
|
+
* @param isInline - Whether to parse as inline markdown (no block elements)
|
|
63
|
+
* @returns Promise resolving to cleaned and cached token array
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* import { parseAndCacheTokensAsync } from './parse-and-cache.js'
|
|
68
|
+
*
|
|
69
|
+
* const tokens = await parseAndCacheTokensAsync('# Hello', opts, false)
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export async function parseAndCacheTokensAsync(source, options, isInline) {
|
|
73
|
+
// Check cache first - avoids expensive parsing
|
|
74
|
+
const cached = tokenCache.getTokens(source, options);
|
|
75
|
+
if (cached) {
|
|
76
|
+
return cached;
|
|
77
|
+
}
|
|
78
|
+
// Cache miss - parse and store
|
|
79
|
+
const cleanedTokens = lexAndClean(source, options, isInline);
|
|
80
|
+
if (typeof options.walkTokens === 'function') {
|
|
81
|
+
// Use Marked's recursive walkTokens which handles tables, lists,
|
|
82
|
+
// nested tokens, and extension childTokens. Await all returned
|
|
83
|
+
// promises so async walkTokens callbacks complete before caching.
|
|
84
|
+
const marked = new Marked();
|
|
85
|
+
marked.defaults = { ...marked.defaults, ...options };
|
|
86
|
+
const results = marked.walkTokens(cleanedTokens, options.walkTokens);
|
|
87
|
+
await Promise.all(results);
|
|
88
|
+
}
|
|
89
|
+
// Cache the cleaned tokens for next time
|
|
90
|
+
tokenCache.setTokens(source, options, cleanedTokens);
|
|
91
|
+
return cleanedTokens;
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-markdown",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Fast, customizable markdown renderer for Svelte with built-in caching, TypeScript support, and Svelte 5 runes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
".": {
|
|
48
48
|
"types": "./dist/index.d.ts",
|
|
49
49
|
"svelte": "./dist/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./extensions": {
|
|
52
|
+
"types": "./dist/extensions/index.d.ts",
|
|
53
|
+
"svelte": "./dist/extensions/index.js"
|
|
50
54
|
}
|
|
51
55
|
},
|
|
52
56
|
"svelte": "./dist/index.js",
|
|
@@ -74,18 +78,18 @@
|
|
|
74
78
|
"@playwright/cli": "^0.1.1",
|
|
75
79
|
"@playwright/test": "^1.58.2",
|
|
76
80
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
77
|
-
"@sveltejs/kit": "^2.
|
|
81
|
+
"@sveltejs/kit": "^2.53.3",
|
|
78
82
|
"@sveltejs/package": "^2.5.7",
|
|
79
83
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
80
84
|
"@testing-library/jest-dom": "^6.9.1",
|
|
81
85
|
"@testing-library/svelte": "^5.3.1",
|
|
82
86
|
"@testing-library/user-event": "^14.6.1",
|
|
83
|
-
"@types/
|
|
84
|
-
"@
|
|
85
|
-
"@typescript-eslint/
|
|
87
|
+
"@types/katex": "^0.16.8",
|
|
88
|
+
"@types/node": "^25.3.2",
|
|
89
|
+
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
|
90
|
+
"@typescript-eslint/parser": "^8.56.1",
|
|
86
91
|
"@vitest/coverage-v8": "^4.0.18",
|
|
87
|
-
"
|
|
88
|
-
"eslint": "^10.0.0",
|
|
92
|
+
"eslint": "^10.0.2",
|
|
89
93
|
"eslint-config-prettier": "^10.1.8",
|
|
90
94
|
"eslint-plugin-import": "^2.32.0",
|
|
91
95
|
"eslint-plugin-svelte": "^3.15.0",
|
|
@@ -93,23 +97,33 @@
|
|
|
93
97
|
"globals": "^17.3.0",
|
|
94
98
|
"husky": "^9.1.7",
|
|
95
99
|
"jsdom": "^28.1.0",
|
|
100
|
+
"katex": "^0.16.33",
|
|
101
|
+
"marked-katex-extension": "^5.1.7",
|
|
102
|
+
"mermaid": "^11.12.3",
|
|
103
|
+
"mprocs": "^0.8.3",
|
|
96
104
|
"prettier": "^3.8.1",
|
|
97
105
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
98
106
|
"prettier-plugin-svelte": "^3.5.0",
|
|
99
107
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
100
108
|
"publint": "^0.3.17",
|
|
101
|
-
"svelte": "^5.53.
|
|
102
|
-
"svelte-check": "^4.4.
|
|
109
|
+
"svelte": "^5.53.5",
|
|
110
|
+
"svelte-check": "^4.4.4",
|
|
103
111
|
"typescript": "^5.9.3",
|
|
104
|
-
"typescript-eslint": "^8.56.
|
|
112
|
+
"typescript-eslint": "^8.56.1",
|
|
105
113
|
"vite": "^7.3.1",
|
|
106
114
|
"vitest": "^4.0.18"
|
|
107
115
|
},
|
|
108
116
|
"peerDependencies": {
|
|
117
|
+
"mermaid": ">=10.0.0",
|
|
109
118
|
"svelte": "^5.0.0"
|
|
110
119
|
},
|
|
120
|
+
"peerDependenciesMeta": {
|
|
121
|
+
"mermaid": {
|
|
122
|
+
"optional": true
|
|
123
|
+
}
|
|
124
|
+
},
|
|
111
125
|
"volta": {
|
|
112
|
-
"node": "
|
|
126
|
+
"node": "22.16.0"
|
|
113
127
|
},
|
|
114
128
|
"publishConfig": {
|
|
115
129
|
"access": "public"
|
|
@@ -123,7 +137,7 @@
|
|
|
123
137
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
124
138
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
125
139
|
"dev": "vite dev",
|
|
126
|
-
"dev:all": "
|
|
140
|
+
"dev:all": "mprocs",
|
|
127
141
|
"dev:pkg": "svelte-kit sync && svelte-package --watch",
|
|
128
142
|
"format": "prettier --write .",
|
|
129
143
|
"lint": "prettier --check . && eslint .",
|