astro-mermaid 1.4.0 → 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/astro-mermaid-integration.d.ts +11 -3
- package/astro-mermaid-integration.js +112 -18
- package/package.json +5 -3
|
@@ -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
|
-
*
|
|
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
|
|
20
|
+
loader?: () => Promise<any>;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
export interface AstroMermaidOptions {
|
|
@@ -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
|
+
'&': '&',
|
|
90
|
+
'<': '<',
|
|
91
|
+
'>': '>',
|
|
92
|
+
'"': '"',
|
|
93
|
+
"'": '''
|
|
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
|
}
|
|
@@ -176,6 +234,9 @@ export default function astroMermaid(options = {}) {
|
|
|
176
234
|
enableLog = true
|
|
177
235
|
} = options;
|
|
178
236
|
|
|
237
|
+
// Validate mermaidConfig to prevent prototype pollution
|
|
238
|
+
validateConfig(mermaidConfig);
|
|
239
|
+
|
|
179
240
|
return {
|
|
180
241
|
name: 'astro-mermaid',
|
|
181
242
|
hooks: {
|
|
@@ -213,11 +274,37 @@ export default function astroMermaid(options = {}) {
|
|
|
213
274
|
}
|
|
214
275
|
});
|
|
215
276
|
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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);
|
|
221
308
|
|
|
222
309
|
// Inject client-side mermaid script with conditional loading
|
|
223
310
|
const mermaidScriptContent = `
|
|
@@ -240,13 +327,13 @@ async function loadMermaid() {
|
|
|
240
327
|
log('Loading mermaid.js...');
|
|
241
328
|
|
|
242
329
|
mermaidPromise = import('mermaid').then(async ({ default: mermaid }) => {
|
|
243
|
-
// Register icon packs if provided
|
|
244
|
-
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))};
|
|
245
332
|
if (iconPacks && iconPacks.length > 0) {
|
|
246
333
|
log('Registering', iconPacks.length, 'icon packs');
|
|
247
334
|
const packs = iconPacks.map(pack => ({
|
|
248
335
|
name: pack.name,
|
|
249
|
-
loader:
|
|
336
|
+
loader: () => fetch(pack.url).then(res => res.json())
|
|
250
337
|
}));
|
|
251
338
|
await mermaid.registerIconPacks(packs);
|
|
252
339
|
}
|
|
@@ -272,11 +359,11 @@ if (elkModule?.default) {
|
|
|
272
359
|
}
|
|
273
360
|
|
|
274
361
|
// Mermaid configuration
|
|
275
|
-
const defaultConfig = ${JSON.stringify({
|
|
362
|
+
const defaultConfig = ${sanitizeJsonForScript(JSON.stringify({
|
|
276
363
|
startOnLoad: false,
|
|
277
364
|
theme: theme,
|
|
278
365
|
...mermaidConfig
|
|
279
|
-
})};
|
|
366
|
+
}))};
|
|
280
367
|
|
|
281
368
|
// Theme mapping for auto-theme switching
|
|
282
369
|
const themeMap = {
|
|
@@ -350,10 +437,17 @@ async function initMermaid() {
|
|
|
350
437
|
log('Successfully rendered diagram:', id);
|
|
351
438
|
} catch (error) {
|
|
352
439
|
logError('Mermaid rendering error for diagram:', id, error);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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);
|
|
357
451
|
diagram.setAttribute('data-processed', 'true');
|
|
358
452
|
}
|
|
359
453
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-mermaid",
|
|
3
|
-
"version": "
|
|
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",
|
|
@@ -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": "^
|
|
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"
|