@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.
@@ -1 +1 @@
1
- {"version":3,"file":"svg-renderer.extension.js","sourceRoot":"","sources":["../../../src/lib/extensions/svg-renderer.extension.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAAsB;IACvD,yBAAyB;IACzB,MAAM,OAAO,GAAG,SAAS,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAC;IACrD,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IAE3C,0CAA0C;IAC1C,MAAM,WAAW,GAAG,SAAS,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC;IACpD,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE;QACvB,yCAAyC;QACzC,MAAM,cAAc,GAAG;YACrB,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,aAAa,EAAE,YAAY;YAC3D,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS;YACtD,WAAW,EAAE,SAAS,EAAE,YAAY;SACrC,CAAC;QAEF,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE;YAC5B,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC1B,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;YAC3B,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,+CAA+C;QAC/C,IAAI,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,CAAC;YAC5B,MAAM,IAAI,GAAG,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;YAC3C,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;gBACjD,EAAE,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;YAC7B,CAAC;QACH,CAAC;QACD,IAAI,EAAE,CAAC,YAAY,CAAC,YAAY,CAAC,EAAE,CAAC;YAClC,MAAM,IAAI,GAAG,EAAE,CAAC,YAAY,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC;YACjD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;gBACjD,EAAE,CAAC,eAAe,CAAC,YAAY,CAAC,CAAC;YACnC,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,2DAA2D;IAC3D,MAAM,cAAc,GAAG,SAAS,CAAC,gBAAgB,CAAC,eAAe,CAAC,CAAC;IACnE,cAAc,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC;IAE1C,qDAAqD;IACrD,MAAM,WAAW,GAAG,SAAS,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC;IACtD,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE;QACxB,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC,YAAY,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC;QAC9E,IAAI,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACvF,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/**\n * Helper function to sanitize SVG content by removing potentially dangerous elements.\n * Call this on the container element after rendering if you need additional security.\n *\n * @param container The DOM element containing rendered SVG\n */\nexport function sanitizeSvgContent(container: HTMLElement): void {\n // Remove script elements\n const scripts = container.querySelectorAll('script');\n scripts.forEach(script => script.remove());\n\n // Remove event handlers from all elements\n const allElements = container.querySelectorAll('*');\n allElements.forEach(el => {\n // Remove common event handler attributes\n const dangerousAttrs = [\n 'onload', 'onerror', 'onclick', 'onmouseover', 'onmouseout',\n 'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset',\n 'onkeydown', 'onkeyup', 'onkeypress'\n ];\n\n dangerousAttrs.forEach(attr => {\n if (el.hasAttribute(attr)) {\n el.removeAttribute(attr);\n }\n });\n\n // Remove javascript: URLs from href/xlink:href\n if (el.hasAttribute('href')) {\n const href = el.getAttribute('href') || '';\n if (href.toLowerCase().startsWith('javascript:')) {\n el.removeAttribute('href');\n }\n }\n if (el.hasAttribute('xlink:href')) {\n const href = el.getAttribute('xlink:href') || '';\n if (href.toLowerCase().startsWith('javascript:')) {\n el.removeAttribute('xlink:href');\n }\n }\n });\n\n // Remove foreignObject elements (can contain HTML/scripts)\n const foreignObjects = container.querySelectorAll('foreignObject');\n foreignObjects.forEach(fo => fo.remove());\n\n // Remove use elements pointing to external resources\n const useElements = container.querySelectorAll('use');\n useElements.forEach(use => {\n const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';\n if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {\n use.remove();\n }\n });\n}\n"]}
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":"AAGA,OAAO,EAEL,cAAc,EAEd,sBAAsB,EACtB,WAAW,EAEZ,MAAM,+BAA+B,CAAC;AAIvC,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;;AAE1C;;;;;;;;;GASG;AACH,qBAGa,eAAe;IAC1B,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,aAAa,CAA0D;IAE/E;;;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;IAoBxE;;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;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAmBzB;;;;OAIG;IACU,aAAa,CAAC,SAAS,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IA0CpE;;;;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;IAsE1B;;;;OAIG;IACH,OAAO,CAAC,uBAAuB;yCA5VpB,eAAe;6CAAf,eAAe;CAoX3B"}
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>&lt;style&gt;.card{color:red}&lt;/style&gt;&lt;div class="card"&gt;&lt;img src="x" onerror="alert(1)"&gt;&lt;p&gt;hi&lt;/p&gt;&lt;/div&gt;</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>&lt;svg&gt;&lt;rect/&gt;&lt;/svg&gt;</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\">&lt;div&gt;&lt;span&gt;x&lt;/span&gt;&lt;/div&gt;</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>&lt;div&gt;&lt;span&gt;x&lt;/span&gt;&lt;/div&gt;</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('&lt;div&gt;');\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>&lt;br&gt;</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>&lt;div&gt;&lt;span&gt;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>&lt;svg&gt;&lt;rect/&gt;&lt;/svg&gt;</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\">&lt;div&gt;&lt;span&gt;x&lt;/span&gt;&lt;/div&gt;</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>&lt;div&gt;&lt;span&gt;x&lt;/span&gt;&lt;/div&gt;</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('&lt;div&gt;');\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>&lt;br&gt;</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>&lt;div&gt;&lt;span&gt;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>&lt;style&gt;.card{color:red}&lt;/style&gt;&lt;div class=\"card\"&gt;&lt;img src=\"x\" onerror=\"alert(1)\"&gt;&lt;p&gt;hi&lt;/p&gt;&lt;/div&gt;</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.initializeMermaid();
132
- // Find all mermaid code blocks
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
- // Add error class to show it failed
157
- const parent = block.tagName === 'CODE' ? block.parentElement : block;
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
- const wrapper = document.createElement('div');
309
- wrapper.className = 'unwrapped-html';
310
- wrapper.innerHTML = content;
311
- // Move all children from wrapper to replace pre
312
- const fragment = document.createDocumentFragment();
313
- while (wrapper.firstChild) {
314
- fragment.appendChild(wrapper.firstChild);
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
  }