@humanspeak/svelte-markdown 1.0.4 → 1.0.5

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.
@@ -56,6 +56,9 @@
56
56
  RendererComponent
57
57
  } from './utils/markdown-parser.js'
58
58
 
59
+ // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
60
+ type AnySnippet = (..._args: any[]) => any
61
+
59
62
  interface Props<T extends Renderers = Renderers> {
60
63
  type?: string
61
64
  tokens?: Token[] | TokensList
@@ -63,8 +66,8 @@
63
66
  rows?: Tokens.TableCell[][]
64
67
  ordered?: boolean
65
68
  renderers: T
66
- snippetOverrides?: Record<string, any> // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
67
- htmlSnippetOverrides?: Record<string, any> // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
69
+ snippetOverrides?: Record<string, AnySnippet>
70
+ htmlSnippetOverrides?: Record<string, AnySnippet>
68
71
  }
69
72
 
70
73
  const {
@@ -121,14 +124,14 @@
121
124
  {#if cellSnippet}
122
125
  {@render cellSnippet({
123
126
  header: true,
124
- align: (rest.align as string[])[i],
127
+ align: (rest.align as string[] | undefined)?.[i] ?? null,
125
128
  ...cellRest,
126
129
  children: headerCellContent
127
130
  })}
128
131
  {:else}
129
132
  <renderers.tablecell
130
133
  header={true}
131
- align={(rest.align as string[])[i]}
134
+ align={(rest.align as string[] | undefined)?.[i] ?? null}
132
135
  {...cellRest}
133
136
  >
134
137
  {@render headerCellContent()}
@@ -172,7 +175,8 @@
172
175
  {#if cellSnippet}
173
176
  {@render cellSnippet({
174
177
  header: false,
175
- align: (rest.align as string[])[j],
178
+ align:
179
+ (rest.align as string[] | undefined)?.[j] ?? null,
176
180
  ...cellRest,
177
181
  children: bodyCellContent
178
182
  })}
@@ -180,7 +184,8 @@
180
184
  <renderers.tablecell
181
185
  {...cellRest}
182
186
  header={false}
183
- align={(rest.align as string[])[j]}
187
+ align={(rest.align as string[] | undefined)?.[j] ??
188
+ null}
184
189
  >
185
190
  {@render bodyCellContent()}
186
191
  </renderers.tablecell>
@@ -46,6 +46,7 @@
46
46
  */
47
47
  import Parser from './Parser.svelte';
48
48
  import type { Renderers, Token, TokensList, Tokens } from './utils/markdown-parser.js';
49
+ type AnySnippet = (..._args: any[]) => any;
49
50
  interface Props<T extends Renderers = Renderers> {
50
51
  type?: string;
51
52
  tokens?: Token[] | TokensList;
@@ -53,8 +54,8 @@ interface Props<T extends Renderers = Renderers> {
53
54
  rows?: Tokens.TableCell[][];
54
55
  ordered?: boolean;
55
56
  renderers: T;
56
- snippetOverrides?: Record<string, any>;
57
- htmlSnippetOverrides?: Record<string, any>;
57
+ snippetOverrides?: Record<string, AnySnippet>;
58
+ htmlSnippetOverrides?: Record<string, AnySnippet>;
58
59
  }
59
60
  type $$ComponentProps = Props & {
60
61
  [key: string]: unknown;
@@ -62,6 +62,9 @@
62
62
  import { rendererKeysInternal } from './utils/rendererKeys.js'
63
63
  import { Marked } from 'marked'
64
64
 
65
+ // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
66
+ type AnySnippet = (..._args: any[]) => any
67
+
65
68
  const {
66
69
  source = [],
67
70
  renderers = {},
@@ -174,7 +177,7 @@
174
177
  allRendererKeys
175
178
  .filter((key) => key in rest && rest[key] != null)
176
179
  .map((key) => [key, rest[key]])
177
- )
180
+ ) as Record<string, AnySnippet>
178
181
  )
179
182
 
180
183
  // Collect HTML snippet overrides (keys matching html_<tag>)
@@ -183,7 +186,7 @@
183
186
  Object.entries(rest)
184
187
  .filter(([key, val]) => key.startsWith('html_') && val != null)
185
188
  .map(([key, val]) => [key.slice(5), val])
186
- )
189
+ ) as Record<string, AnySnippet>
187
190
  )
188
191
 
189
192
  // Passthrough: everything that isn't a known snippet override
@@ -199,7 +202,7 @@
199
202
  {tokens}
200
203
  {...passThroughProps}
