@memberjunction/ng-markdown 6.1.0-edge.4 → 6.1.0-edge.6
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/dist/__tests__/web-dom-utils.test.js +57 -0
- package/dist/__tests__/web-dom-utils.test.js.map +1 -1
- package/dist/lib/components/markdown.component.d.ts +0 -10
- package/dist/lib/components/markdown.component.d.ts.map +1 -1
- package/dist/lib/components/markdown.component.dom.test.js +119 -0
- package/dist/lib/components/markdown.component.dom.test.js.map +1 -1
- package/dist/lib/components/markdown.component.js +5 -68
- package/dist/lib/components/markdown.component.js.map +1 -1
- package/dist/lib/extensions/svg-renderer.extension.d.ts +6 -1
- package/dist/lib/extensions/svg-renderer.extension.d.ts.map +1 -1
- package/dist/lib/extensions/svg-renderer.extension.js +47 -36
- package/dist/lib/extensions/svg-renderer.extension.js.map +1 -1
- package/dist/lib/services/markdown.service.d.ts +39 -10
- package/dist/lib/services/markdown.service.d.ts.map +1 -1
- package/dist/lib/services/markdown.service.dom.test.js +80 -1
- package/dist/lib/services/markdown.service.dom.test.js.map +1 -1
- package/dist/lib/services/markdown.service.js +160 -17
- package/dist/lib/services/markdown.service.js.map +1 -1
- package/package.json +10 -9
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"svg-renderer.extension.js","sourceRoot":"","sources":["../../../src/lib/extensions/svg-renderer.extension.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH
|
|
1
|
+
{"version":3,"file":"svg-renderer.extension.js","sourceRoot":"","sources":["../../../src/lib/extensions/svg-renderer.extension.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,oGAAoG;AACpG,MAAM,iBAAiB,GAAG,8BAA8B,CAAC;AAEzD,4EAA4E;AAC5E,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC,CAAC;AAEtF;;;;;;;GAOG;AACH,SAAS,WAAW,CAAC,OAAgB,EAAE,aAAqB,EAAE,KAAa;IACzE,4CAA4C;IAC5C,MAAM,UAAU,GAAG,KAAK,CAAC,OAAO,CAAC,wBAAwB,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IAC7E,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;QACxC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,WAAW,GACf,OAAO,CAAC,OAAO,CAAC,WAAW,EAAE,KAAK,OAAO;QACzC,CAAC,aAAa,KAAK,MAAM,IAAI,aAAa,KAAK,YAAY,CAAC;QAC5D,UAAU,CAAC,UAAU,CAAC,aAAa,CAAC;QACpC,CAAC,UAAU,CAAC,UAAU,CAAC,gBAAgB,CAAC,CAAC;IAC3C,OAAO,CAAC,WAAW,CAAC;AACtB,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAAsB;IACvD,SAAS,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1E,SAAS,CAAC,gBAAgB,CAAC,eAAe,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC;IAEzE,SAAS,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE;QAC7C,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC;YAC7C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;YACrC,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC1B,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAC9B,SAAS;YACX,CAAC;YACD,IAAI,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,WAAW,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;gBAClE,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAChC,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,SAAS,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;QAChD,sFAAsF;QACtF,0FAA0F;QAC1F,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC,YAAY,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC;YAC7E,4CAA4C;aAC3C,OAAO,CAAC,wBAAwB,EAAE,EAAE,CAAC,CAAC;QACzC,IAAI,IAAI,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YACzC,GAAG,CAAC,MAAM,EAAE,CAAC;QACf,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["/**\n * Web-only DOM utilities that complement the SVG renderer marked extension.\n *\n * The framework-agnostic SVG tokenizer/renderer (`createSvgRendererExtension`,\n * `isSvgContent`) now lives in `@memberjunction/markdown-core`. This file keeps\n * the browser-only sanitization helper, which operates on a live DOM subtree\n * after the SVG has been inserted into the page.\n */\n\n/** URL schemes that can execute script or smuggle markup when used as a link or resource target. */\nconst SCRIPT_URL_SCHEME = /^(javascript|vbscript|data):/;\n\n/** Attributes whose value is a URL the browser will navigate to or load. */\nconst URL_ATTRIBUTES = new Set(['href', 'xlink:href', 'src', 'action', 'formaction']);\n\n/**\n * True when an attribute value is a script-capable URL. Whitespace and control\n * characters are removed before the scheme check because browsers ignore them\n * (a tab inside `javascript:` still navigates), and the comparison is case-insensitive.\n *\n * `data:image/...` (other than SVG, which can carry script) is allowed on `<image>`\n * so embedded raster images inside an SVG keep working.\n */\nfunction isScriptUrl(element: Element, attributeName: string, value: string): boolean {\n // eslint-disable-next-line no-control-regex\n const normalized = value.replace(/[\\u0000-\\u0020\\u007f]/g, '').toLowerCase();\n if (!SCRIPT_URL_SCHEME.test(normalized)) {\n return false;\n }\n const isImageData =\n element.tagName.toLowerCase() === 'image' &&\n (attributeName === 'href' || attributeName === 'xlink:href') &&\n normalized.startsWith('data:image/') &&\n !normalized.startsWith('data:image/svg');\n return !isImageData;\n}\n\n/**\n * Sanitize rendered SVG in place by removing the vectors that can run script.\n * Call this on the container element after rendering if you need additional security.\n *\n * Removes `<script>` and `<foreignObject>` elements, every `on*` event-handler\n * attribute (not a fixed list: any attribute whose name starts with `on`),\n * `javascript:` / `vbscript:` / `data:` URLs on link and resource attributes, and\n * `<use>` elements that reference an external document.\n *\n * @param container The DOM element containing rendered SVG\n */\nexport function sanitizeSvgContent(container: HTMLElement): void {\n container.querySelectorAll('script').forEach((script) => script.remove());\n container.querySelectorAll('foreignObject').forEach((fo) => fo.remove());\n\n container.querySelectorAll('*').forEach((el) => {\n for (const attr of Array.from(el.attributes)) {\n const name = attr.name.toLowerCase();\n if (name.startsWith('on')) {\n el.removeAttribute(attr.name);\n continue;\n }\n if (URL_ATTRIBUTES.has(name) && isScriptUrl(el, name, attr.value)) {\n el.removeAttribute(attr.name);\n }\n }\n });\n\n container.querySelectorAll('use').forEach((use) => {\n // Same-document references only; anything that is not a fragment can load an external\n // document. Normalised the same way isScriptUrl normalises, so obfuscation does not help.\n const href = (use.getAttribute('href') || use.getAttribute('xlink:href') || '')\n // eslint-disable-next-line no-control-regex\n .replace(/[\\u0000-\\u0020\\u007f]/g, '');\n if (href !== '' && !href.startsWith('#')) {\n use.remove();\n }\n });\n}\n"]}
|
|
@@ -14,21 +14,15 @@ import 'prismjs/components/prism-yaml';
|
|
|
14
14
|
import 'prismjs/components/prism-markdown';
|
|
15
15
|
import 'prismjs/components/prism-graphql';
|
|
16
16
|
import * as i0 from "@angular/core";
|
|
17
|
-
/**
|
|
18
|
-
* Service for parsing and rendering markdown content on the web.
|
|
19
|
-
*
|
|
20
|
-
* The parsing/configuration work (marked setup, custom extensions, HTML and
|
|
21
|
-
* token output) is owned by the framework-agnostic `@memberjunction/markdown-core`
|
|
22
|
-
* {@link MarkdownEngine}. This Angular service is the web shell around it: it
|
|
23
|
-
* injects a Prism-based highlighter into the engine and keeps the browser-only
|
|
24
|
-
* concerns here — Mermaid rendering, copy buttons, and the DOM-based fixup of
|
|
25
|
-
* HTML that marked miscoded as a code block.
|
|
26
|
-
*/
|
|
27
17
|
export declare class MarkdownService {
|
|
28
18
|
private engine;
|
|
29
19
|
private mermaidInitialized;
|
|
30
20
|
private lastMermaidTheme;
|
|
31
21
|
private currentConfig;
|
|
22
|
+
/** The mermaid engine, once its lazy chunk has landed. Null until a diagram is actually rendered. */
|
|
23
|
+
private mermaidEngine;
|
|
24
|
+
/** In-flight load, so N diagrams in one document share ONE chunk fetch. */
|
|
25
|
+
private mermaidLoad;
|
|
32
26
|
/**
|
|
33
27
|
* Prism-backed highlight function injected into the core engine. The engine
|
|
34
28
|
* itself has no Prism dependency — it calls this for the HTML output path.
|
|
@@ -46,6 +40,13 @@ export declare class MarkdownService {
|
|
|
46
40
|
* @returns The rendered HTML string
|
|
47
41
|
*/
|
|
48
42
|
parse(markdown: string, config?: Partial<MarkdownConfig>): string;
|
|
43
|
+
/**
|
|
44
|
+
* Sanitize rendered HTML for binding to innerHTML while preserving layout HTML, inline
|
|
45
|
+
* styles and inline SVG. See the DOMPurify configuration at the top of this file for
|
|
46
|
+
* what is kept and removed. Where no DOM is available to sanitize with, the markup is
|
|
47
|
+
* escaped and rendered as text rather than trusted.
|
|
48
|
+
*/
|
|
49
|
+
sanitizeHtml(html: string): string;
|
|
49
50
|
/**
|
|
50
51
|
* Parse markdown asynchronously (useful for large documents)
|
|
51
52
|
*/
|
|
@@ -55,9 +56,26 @@ export declare class MarkdownService {
|
|
|
55
56
|
* 'auto' maps to 'dark' or 'default' based on the document's data-theme attribute.
|
|
56
57
|
*/
|
|
57
58
|
private resolveEffectiveMermaidTheme;
|
|
59
|
+
/**
|
|
60
|
+
* Fetch the mermaid engine, on first use only.
|
|
61
|
+
*
|
|
62
|
+
* MEASURED BUNDLE DEFERRAL — this is deliberately a dynamic `import()`, NOT a top-level
|
|
63
|
+
* one, and must not be "tidied" back into a static import. `mermaid` and the parsing /
|
|
64
|
+
* graph engines it pulls with it (`@mermaid-js/parser`, `langium`, `chevrotain`,
|
|
65
|
+
* `cytoscape`, `katex`, `d3`) were ~0.95 MB raw of every host's initial download even
|
|
66
|
+
* when no document ever contained a diagram. That is a rounding error for an in-app
|
|
67
|
+
* host and the whole cost for a CDN-loaded embedding of the realtime session overlay
|
|
68
|
+
* (MJ #3882). Documents with no diagram now never fetch the chunk at all.
|
|
69
|
+
*
|
|
70
|
+
* The in-flight promise is cached so N diagrams in one document share one fetch; a
|
|
71
|
+
* FAILED load clears it, so a later render can retry. Retries are bounded by
|
|
72
|
+
* user-driven render calls — there is no loop here.
|
|
73
|
+
*/
|
|
74
|
+
private loadMermaid;
|
|
58
75
|
/**
|
|
59
76
|
* Initialize Mermaid with the current theme configuration.
|
|
60
77
|
* Re-initializes when the effective theme changes.
|
|
78
|
+
* @param mermaid The lazily-loaded mermaid engine (see {@link loadMermaid})
|
|
61
79
|
*/
|
|
62
80
|
private initializeMermaid;
|
|
63
81
|
/**
|
|
@@ -66,6 +84,17 @@ export declare class MarkdownService {
|
|
|
66
84
|
* @param container The DOM element containing mermaid code blocks
|
|
67
85
|
*/
|
|
68
86
|
renderMermaid(container: HTMLElement): Promise<boolean>;
|
|
87
|
+
/**
|
|
88
|
+
* Mark mermaid blocks as un-renderable, so the failure is VISIBLE rather than a block of
|
|
89
|
+
* raw diagram source the reader can't explain. The `.mermaid-error` class carries the
|
|
90
|
+
* error styling and a default caption; `data-mermaid-error` overrides that caption with
|
|
91
|
+
* the specific reason (see `.mermaid-error[data-mermaid-error]::before` in the component
|
|
92
|
+
* stylesheet).
|
|
93
|
+
*
|
|
94
|
+
* @param blocks The mermaid code blocks (or `.mermaid` elements) that could not be rendered
|
|
95
|
+
* @param message Short reason shown above the un-rendered source
|
|
96
|
+
*/
|
|
97
|
+
private markMermaidBlocksFailed;
|
|
69
98
|
/**
|
|
70
99
|
* Highlight code blocks with Prism
|
|
71
100
|
* Call this after the HTML has been inserted into the DOM
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown.service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/markdown.service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"markdown.service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/markdown.service.ts"],"names":[],"mappings":"AAKA,OAAO,EAEL,cAAc,EAEd,sBAAsB,EACtB,WAAW,EAEZ,MAAM,+BAA+B,CAAC;AAMvC,OAAO,qCAAqC,CAAC;AAC7C,OAAO,qCAAqC,CAAC;AAC7C,OAAO,8BAA8B,CAAC;AACtC,OAAO,+BAA+B,CAAC;AACvC,OAAO,+BAA+B,CAAC;AACvC,OAAO,+BAA+B,CAAC;AACvC,OAAO,8BAA8B,CAAC;AACtC,OAAO,iCAAiC,CAAC;AACzC,OAAO,iCAAiC,CAAC;AACzC,OAAO,+BAA+B,CAAC;AACvC,OAAO,iCAAiC,CAAC;AACzC,OAAO,+BAA+B,CAAC;AACvC,OAAO,mCAAmC,CAAC;AAC3C,OAAO,kCAAkC,CAAC;;AAuE1C,qBAGa,eAAe;IAC1B,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,aAAa,CAA0D;IAC/E,qGAAqG;IACrG,OAAO,CAAC,aAAa,CAAwB;IAC7C,2EAA2E;IAC3E,OAAO,CAAC,WAAW,CAAiC;IAEpD;;;OAGG;IACH,OAAO,CAAC,cAAc,CAUpB;;IAMF;;OAEG;IACI,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAKpD;;;;;OAKG;IACI,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,MAAM;IA2BxE;;;;;OAKG;IACI,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAQzC;;OAEG;IACU,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;IAI5F;;;OAGG;IACH,OAAO,CAAC,4BAA4B;IAUpC;;;;;;;;;;;;;;OAcG;YACW,WAAW;IAgBzB;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAmBzB;;;;OAIG;IACU,aAAa,CAAC,SAAS,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IAsDpE;;;;;;;;;OASG;IACH,OAAO,CAAC,uBAAuB;IAS/B;;;;OAIG;IACI,aAAa,CAAC,SAAS,EAAE,WAAW,GAAG,IAAI;IAOlD;;;OAGG;IACI,kBAAkB,CAAC,SAAS,EAAE,WAAW,GAAG,IAAI;IAiDvD;;;;OAIG;IACI,6BAA6B,CAAC,UAAU,EAAE,WAAW,GAAG,IAAI;IAKnE;;;OAGG;IACI,cAAc,IAAI,WAAW,EAAE;IAItC;;OAEG;IACI,SAAS,IAAI,sBAAsB;IAI1C;;OAEG;IACI,WAAW,IAAI,IAAI;IAI1B;;OAEG;IACI,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIjD;;OAEG;IACI,qBAAqB,IAAI,MAAM,EAAE;IAMxC;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;IAoE1B;;;;OAIG;IACH,OAAO,CAAC,uBAAuB;yCAlbpB,eAAe;6CAAf,eAAe;CA0c3B"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import { MarkdownService } from './markdown.service';
|
|
3
3
|
describe('MarkdownService (DOM)', () => {
|
|
4
4
|
let service;
|
|
@@ -82,5 +82,84 @@ describe('MarkdownService (DOM)', () => {
|
|
|
82
82
|
expect(service.isLanguageSupported('definitely-not-a-language')).toBe(false);
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
|
+
describe('renderMermaid', () => {
|
|
86
|
+
/** Stub the private lazy loader — the point of these specs is WHEN it is called, not what it returns. */
|
|
87
|
+
const stubLoader = (impl) => {
|
|
88
|
+
service.loadMermaid = impl;
|
|
89
|
+
};
|
|
90
|
+
const containerWith = (html) => {
|
|
91
|
+
const container = document.createElement('div');
|
|
92
|
+
container.innerHTML = html;
|
|
93
|
+
return container;
|
|
94
|
+
};
|
|
95
|
+
it('never loads the diagram engine when the document has no diagram', async () => {
|
|
96
|
+
let loads = 0;
|
|
97
|
+
stubLoader(async () => {
|
|
98
|
+
loads++;
|
|
99
|
+
throw new Error('the engine must not be fetched for a diagram-free document');
|
|
100
|
+
});
|
|
101
|
+
const result = await service.renderMermaid(containerWith('<p>no diagrams here</p>'));
|
|
102
|
+
expect(result).toBe(false);
|
|
103
|
+
expect(loads).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
it('shows a visible failure on every block when the lazy chunk cannot be loaded', async () => {
|
|
106
|
+
stubLoader(() => Promise.reject(new Error('chunk fetch failed')));
|
|
107
|
+
const container = containerWith('<pre><code class="language-mermaid">graph TD; A-->B;</code></pre>' +
|
|
108
|
+
'<div class="mermaid">graph TD; C-->D;</div>');
|
|
109
|
+
const result = await service.renderMermaid(container);
|
|
110
|
+
expect(result).toBe(false);
|
|
111
|
+
const failed = container.querySelectorAll('.mermaid-error');
|
|
112
|
+
expect(failed.length).toBe(2);
|
|
113
|
+
for (const element of Array.from(failed)) {
|
|
114
|
+
expect(element.getAttribute('data-mermaid-error')).toBe('Diagram engine failed to load');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
it('does nothing at all when mermaid is disabled by config', async () => {
|
|
118
|
+
let loads = 0;
|
|
119
|
+
stubLoader(async () => {
|
|
120
|
+
loads++;
|
|
121
|
+
throw new Error('the engine must not be fetched when mermaid is disabled');
|
|
122
|
+
});
|
|
123
|
+
service.configureMarked({ enableMermaid: false });
|
|
124
|
+
const result = await service.renderMermaid(containerWith('<pre><code class="language-mermaid">graph TD; A-->B;</code></pre>'));
|
|
125
|
+
expect(result).toBe(false);
|
|
126
|
+
expect(loads).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('parse sanitizes unless scripts are explicitly enabled', () => {
|
|
130
|
+
it('strips handlers and script from passthrough HTML', () => {
|
|
131
|
+
const out = service.parse('<div><img src="x" onerror="alert(1)"><script>alert(2)</script></div>', { enableHtml: true });
|
|
132
|
+
expect(out).not.toContain('onerror');
|
|
133
|
+
expect(out).not.toContain('<script');
|
|
134
|
+
expect(out).toContain('<img');
|
|
135
|
+
});
|
|
136
|
+
it('leaves script in place when enableJavaScript is true (the opt-out)', () => {
|
|
137
|
+
const out = service.parse('<div>x</div><script>alert(1)</script>', { enableHtml: true, enableJavaScript: true });
|
|
138
|
+
expect(out).toContain('<script>');
|
|
139
|
+
});
|
|
140
|
+
it('sanitizeHtml is exposed for consumers that bind parse() output themselves', () => {
|
|
141
|
+
expect(service.sanitizeHtml('<a href="javascript:alert(1)" onclick="x()">x</a>')).toBe('<a>x</a>');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('unwrapMiscodedHtml is inert and keeps leading style', () => {
|
|
145
|
+
afterEach(() => vi.restoreAllMocks());
|
|
146
|
+
const miscoded = '<pre><code><style>.card{color:red}</style><div class="card"><img src="x" onerror="alert(1)"><p>hi</p></div></code></pre>';
|
|
147
|
+
it('never creates elements on the live document while unwrapping', () => {
|
|
148
|
+
// Building the replacement on the live document would run image error handlers at
|
|
149
|
+
// parse time, before parse() sanitizes the returned string.
|
|
150
|
+
const createElement = vi.spyOn(document, 'createElement');
|
|
151
|
+
unwrap(miscoded);
|
|
152
|
+
expect(createElement).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
it('unwraps the structural HTML and keeps a leading <style>', () => {
|
|
155
|
+
const out = unwrap(miscoded);
|
|
156
|
+
expect(out).not.toContain('<pre>');
|
|
157
|
+
expect(out).toContain('<style>.card{color:red}</style>');
|
|
158
|
+
expect(out).toContain('<div class="card">');
|
|
159
|
+
});
|
|
160
|
+
it('returns markup for parse() to sanitize rather than executing it', () => {
|
|
161
|
+
expect(unwrap(miscoded)).toContain('onerror="alert(1)"');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
85
164
|
});
|
|
86
165
|
//# sourceMappingURL=markdown.service.dom.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown.service.dom.test.js","sourceRoot":"","sources":["../../../src/lib/services/markdown.service.dom.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect, beforeEach } from 'vitest';\nimport { MarkdownService } from './markdown.service';\n\n/**\n * jsdom specs for the web shell around @memberjunction/markdown-core.\n *\n * These focus on the browser-only concerns that stay in the Angular service:\n * the `parse()` entry points and the DOM-based `unwrapMiscodedHtml()` fixup.\n * The framework-agnostic parsing itself is covered in @memberjunction/markdown-core.\n *\n * `unwrapMiscodedHtml` is private, so we reach it through a typed cast — the same\n * pattern the markdown-core engine spec uses to poke the internal marked instance.\n * This keeps the assertions deterministic (exact HTML in, exact behavior out)\n * rather than depending on which fixtures marked happens to miscode.\n */\ntype UnwrapAccessor = { unwrapMiscodedHtml(html: string): string };\n\ndescribe('MarkdownService (DOM)', () => {\n let service: MarkdownService;\n let unwrap: (html: string) => string;\n\n beforeEach(() => {\n service = new MarkdownService();\n unwrap = (html: string) => (service as unknown as UnwrapAccessor).unwrapMiscodedHtml(html);\n });\n\n describe('parse', () => {\n it('returns empty string for empty input', () => {\n expect(service.parse('')).toBe('');\n });\n\n it('renders basic prose to a paragraph', () => {\n expect(service.parse('hello world')).toContain('<p>');\n });\n\n it('accepts per-call config overrides without throwing', () => {\n expect(() => service.parse('# Title', { enableHtml: true })).not.toThrow();\n });\n\n it('parseAsync resolves to the same output as parse', async () => {\n const md = '**bold**';\n expect(await service.parseAsync(md)).toBe(service.parse(md));\n });\n });\n\n describe('unwrapMiscodedHtml', () => {\n it('is a no-op when there are no <pre> blocks', () => {\n const html = '<p>just a paragraph</p>';\n expect(unwrap(html)).toBe(html);\n });\n\n it('returns empty input unchanged', () => {\n expect(unwrap('')).toBe('');\n });\n\n it('skips content containing <svg (DOMParser would mangle SVG namespaces)', () => {\n const html = '<pre><code><svg><rect/></svg></code></pre>';\n // Left untouched precisely because it contains \"<svg\".\n expect(unwrap(html)).toBe(html);\n });\n\n it('preserves an intentional code block that carries a language class', () => {\n const html = '<pre><code class=\"language-html\"><div><span>x</span></div></code></pre>';\n const out = unwrap(html);\n // Language-classed code is an intentional example → NOT unwrapped.\n expect(out).toContain('language-html');\n expect(out).toContain('<pre>');\n });\n\n it('unwraps structural HTML that marked miscoded into a <pre><code> block', () => {\n // textContent decodes to <div><span>x</span></div> — multi-tag structural HTML.\n const html = '<pre><code><div><span>x</span></div></code></pre>';\n const out = unwrap(html);\n expect(out).toContain('<div>');\n expect(out).toContain('<span>x</span>');\n // The escaped code-block form must be gone.\n expect(out).not.toContain('<div>');\n expect(out).not.toContain('<pre>');\n });\n\n it('does not unwrap a single self-closing tag (needs 2+ tags to qualify)', () => {\n const html = '<pre><code><br></code></pre>';\n // tagCount < 2 → looksLikeStructuralHtml returns false → left as-is.\n expect(unwrap(html)).toBe(html);\n });\n\n it('does not unwrap an empty code block', () => {\n const html = '<pre><code></code></pre>';\n expect(unwrap(html)).toBe(html);\n });\n\n it('does not unwrap plain non-HTML code content', () => {\n const html = '<pre><code>const x = 1; // not html</code></pre>';\n expect(unwrap(html)).toBe(html);\n });\n\n it('does not throw on malformed markup', () => {\n expect(() => unwrap('<pre><code><div><span>unclosed')).not.toThrow();\n });\n });\n\n describe('config accessors', () => {\n it('resetConfig restores enableHtml to the default (false)', () => {\n service.configureMarked({ enableHtml: true });\n expect(service.getConfig().enableHtml).toBe(true);\n service.resetConfig();\n expect(service.getConfig().enableHtml).toBe(false);\n });\n\n it('isLanguageSupported reflects Prism registration', () => {\n // typescript is imported by the service; a made-up language is not.\n expect(service.isLanguageSupported('typescript')).toBe(true);\n expect(service.isLanguageSupported('definitely-not-a-language')).toBe(false);\n });\n });\n});\n"]}
|
|
1
|
+
{"version":3,"file":"markdown.service.dom.test.js","sourceRoot":"","sources":["../../../src/lib/services/markdown.service.dom.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';\nimport type { Mermaid } from 'mermaid';\nimport { MarkdownService } from './markdown.service';\n\n/**\n * jsdom specs for the web shell around @memberjunction/markdown-core.\n *\n * These focus on the browser-only concerns that stay in the Angular service:\n * the `parse()` entry points and the DOM-based `unwrapMiscodedHtml()` fixup.\n * The framework-agnostic parsing itself is covered in @memberjunction/markdown-core.\n *\n * `unwrapMiscodedHtml` is private, so we reach it through a typed cast — the same\n * pattern the markdown-core engine spec uses to poke the internal marked instance.\n * This keeps the assertions deterministic (exact HTML in, exact behavior out)\n * rather than depending on which fixtures marked happens to miscode.\n */\ntype UnwrapAccessor = { unwrapMiscodedHtml(html: string): string };\n\n/** Same private-member reach-in, for the lazily-loaded mermaid engine (see `loadMermaid`). */\ntype MermaidLoaderAccessor = { loadMermaid(): Promise<Mermaid> };\n\ndescribe('MarkdownService (DOM)', () => {\n let service: MarkdownService;\n let unwrap: (html: string) => string;\n\n beforeEach(() => {\n service = new MarkdownService();\n unwrap = (html: string) => (service as unknown as UnwrapAccessor).unwrapMiscodedHtml(html);\n });\n\n describe('parse', () => {\n it('returns empty string for empty input', () => {\n expect(service.parse('')).toBe('');\n });\n\n it('renders basic prose to a paragraph', () => {\n expect(service.parse('hello world')).toContain('<p>');\n });\n\n it('accepts per-call config overrides without throwing', () => {\n expect(() => service.parse('# Title', { enableHtml: true })).not.toThrow();\n });\n\n it('parseAsync resolves to the same output as parse', async () => {\n const md = '**bold**';\n expect(await service.parseAsync(md)).toBe(service.parse(md));\n });\n });\n\n describe('unwrapMiscodedHtml', () => {\n it('is a no-op when there are no <pre> blocks', () => {\n const html = '<p>just a paragraph</p>';\n expect(unwrap(html)).toBe(html);\n });\n\n it('returns empty input unchanged', () => {\n expect(unwrap('')).toBe('');\n });\n\n it('skips content containing <svg (DOMParser would mangle SVG namespaces)', () => {\n const html = '<pre><code><svg><rect/></svg></code></pre>';\n // Left untouched precisely because it contains \"<svg\".\n expect(unwrap(html)).toBe(html);\n });\n\n it('preserves an intentional code block that carries a language class', () => {\n const html = '<pre><code class=\"language-html\"><div><span>x</span></div></code></pre>';\n const out = unwrap(html);\n // Language-classed code is an intentional example → NOT unwrapped.\n expect(out).toContain('language-html');\n expect(out).toContain('<pre>');\n });\n\n it('unwraps structural HTML that marked miscoded into a <pre><code> block', () => {\n // textContent decodes to <div><span>x</span></div> — multi-tag structural HTML.\n const html = '<pre><code><div><span>x</span></div></code></pre>';\n const out = unwrap(html);\n expect(out).toContain('<div>');\n expect(out).toContain('<span>x</span>');\n // The escaped code-block form must be gone.\n expect(out).not.toContain('<div>');\n expect(out).not.toContain('<pre>');\n });\n\n it('does not unwrap a single self-closing tag (needs 2+ tags to qualify)', () => {\n const html = '<pre><code><br></code></pre>';\n // tagCount < 2 → looksLikeStructuralHtml returns false → left as-is.\n expect(unwrap(html)).toBe(html);\n });\n\n it('does not unwrap an empty code block', () => {\n const html = '<pre><code></code></pre>';\n expect(unwrap(html)).toBe(html);\n });\n\n it('does not unwrap plain non-HTML code content', () => {\n const html = '<pre><code>const x = 1; // not html</code></pre>';\n expect(unwrap(html)).toBe(html);\n });\n\n it('does not throw on malformed markup', () => {\n expect(() => unwrap('<pre><code><div><span>unclosed')).not.toThrow();\n });\n });\n\n describe('config accessors', () => {\n it('resetConfig restores enableHtml to the default (false)', () => {\n service.configureMarked({ enableHtml: true });\n expect(service.getConfig().enableHtml).toBe(true);\n service.resetConfig();\n expect(service.getConfig().enableHtml).toBe(false);\n });\n\n it('isLanguageSupported reflects Prism registration', () => {\n // typescript is imported by the service; a made-up language is not.\n expect(service.isLanguageSupported('typescript')).toBe(true);\n expect(service.isLanguageSupported('definitely-not-a-language')).toBe(false);\n });\n });\n\n describe('renderMermaid', () => {\n /** Stub the private lazy loader — the point of these specs is WHEN it is called, not what it returns. */\n const stubLoader = (impl: () => Promise<Mermaid>): void => {\n (service as unknown as MermaidLoaderAccessor).loadMermaid = impl;\n };\n\n const containerWith = (html: string): HTMLElement => {\n const container = document.createElement('div');\n container.innerHTML = html;\n return container;\n };\n\n it('never loads the diagram engine when the document has no diagram', async () => {\n let loads = 0;\n stubLoader(async () => {\n loads++;\n throw new Error('the engine must not be fetched for a diagram-free document');\n });\n\n const result = await service.renderMermaid(containerWith('<p>no diagrams here</p>'));\n\n expect(result).toBe(false);\n expect(loads).toBe(0);\n });\n\n it('shows a visible failure on every block when the lazy chunk cannot be loaded', async () => {\n stubLoader(() => Promise.reject(new Error('chunk fetch failed')));\n const container = containerWith(\n '<pre><code class=\"language-mermaid\">graph TD; A-->B;</code></pre>' +\n '<div class=\"mermaid\">graph TD; C-->D;</div>',\n );\n\n const result = await service.renderMermaid(container);\n\n expect(result).toBe(false);\n const failed = container.querySelectorAll('.mermaid-error');\n expect(failed.length).toBe(2);\n for (const element of Array.from(failed)) {\n expect(element.getAttribute('data-mermaid-error')).toBe('Diagram engine failed to load');\n }\n });\n\n it('does nothing at all when mermaid is disabled by config', async () => {\n let loads = 0;\n stubLoader(async () => {\n loads++;\n throw new Error('the engine must not be fetched when mermaid is disabled');\n });\n service.configureMarked({ enableMermaid: false });\n\n const result = await service.renderMermaid(\n containerWith('<pre><code class=\"language-mermaid\">graph TD; A-->B;</code></pre>'),\n );\n\n expect(result).toBe(false);\n expect(loads).toBe(0);\n });\n });\n\n describe('parse sanitizes unless scripts are explicitly enabled', () => {\n it('strips handlers and script from passthrough HTML', () => {\n const out = service.parse('<div><img src=\"x\" onerror=\"alert(1)\"><script>alert(2)</script></div>', { enableHtml: true });\n expect(out).not.toContain('onerror');\n expect(out).not.toContain('<script');\n expect(out).toContain('<img');\n });\n\n it('leaves script in place when enableJavaScript is true (the opt-out)', () => {\n const out = service.parse('<div>x</div><script>alert(1)</script>', { enableHtml: true, enableJavaScript: true });\n expect(out).toContain('<script>');\n });\n\n it('sanitizeHtml is exposed for consumers that bind parse() output themselves', () => {\n expect(service.sanitizeHtml('<a href=\"javascript:alert(1)\" onclick=\"x()\">x</a>')).toBe('<a>x</a>');\n });\n });\n\n describe('unwrapMiscodedHtml is inert and keeps leading style', () => {\n afterEach(() => vi.restoreAllMocks());\n const miscoded = '<pre><code><style>.card{color:red}</style><div class=\"card\"><img src=\"x\" onerror=\"alert(1)\"><p>hi</p></div></code></pre>';\n\n it('never creates elements on the live document while unwrapping', () => {\n // Building the replacement on the live document would run image error handlers at\n // parse time, before parse() sanitizes the returned string.\n const createElement = vi.spyOn(document, 'createElement');\n unwrap(miscoded);\n expect(createElement).not.toHaveBeenCalled();\n });\n\n it('unwraps the structural HTML and keeps a leading <style>', () => {\n const out = unwrap(miscoded);\n expect(out).not.toContain('<pre>');\n expect(out).toContain('<style>.card{color:red}</style>');\n expect(out).toContain('<div class=\"card\">');\n });\n\n it('returns markup for parse() to sanitize rather than executing it', () => {\n expect(unwrap(miscoded)).toContain('onerror=\"alert(1)\"');\n });\n });\n});\n"]}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Injectable } from '@angular/core';
|
|
2
2
|
import Prism from 'prismjs';
|
|
3
|
-
import mermaid from 'mermaid';
|
|
4
3
|
import { MarkdownEngine, DEFAULT_MARKDOWN_CONFIG } from '@memberjunction/markdown-core';
|
|
4
|
+
import DOMPurify from 'dompurify';
|
|
5
|
+
import { escapeHtml } from '@memberjunction/markdown-core';
|
|
5
6
|
// Import common Prism language components
|
|
6
7
|
// Additional languages can be imported by the consuming application
|
|
7
8
|
import 'prismjs/components/prism-typescript';
|
|
@@ -29,12 +30,76 @@ import * as i0 from "@angular/core";
|
|
|
29
30
|
* concerns here — Mermaid rendering, copy buttons, and the DOM-based fixup of
|
|
30
31
|
* HTML that marked miscoded as a code block.
|
|
31
32
|
*/
|
|
33
|
+
/**
|
|
34
|
+
* The DOMPurify instance used for rendered HTML, created once per window and configured
|
|
35
|
+
* once. A private instance rather than the shared default so the hooks below cannot leak
|
|
36
|
+
* into other packages' DOMPurify usage, and so the configuration is parsed once instead
|
|
37
|
+
* of on every render.
|
|
38
|
+
*
|
|
39
|
+
* Profile: HTML + SVG + SVG filters, which keeps structural markup, inline styles, data
|
|
40
|
+
* attributes and inline vector graphics and removes the script vectors. Deviations from
|
|
41
|
+
* DOMPurify's defaults, each deliberate:
|
|
42
|
+
* - FORCE_BODY: a document that starts with `<style>` keeps it (otherwise the parser hoists
|
|
43
|
+
* a leading style element into <head> and it is dropped); agent-authored mockups start
|
|
44
|
+
* that way.
|
|
45
|
+
* - SANITIZE_DOM off: DOMPurify would strip any id or name that collides with a document
|
|
46
|
+
* or form property (`title`, `name`, `location`, ...), which are exactly the slugs
|
|
47
|
+
* heading ids produce. This app does not read globals off `window` by element id, so
|
|
48
|
+
* DOM clobbering has no effect here, and heading anchors matter.
|
|
49
|
+
* - `target` on links is kept, with `rel="noopener noreferrer"` enforced when it is set.
|
|
50
|
+
* - `<use>` is allowed only with a same-document reference (`#id`); external references
|
|
51
|
+
* are removed. SMIL `<animate>`/`<set>` stay disallowed: they can rewrite `href`.
|
|
52
|
+
*/
|
|
53
|
+
let purifier = null;
|
|
54
|
+
function getPurifier() {
|
|
55
|
+
if (purifier)
|
|
56
|
+
return purifier;
|
|
57
|
+
if (typeof window === 'undefined')
|
|
58
|
+
return null;
|
|
59
|
+
const instance = DOMPurify(window);
|
|
60
|
+
if (!instance.isSupported)
|
|
61
|
+
return null;
|
|
62
|
+
instance.setConfig({
|
|
63
|
+
USE_PROFILES: { html: true, svg: true, svgFilters: true },
|
|
64
|
+
ADD_TAGS: ['use'],
|
|
65
|
+
ADD_ATTR: ['target'],
|
|
66
|
+
FORCE_BODY: true,
|
|
67
|
+
SANITIZE_DOM: false,
|
|
68
|
+
});
|
|
69
|
+
instance.addHook('uponSanitizeAttribute', (node, data) => {
|
|
70
|
+
const name = data.attrName.toLowerCase();
|
|
71
|
+
if (node.nodeName.toLowerCase() === 'use' && (name === 'href' || name === 'xlink:href')) {
|
|
72
|
+
// Same-document sprite references only. Anything else can load an external document.
|
|
73
|
+
data.keepAttr = data.attrValue.trim().startsWith('#');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
instance.addHook('afterSanitizeAttributes', (node) => {
|
|
77
|
+
const tag = node.nodeName.toLowerCase();
|
|
78
|
+
if (tag === 'a' && node.hasAttribute('target')) {
|
|
79
|
+
node.setAttribute('rel', 'noopener noreferrer');
|
|
80
|
+
}
|
|
81
|
+
if (tag === 'use') {
|
|
82
|
+
// The attribute hook above has already dropped any non-fragment reference; a <use>
|
|
83
|
+
// with nothing left to reference is removed rather than left as an empty element.
|
|
84
|
+
const ref = node.getAttribute('href') ?? node.getAttribute('xlink:href');
|
|
85
|
+
if (!ref || !ref.trim().startsWith('#')) {
|
|
86
|
+
node.parentNode?.removeChild(node);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
purifier = instance;
|
|
91
|
+
return purifier;
|
|
92
|
+
}
|
|
32
93
|
export class MarkdownService {
|
|
33
94
|
constructor() {
|
|
34
95
|
this.engine = new MarkdownEngine();
|
|
35
96
|
this.mermaidInitialized = false;
|
|
36
97
|
this.lastMermaidTheme = null;
|
|
37
98
|
this.currentConfig = { ...DEFAULT_MARKDOWN_CONFIG };
|
|
99
|
+
/** The mermaid engine, once its lazy chunk has landed. Null until a diagram is actually rendered. */
|
|
100
|
+
this.mermaidEngine = null;
|
|
101
|
+
/** In-flight load, so N diagrams in one document share ONE chunk fetch. */
|
|
102
|
+
this.mermaidLoad = null;
|
|
38
103
|
/**
|
|
39
104
|
* Prism-backed highlight function injected into the core engine. The engine
|
|
40
105
|
* itself has no Prism dependency — it calls this for the HTML output path.
|
|
@@ -80,8 +145,27 @@ export class MarkdownService {
|
|
|
80
145
|
if (this.currentConfig.enableHtml) {
|
|
81
146
|
html = this.unwrapMiscodedHtml(html);
|
|
82
147
|
}
|
|
148
|
+
// The service is the one place every consumer passes through (the component, the chat
|
|
149
|
+
// widget, anything binding parse() output to innerHTML), so the sanitizer runs here.
|
|
150
|
+
// enableJavaScript is the explicit opt-out.
|
|
151
|
+
if (!this.currentConfig.enableJavaScript) {
|
|
152
|
+
html = this.sanitizeHtml(html);
|
|
153
|
+
}
|
|
83
154
|
return html;
|
|
84
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Sanitize rendered HTML for binding to innerHTML while preserving layout HTML, inline
|
|
158
|
+
* styles and inline SVG. See the DOMPurify configuration at the top of this file for
|
|
159
|
+
* what is kept and removed. Where no DOM is available to sanitize with, the markup is
|
|
160
|
+
* escaped and rendered as text rather than trusted.
|
|
161
|
+
*/
|
|
162
|
+
sanitizeHtml(html) {
|
|
163
|
+
const instance = getPurifier();
|
|
164
|
+
if (!instance) {
|
|
165
|
+
return escapeHtml(html);
|
|
166
|
+
}
|
|
167
|
+
return instance.sanitize(html);
|
|
168
|
+
}
|
|
85
169
|
/**
|
|
86
170
|
* Parse markdown asynchronously (useful for large documents)
|
|
87
171
|
*/
|
|
@@ -101,11 +185,41 @@ export class MarkdownService {
|
|
|
101
185
|
&& document.documentElement.getAttribute('data-theme') === 'dark';
|
|
102
186
|
return isDark ? 'dark' : 'default';
|
|
103
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Fetch the mermaid engine, on first use only.
|
|
190
|
+
*
|
|
191
|
+
* MEASURED BUNDLE DEFERRAL — this is deliberately a dynamic `import()`, NOT a top-level
|
|
192
|
+
* one, and must not be "tidied" back into a static import. `mermaid` and the parsing /
|
|
193
|
+
* graph engines it pulls with it (`@mermaid-js/parser`, `langium`, `chevrotain`,
|
|
194
|
+
* `cytoscape`, `katex`, `d3`) were ~0.95 MB raw of every host's initial download even
|
|
195
|
+
* when no document ever contained a diagram. That is a rounding error for an in-app
|
|
196
|
+
* host and the whole cost for a CDN-loaded embedding of the realtime session overlay
|
|
197
|
+
* (MJ #3882). Documents with no diagram now never fetch the chunk at all.
|
|
198
|
+
*
|
|
199
|
+
* The in-flight promise is cached so N diagrams in one document share one fetch; a
|
|
200
|
+
* FAILED load clears it, so a later render can retry. Retries are bounded by
|
|
201
|
+
* user-driven render calls — there is no loop here.
|
|
202
|
+
*/
|
|
203
|
+
async loadMermaid() {
|
|
204
|
+
if (this.mermaidEngine) {
|
|
205
|
+
return this.mermaidEngine;
|
|
206
|
+
}
|
|
207
|
+
this.mermaidLoad ??= import('mermaid').then((module) => module.default);
|
|
208
|
+
try {
|
|
209
|
+
this.mermaidEngine = await this.mermaidLoad;
|
|
210
|
+
return this.mermaidEngine;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
this.mermaidLoad = null;
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
104
217
|
/**
|
|
105
218
|
* Initialize Mermaid with the current theme configuration.
|
|
106
219
|
* Re-initializes when the effective theme changes.
|
|
220
|
+
* @param mermaid The lazily-loaded mermaid engine (see {@link loadMermaid})
|
|
107
221
|
*/
|
|
108
|
-
initializeMermaid() {
|
|
222
|
+
initializeMermaid(mermaid) {
|
|
109
223
|
const effectiveTheme = this.resolveEffectiveMermaidTheme();
|
|
110
224
|
if (this.mermaidInitialized && this.lastMermaidTheme === effectiveTheme) {
|
|
111
225
|
return;
|
|
@@ -128,11 +242,24 @@ export class MarkdownService {
|
|
|
128
242
|
async renderMermaid(container) {
|
|
129
243
|
if (!this.currentConfig.enableMermaid)
|
|
130
244
|
return false;
|
|
131
|
-
this
|
|
132
|
-
//
|
|
245
|
+
// Find all mermaid code blocks BEFORE touching the engine — this is the branch the
|
|
246
|
+
// deferral hangs off: no diagrams in this document means no chunk fetch at all.
|
|
133
247
|
const mermaidBlocks = container.querySelectorAll('pre > code.language-mermaid, .mermaid');
|
|
134
248
|
if (mermaidBlocks.length === 0)
|
|
135
249
|
return false;
|
|
250
|
+
let mermaid;
|
|
251
|
+
try {
|
|
252
|
+
mermaid = await this.loadMermaid();
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
// The chunk could not be fetched (offline, blocked host, bad deploy). Say so on
|
|
256
|
+
// every block we were going to render — an empty panel would look like the diagram
|
|
257
|
+
// simply wasn't there.
|
|
258
|
+
console.error('Failed to load the Mermaid diagram engine:', error);
|
|
259
|
+
this.markMermaidBlocksFailed(mermaidBlocks, 'Diagram engine failed to load');
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
this.initializeMermaid(mermaid);
|
|
136
263
|
for (let i = 0; i < mermaidBlocks.length; i++) {
|
|
137
264
|
const block = mermaidBlocks[i];
|
|
138
265
|
const code = block.textContent || '';
|
|
@@ -153,13 +280,30 @@ export class MarkdownService {
|
|
|
153
280
|
}
|
|
154
281
|
catch (error) {
|
|
155
282
|
console.warn('Mermaid rendering failed:', error);
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
parent?.classList.add('mermaid-error');
|
|
283
|
+
// Show it failed on this block — the source stays visible underneath.
|
|
284
|
+
this.markMermaidBlocksFailed([block], 'Diagram rendering failed');
|
|
159
285
|
}
|
|
160
286
|
}
|
|
161
287
|
return true;
|
|
162
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Mark mermaid blocks as un-renderable, so the failure is VISIBLE rather than a block of
|
|
291
|
+
* raw diagram source the reader can't explain. The `.mermaid-error` class carries the
|
|
292
|
+
* error styling and a default caption; `data-mermaid-error` overrides that caption with
|
|
293
|
+
* the specific reason (see `.mermaid-error[data-mermaid-error]::before` in the component
|
|
294
|
+
* stylesheet).
|
|
295
|
+
*
|
|
296
|
+
* @param blocks The mermaid code blocks (or `.mermaid` elements) that could not be rendered
|
|
297
|
+
* @param message Short reason shown above the un-rendered source
|
|
298
|
+
*/
|
|
299
|
+
markMermaidBlocksFailed(blocks, message) {
|
|
300
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
301
|
+
const block = blocks[i];
|
|
302
|
+
const target = block.tagName === 'CODE' ? block.parentElement : block;
|
|
303
|
+
target?.classList.add('mermaid-error');
|
|
304
|
+
target?.setAttribute('data-mermaid-error', message);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
163
307
|
/**
|
|
164
308
|
* Highlight code blocks with Prism
|
|
165
309
|
* Call this after the HTML has been inserted into the DOM
|
|
@@ -304,16 +448,15 @@ export class MarkdownService {
|
|
|
304
448
|
(testDoc.body.innerHTML.trim().length > 0 &&
|
|
305
449
|
testDoc.body.innerHTML.includes('<'));
|
|
306
450
|
if (hasStructure) {
|
|
307
|
-
// Replace the <pre> with the actual HTML content
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
pre.parentNode?.replaceChild(fragment, pre);
|
|
451
|
+
// Replace the <pre> with the actual HTML content. The nodes are built inside a
|
|
452
|
+
// <template> of the inert DOMParser document, never on the live `document`:
|
|
453
|
+
// setting innerHTML on a live element fires image error handlers at parse time,
|
|
454
|
+
// before the sanitizer in parse() has seen the markup. A <template> also parses
|
|
455
|
+
// in body context, so a mockup that starts with <style> keeps it (a full-document
|
|
456
|
+
// parse would hoist it into <head> and drop it).
|
|
457
|
+
const template = doc.createElement('template');
|
|
458
|
+
template.innerHTML = content;
|
|
459
|
+
pre.replaceWith(template.content);
|
|
317
460
|
modified = true;
|
|
318
461
|
}
|
|
319
462
|
}
|