diff --git a/angular.json b/angular.json index fddbd81473..0a7b99176b 100644 --- a/angular.json +++ b/angular.json @@ -47,7 +47,7 @@ "stylePreprocessorOptions": { "includePaths": ["node_modules/"] }, - "styles": ["src/styles.scss"], + "styles": ["src/styles.scss", "node_modules/katex/dist/katex.min.css"], "scripts": [], "allowedCommonJsDependencies": [ "@babel/standalone", diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md index 089ac799bc..b00702bea0 100644 --- a/api-goldens/element-ng/markdown-renderer/index.api.md +++ b/api-goldens/element-ng/markdown-renderer/index.api.md @@ -4,16 +4,43 @@ ```ts -import { DomSanitizer } from '@angular/platform-browser'; -import * as i0 from '@angular/core'; +import * as _angular_core from '@angular/core'; +import { Extension } from 'micromark-util-types'; +import { HtmlExtension } from 'micromark-util-types'; +import { InjectionToken } from '@angular/core'; +import { Provider } from '@angular/core'; +import { SiTranslateService } from '@siemens/element-translate-ng/translate'; +import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; // @public -export const getMarkdownRenderer: (sanitizer: DomSanitizer) => ((text: string) => Node); +export const injectMarkdownRenderer: (options?: MarkdownRendererOptions) => MarkdownRenderer; + +// @public +export type MarkdownRenderer = (text: string) => Node; + +// @public (undocumented) +export interface MarkdownRendererOptions { + copyCodeButton?: TranslatableString; + downloadTableButton?: TranslatableString; + mathExtensions?: { + syntax: Extension; + html: HtmlExtension; + }; + syntaxHighlighter?: (code: string, language?: string) => string | undefined; + translateSync?: SiTranslateService['translateSync']; +} + +// @public +export const provideMarkdownRenderer: (options?: MarkdownRendererOptions) => Provider; + +// @public +export const SI_MARKDOWN_RENDERER: InjectionToken; // @public export class SiMarkdownRendererComponent { constructor(); - readonly text: i0.InputSignal; + readonly renderer: _angular_core.InputSignal; + readonly text: _angular_core.InputSignal; } // (No @packageDocumentation comment for this package) diff --git a/api-goldens/element-ng/translate/index.api.md b/api-goldens/element-ng/translate/index.api.md index 1bbd88786e..a682f51667 100644 --- a/api-goldens/element-ng/translate/index.api.md +++ b/api-goldens/element-ng/translate/index.api.md @@ -352,6 +352,10 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_MAIN_DETAIL_CONTAINER.BACK'?: string; // (undocumented) + 'SI_MARKDOWN_RENDERER.COPY_CODE'?: string; + // (undocumented) + 'SI_MARKDOWN_RENDERER.DOWNLOAD'?: string; + // (undocumented) 'SI_NAVBAR.OPEN_LAUNCHPAD'?: string; // (undocumented) 'SI_NAVBAR.TOGGLE_NAVIGATION'?: string; diff --git a/package-lock.json b/package-lock.json index a81e580e3b..8940bde357 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "@siemens/stylelint-config-scss": "4.2.1", "@types/geojson": "7946.0.16", "@types/google-libphonenumber": "7.4.30", + "@types/katex": "0.16.8", "@types/node": "24.12.2", "@vitest/browser-playwright": "4.1.4", "@vitest/coverage-v8": "4.1.4", @@ -89,8 +90,16 @@ "eslint-plugin-perfectionist": "5.8.0", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-tsdoc": "0.5.2", + "highlight.js": "11.11.1", "http-server": "14.1.1", "husky": "9.1.7", + "katex": "0.16.44", + "micromark": "4.0.2", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-strikethrough": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", "ng-packagr": "21.0.1", "piscina": "5.1.4", "postcss": "8.5.9", @@ -12892,6 +12901,16 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -13008,6 +13027,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -13015,6 +13041,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", @@ -15534,6 +15567,17 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -15700,6 +15744,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-highlight/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/cli-highlight/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -16803,6 +16857,20 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -16892,6 +16960,16 @@ "node": ">=4" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -16921,6 +16999,20 @@ "dev": true, "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -19464,13 +19556,13 @@ } }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "dev": true, "license": "BSD-3-Clause", "engines": { - "node": "*" + "node": ">=12.0.0" } }, "node_modules/homedir-polyfill": { @@ -20696,6 +20788,33 @@ "source-map-support": "^0.5.5" } }, + "node_modules/katex": { + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "devOptional": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -21500,6 +21619,520 @@ "node": ">= 0.6" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "devOptional": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -32466,6 +33099,12 @@ "ag-grid-community": "^34.3.1", "flag-icons": "^7.3.2", "google-libphonenumber": "^3.2.40", + "micromark": "4.0.2", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-strikethrough": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", "ngx-image-cropper": "^9.0.0" }, "peerDependenciesMeta": { @@ -32487,6 +33126,24 @@ "google-libphonenumber": { "optional": true }, + "micromark": { + "optional": true + }, + "micromark-extension-gfm-autolink-literal": { + "optional": true + }, + "micromark-extension-gfm-strikethrough": { + "optional": true + }, + "micromark-extension-gfm-table": { + "optional": true + }, + "micromark-extension-math": { + "optional": true + }, + "micromark-util-types": { + "optional": true + }, "ngx-image-cropper": { "optional": true } diff --git a/package.json b/package.json index b45741e199..65f7469648 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "@siemens/stylelint-config-scss": "4.2.1", "@types/geojson": "7946.0.16", "@types/google-libphonenumber": "7.4.30", + "@types/katex": "0.16.8", "@types/node": "24.12.2", "@vitest/browser-playwright": "4.1.4", "@vitest/coverage-v8": "4.1.4", @@ -157,8 +158,16 @@ "eslint-plugin-perfectionist": "5.8.0", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-tsdoc": "0.5.2", + "highlight.js": "11.11.1", "http-server": "14.1.1", "husky": "9.1.7", + "katex": "0.16.44", + "micromark": "4.0.2", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-strikethrough": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", "ng-packagr": "21.0.1", "piscina": "5.1.4", "postcss": "8.5.9", diff --git a/playwright/e2e/element-examples/static.spec.ts b/playwright/e2e/element-examples/static.spec.ts index 621ee716aa..54d37a7868 100644 --- a/playwright/e2e/element-examples/static.spec.ts +++ b/playwright/e2e/element-examples/static.spec.ts @@ -110,7 +110,16 @@ test('typography/type-styles', ({ si }) => si.static()); test('typography/display-styles', ({ si }) => si.static()); test('typography/typography', ({ si }) => si.static()); test('si-markdown-renderer/si-markdown-renderer', ({ si }) => - si.static({ disabledA11yRules: ['link-in-text-block'] })); + si.static({ + disabledA11yRules: ['link-in-text-block', 'color-contrast'], + waitCallback: async page => { + await page.waitForSelector('.katex', { timeout: 5000 }); + await page.waitForTimeout(500); + const height = await page.evaluate(() => document.body.scrollHeight); + const width = await page.evaluate(() => document.body.scrollWidth); + await page.setViewportSize({ height, width }); + } + })); test('si-chat-messages/si-ai-message', ({ si }) => si.static()); test('si-chat-messages/si-user-message', ({ si }) => si.static()); test('si-chat-messages/si-chat-message', ({ si }) => si.static()); diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png index c77de3d064..591238b7ce 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47c7cd95f13553e780ee41623da9f17efd99f4e95e73767444b635e835c74c2f -size 13969 +oid sha256:6aa19c11e9134485ddb9ffa6253d74bdd77dab9483e67f3aaa3a463a18cd4b9c +size 13937 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png index aed7c87151..f6e130d1c4 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cc4bcfe0c9531ca60e34fb192e5a302abe860256a1ad38671c44a39b5120c888 -size 13646 +oid sha256:996a44056e13397dbabfc10f91dd18182d6abe470e4da10f1a52c50f730f3b25 +size 13596 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png index 65c8a51606..af53fac499 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6de6fc3807f942f4be73f70f63f44d5fc153763701852e924a9196508711c9f8 -size 21207 +oid sha256:1f7c61f4344c9e4998399b4fa3e75c89a80d779cd58f8c1517997d7f61e707fb +size 20497 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png index 9a6a985ef1..06dd2361e4 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5e122c5281e09d8b672acf2ad5758b8d4c1520c92c7207d2dfc56a5398d28b2 -size 20107 +oid sha256:2c0cbe3ed73288cfcd421d46ae7291bfc3d2f8005f9d0d8644ae1129a88c57e4 +size 19456 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message.yaml index 4844f4d07a..0011787f4c 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message.yaml @@ -1,9 +1,6 @@ - group: error-log.txt - group: screenshot.png -- paragraph: - - text: Can you help me with this - - strong: code snippet - - text: "? I'm getting an error when I run it." +- text: Can you help me with this **code snippet**? I'm getting an error when I run it. - button "Copy" - button "Edit" - button "More actions" diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png index fb100d1d7d..48d8708bff 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df0053753dd2184103eec236e4c6fc739e042220f0d609a50206542cdf1a8465 -size 15077 +oid sha256:ee44396e5c9e9f9fe815edfe483b4500151b431374f82763eb73a82e67b66d0f +size 15049 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png index a88725ae48..3a3aef966e 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0ee28a21284d866342998a330e044107a61fe58eb9628eb243740c30f13c7fe -size 14654 +oid sha256:960d76c83b7f16148da29d60937d3ec2d75dbda9b9c6730200b99c3029b2bc92 +size 14659 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png index 5ce87f7b9d..9c9f3ca4b8 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9099595741c83cc68b109c3e10c580a16baa955edada83be763a4e0463c4b562 -size 142664 +oid sha256:b48cb48db900a1f4ff0afe0af08d7aa4e5956fd2698299842adf47edc642e0de +size 503960 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png index 1879f35f28..eeb9320d9e 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6afc8c75c504ed3890c26dfab579c9a23ccc0d2a88382c67e8fe2da7758c230 -size 139215 +oid sha256:4d25c40565c93d87d4223d834f46d730b0dcc8d2b9bb92a00c8cee116be72fa4 +size 501185 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml index caf1199cb2..91297e69c4 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml @@ -1,5 +1,5 @@ -- heading "AI Assistant Response" [level=2]: - - strong: AI Assistant Response +- heading "Sample Markdown Content" [level=2]: + - strong: Sample Markdown Content - paragraph: - text: Here's a - strong: comprehensive example @@ -10,9 +10,9 @@ - text: You can use inline code like - code: console.log('Hello World') - text: "or multi-line code blocks:" -- paragraph +- text: javascript - code: "function calculateSum(a, b) { return a + b; } const result = calculateSum(5, 3); console.log(`Result: ${result}`);" -- paragraph +- separator - heading "Formatting Options" [level=2] - paragraph: Here's a paragraph explaining the formatting options available. - paragraph: "Another paragraph with different formatting elements:" @@ -39,6 +39,7 @@ - text: "Links are also automatically detected:" - link "https://angular.io": - /url: https://angular.io +- separator - heading "Lists and Bullets" [level=2] - paragraph: "Here are the key features:" - list: @@ -47,6 +48,8 @@ - listitem: Inline code highlighting - listitem: Bullet point lists - listitem: Blockquote support + - listitem: Or in the alternate format + - listitem: Another bullet point - paragraph: This paragraph appears after the list to show proper spacing. - heading "Ordered Lists" [level=2] - paragraph: "Step-by-step instructions:" @@ -54,7 +57,9 @@ - listitem: First, analyze the requirements - listitem: Then, implement the solution - listitem: Finally, test the implementation -- blockquote: This is a blockquote that demonstrates how quoted text appears in the markdown content component. +- separator +- blockquote: + - paragraph: This is a blockquote that demonstrates how quoted text appears in the markdown content component. - paragraph: This paragraph follows the blockquotes to demonstrate proper paragraph separation. - paragraph: This is a separate paragraph created by double line breaks. - list: @@ -65,79 +70,68 @@ - listitem: First ordered item - listitem: Second ordered item - paragraph: Final paragraph to show proper spacing. +- separator +- heading "Images" [level=2] +- paragraph: "Images can be included as follows:" +- paragraph: + - img "Building Image" +- separator - heading "Tables" [level=2] - paragraph: "Tables are also supported:" -- paragraph - table: - rowgroup: - row "Feature Examples Status Notes": - - cell "Feature": - - paragraph: Feature - - cell "Examples": - - paragraph: Examples - - cell "Status": - - paragraph: Status - - cell "Notes": - - paragraph: Notes + - columnheader "Feature" + - columnheader "Examples" + - columnheader "Status" + - columnheader "Notes" + - rowgroup: - row "Basic content Alice Johnson Bob Smith ✓ Complete Simple text and line breaks": - cell "Basic content": - - paragraph: - - strong: Basic content - - cell "Alice Johnson Bob Smith": - - paragraph: Alice Johnson Bob Smith - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Simple text and line breaks": - - paragraph: Simple text and line breaks + - strong: Basic content + - cell "Alice Johnson Bob Smith" + - cell "✓ Complete" + - cell "Simple text and line breaks" - row "Formatting Bold and italic Plus code and multiline code ✓ Complete Multiple markdown formats": - cell "Formatting": - - paragraph: - - emphasis: Formatting + - emphasis: Formatting - cell "Bold and italic Plus code and multiline code": - - paragraph: - - strong: Bold - - text: and - - emphasis: italic - - text: Plus - - code: code - - text: and - - code: multiline code - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Multiple markdown formats": - - paragraph: Multiple markdown formats + - strong: Bold + - text: "" + - emphasis: italic + - text: "" + - code: code + - text: "" + - code: multiline code + - cell "✓ Complete" + - cell "Multiple markdown formats" - row "Lists in cells First item Second item Third item ✓ Complete Bullet lists work properly": - - cell "Lists in cells": - - paragraph: Lists in cells + - cell "Lists in cells" - cell "First item Second item Third item": - list: - listitem: First item - listitem: Second item - listitem: Third item - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Bullet lists work properly": - - paragraph: Bullet lists work properly - - 'row "Escaped pipes grep \"text|pattern\" awk ''{print $1|$2}'' ✓ Complete Use | for literal pipes"': - - cell "Escaped pipes": - - paragraph: Escaped pipes - - 'cell "grep \"text|pattern\" awk ''{print $1|$2}''"': - - paragraph: "grep \"text|pattern\" awk '{print $1|$2}'" - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Use | for literal pipes": - - paragraph: Use | for literal pipes - - row "Line breaks Line 1 Line 2 Line 3 ✓ Complete Uses
tags": - - cell "Line breaks": - - paragraph: Line breaks - - cell "Line 1 Line 2 Line 3": - - paragraph: Line 1 Line 2 Line 3 - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Uses
tags": - - paragraph: - - text: Uses - - code:
- - text: tags -- text: This paragraph appears after the tables to demonstrate proper spacing. -- paragraph \ No newline at end of file + - cell "✓ Complete" + - cell "Bullet lists work properly" + - 'row "Escaped pipes grep \"text|pattern\" awk ''{print 2}'' ✓ Complete Use | for literal pipes"': + - cell "Escaped pipes" + - 'cell "grep \"text|pattern\" awk ''{print 2}''"' + - cell "✓ Complete" + - cell "Use | for literal pipes" + - row "Line breaks Line 1 Line 2 Line 3 ✓ Complete Uses tags": + - cell "Line breaks" + - cell "Line 1 Line 2 Line 3" + - cell "✓ Complete" + - cell "Uses tags": + - text: "" + - code + - text: "" +- paragraph: This paragraph appears after the tables to demonstrate proper spacing. +- separator +- heading "Math Expressions" [level=2] +- paragraph: LaTeX math expressions are supported for mathematical notation. +- paragraph: "Inline math can be written like this: or the quadratic formula ." +- paragraph: "/You can escape dollar signs with a backslash to show literal prices: \\$\\d+, \\$\\d+, \\$\\d+\\./" +- paragraph: "Display math uses double dollar signs for block equations:" +- paragraph: UML is not supported in this markdown component. \ No newline at end of file diff --git a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts index 82868ac14d..5556d2c07f 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts @@ -4,8 +4,8 @@ */ import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By, DomSanitizer } from '@angular/platform-browser'; -import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; +import { By } from '@angular/platform-browser'; +import { injectMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; import { MenuItem } from '@siemens/element-ng/menu'; import { MessageAction } from './message-action.model'; @@ -41,8 +41,7 @@ describe('SiAiMessageComponent', () => { ] }); debugElement = fixture.debugElement; - const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); + markdownRenderer = TestBed.runInInjectionContext(() => injectMarkdownRenderer()); }); it('should render markdown content', async () => { diff --git a/projects/element-ng/chat-messages/si-ai-message.component.ts b/projects/element-ng/chat-messages/si-ai-message.component.ts index 6f5f9f8b5a..c18888e8a4 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.ts @@ -31,13 +31,13 @@ import { SiChatMessageComponent } from './si-chat-message.component'; * * The component automatically handles: * - Styling for AI messages distinct from user or generic chat messages - * - Option to render markdown content, provide via `contentFormatter` input with a markdown renderer function (e.g., from {@link getMarkdownRenderer}) + * - Option to render markdown content, provide via `contentFormatter` input with a markdown renderer function (e.g., from {@link injectMarkdownRenderer}) * - Showing loading states with skeleton UI during generation * - Displaying primary and secondary actions * * @see {@link SiChatMessageComponent} for the base message wrapper component * @see {@link SiUserMessageComponent} for the user message component - * @see {@link getMarkdownRenderer} for markdown formatting support + * @see {@link injectMarkdownRenderer} for markdown formatting support * @see {@link SiChatContainerComponent} for the chat container to use this within * * @experimental @@ -72,7 +72,7 @@ export class SiAiMessageComponent { * * **Note:** If using a markdown renderer, make sure to apply the `markdown-content` class * to the root element to ensure proper styling using the Element theme (e.g., `div.className = 'markdown-content'`). - * The function returned by {@link getMarkdownRenderer} does this automatically. + * The function returned by {@link injectMarkdownRenderer} does this automatically. * * **Warning:** When returning a Node, ensure the content is safe to prevent XSS attacks * @defaultValue undefined diff --git a/projects/element-ng/chat-messages/si-user-message.component.spec.ts b/projects/element-ng/chat-messages/si-user-message.component.spec.ts index ca1e94ffe8..208dc6164f 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-user-message.component.spec.ts @@ -4,8 +4,8 @@ */ import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By, DomSanitizer } from '@angular/platform-browser'; -import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; +import { By } from '@angular/platform-browser'; +import { injectMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; import { MenuItem } from '@siemens/element-ng/menu'; import { MessageAction } from './message-action.model'; @@ -42,8 +42,7 @@ describe('SiUserMessageComponent', () => { ] }); debugElement = fixture.debugElement; - const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); + markdownRenderer = TestBed.runInInjectionContext(() => injectMarkdownRenderer()); }); it('should render markdown content', async () => { diff --git a/projects/element-ng/chat-messages/si-user-message.component.ts b/projects/element-ng/chat-messages/si-user-message.component.ts index 1b6538160f..7f7fa9911a 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.ts +++ b/projects/element-ng/chat-messages/si-user-message.component.ts @@ -24,14 +24,14 @@ import { SiChatMessageComponent } from './si-chat-message.component'; * * The component automatically handles: * - Styling for user messages distinct from AI or generic chat messages - * - Option to render markdown content, provide via `contentFormatter` input with a markdown renderer function (e.g., from {@link getMarkdownRenderer}) + * - Option to render markdown content, provide via `contentFormatter` input with a markdown renderer function (e.g., from {@link injectMarkdownRenderer}) * - Displaying attachments above the message bubble * - Displaying primary and secondary actions * * @see {@link SiChatMessageComponent} for the base message wrapper component * @see {@link SiAiMessageComponent} for the AI message component * @see {@link SiAttachmentListComponent} for the base attachment component - * @see {@link getMarkdownRenderer} for markdown formatting support + * @see {@link injectMarkdownRenderer} for markdown formatting support * @see {@link SiChatContainerComponent} for the chat container to use this within * * @experimental @@ -67,7 +67,7 @@ export class SiUserMessageComponent { * * **Note:** When returning a Node with formatted content, apply the `markdown-content` class * to the root element to ensure proper styling (e.g., `div.className = 'markdown-content'`). - * The function returned by {@link getMarkdownRenderer} does this automatically. + * The function returned by {@link injectMarkdownRenderer} does this automatically. * * **Warning:** When returning a Node, ensure the content is safe to prevent XSS attacks * @defaultValue undefined diff --git a/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts new file mode 100644 index 0000000000..da40565b63 --- /dev/null +++ b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ + +/** + * Gets a cached HTML element or creates a new one if not in cache. + * Implements LRU caching strategy. + */ +export const getCachedOrCreateElement = ( + cache: Map, + cacheOrder: string[], + cacheSize: number, + key: string, + createHtml: () => string, + doc: Document +): HTMLElement => { + const cached = cache.get(key); + if (cached) { + const orderIndex = cacheOrder.indexOf(key); + if (orderIndex > -1) { + cacheOrder.splice(orderIndex, 1); + } + cacheOrder.push(key); + return cached; + } + + const tempDiv = doc.createElement('div'); + tempDiv.innerHTML = createHtml(); + const element = tempDiv.firstElementChild as HTMLElement; + + cache.set(key, element); + cacheOrder.push(key); + + if (cacheOrder.length > cacheSize) { + const oldestKey = cacheOrder.shift(); + if (oldestKey) { + cache.delete(oldestKey); + } + } + + return element; +}; diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index d7f1022c2d..5fa4f1094e 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -2,262 +2,626 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { SecurityContext } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, InjectionToken, PLATFORM_ID, Provider, SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import { + injectSiTranslateService, + t, + type SiTranslateService +} from '@siemens/element-translate-ng/translate'; +import { type TranslatableString } from '@siemens/element-translate-ng/translate-types'; +import { micromark } from 'micromark'; +import { + gfmAutolinkLiteral, + gfmAutolinkLiteralHtml +} from 'micromark-extension-gfm-autolink-literal'; +import { gfmStrikethrough, gfmStrikethroughHtml } from 'micromark-extension-gfm-strikethrough'; +import { gfmTable, gfmTableHtml } from 'micromark-extension-gfm-table'; +import type { CompileContext, Extension, HtmlExtension, Token } from 'micromark-util-types'; + +// Augment CompileData with custom fields used by our HTML extension +declare module 'micromark-util-types' { + interface CompileData { + codeLanguage?: string | undefined; + codeContent?: string | undefined; + currentTableId?: string | undefined; + currentTableIndex?: number | undefined; + inParagraph?: boolean | undefined; + } +} + +export interface MarkdownRendererOptions { + /** + * Optional syntax highlighter function for code blocks. + * Receives code content and optional language, returns highlighted HTML or undefined. + */ + syntaxHighlighter?: (code: string, language?: string) => string | undefined; + + /** + * Provide this to enable the copy code button functionality. + * Label for the copy code button (will be translated internally if translateSync is provided). + */ + copyCodeButton?: TranslatableString; + + /** + * Provide this to enable the download table button functionality. + * Label for the download table button (will be translated internally if translateSync is provided). + */ + downloadTableButton?: TranslatableString; + + /** + * Synchronous translation function for button labels. + */ + translateSync?: SiTranslateService['translateSync']; + + /** + * Micromark syntax and HTML extensions for math support. + * Pass the result of dynamically importing `micromark-extension-math` to enable LaTeX rendering. + * + * @example + * ```typescript + * const { math, mathHtml } = await import('micromark-extension-math'); + * const options = { + * mathExtensions: { syntax: math(), html: mathHtml() } + * }; + * ``` + */ + mathExtensions?: { + syntax: Extension; + html: HtmlExtension; + }; +} /** - * Returns a markdown renderer function which_ - * - Transforms markdown text into formatted HTML. - * - Returns a DOM node containing the formatted content. + * Returns a function that transforms markdown text into a formatted HTML node. * - * **Warning:** The returned Node is inserted without additional sanitization. - * Input content is sanitized before processing. + * Uses [micromark](https://github.com/micromark/micromark) for CommonMark-compliant parsing + * with GFM extensions (tables, autolink literals, strikethrough). * * @experimental - * @param sanitizer - Angular DomSanitizer instance - * @returns A function taking the markdown text to transform and returning a DOM div element containing the formatted HTML */ -export const getMarkdownRenderer = (sanitizer: DomSanitizer): ((text: string) => Node) => { - return (text: string): Node => { - const div = document.createElement('div'); - div.className = 'markdown-content text-break'; +const createMarkdownRenderer = ( + sanitizer: DomSanitizer, + options?: MarkdownRendererOptions, + doc?: Document, + isBrowser?: boolean +): ((text: string) => Node) => { + const docRef = doc ?? document; + const isInBrowser = isBrowser ?? true; + + // Build micromark extensions + const syntaxExtensions: Extension[] = [gfmTable(), gfmAutolinkLiteral(), gfmStrikethrough()]; + const htmlExtensions: HtmlExtension[] = [ + gfmTableHtml(), + gfmAutolinkLiteralHtml(), + gfmStrikethroughHtml() + ]; + + if (options?.mathExtensions) { + syntaxExtensions.push(options.mathExtensions.syntax); + htmlExtensions.push(options.mathExtensions.html); + } - if (!text) { - return div; + // Custom HTML extension — placed last so it overrides defaults + htmlExtensions.push(createCustomHtmlExtension(options)); + + const preprocessText = (text: string): string => { + let result = text; + result = result.replace(/^[•] /gm, '- '); + result = result.replace(/^• /gm, '- '); + result = result.replace(/^\* /gm, '- '); + + if (options?.mathExtensions) { + result = result.replace(/\$\$([^\n]+?)\$\$/g, '$$$$\n$1\n$$$$'); } - // Generate a random placeholder for newlines to preserve them during HTML sanitization - const newlinePlaceholder = `--NEWLINE-${Math.random().toString(36).substring(2, 15)}--`; - - // Replace newlines with placeholder before sanitization - const valueWithPlaceholders = text.replace(/\n/g, newlinePlaceholder); - - // Sanitize the input using Angular's HTML sanitizer - const sanitizedInput = sanitizer.sanitize(SecurityContext.HTML, valueWithPlaceholders) ?? ''; - - // Restore newlines from placeholder for markdown processing. - let html = sanitizedInput.replace(new RegExp(newlinePlaceholder, 'g'), '\n'); - - // Process tables first - html = html - // Remove table separator lines first - .replace(/^\|\s*[-:]+.*\|\s*$/gm, '') - // Process table rows - .replace(/^\|(.+)\|\s*$/gm, (_match, htmlContent) => { - // Handle escaped pipes by temporarily replacing them - const escapedPipePlaceholder = `--ESCAPED-PIPE-${Math.random().toString(36).substring(2, 15)}--`; - const contentWithPlaceholders = htmlContent.replace(/\\\|/g, escapedPipePlaceholder); - const cells = contentWithPlaceholders.split('|').map((cell: string) => { - const trimmedCell = cell.trim(); - // Restore escaped pipes - const cellWithPipes = trimmedCell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|'); - - return cellWithPipes; - }); - // Make cell ready for markdown processing by replacing code blocks with inline code and
with newlines - const cellsWithNewlines = cells.map((cell: string) => { - // Replace multiline code blocks with single line code blocks - const cellWithoutMultilineCode = cell.replace( - /```([\s\S]*?)```/g, - (_innerMatch, inlineCodeContent) => { - return '`' + inlineCodeContent.replace(/`/g, '') + '`'; - } - ); - // Temporarily replace single line code blocks to avoid replacing
inside them - const tableInlineCodeBrPlaceholder = `--INLINE-CODE-BR--${Math.random().toString(36).substring(2, 15)}--`; - const cellWithPlaceholders = cellWithoutMultilineCode.replace( - /(`[^`]*`)/g, - inlineCodeMatch => { - return inlineCodeMatch.replace(/
/g, tableInlineCodeBrPlaceholder); - } - ); - // Replace
with newlines - const cellWithNewlines = cellWithPlaceholders.replace(//gi, '\n'); - // Restore
in inline code placeholders - const preProcessedCell = cellWithNewlines.replace( - new RegExp(tableInlineCodeBrPlaceholder, 'g'), - '
' - ); - return preProcessedCell; - }); + return result; + }; - // Recursively process cell content for markdown formatting - const processedCells = cellsWithNewlines.map((cell: string) => { - return transformMarkdownText(cell, false, sanitizer); + const attachEventListeners = (container: HTMLElement): void => { + if (!isInBrowser) { + return; + } + + container.querySelectorAll('.copy-code-btn').forEach(btn => { + btn.addEventListener('click', e => { + const button = (e.target as HTMLElement).closest('.copy-code-btn') as HTMLButtonElement; + const codeId = button?.getAttribute('data-code-id'); + if (!codeId) { + return; + } + const codeElement = container.querySelector(`#${codeId}`); + if (!codeElement) { + return; + } + navigator.clipboard.writeText(codeElement.textContent ?? '').catch(() => { + console.warn('Failed to copy code to clipboard'); }); + }); + }); + + container.querySelectorAll('.download-table-btn').forEach(btn => { + btn.addEventListener('click', e => { + const button = (e.target as HTMLElement).closest( + '.download-table-btn' + ) as HTMLButtonElement; + const tableId = button?.getAttribute('data-table-id'); + if (!tableId) { + return; + } + const tableElement = container.querySelector(`#${tableId}`) as HTMLTableElement; + if (!tableElement) { + return; + } + + const csv = Array.from(tableElement.querySelectorAll('tr')) + .filter(row => + Array.from(row.querySelectorAll('td, th')).some( + cell => (cell.textContent ?? '').trim().length > 0 + ) + ) + .map(row => + Array.from(row.querySelectorAll('td, th')) + .map(cell => { + const text = cell.textContent ?? ''; + return text.includes(',') || text.includes('"') || text.includes('\n') + ? `"${text.replace(/"/g, '""')}"` + : t; + }) + .join(',') + ) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = docRef.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', 'table.csv'); + link.style.visibility = 'hidden'; + docRef.body.appendChild(link); + link.click(); + docRef.body.removeChild(link); + URL.revokeObjectURL(url); + }); + }); + }; + + /** + * Post-process the DOM to apply code block wrappers. + * Code blocks require DOM manipulation because the wrapper must surround + * the
 element that micromark emits before we can intervene.
+   */
+  const applyCodeBlockWrappers = (container: HTMLElement): void => {
+    container.querySelectorAll('pre').forEach(pre => {
+      const codeElement = pre.querySelector('code');
+      if (!codeElement) {
+        return;
+      }
+
+      const langClass = Array.from(codeElement.classList).find(c => c.startsWith('language-'));
+      const language = langClass?.replace('language-', '') ?? '';
+      const code = codeElement.textContent ?? '';
+      const codeId = `code-${Math.random().toString(36).substring(2, 15)}`;
+
+      codeElement.id = codeId;
+
+      // Apply syntax highlighting
+      if (options?.syntaxHighlighter && language) {
+        const highlighted = options.syntaxHighlighter(code, language);
+        if (highlighted) {
+          codeElement.innerHTML = sanitizer.sanitize(SecurityContext.HTML, highlighted) ?? '';
+        }
+      }
+
+      // Build copy button
+      let copyButton = '';
+      if (options?.copyCodeButton) {
+        const translatedLabel = options.translateSync
+          ? options.translateSync(options.copyCodeButton)
+          : options.copyCodeButton;
+        const buttonLabel = escapeHtml(translatedLabel);
+        copyButton = ``;
+      }
+
+      const languageLabel = language
+        ? `${escapeHtml(language)}`
+        : '';
+      const headerContent =
+        copyButton || languageLabel
+          ? `
${languageLabel}${copyButton}
` + : ''; + const wrapperClass = headerContent ? 'code-wrapper has-header' : 'code-wrapper'; + + // Wrap the
 element
+      const wrapper = docRef.createElement('div');
+      wrapper.className = wrapperClass;
+      wrapper.innerHTML = headerContent;
+      pre.parentNode?.insertBefore(wrapper, pre);
+      wrapper.appendChild(pre);
+    });
+  };
 
-        return `${processedCells.map((cell: string) => `${cell}`).join('')}`;
-      })
-      // Wrap table rows in table elements
-      .replace(/(.*?<\/tr>)/gs, '$1
') - // Remove duplicate table tags - .replace(/<\/table>\s*/g, ''); + /** + * Post-process table cells: + * - Restore <br> to actual
elements (escaped by micromark) + * - Convert list-like content (- item
- item) into proper
  • + */ + const processTableCells = (container: HTMLElement): void => { + container.querySelectorAll('td, th').forEach(cell => { + let html = cell.innerHTML; + + // Restore escaped
    tags to real
    + html = html.replace(/<br\s*\/?>/gi, '
    '); + cell.innerHTML = html; + + // Convert list patterns (- item
    - item) into
    • + html = cell.innerHTML; + if (!/(?:^|\n|)- /.test(html)) { + return; + } - html = transformMarkdownText(html, true, sanitizer); + const parts = html.split(//i); + const listItems: string[] = []; + const nonListParts: string[] = []; + + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith('- ')) { + listItems.push(trimmed.slice(2)); + } else if (listItems.length > 0) { + nonListParts.push('
        ' + listItems.map(item => `
      • ${item}
      • `).join('') + '
      '); + listItems.length = 0; + nonListParts.push(trimmed); + } else { + nonListParts.push(trimmed); + } + } + if (listItems.length > 0) { + nonListParts.push('
        ' + listItems.map(item => `
      • ${item}
      • `).join('') + '
      '); + } + cell.innerHTML = nonListParts.join('
      '); + }); + }; + + return (text: string): HTMLElement => { + const div = docRef.createElement('div'); + div.className = 'markdown-content text-break'; + + if (text === null || text === undefined) { + return div; + } + + const html = micromark(preprocessText(text), { + allowDangerousHtml: true, + extensions: syntaxExtensions, + htmlExtensions + }); div.innerHTML = html; + sanitizeDom(div); + customizeLinks(div, sanitizer); + if (options?.mathExtensions) { + wrapDisplayMath(div, docRef); + } + applyCodeBlockWrappers(div); + processTableCells(div); + attachEventListeners(div); return div; }; }; -const transformMarkdownText = ( - html: string, - keepAdditionalNewlines = true, - sanitizer: DomSanitizer -): string => { - // Generate a random placeholder for inner code blocks to prevent markdown processing inside them - const innerCodeQuotePlaceholder = `--INNER-CODE-${Math.random().toString(36).substring(2, 15)}--`; - const codeSectionPlaceholderMap = new Map(); - - const escapedAsteriskPlaceholder = `--ASTERISK-${Math.random().toString(36).substring(2, 15)}--`; - const escapedUnderscorePlaceholder = `--UNDERSCORE-${Math.random().toString(36).substring(2, 15)}--`; - - // Apply markdown transformations to the sanitized content - html = html - // Multiline code blocks ```code``` with placeholder - .replace(/```[^\n]*\n?([\s\S]*?)\n?```/g, (match, content) => { - // Escape HTML special characters in code blocks (not for security, but for correct display) and preserve inner backticks - const code = `
      ${content.replace(//g, '>').replace(/`/g, innerCodeQuotePlaceholder)}
      `; - const codePlaceholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; - codeSectionPlaceholderMap.set(codePlaceholder, code); - return codePlaceholder; - }) - - // Inline code `text` - .replace(/`(.*?)`/g, (match, content) => { - // Escape HTML special characters in inline code (not for security, but for correct display) - const code = `${content.replace(//g, '>')}`; - const codePlaceholder = `--INLINE-CODE-${Math.random().toString(36).substring(2, 15)}--`; - codeSectionPlaceholderMap.set(codePlaceholder, code); - return codePlaceholder; - }) - - // Images ![alt](url) - .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { - const sanitizedUrl = sanitizeUrl(url, sanitizer); - const escapedAlt = alt - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); - return `${escapedAlt}`; - }) - - // Links [text](url) - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { - const sanitizedUrl = sanitizeUrl(url, sanitizer); - return `${text}`; - }) - - // Auto-detect URLs and convert to links - .replace(/(? { - const sanitizedUrl = sanitizeUrl(match, sanitizer); - return `${match}`; - }) - - .replace(/(?$1') - .replace(/__(.*?)__/g, '$1') - - // Italic *text* or _text_ - .replace(/\*(.*?)\*/g, '$1') - .replace(/_(.*?)_/g, '$1') - - .replace(new RegExp(escapedAsteriskPlaceholder, 'g'), '*') - .replace(new RegExp(escapedUnderscorePlaceholder, 'g'), '_') - - // Headings #, ##, ###, etc. - .replace(/^###### (.*$)/gm, '$1') - .replace(/^##### (.*$)/gm, '
      $1
      ') - .replace(/^#### (.*$)/gm, '

      $1

      ') - .replace(/^### (.*$)/gm, '

      $1

      ') - .replace(/^## (.*$)/gm, '

      $1

      ') - .replace(/^# (.*$)/gm, '

      $1

      '); - - html = html - // Bullet points - handle each type separately (• gets converted to • by sanitizer) - .replace(/^• (.*$)/gm, '
    • $1
    • ') - .replace(/^- (.*$)/gm, '
    • $1
    • ') - .replace(/^\* (.*$)/gm, '
    • $1
    • ') - - // Ordered list items (1., 2., 3., etc.) - .replace(/^\d+\. (.*$)/gm, '
    • $1
    • '); - - html = html.replace(/^\s*(?:>|>)\s*(.*)$/gm, '
      $1
      '); - - // Generate a random placeholder for newlines to differentiate them from those used for paragraphs - const finalNewlinePlaceholder = `--NEWLINE-${Math.random().toString(36).substring(2, 15)}--`; - - html = html - // Wrap ordered lists - .replace(/(
    • .*?<\/li>)/gs, '
        $1
      ') - - // Wrap unordered lists - .replace(/(
    • .*?<\/li>)/gs, '
        $1
      ') - - // Remove duplicate ol/ul tags - .replace(/<\/ol>\s*
        /g, '') - .replace(/<\/ul>\s*
          /g, '') - - // Clean up class attributes - .replace(/ class="ordered"/g, '') - .replace(/ class="unordered"/g, ''); - - html = html - // Convert double newlines to paragraphs (before single line breaks) - .split(/\n{2}/g) - // Wrap non-block elements in

          tags - .map(segment => { - // If the segment starts with a block element, return as is - if (!segment.trim() || /^\s*<(h[1-6]|pre|blockquote|ul|ol)/.test(segment.trim())) { - // Replace newlines inside blocks with the placeholder - return segment.replace(/\n/g, finalNewlinePlaceholder); +/** + * Custom micromark HTML extension that produces the correct output inline + * during compilation, avoiding regex-based post-processing. + * + * Overrides: + * - Headings: h1 → h2+strong, h6 → strong + * - Lists: always tight (no

          wrapping in

        • ) + * - Paragraphs: track inParagraph state for lineEnding handler + * - Tables: class="table table-hover", wrapped in .table-wrapper, download button + * - Line endings: soft breaks →
          inside paragraphs only + * + * Customizations done via DOM post-processing (not here): + * - Links: target="_blank", rel="noopener noreferrer", class="link-text" + * - Math display: wrapped in .latex-display-wrapper + * - HTML sanitization: dangerous elements/attributes removed + */ +const createCustomHtmlExtension = (options?: MarkdownRendererOptions): HtmlExtension => { + let tableCounter = 0; + + const ext: HtmlExtension = { + enter: { + // Force all lists to be tight (replicate default handler logic after overriding _loose) + listOrdered(this: CompileContext, token: Token) { + token._loose = false; + const tightStack = this.getData('tightStack') as boolean[]; + tightStack.push(true); + this.lineEndingIfNeeded(); + this.tag(' inside paragraphs + paragraph(this: CompileContext) { + const tightStack = this.getData('tightStack') as boolean[]; + if (!tightStack[tightStack.length - 1]) { + this.lineEndingIfNeeded(); + this.tag('

          '); + } + this.setData('slurpAllLineEndings'); + this.setData('inParagraph', true); + }, + + // Tables: wrapper + classes + table(this: CompileContext, token: Token) { + const tableId = `table-${Math.random().toString(36).substring(2, 15)}`; + this.setData('currentTableId', tableId); + this.setData('currentTableIndex', tableCounter++); + this.lineEndingIfNeeded(); + this.raw('

          '); + this.tag(`
`); + this.setData( + 'tableAlign', + (token as unknown as Record)._align as undefined + ); + } + }, + exit: { + // Headings: h1→h2+strong, h6→strong + atxHeadingSequence(this: CompileContext, token: Token) { + if (this.getData('headingRank')) { + return; + } + const rank = this.sliceSerialize(token).length; + this.setData('headingRank', rank); + this.lineEndingIfNeeded(); + if (rank === 1) { + this.tag('

'); + } else if (rank === 6) { + this.tag(''); + } else { + this.tag(``); + } + }, + atxHeading(this: CompileContext) { + const rank = this.getData('headingRank') as number; + if (rank === 1) { + this.tag('

'); + } else if (rank === 6) { + this.tag(''); + } else { + this.tag(``); + } + this.setData('headingRank'); + }, + + // Tables: close wrappers, add download button + table(this: CompileContext) { + this.setData('tableAlign'); + this.setData('slurpAllLineEndings'); + this.lineEndingIfNeeded(); + this.tag('
'); + this.raw(''); // close .table-scroll-container + + if (options?.downloadTableButton) { + const translatedLabel = options.translateSync + ? options.translateSync(options.downloadTableButton) + : options.downloadTableButton; + const buttonLabel = escapeHtml(translatedLabel); + const tableId = this.getData('currentTableId') ?? ''; + const tableIndex = this.getData('currentTableIndex') ?? 0; + this.raw( + `` + ); + } + + this.raw(''); // close .table-wrapper + }, + + // Track paragraph exit + replicate default behavior + paragraph(this: CompileContext) { + this.setData('inParagraph'); + const tightStack = this.getData('tightStack') as boolean[]; + if (tightStack[tightStack.length - 1]) { + this.setData('slurpAllLineEndings', true); + } else { + this.tag('

'); + } + }, + + // Soft line breaks →
(only inside paragraphs) + lineEnding(this: CompileContext, token: Token) { + if (this.getData('slurpAllLineEndings')) { + return; + } + if (this.getData('slurpOneLineEnding')) { + this.setData('slurpOneLineEnding'); + return; + } + if (this.getData('inCodeText')) { + this.raw(' '); + return; + } + if (this.getData('inParagraph')) { + this.tag('
'); + } + this.raw(this.encode(this.sliceSerialize(token))); } - // Otherwise, wrap in

tags - return `

${segment}

`; - }) - // Use newline placeholder again so as not to replace newlines between blocks - .join(finalNewlinePlaceholder) - // Convert remaining newlines to line breaks (do this LAST) - .replace(/\n/g, '
') - // Restore newline placeholders - .replace(new RegExp(finalNewlinePlaceholder, 'g'), keepAdditionalNewlines ? '\n' : ' '); - - // Restore code placeholders - codeSectionPlaceholderMap.forEach((code, placeholder) => { - html = html.replace(new RegExp(placeholder, 'g'), code); + } + }; + + return ext; +}; + +/** + * DOM-based link customization: adds class, target, and rel attributes + * to all anchor elements, and sanitizes URLs. + */ +const customizeLinks = (container: HTMLElement, sanitizer: DomSanitizer): void => { + Array.from(container.querySelectorAll('a')).forEach(a => { + const href = a.getAttribute('href') ?? ''; + const sanitized = sanitizeUrl(href, sanitizer); + a.setAttribute('href', sanitized); + a.classList.add('link-text'); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); }); +}; - // Restore inner code block placeholders - html = html.replace(new RegExp(innerCodeQuotePlaceholder, 'g'), '`'); +/** + * DOM-based display math wrapping: wraps .math.math-display elements + * in a .latex-display-wrapper div for proper styling. + */ +const wrapDisplayMath = (container: HTMLElement, docRef: Document): void => { + Array.from(container.querySelectorAll('.math.math-display')).forEach(el => { + const wrapper = docRef.createElement('div'); + wrapper.className = 'latex-display-wrapper'; + el.parentNode?.insertBefore(wrapper, el); + wrapper.appendChild(el); + }); +}; + +/** + * Dangerous HTML elements that should be completely removed from the DOM. + */ +const DANGEROUS_ELEMENTS = ['script', 'iframe', 'object', 'embed', 'form', 'meta', 'link', 'style']; + +/** + * Dangerous HTML attribute prefixes/patterns. + */ +const DANGEROUS_ATTR_PATTERN = /^on|^formaction$/i; + +/** + * DOM-based sanitization that removes dangerous elements and attributes. + * This runs after innerHTML is set, so it catches both actual HTML elements + * and entity-encoded HTML that the browser decoded. + */ +const sanitizeDom = (container: HTMLElement): void => { + // Remove dangerous elements + for (const tagName of DANGEROUS_ELEMENTS) { + const elements = Array.from(container.querySelectorAll(tagName)); + for (let i = elements.length - 1; i >= 0; i--) { + elements[i].remove(); + } + } - return html; + // Remove dangerous attributes from all remaining elements + const allElements = Array.from(container.querySelectorAll('*')); + for (const el of allElements) { + const attrsToRemove: string[] = []; + for (const attr of Array.from(el.attributes)) { + if (DANGEROUS_ATTR_PATTERN.test(attr.name)) { + attrsToRemove.push(attr.name); + } + if (attr.name === 'href' || attr.name === 'src' || attr.name === 'action') { + if (/^\s*javascript:/i.test(attr.value)) { + attrsToRemove.push(attr.name); + } + } + } + for (const name of attrsToRemove) { + el.removeAttribute(name); + } + } }; /** - * Sanitizes a URL to prevent XSS attacks - * @param url - The URL to sanitize - * @param sanitizer - Angular DomSanitizer instance - * @returns The sanitized URL or '#' if invalid + * Sanitize URL to prevent XSS attacks. */ const sanitizeUrl = (url: string, sanitizer: DomSanitizer): string => { - // Remove any whitespace url = url.trim(); - - // Allow only http, https, and mailto protocols const allowed = /^(https?:\/\/|mailto:|\/(?!\/)|\.{1,2}\/|#)/i; - - // Sanitize the URL using Angular's sanitizer if (!allowed.test(url)) { return '#'; } + return sanitizer.sanitize(SecurityContext.URL, url) ?? '#'; +}; - // Sanitize the URL using Angular's sanitizer - const sanitized = sanitizer.sanitize(SecurityContext.URL, url); +/** + * Escape HTML special characters. + */ +const escapeHtml = (text: string): string => { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return sanitized || '#'; +/** + * Type alias for the markdown renderer function returned by {@link injectMarkdownRenderer}. + * + * @experimental + */ +export type MarkdownRenderer = (text: string) => Node; + +/** + * Creates a markdown renderer function within an Angular injection context. + * + * Must be called in an injection context (e.g. field initializer, constructor, or `runInInjectionContext`). + * + * @example + * ```typescript + * protected renderer = injectMarkdownRenderer({ syntaxHighlighter: myHighlighter }); + * ``` + * + * @experimental + * @param options - Optional configuration for the markdown renderer + * @returns A function taking the markdown text to transform and returning a DOM node containing the formatted HTML + */ +export const injectMarkdownRenderer = (options?: MarkdownRendererOptions): MarkdownRenderer => { + const sanitizer = inject(DomSanitizer); + const doc = inject(DOCUMENT); + const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + const translateService = injectSiTranslateService(); + + const resolvedOptions: MarkdownRendererOptions = { + copyCodeButton: t(() => $localize`:@@SI_MARKDOWN_RENDERER.COPY_CODE:Copy code`), + downloadTableButton: t(() => $localize`:@@SI_MARKDOWN_RENDERER.DOWNLOAD:Download CSV`), + translateSync: translateService.translateSync.bind(translateService), + ...options + }; + + return createMarkdownRenderer(sanitizer, resolvedOptions, doc, isBrowser); }; + +/** + * Injection token for a markdown renderer function. + * + * Provide it via {@link provideMarkdownRenderer} and inject where needed + * to pass as an input (e.g. `contentFormatter`) to components: + * + * @example + * ```typescript + * // in app config + * providers: [provideMarkdownRenderer({ syntaxHighlighter: myHighlighter })] + * + * // in a component + * protected markdownRenderer = inject(SI_MARKDOWN_RENDERER); + * ``` + * + * @experimental + */ +export const SI_MARKDOWN_RENDERER = new InjectionToken('SI_MARKDOWN_RENDERER'); + +/** + * Provider function to provide {@link SI_MARKDOWN_RENDERER} with custom options. + * + * @experimental + * @param options - Configuration for the markdown renderer + * @returns A provider for `SI_MARKDOWN_RENDERER` + */ +export const provideMarkdownRenderer = (options?: MarkdownRendererOptions): Provider => ({ + provide: SI_MARKDOWN_RENDERER, + useFactory: () => injectMarkdownRenderer(options) +}); diff --git a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts index 67e1580f0b..d87d90c1d8 100644 --- a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts +++ b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts @@ -4,290 +4,406 @@ */ import { inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { math, mathHtml } from 'micromark-extension-math'; +import { injectMarkdownRenderer, type MarkdownRenderer } from './markdown-renderer'; import { SiMarkdownRendererComponent as TestComponent } from './si-markdown-renderer.component'; describe('SiMarkdownRendererComponent', () => { let fixture: ComponentFixture; let hostElement: HTMLElement; let text: WritableSignal; - - beforeEach(() => { - text = signal(''); - fixture = TestBed.createComponent(TestComponent, { - bindings: [inputBinding('text', text)] + let renderer: WritableSignal; + + describe('with renderer', () => { + beforeEach(() => { + text = signal(''); + renderer = signal( + TestBed.runInInjectionContext(() => injectMarkdownRenderer()) + ); + fixture = TestBed.createComponent(TestComponent, { + bindings: [inputBinding('text', text), inputBinding('renderer', renderer)] + }); + hostElement = fixture.nativeElement; }); - hostElement = fixture.nativeElement; - }); - it('should render empty content for null/undefined input', async () => { - text.set(null); - await fixture.whenStable(); + it('should render empty content for null/undefined input', async () => { + text.set(null); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - expect(markdownDiv.innerHTML).toBe(''); - }); + const markdownDiv = hostElement.firstElementChild!; + expect(markdownDiv.innerHTML).toBe(''); + }); - it('should render plain text without transformation', async () => { - const plainText = 'This is plain text'; - text.set(plainText); - await fixture.whenStable(); + it('should render plain text without transformation', async () => { + const plainText = 'This is plain text'; + text.set(plainText); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - expect(markdownDiv).toHaveTextContent(plainText); - }); + const markdownDiv = hostElement.firstElementChild!; + expect(markdownDiv).toHaveTextContent(plainText); + }); - it('should transform bold markdown **text**', async () => { - text.set('This is **bold** text'); - await fixture.whenStable(); + it('should transform bold markdown **text**', async () => { + text.set('This is **bold** text'); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const strongElement = markdownDiv.querySelector('strong')!; - expect(strongElement).toHaveTextContent('bold'); - }); + const markdownDiv = hostElement.firstElementChild!; + const strongElement = markdownDiv.querySelector('strong')!; + expect(strongElement).toHaveTextContent('bold'); + }); - it('should transform italic markdown *text*', async () => { - text.set('This is *italic* text'); - await fixture.whenStable(); + it('should transform italic markdown *text*', async () => { + text.set('This is *italic* text'); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const emElement = markdownDiv.querySelector('em')!; - expect(emElement).toHaveTextContent('italic'); - }); + const markdownDiv = hostElement.firstElementChild!; + const emElement = markdownDiv.querySelector('em')!; + expect(emElement).toHaveTextContent('italic'); + }); - it('should transform inline code `text`', async () => { - text.set('This is `code_` text'); - await fixture.whenStable(); + it('should transform inline code `text`', async () => { + text.set('This is `code_` text'); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const codeElement = markdownDiv.querySelector('code')!; - expect(codeElement).toHaveTextContent('code_'); - }); + const markdownDiv = hostElement.firstElementChild!; + const codeElement = markdownDiv.querySelector('code')!; + expect(codeElement).toHaveTextContent('code_'); + }); - it('should transform code blocks ```code```', async () => { - text.set('```\nconst x = 1;\n```'); - await fixture.whenStable(); + it('should transform code blocks ```code```', async () => { + text.set('```\nconst x = 1;\n```'); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const preElement = markdownDiv.querySelector('pre')!; - const codeElement = preElement.querySelector('code')!; - expect(codeElement).toHaveTextContent('const x = 1;'); - }); + const markdownDiv = hostElement.firstElementChild!; + const preElement = markdownDiv.querySelector('pre')!; + const codeElement = preElement.querySelector('code')!; + expect(codeElement).toHaveTextContent('const x = 1;'); + }); - it('should transform bullet points to lists (• character)', async () => { - text.set('• First item\n• Second item'); - await fixture.whenStable(); + it('should transform bullet points to lists (\u2022 character)', async () => { + text.set('\u2022 First item\n\u2022 Second item'); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const innerHTML = markdownDiv.innerHTML; + const markdownDiv = hostElement.firstElementChild!; + const innerHTML = markdownDiv.innerHTML; - expect(innerHTML).toContain('
  • First item
  • '); - expect(innerHTML).toContain('
  • Second item
  • '); - expect(innerHTML).toContain('
      '); - }); + expect(innerHTML).toContain('
    • First item
    • '); + expect(innerHTML).toContain('
    • Second item
    • '); + expect(innerHTML).toContain('
        '); + }); - it('should transform bullet points to lists (- character)', async () => { - text.set('- First item\n- Second item'); - await fixture.whenStable(); + it('should transform bullet points to lists (- character)', async () => { + text.set('- First item\n- Second item'); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const innerHTML = markdownDiv.innerHTML; + const markdownDiv = hostElement.firstElementChild!; + const innerHTML = markdownDiv.innerHTML; - expect(innerHTML).toContain('
      • First item
      • '); - expect(innerHTML).toContain('
      • Second item
      • '); - expect(innerHTML).toContain('
          '); - }); + expect(innerHTML).toContain('
        • First item
        • '); + expect(innerHTML).toContain('
        • Second item
        • '); + expect(innerHTML).toContain('
            '); + }); - it('should convert newlines to line breaks', async () => { - text.set('Line 1\nLine 2'); - await fixture.whenStable(); + it('should convert newlines to line breaks', async () => { + text.set('Line 1\nLine 2'); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const brElements = markdownDiv.querySelectorAll('br'); - expect(brElements.length).toBe(1); - }); + const markdownDiv = hostElement.firstElementChild!; + const brElements = markdownDiv.querySelectorAll('br'); + expect(brElements.length).toBe(1); + }); - it('should handle complex markdown with multiple elements', async () => { - const complexMarkdown = `This is **bold** text with _italic_, escaped \\_ and \\* and \`code\`. + it('should handle complex markdown with multiple elements', async () => { + const complexMarkdown = `This is **bold** text with _italic_, escaped \\_ and \\* and \`code\`. -• First item -• Second item +\u2022 First item +\u2022 Second item \`\`\` const example = "code block"; \`\`\``; - text.set(complexMarkdown); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const innerHTML = markdownDiv.innerHTML; - - // Check for transformed markdown in the HTML string - expect(innerHTML).toContain('bold'); - expect(innerHTML).toContain('italic'); - expect(innerHTML).toContain('code'); - expect(innerHTML).toContain('
            ');
            -    expect(innerHTML).toContain('
          • First item
          • '); - }); + text.set(complexMarkdown); + await fixture.whenStable(); + + const markdownDiv = hostElement.firstElementChild!; + const innerHTML = markdownDiv.innerHTML; + + expect(innerHTML).toContain('bold'); + expect(innerHTML).toContain('italic'); + expect(innerHTML).toContain('code'); + const codeWrapper = markdownDiv.querySelector('.code-wrapper')!; + expect(codeWrapper).toBeTruthy(); + const preElement = codeWrapper.querySelector('pre')!; + expect(preElement).toBeTruthy(); + expect(preElement.querySelector('code')).toBeTruthy(); + expect(innerHTML).toContain('
          • First item
          • '); + }); - it('should sanitize potentially dangerous HTML', async () => { - text.set('Safe text'); - await fixture.whenStable(); + it('should sanitize potentially dangerous HTML', async () => { + text.set('Safe text'); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const innerHTML = markdownDiv.innerHTML; + const markdownDiv = hostElement.firstElementChild!; + const innerHTML = markdownDiv.innerHTML; - // Script tags should be completely removed by Angular's sanitizer - expect(innerHTML).not.toContain(' | Bold |`; - text.set(tableMarkdown); - await fixture.whenStable(); + text.set(tableMarkdown); + await fixture.whenStable(); - const markdownDiv = hostElement.firstElementChild!; - const tdElements = markdownDiv.querySelectorAll('td'); + const markdownDiv = hostElement.firstElementChild!; + const tdElements = markdownDiv.querySelectorAll('td'); - expect(tdElements[2].innerHTML).not.toContain('