201
204
  options={combinedOptions}
202
- slug={(val: string): string => (slugger ? slugger.slug(val) : '')}
205
+ slug={(val: string): string => slugger.slug(val)}
203
206
  renderers={combinedRenderers}
204
207
  {snippetOverrides}
205
208
  {htmlSnippetOverrides}
@@ -4,7 +4,7 @@ Renders a table cell as either `<th>` (header) or `<td>` (data), with optional
4
4
  text alignment applied as an inline `text-align` style.
5
5
 
6
6
  @prop {boolean} header - When `true`, renders a `<th>`; otherwise renders a `<td>`.
7
- @prop {'left'|'center'|'right'|'justify'|'char'|null|undefined} align - Column alignment.
7
+ @prop {'left'|'center'|'right'|null} align - Column alignment.
8
8
  @prop {Snippet} [children] - Cell content.
9
9
  -->
10
10
  <script lang="ts">
@@ -12,7 +12,7 @@ text alignment applied as an inline `text-align` style.
12
12
 
13
13
  interface Props {
14
14
  header: boolean
15
- align: 'left' | 'center' | 'right' | 'justify' | 'char' | null | undefined
15
+ align: 'left' | 'center' | 'right' | null
16
16
  children?: Snippet
17
17
  }
18
18
 
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  interface Props {
3
3
  header: boolean;
4
- align: 'left' | 'center' | 'right' | 'justify' | 'char' | null | undefined;
4
+ align: 'left' | 'center' | 'right' | null;
5
5
  children?: Snippet;
6
6
  }
7
7
  /**
@@ -9,7 +9,7 @@ interface Props {
9
9
  * text alignment applied as an inline `text-align` style.
10
10
  *
11
11
  * @prop {boolean} header - When `true`, renders a `<th>`; otherwise renders a `<td>`.
12
- * @prop {'left'|'center'|'right'|'justify'|'char'|null|undefined} align - Column alignment.
12
+ * @prop {'left'|'center'|'right'|null} align - Column alignment.
13
13
  * @prop {Snippet} [children] - Cell content.
14
14
  */
15
15
  declare const TableCell: import("svelte").Component<Props, {}, "">;
package/dist/types.d.ts CHANGED
@@ -75,7 +75,7 @@ export interface TableRowSnippetProps {
75
75
  }
76
76
  export interface TableCellSnippetProps {
77
77
  header: boolean;
78
- align: 'left' | 'center' | 'right' | 'justify' | 'char' | null | undefined;
78
+ align: 'left' | 'center' | 'right' | null;
79
79
  children?: Snippet;
80
80
  }
