astro-mermaid 1.3.1 → 2.0.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/README.md CHANGED
@@ -100,17 +100,21 @@ export default defineConfig({
100
100
  mermaid({
101
101
  // Default theme: 'default', 'dark', 'forest', 'neutral', 'base'
102
102
  theme: 'forest',
103
-
103
+
104
104
  // Enable automatic theme switching based on data-theme attribute
105
105
  autoTheme: true,
106
-
106
+
107
+ // Enable client-side logging (default: true). Set to false to suppress
108
+ // console.log output in the browser. Errors are always logged.
109
+ enableLog: false,
110
+
107
111
  // Additional mermaid configuration
108
112
  mermaidConfig: {
109
113
  flowchart: {
110
114
  curve: 'basis'
111
115
  }
112
116
  },
113
-
117
+
114
118
  // Register icon packs for use in diagrams
115
119
  iconPacks: [
116
120
  {
@@ -218,8 +222,6 @@ All mermaid diagram types are supported:
218
222
 
219
223
  ## Version
220
224
 
221
- **Current:** `v1.2.0` - Enhanced universal compatibility with dual plugin system
222
-
223
225
  See [changelog](https://github.com/joesaby/astro-mermaid/releases) for version history.
224
226
 
225
227
  ## Contributing
@@ -5,11 +5,19 @@ export interface IconPack {
5
5
  * Name of the icon pack
6
6
  */
7
7
  name: string;
8
-
8
+
9
+ /**
10
+ * URL to the icon pack JSON file (preferred, safe serialization).
11
+ * @example 'https://unpkg.com/@iconify-json/logos@1/icons.json'
12
+ */
13
+ url?: string;
14
+
9
15
  /**
10
- * Function that returns a promise resolving to the icon pack data
16
+ * Legacy: loader function whose source is inspected for a fetch() URL.
17
+ * Prefer using the `url` property instead for safer serialization.
18
+ * @deprecated Use `url` instead.
11
19
  */
12
- loader: () => Promise<any>;
20
+ loader?: () => Promise<any>;
13
21
  }
14
22
 
15
23
  export interface AstroMermaidOptions {
@@ -24,7 +32,13 @@ export interface AstroMermaidOptions {
24
32
  * @default true
25
33
  */
26
34
  autoTheme?: boolean;
27
-
35
+
36
+ /**
37
+ * Enable client-side logging
38
+ * @default true
39
+ */
40
+ enableLog?: boolean;
41
+
28
42
  /**
29
43
  * Additional mermaid configuration options
30
44
  * @see https://mermaid.js.org/config/setup/modules/mermaidAPI.html#mermaidapi-configuration-defaults
@@ -15,6 +15,36 @@ function escapeHtml(text) {
15
15
  return text.replace(/[&<>"']/g, char => htmlEntities[char]);
16
16
  }
17
17
 
18
+ /**
19
+ * Sanitize a JSON string for safe embedding inside a <script> tag.
20
+ * Prevents premature script termination via </script> or <!-- sequences.
21
+ */
22
+ function sanitizeJsonForScript(jsonStr) {
23
+ return jsonStr
24
+ .replace(/<\//g, '<\\/')
25
+ .replace(/<!--/g, '<\\!--');
26
+ }
27
+
28
+ /**
29
+ * Validate that mermaidConfig is a plain object (not an array, null, etc.)
30
+ * and does not contain __proto__ or constructor keys to prevent prototype pollution.
31
+ */
32
+ function validateConfig(obj, path = 'mermaidConfig') {
33
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
34
+ return;
35
+ }
36
+ const dangerous = ['__proto__', 'constructor', 'prototype'];
37
+ // Use getOwnPropertyNames to catch __proto__ which Object.keys skips
38
+ for (const key of Object.getOwnPropertyNames(obj)) {
39
+ if (dangerous.includes(key)) {
40
+ throw new Error(`astro-mermaid: "${key}" is not allowed in ${path}`);
41
+ }
42
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
43
+ validateConfig(obj[key], `${path}.${key}`);
44
+ }
45
+ }
46
+ }
47
+
18
48
  /**
19
49
  * Remark plugin to transform mermaid code blocks at the markdown level
20
50
  */
@@ -51,6 +81,27 @@ function remarkMermaidPlugin(options = {}) {
51
81
  };
52
82
  }
53
83
 
84
+ /**
85
+ * Escape a string for safe use inside an HTML attribute value.
86
+ */
87
+ function escapeAttribute(value) {
88
+ return String(value).replace(/[&<>"']/g, char => ({
89
+ '&': '&amp;',
90
+ '<': '&lt;',
91
+ '>': '&gt;',
92
+ '"': '&quot;',
93
+ "'": '&#39;'
94
+ })[char]);
95
+ }
96
+
97
+ /**
98
+ * Allowlist of safe HTML tag names that may appear inside mermaid HAST content.
99
+ */
100
+ const ALLOWED_TAG_NAMES = new Set([
101
+ 'b', 'i', 'u', 'em', 'strong', 'br', 'hr', 'sub', 'sup', 'span', 'div',
102
+ 'code', 'pre', 'img', 'a', 'p', 'ul', 'ol', 'li'
103
+ ]);
104
+
54
105
  /**
55
106
  * Helper function to serialize HAST nodes back to HTML text
56
107
  * This preserves HTML tags within the mermaid content
@@ -62,19 +113,26 @@ function serializeHastChildren(children) {
62
113
  if (child.type === 'text') {
63
114
  result += child.value;
64
115
  } else if (child.type === 'element') {
65
- // Reconstruct the HTML tag
116
+ // Reconstruct the HTML tag — only allow safe tag names
66
117
  const tagName = child.tagName;
118
+ if (!ALLOWED_TAG_NAMES.has(tagName)) {
119
+ // Skip disallowed tags, but still serialize their text children
120
+ if (child.children && child.children.length > 0) {
121
+ result += serializeHastChildren(child.children);
122
+ }
123
+ continue;
124
+ }
67
125
  const selfClosing = ['br', 'hr', 'img', 'input', 'meta', 'link'].includes(tagName);
68
126
 
69
127
  result += `<${tagName}`;
70
128
 
71
- // Add attributes if any
129
+ // Add attributes if any — escape all attribute values
72
130
  if (child.properties) {
73
131
  for (const [key, value] of Object.entries(child.properties)) {
74
132
  if (key !== 'className') {
75
- result += ` ${key}="${value}"`;
133
+ result += ` ${key}="${escapeAttribute(value)}"`;
76
134
  } else if (Array.isArray(value)) {
77
- result += ` class="${value.join(' ')}"`;
135
+ result += ` class="${escapeAttribute(value.join(' '))}"`;
78
136
  }
79
137
  }
80
138
  }
@@ -164,6 +222,7 @@ async function isElkInstalled(logger, consumerRoot) {
164
222
  * @param {string} [options.theme='default'] - Default theme ('default', 'dark', 'forest', 'neutral')
165
223
  * @param {boolean} [options.autoTheme=true] - Enable automatic theme switching based on data-theme attribute
166
224
  * @param {Object} [options.mermaidConfig={}] - Additional mermaid configuration options
225
+ * @param {boolean} [options.enableLog=true] - Enable client-side logging
167
226
  * @returns {import('astro').AstroIntegration}
168
227
  */
169
228
  export default function astroMermaid(options = {}) {
@@ -171,9 +230,13 @@ export default function astroMermaid(options = {}) {
171
230
  theme = 'default',
172
231
  autoTheme = true,
173
232
  mermaidConfig = {},
174
- iconPacks = []
233
+ iconPacks = [],
234
+ enableLog = true
175
235
  } = options;
176
236
 
237
+ // Validate mermaidConfig to prevent prototype pollution
238
+ validateConfig(mermaidConfig);
239
+
177
240
  return {
178
241
  name: 'astro-mermaid',
179
242
  hooks: {
@@ -211,14 +274,44 @@ export default function astroMermaid(options = {}) {
211
274
  }
212
275
  });
213
276
 
214
- // Serialize icon packs for client-side use
215
- const iconPacksConfig = iconPacks.map(pack => ({
216
- name: pack.name,
217
- loader: pack.loader.toString()
218
- }));
277
+ // Validate and serialize icon packs for client-side use.
278
+ // Only the pack name and a JSON URL string are forwarded to the
279
+ // client — we never serialize arbitrary function bodies.
280
+ const iconPacksConfig = iconPacks.map(pack => {
281
+ if (typeof pack.name !== 'string' || !pack.name) {
282
+ throw new Error('astro-mermaid: each iconPack must have a non-empty "name" string');
283
+ }
284
+ if (typeof pack.url === 'string') {
285
+ // Preferred: explicit URL
286
+ return { name: pack.name, url: pack.url };
287
+ }
288
+ if (typeof pack.loader === 'function') {
289
+ // Legacy: extract URL from loader().toString() if it contains a
290
+ // fetch('...') call, otherwise warn and skip.
291
+ const src = pack.loader.toString();
292
+ const urlMatch = src.match(/fetch\s*\(\s*['"]([^'"]+)['"]\s*\)/);
293
+ if (urlMatch) {
294
+ return { name: pack.name, url: urlMatch[1] };
295
+ }
296
+ logger.warn(
297
+ `astro-mermaid: iconPack "${pack.name}" uses a loader function ` +
298
+ `that could not be safely serialized. Please provide a "url" ` +
299
+ `property instead. This pack will be skipped.`
300
+ );
301
+ return null;
302
+ }
303
+ throw new Error(
304
+ `astro-mermaid: iconPack "${pack.name}" must have a "url" string ` +
305
+ `or a "loader" function`
306
+ );
307
+ }).filter(Boolean);
219
308
 
220
309
  // Inject client-side mermaid script with conditional loading
221
310
  const mermaidScriptContent = `
311
+ // Logging helpers — controlled by enableLog option
312
+ const log = ${enableLog} ? (...args) => console.log('[astro-mermaid]', ...args) : () => {};
313
+ const logError = (...args) => console.error('[astro-mermaid]', ...args);
314
+
222
315
  // Check if page has mermaid diagrams
223
316
  const hasMermaidDiagrams = () => {
224
317
  return document.querySelectorAll('pre.mermaid').length > 0;
@@ -231,16 +324,16 @@ let mermaidInstance = null;
231
324
  async function loadMermaid() {
232
325
  if (mermaidPromise) return mermaidPromise;
233
326
 
234
- console.log('[astro-mermaid] Loading mermaid.js...');
327
+ log('Loading mermaid.js...');
235
328
 
236
329
  mermaidPromise = import('mermaid').then(async ({ default: mermaid }) => {
237
- // Register icon packs if provided
238
- const iconPacks = ${JSON.stringify(iconPacksConfig)};
330
+ // Register icon packs if provided — uses safe fetch(url) instead of eval
331
+ const iconPacks = ${sanitizeJsonForScript(JSON.stringify(iconPacksConfig))};
239
332
  if (iconPacks && iconPacks.length > 0) {
240
- console.log('[astro-mermaid] Registering', iconPacks.length, 'icon packs');
333
+ log('Registering', iconPacks.length, 'icon packs');
241
334
  const packs = iconPacks.map(pack => ({
242
335
  name: pack.name,
243
- loader: new Function('return ' + pack.loader)()
336
+ loader: () => fetch(pack.url).then(res => res.json())
244
337
  }));
245
338
  await mermaid.registerIconPacks(packs);
246
339
  }
@@ -249,7 +342,7 @@ async function loadMermaid() {
249
342
  ${useElk ? `
250
343
  const elkModule = await import("@mermaid-js/layout-elk").catch(() => null);
251
344
  if (elkModule?.default) {
252
- console.log("[astro-mermaid] Registering elk layouts");
345
+ log('Registering elk layouts');
253
346
  mermaid.registerLayoutLoaders(elkModule.default);
254
347
  }
255
348
  ` : ``}
@@ -257,7 +350,7 @@ if (elkModule?.default) {
257
350
  mermaidInstance = mermaid;
258
351
  return mermaid;
259
352
  }).catch(error => {
260
- console.error('[astro-mermaid] Failed to load mermaid:', error);
353
+ logError('Failed to load mermaid:', error);
261
354
  mermaidPromise = null;
262
355
  throw error;
263
356
  });
@@ -266,11 +359,11 @@ if (elkModule?.default) {
266
359
  }
267
360
 
268
361
  // Mermaid configuration
269
- const defaultConfig = ${JSON.stringify({
362
+ const defaultConfig = ${sanitizeJsonForScript(JSON.stringify({
270
363
  startOnLoad: false,
271
364
  theme: theme,
272
365
  ...mermaidConfig
273
- })};
366
+ }))};
274
367
 
275
368
  // Theme mapping for auto-theme switching
276
369
  const themeMap = {
@@ -280,10 +373,10 @@ const themeMap = {
280
373
 
281
374
  // Initialize all mermaid diagrams
282
375
  async function initMermaid() {
283
- console.log('[astro-mermaid] Initializing mermaid diagrams...');
376
+ log('Initializing mermaid diagrams...');
284
377
  const diagrams = document.querySelectorAll('pre.mermaid');
285
378
 
286
- console.log('[astro-mermaid] Found', diagrams.length, 'mermaid diagrams');
379
+ log('Found', diagrams.length, 'mermaid diagrams');
287
380
 
288
381
  if (diagrams.length === 0) {
289
382
  return;
@@ -301,7 +394,7 @@ async function initMermaid() {
301
394
  const bodyTheme = document.body.getAttribute('data-theme');
302
395
  const dataTheme = htmlTheme || bodyTheme;
303
396
  currentTheme = themeMap[dataTheme] || defaultConfig.theme;
304
- console.log('[astro-mermaid] Using theme:', currentTheme, 'from', htmlTheme ? 'html' : 'body');
397
+ log('Using theme:', currentTheme, 'from', htmlTheme ? 'html' : 'body');
305
398
  }
306
399
 
307
400
  // Configure mermaid with gitGraph support
@@ -329,7 +422,7 @@ async function initMermaid() {
329
422
  const diagramDefinition = diagram.getAttribute('data-diagram') || '';
330
423
  const id = 'mermaid-' + Math.random().toString(36).slice(2, 11);
331
424
 
332
- console.log('[astro-mermaid] Rendering diagram:', id);
425
+ log('Rendering diagram:', id);
333
426
 
334
427
  try {
335
428
  // Clear any existing error state
@@ -341,13 +434,20 @@ async function initMermaid() {
341
434
  const { svg } = await mermaid.render(id, diagramDefinition);
342
435
  diagram.innerHTML = svg;
343
436
  diagram.setAttribute('data-processed', 'true');
344
- console.log('[astro-mermaid] Successfully rendered diagram:', id);
437
+ log('Successfully rendered diagram:', id);
345
438
  } catch (error) {
346
- console.error('[astro-mermaid] Mermaid rendering error for diagram:', id, error);
347
- diagram.innerHTML = \`<div style="color: red; padding: 1rem; border: 1px solid red; border-radius: 0.5rem;">
348
- <strong>Error rendering diagram:</strong><br/>
349
- \${error.message || 'Unknown error'}
350
- </div>\`;
439
+ logError('Mermaid rendering error for diagram:', id, error);
440
+ // Build error UI safely use textContent to prevent XSS via error messages
441
+ const errorDiv = document.createElement('div');
442
+ errorDiv.style.cssText = 'color: red; padding: 1rem; border: 1px solid red; border-radius: 0.5rem;';
443
+ const strong = document.createElement('strong');
444
+ strong.textContent = 'Error rendering diagram:';
445
+ const msg = document.createElement('span');
446
+ msg.textContent = ' ' + (error.message || 'Unknown error');
447
+ errorDiv.appendChild(strong);
448
+ errorDiv.appendChild(msg);
449
+ diagram.textContent = '';
450
+ diagram.appendChild(errorDiv);
351
451
  diagram.setAttribute('data-processed', 'true');
352
452
  }
353
453
  }
@@ -355,10 +455,10 @@ async function initMermaid() {
355
455
 
356
456
  // Initialize on first load if there are diagrams
357
457
  if (hasMermaidDiagrams()) {
358
- console.log('[astro-mermaid] Mermaid diagrams detected on initial load');
458
+ log('Mermaid diagrams detected on initial load');
359
459
  initMermaid();
360
460
  } else {
361
- console.log('[astro-mermaid] No mermaid diagrams found on initial load');
461
+ log('No mermaid diagrams found on initial load');
362
462
  }
363
463
 
364
464
  // Re-render on theme change if auto-theme is enabled
@@ -389,7 +489,7 @@ if (${autoTheme}) {
389
489
  // Handle view transitions (for Astro View Transitions API)
390
490
  // This is registered ALWAYS, not just when initial page has diagrams
391
491
  document.addEventListener('astro:after-swap', () => {
392
- console.log('[astro-mermaid] View transition detected');
492
+ log('View transition detected');
393
493
  // Check if new page has diagrams
394
494
  if (hasMermaidDiagrams()) {
395
495
  initMermaid();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-mermaid",
3
- "version": "1.3.1",
3
+ "version": "2.0.0",
4
4
  "description": "An Astro integration for rendering Mermaid diagrams with automatic theme switching and client-side rendering",
5
5
  "type": "module",
6
6
  "main": "./astro-mermaid-integration.js",
@@ -30,7 +30,7 @@
30
30
  "license": "MIT",
31
31
  "peerDependencies": {
32
32
  "@mermaid-js/layout-elk": "^0.2.0",
33
- "astro": "^4.0.0 || ^5.0.0",
33
+ "astro": ">=4",
34
34
  "mermaid": "^10.0.0 || ^11.0.0"
35
35
  },
36
36
  "peerDependenciesMeta": {
@@ -46,18 +46,20 @@
46
46
  "scripts": {
47
47
  "test": "vitest",
48
48
  "test:ui": "vitest --ui",
49
- "test:coverage": "vitest --coverage"
49
+ "test:coverage": "vitest --coverage",
50
+ "semantic-release": "semantic-release"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@types/hast": "^3.0.4",
53
54
  "@vitest/ui": "^3.2.4",
54
- "astro": "^5.0.0",
55
+ "astro": "^6.0.0",
55
56
  "hast-util-from-html": "^2.0.3",
56
57
  "mermaid": "^11.0.0",
57
58
  "rehype-parse": "^9.0.1",
58
59
  "rehype-stringify": "^10.0.1",
59
60
  "remark-mermaid": "^0.2.0",
60
61
  "remark-parse": "^11.0.0",
62
+ "semantic-release": "^25.0.3",
61
63
  "typescript": "^5.0.0",
62
64
  "unified": "^11.0.5",
63
65
  "vitest": "^3.2.4"
@@ -70,4 +72,4 @@
70
72
  "bugs": {
71
73
  "url": "https://github.com/joesaby/astro-mermaid/issues"
72
74
  }
73
- }
75
+ }