@humanspeak/svelte-markdown 1.0.2 → 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/README.md CHANGED
@@ -438,6 +438,53 @@ Where `KatexRenderer.svelte` is:
438
438
  </SvelteMarkdown>
439
439
  ```
440
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
+
441
488
  ### How It Works
442
489
 
443
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**.
@@ -58,7 +58,7 @@
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
63
  import { Marked } from 'marked'
64
64
 
@@ -87,7 +87,13 @@
87
87
  const combinedOptions = $derived({ ...defaultOptions, ...extensionDefaults, ...options })
88
88
  const slugger = new Slugger()
89
89
 
90
- const tokens = $derived.by(() => {
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
+
91
97
  // Pre-parsed tokens - skip caching and parsing
92
98
  if (Array.isArray(source)) {
93
99
  return source as Token[]
@@ -102,6 +108,47 @@
102
108
  return parseAndCacheTokens(source as string, combinedOptions, isInline)
103
109
  }) satisfies Token[] | TokensList | undefined
104
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
+
105
152
  $effect(() => {
106
153
  if (!tokens) return
107
154
  parsed(tokens)
@@ -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,8 @@
1
+ interface Props {
2
+ text: string;
3
+ lightTheme?: string;
4
+ darkTheme?: string;
5
+ }
6
+ declare const MermaidRenderer: import("svelte").Component<Props, {}, "">;
7
+ type MermaidRenderer = ReturnType<typeof MermaidRenderer>;
8
+ export default MermaidRenderer;
@@ -0,0 +1,2 @@
1
+ export { markedMermaid } from './markedMermaid.js';
2
+ export { default as MermaidRenderer } from './MermaidRenderer.svelte';
@@ -0,0 +1,2 @@
1
+ export { markedMermaid } from './markedMermaid.js';
2
+ export { default as MermaidRenderer } from './MermaidRenderer.svelte';
@@ -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
+ }
@@ -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
- * Useful for starting with a "deny all" approach.
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] = unsupportedComponent;
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
- * Parses markdown source with caching.
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 lexer = new Lexer(options);
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.2",
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,19 +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.52.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
87
  "@types/katex": "^0.16.8",
84
- "@types/node": "^25.2.3",
85
- "@typescript-eslint/eslint-plugin": "^8.56.0",
86
- "@typescript-eslint/parser": "^8.56.0",
88
+ "@types/node": "^25.3.2",
89
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
90
+ "@typescript-eslint/parser": "^8.56.1",
87
91
  "@vitest/coverage-v8": "^4.0.18",
88
- "concurrently": "^9.2.1",
89
- "eslint": "^10.0.0",
92
+ "eslint": "^10.0.2",
90
93
  "eslint-config-prettier": "^10.1.8",
91
94
  "eslint-plugin-import": "^2.32.0",
92
95
  "eslint-plugin-svelte": "^3.15.0",
@@ -94,25 +97,33 @@
94
97
  "globals": "^17.3.0",
95
98
  "husky": "^9.1.7",
96
99
  "jsdom": "^28.1.0",
97
- "katex": "^0.16.28",
100
+ "katex": "^0.16.33",
98
101
  "marked-katex-extension": "^5.1.7",
102
+ "mermaid": "^11.12.3",
103
+ "mprocs": "^0.8.3",
99
104
  "prettier": "^3.8.1",
100
105
  "prettier-plugin-organize-imports": "^4.3.0",
101
106
  "prettier-plugin-svelte": "^3.5.0",
102
107
  "prettier-plugin-tailwindcss": "^0.7.2",
103
108
  "publint": "^0.3.17",
104
- "svelte": "^5.53.0",
105
- "svelte-check": "^4.4.1",
109
+ "svelte": "^5.53.5",
110
+ "svelte-check": "^4.4.4",
106
111
  "typescript": "^5.9.3",
107
- "typescript-eslint": "^8.56.0",
112
+ "typescript-eslint": "^8.56.1",
108
113
  "vite": "^7.3.1",
109
114
  "vitest": "^4.0.18"
110
115
  },
111
116
  "peerDependencies": {
117
+ "mermaid": ">=10.0.0",
112
118
  "svelte": "^5.0.0"
113
119
  },
120
+ "peerDependenciesMeta": {
121
+ "mermaid": {
122
+ "optional": true
123
+ }
124
+ },
114
125
  "volta": {
115
- "node": "24.12.0"
126
+ "node": "22.16.0"
116
127
  },
117
128
  "publishConfig": {
118
129
  "access": "public"
@@ -126,7 +137,7 @@
126
137
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
127
138
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
128
139
  "dev": "vite dev",
129
- "dev:all": "concurrently -k -n pkg,docs -c green,cyan \"pnpm -w -r --filter @humanspeak/svelte-markdown run dev:pkg\" \"pnpm --filter docs run dev\"",
140
+ "dev:all": "mprocs",
130
141
  "dev:pkg": "svelte-kit sync && svelte-package --watch",
131
142
  "format": "prettier --write .",
132
143
  "lint": "prettier --check . && eslint .",