81
81
  export interface EmSnippetProps {
@@ -124,8 +124,16 @@ export type SnippetOverrides = {
124
124
  rawtext?: Snippet<[RawTextSnippetProps]>;
125
125
  escape?: Snippet<[EscapeSnippetProps]>;
126
126
  };
127
+ /**
128
+ * Props passed to HTML snippet overrides.
129
+ *
130
+ * **Security note:** `attributes` are spread directly onto the rendered HTML element.
131
+ * This includes any attribute from the source markdown, such as `onclick` or `onerror`.
132
+ * If rendering untrusted markdown, use `allowHtmlOnly`/`excludeHtmlOnly` to restrict
133
+ * allowed tags, or integrate your own sanitizer to strip dangerous attributes.
134
+ */
127
135
  export interface HtmlSnippetProps {
128
- attributes?: Record<string, any>;
136
+ attributes?: Record<string, string | number | boolean | undefined>;
129
137
  children?: Snippet;
130
138
  }
131
139
  export type HtmlSnippetOverrides = {
@@ -28,7 +28,7 @@ import type { Token, TokensList } from './markdown-parser.js';
28
28
  * const cachedTokens = parseAndCacheTokens('# Hello World', { gfm: true }, false)
29
29
  * ```
30
30
  */
31
- export declare function parseAndCacheTokens(source: string, options: SvelteMarkdownOptions, isInline: boolean): Token[] | TokensList;
31
+ export declare const parseAndCacheTokens: (source: string, options: SvelteMarkdownOptions, isInline: boolean) => Token[] | TokensList;
32
32
  /**
33
33
  * Parses markdown source with caching (async path).
34
34
  * Uses Marked's recursive walkTokens with Promise.all to properly
@@ -46,4 +46,4 @@ export declare function parseAndCacheTokens(source: string, options: SvelteMarkd
46
46
  * const tokens = await parseAndCacheTokensAsync('# Hello', opts, false)
47
47
  * ```
48
48
  */
49
- export declare function parseAndCacheTokensAsync(source: string, options: SvelteMarkdownOptions, isInline: boolean): Promise<Token[] | TokensList>;
49
+ export declare const parseAndCacheTokensAsync: (source: string, options: SvelteMarkdownOptions, isInline: boolean) => Promise<Token[] | TokensList>;
@@ -10,13 +10,20 @@ import { tokenCache } from './token-cache.js';
10
10
  import { shrinkHtmlTokens } from './token-cleanup.js';
11
11
  import { Lexer, Marked } from 'marked';
12
12
  /**
13
- * Lex and clean tokens from markdown source. Shared by sync and async paths.
13
+ * Lexes markdown source and cleans the resulting tokens. Shared by sync and async paths.
14
+ *
15
+ * @param source - Raw markdown string to lex
16
+ * @param options - Parser options forwarded to the Marked lexer
17
+ * @param isInline - When true, uses inline tokenization (no block elements)
18
+ * @returns Cleaned token array with HTML tokens properly nested
19
+ *
20
+ * @internal
14
21
  */
15
- function lexAndClean(source, options, isInline) {
22
+ const lexAndClean = (source, options, isInline) => {
16
23
  const lexer = new Lexer(options);
17
24
  const parsedTokens = isInline ? lexer.inlineTokens(source) : lexer.lex(source);
18
25
  return shrinkHtmlTokens(parsedTokens);
19
- }
26
+ };
20
27
  /**
21
28
  * Parses markdown source with caching (synchronous path).
22
29
  * Checks cache first, parses on miss, stores result, and returns tokens.
@@ -37,7 +44,7 @@ function lexAndClean(source, options, isInline) {
37
44
  * const cachedTokens = parseAndCacheTokens('# Hello World', { gfm: true }, false)
38
45
  * ```
39
46
  */
40
- export function parseAndCacheTokens(source, options, isInline) {
47
+ export const parseAndCacheTokens = (source, options, isInline) => {
41
48
  // Check cache first - avoids expensive parsing
42
49
  const cached = tokenCache.getTokens(source, options);
43
50
  if (cached) {
@@ -51,7 +58,7 @@ export function parseAndCacheTokens(source, options, isInline) {
51
58
  // Cache the cleaned tokens for next time
52
59
  tokenCache.setTokens(source, options, cleanedTokens);
53
60
  return cleanedTokens;
54
- }
61
+ };
55
62
  /**
56
63
  * Parses markdown source with caching (async path).
57
64
  * Uses Marked's recursive walkTokens with Promise.all to properly
@@ -69,7 +76,7 @@ export function parseAndCacheTokens(source, options, isInline) {
69
76
  * const tokens = await parseAndCacheTokensAsync('# Hello', opts, false)
70
77
  * ```
71
78
  */
72
- export async function parseAndCacheTokensAsync(source, options, isInline) {
79
+ export const parseAndCacheTokensAsync = async (source, options, isInline) => {
73
80
  // Check cache first - avoids expensive parsing
74
81
  const cached = tokenCache.getTokens(source, options);
75
82
  if (cached) {
@@ -89,4 +96,4 @@ export async function parseAndCacheTokensAsync(source, options, isInline) {
89
96
  // Cache the cleaned tokens for next time
90
97
  tokenCache.setTokens(source, options, cleanedTokens);
91
98
  return cleanedTokens;
92
- }
99
+ };
@@ -35,7 +35,7 @@ import type { Token, TokensList } from './markdown-parser.js';
35
35
  * console.log(hash1 !== hash2) // true - different content = different hash
36
36
  * ```
37
37
  */
38
- declare function hashString(str: string): string;
38
+ declare const hashString: (str: string) => string;
39
39
  /**
40
40
  * Specialized cache for markdown token storage.
41
41
  * Extends MemoryCache with markdown-specific convenience methods.
@@ -33,7 +33,7 @@ import { MemoryCache } from './cache.js';
33
33
  * console.log(hash1 !== hash2) // true - different content = different hash
34
34
  * ```
35
35
  */
36
- function hashString(str) {
36
+ const hashString = (str) => {
37
37
  let hash = 2166136261; // FNV offset basis (32-bit)
38
38
  for (let i = 0; i < str.length; i++) {
39
39
  hash ^= str.charCodeAt(i);
@@ -42,7 +42,7 @@ function hashString(str) {
42
42
  }
43
43
  // Convert to unsigned 32-bit integer and base36 string
44
44
  return (hash >>> 0).toString(36);
45
- }
45
+ };
46
46
  /**
47
47
  * Generates a cache key from markdown source and parser options.
48
48
  * Combines hashes of both source content and options to ensure
@@ -71,7 +71,7 @@ function hashString(str) {
71
71
  // reactivity system creates new objects on prop changes ($derived), so
72
72
  // this is safe for all internal usage paths.
73
73
  const optionsHashCache = new WeakMap();
74
- function getCacheKey(source, options) {
74
+ const getCacheKey = (source, options) => {
75
75
  const sourceHash = hashString(source);
76
76
  let optionsHash = optionsHashCache.get(options);
77
77
  if (!optionsHash) {
@@ -90,7 +90,7 @@ function getCacheKey(source, options) {
90
90
  optionsHashCache.set(options, optionsHash);
91
91
  }
92
92
  return `${sourceHash}:${optionsHash}`;
93
- }
93
+ };
94
94
  /**
95
95
  * Specialized cache for markdown token storage.
96
96
  * Extends MemoryCache with markdown-specific convenience methods.
@@ -32,51 +32,6 @@ export declare const isHtmlOpenTag: (raw: string) => {
32
32
  * @internal
33
33
  */
34
34
  export declare const extractAttributes: (raw: string) => Record<string, string>;
35
- /**
36
- * Converts an HTML string into a sequence of tokens using htmlparser2.
37
- * Handles complex nested structures while maintaining proper order and relationships.
38
- *
39
- * Key features:
40
- * - Preserves original HTML structure without automatic tag closing
41
- * - Handles self-closing tags with proper XML syntax (e.g., <br/> instead of <br>)
42
- * - Gracefully handles malformed HTML by preserving the original structure
43
- * - Maintains attribute information in opening tags
44
- * - Processes text content between tags
45
- *
46
- * @param {string} html - HTML string to be parsed
47
- * @returns {Token[]} Array of tokens representing the HTML structure
48
- *
49
- * @example
50
- * // Well-formed HTML
51
- * parseHtmlBlock('<div>Hello <span>world</span></div>')
52
- * // Returns [
53
- * // { type: 'html', raw: '<div>', ... },
54
- * // { type: 'text', raw: 'Hello ', ... },
55
- * // { type: 'html', raw: '<span>', ... },
56
- * // { type: 'text', raw: 'world', ... },
57
- * // { type: 'html', raw: '</span>', ... },
58
- * // { type: 'html', raw: '</div>', ... }
59
- * // ]
60
- *
61
- * // Self-closing tags
62
- * parseHtmlBlock('<div>Before<br/>After</div>')
63
- * // Returns [
64
- * // { type: 'html', raw: '<div>', ... },
65
- * // { type: 'text', raw: 'Before', ... },
66
- * // { type: 'html', raw: '<br/>', ... },
67
- * // { type: 'text', raw: 'After', ... },
68
- * // { type: 'html', raw: '</div>', ... }
69
- * // ]
70
- *
71
- * // Malformed HTML
72
- * parseHtmlBlock('<div>Unclosed')
73
- * // Returns [
74
- * // { type: 'html', raw: '<div>', ... },
75
- * // { type: 'text', raw: 'Unclosed', ... }
76
- * // ]
77
- *
78
- * @internal
79
- */
80
35
  export declare const parseHtmlBlock: (html: string) => Token[];
81
36
  export declare const containsMultipleTags: (html: string) => boolean;
82
37
  /**
@@ -141,6 +141,22 @@ export const extractAttributes = (raw) => {
141
141
  *
142
142
  * @internal
143
143
  */
144
+ /**
145
+ * Serializes an HTML attribute map into a string for tag construction.
146
+ * Escapes double quotes in values to prevent attribute injection.
147
+ *
148
+ * @param {Record<string, string>} attributes - Map of attribute names to values
149
+ * @returns {string} Serialized attributes string with leading spaces
150
+ *
151
+ * @example
152
+ * serializeAttributes({ class: 'foo', id: 'bar' })
153
+ * // Returns ' class="foo" id="bar"'
154
+ *
155
+ * @internal
156
+ */
157
+ const serializeAttributes = (attributes) => Object.entries(attributes)
158
+ .map(([key, value]) => ` ${key}="${value.replace(/"/g, '&quot;')}"`)
159
+ .join('');
144
160
  export const parseHtmlBlock = (html) => {
145
161
  const tokens = [];
146
162
  let currentText = '';
@@ -158,9 +174,7 @@ export const parseHtmlBlock = (html) => {
158
174
  if (SELF_CLOSING_TAGS.test(name)) {
159
175
  tokens.push({
160
176
  type: 'html',
161
- raw: `<${name}${Object.entries(attributes)
162
- .map(([key, value]) => ` ${key}="${value}"`)
163
- .join('')}/>`,
177
+ raw: `<${name}${serializeAttributes(attributes)}/>`,
164
178
  tag: name,
165
179
  attributes
166
180
  });
@@ -169,9 +183,7 @@ export const parseHtmlBlock = (html) => {
169
183
  openTags.push(name);
170
184
  tokens.push({
171
185
  type: 'html',
172
- raw: `<${name}${Object.entries(attributes)
173
- .map(([key, value]) => ` ${key}="${value}"`)
174
- .join('')}>`,
186
+ raw: `<${name}${serializeAttributes(attributes)}>`,
175
187
  tag: name,
176
188
  attributes
177
189
  });
@@ -203,8 +215,7 @@ export const parseHtmlBlock = (html) => {
203
215
  }
204
216
  }
205
217
  }, {
206
- xmlMode: true,
207
- // Add this to prevent automatic tag closing
218
+ xmlMode: false,
208
219
  recognizeSelfClosing: true
209
220
  });
210
221
  parser.write(html);
@@ -290,19 +301,16 @@ export const shrinkHtmlTokens = (tokens) => {
290
301
  }
291
302
  else if (token.type === 'table') {
292
303
  // Process header cells
293
- if (token.header) {
294
- // @ts-expect-error: expected any
295
- token.header = token.header.map((cell) => ({
304
+ const tableToken = token;
305
+ if (tableToken.header) {
306
+ tableToken.header = tableToken.header.map((cell) => ({
296
307
  ...cell,
297
308
  tokens: cell.tokens ? shrinkHtmlTokens(cell.tokens) : []
298
309
  }));
299
310
  }
300
311
  // Process row cells
301
- if (token.rows) {
302
- // @ts-expect-error: expected any
303
- token.rows = token.rows.map((row) =>
304
- // @ts-expect-error: expected any
305
- row.map((cell) => ({
312
+ if (tableToken.rows) {
313
+ tableToken.rows = tableToken.rows.map((row) => row.map((cell) => ({
306
314
  ...cell,
307
315
  tokens: cell.tokens ? shrinkHtmlTokens(cell.tokens) : []
308
316
  })));
@@ -394,9 +402,9 @@ export const processHtmlTokens = (tokens) => {
394
402
  result.push(token);
395
403
  }
396
404
  }
397
- // If we have unclosed tags, return original tokens
405
+ // If we have unclosed tags, return partial result (better than discarding all work)
398
406
  if (stack.length > 0) {
399
- return tokens;
407
+ return result;
400
408
  }
401
409
  return result;
402
410
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-markdown",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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",
@@ -86,8 +86,8 @@
86
86
  "@testing-library/user-event": "^14.6.1",
87
87
  "@types/katex": "^0.16.8",
88
88
  "@types/node": "^25.5.0",
89
- "@typescript-eslint/eslint-plugin": "^8.57.0",
90
- "@typescript-eslint/parser": "^8.57.0",
89
+ "@typescript-eslint/eslint-plugin": "^8.57.1",
90
+ "@typescript-eslint/parser": "^8.57.1",
91
91
  "@vitest/coverage-v8": "^4.1.0",
92
92
  "eslint": "^10.0.3",
93
93
  "eslint-config-prettier": "^10.1.8",
@@ -96,7 +96,7 @@
96
96
  "eslint-plugin-unused-imports": "^4.4.1",
97
97
  "globals": "^17.4.0",
98
98
  "husky": "^9.1.7",
99
- "jsdom": "^28.1.0",
99
+ "jsdom": "^29.0.0",
100
100
  "katex": "^0.16.38",
101
101
  "marked-katex-extension": "^5.1.7",
102
102
  "mermaid": "^11.13.0",
@@ -106,10 +106,10 @@
106
106
  "prettier-plugin-svelte": "^3.5.1",
107
107
  "prettier-plugin-tailwindcss": "^0.7.2",
108
108
  "publint": "^0.3.18",
109
- "svelte": "^5.53.11",
109
+ "svelte": "^5.53.13",
110
110
  "svelte-check": "^4.4.5",
111
111
  "typescript": "^5.9.3",
112
- "typescript-eslint": "^8.57.0",
112
+ "typescript-eslint": "^8.57.1",
113
113
  "vite": "^8.0.0",
114
114
  "vitest": "^4.1.0"
115
115
  },