├── .npmignore ├── lib ├── public_api.ts ├── src │ ├── marked-renderer.ts │ ├── prism-plugin.ts │ ├── marked-extensions.ts │ ├── marked-options.ts │ ├── clipboard-options.ts │ ├── sanitize-options.ts │ ├── index.ts │ ├── provide-markdown.ts │ ├── language.pipe.ts │ ├── clipboard-button.component.ts │ ├── markdown.pipe.ts │ ├── language.pipe.spec.ts │ ├── clipboard-button.component.spec.ts │ ├── markdown.module.ts │ ├── markdown.pipe.spec.ts │ ├── katex-options.ts │ ├── markdown.component.ts │ ├── markdown.module.spec.ts │ ├── markdown.component.spec.ts │ └── markdown.service.ts ├── tsconfig.lib.prod.json ├── ng-package.json ├── ng-package.prod.json ├── tsconfig.spec.json ├── tsconfig.lib.json ├── package.json ├── karma.conf.js └── eslint.config.js ├── demo ├── src │ ├── app │ │ ├── bindings │ │ │ ├── remote │ │ │ │ ├── demo.html │ │ │ │ ├── demo.py │ │ │ │ ├── demo.cpp │ │ │ │ ├── language-pipe.html │ │ │ │ ├── demo.md │ │ │ │ ├── markdown-pipe.html │ │ │ │ └── demo.java │ │ │ ├── bindings.component.scss │ │ │ ├── bindings.component.ts │ │ │ └── bindings.component.html │ │ ├── shared │ │ │ ├── anchor │ │ │ │ ├── index.ts │ │ │ │ └── anchor.service.ts │ │ │ ├── http-raw-loader │ │ │ │ ├── index.ts │ │ │ │ └── http-raw-loader.service.ts │ │ │ ├── scrollspy-nav │ │ │ │ ├── index.ts │ │ │ │ ├── scrollspy-nav.component.html │ │ │ │ ├── scrollspy-nav.component.scss │ │ │ │ ├── scrollspy-nav.component.theme.scss │ │ │ │ └── scrollspy-nav.component.ts │ │ │ ├── clipboard-button │ │ │ │ ├── index.ts │ │ │ │ ├── clipboard-button.component.scss │ │ │ │ ├── clipboard-button.component.html │ │ │ │ └── clipboard-button.component.ts │ │ │ └── scrollspy-nav-layout │ │ │ │ ├── index.ts │ │ │ │ ├── scrollspy-nav-layout.component.scss │ │ │ │ ├── scrollspy-nav-layout.animation.ts │ │ │ │ ├── scrollspy-nav-layout.component.html │ │ │ │ └── scrollspy-nav-layout.component.ts │ │ ├── cheat-sheet │ │ │ ├── cheat-sheet.component.scss │ │ │ ├── remote │ │ │ │ ├── horizontal-rule.md │ │ │ │ ├── headers.md │ │ │ │ ├── emphasis.md │ │ │ │ ├── images.md │ │ │ │ ├── blockquotes.md │ │ │ │ ├── code-and-synthax-highlighting.md │ │ │ │ ├── tables.md │ │ │ │ ├── lists.md │ │ │ │ ├── lists-dot.md │ │ │ │ └── links.md │ │ │ ├── cheat-sheet.component.ts │ │ │ └── cheat-sheet.component.html │ │ ├── get-started │ │ │ ├── get-started.component.scss │ │ │ ├── get-started.component.html │ │ │ └── get-started.component.ts │ │ ├── plugins │ │ │ ├── remote │ │ │ │ ├── emoji.html │ │ │ │ ├── katex.html │ │ │ │ ├── mermaid.html │ │ │ │ ├── root-user-without-output.bash │ │ │ │ ├── katex-options.html │ │ │ │ ├── line-numbers.html │ │ │ │ ├── mermaid-options.html │ │ │ │ ├── windows-powershell-with-filter-output.powershell │ │ │ │ ├── line-highlight.html │ │ │ │ ├── non-root-user-with-output.bash │ │ │ │ └── windows-powershell-with-output.powershell │ │ │ ├── plugins.component.scss │ │ │ ├── plugins.component.ts │ │ │ └── plugins.component.html │ │ ├── syntax-highlight │ │ │ ├── syntax-highlight.component.scss │ │ │ ├── remote │ │ │ │ └── for-loop.js │ │ │ ├── syntax-highlight.component.ts │ │ │ └── syntax-highlight.component.html │ │ ├── rerender │ │ │ ├── rerender.component.scss │ │ │ ├── rerender.component.html │ │ │ └── rerender.component.ts │ │ ├── app.constant.ts │ │ ├── app.models.ts │ │ ├── app.animation.ts │ │ ├── app.component.theme.scss │ │ ├── app-routes.ts │ │ ├── app.component.html │ │ ├── app.marked-config.ts │ │ ├── app.config.ts │ │ ├── app.component.scss │ │ └── app.component.ts │ ├── scss │ │ ├── _typography.scss │ │ ├── _dark-theme.scss │ │ ├── _utils.scss │ │ ├── _light-theme.scss │ │ ├── material-theme.scss │ │ └── prism-theme.scss │ ├── main.ts │ ├── global.d.ts │ ├── index.html │ ├── styles.scss │ └── prism.ts ├── public │ ├── favicon.ico │ ├── ngx-markdown.png │ ├── icon-get-started.svg │ ├── icon-chevron-up.svg │ ├── icon-re-render.svg │ ├── icon-syntax-highlight.svg │ ├── icon-bindings.svg │ ├── icon-light-on.svg │ ├── icon-light-off.svg │ ├── icon-cheat-sheet.svg │ ├── icon-plugins.svg │ └── icon-github.svg ├── tsconfig.app.json ├── .browserslistrc └── eslint.config.js ├── .editorconfig ├── .vscode └── settings.json ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── tsconfig.json ├── eslint.config.js ├── .circleci └── config.yml ├── package.json └── angular.json /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/dist -------------------------------------------------------------------------------- /lib/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './src/index'; 2 | -------------------------------------------------------------------------------- /demo/src/app/bindings/remote/demo.html: -------------------------------------------------------------------------------- 1 |

HTML code

-------------------------------------------------------------------------------- /demo/src/app/shared/anchor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './anchor.service'; 2 | -------------------------------------------------------------------------------- /demo/src/app/bindings/remote/demo.py: -------------------------------------------------------------------------------- 1 | s = "Python syntax highlighting" 2 | print s -------------------------------------------------------------------------------- /lib/src/marked-renderer.ts: -------------------------------------------------------------------------------- 1 | export { Renderer as MarkedRenderer } from 'marked'; 2 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/cheat-sheet.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /demo/src/app/get-started/get-started.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /demo/src/app/shared/http-raw-loader/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-raw-loader.service'; 2 | -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scrollspy-nav.component'; 2 | -------------------------------------------------------------------------------- /demo/src/app/shared/clipboard-button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clipboard-button.component'; 2 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfcere/ngx-markdown/HEAD/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/src/app/bindings/remote/demo.cpp: -------------------------------------------------------------------------------- 1 | int main() 2 | { 3 | cout << "Hello world!" << endl; 4 | } -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/emoji.html: -------------------------------------------------------------------------------- 1 | 2 | I :heart: ngx-markdown 3 | -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav-layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scrollspy-nav-layout.component'; 2 | -------------------------------------------------------------------------------- /demo/src/app/syntax-highlight/syntax-highlight.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/katex.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /demo/public/ngx-markdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfcere/ngx-markdown/HEAD/demo/public/ngx-markdown.png -------------------------------------------------------------------------------- /demo/src/app/bindings/remote/language-pipe.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/mermaid.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/root-user-without-output.bash: -------------------------------------------------------------------------------- 1 | cd /usr/local/etc 2 | cp php.ini php.ini.bak 3 | vi php.ini -------------------------------------------------------------------------------- /demo/src/app/bindings/bindings.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | textarea { 6 | min-height: 360px; 7 | } 8 | -------------------------------------------------------------------------------- /demo/src/app/bindings/remote/demo.md: -------------------------------------------------------------------------------- 1 | ### Demo markdown 2 | 3 | ```html 4 |
5 | ``` 6 | -------------------------------------------------------------------------------- /demo/src/app/bindings/remote/markdown-pipe.html: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /demo/src/app/rerender/rerender.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | textarea { 6 | min-height: 340px; 7 | } 8 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/katex-options.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/line-numbers.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/mermaid-options.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/windows-powershell-with-filter-output.powershell: -------------------------------------------------------------------------------- 1 | Get-Date 2 | (out) 3 | (out)Sunday, November 7, 2021 8:19:21 PM 4 | (out) -------------------------------------------------------------------------------- /demo/src/app/bindings/remote/demo.java: -------------------------------------------------------------------------------- 1 | class LinkedList 2 | { 3 | public static void main(string[] args) 4 | { 5 | System.out.println("Hello world"); 6 | } 7 | } -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/horizontal-rule.md: -------------------------------------------------------------------------------- 1 | Three or more... 2 | 3 | --- 4 | 5 | Hyphens 6 | 7 | *** 8 | 9 | Asterisks 10 | ___ 11 | 12 | Underscores -------------------------------------------------------------------------------- /lib/src/prism-plugin.ts: -------------------------------------------------------------------------------- 1 | export enum PrismPlugin { 2 | CommandLine = 'command-line', 3 | LineHighlight = 'line-highlight', 4 | LineNumbers = 'line-numbers', 5 | } 6 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/line-highlight.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /demo/src/app/app.constant.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from './app.models'; 2 | 3 | export const DEFAULT_THEME = Theme.Light; 4 | export const LOCAL_STORAGE_THEME_KEY = 'ngx-markdown:theme'; 5 | -------------------------------------------------------------------------------- /demo/src/app/syntax-highlight/remote/for-loop.js: -------------------------------------------------------------------------------- 1 | for (let step = 0; step < 5; step++) { 2 | // Runs 5 times, with values of step 0 through 4. 3 | console.log('Walking east one step'); 4 | } -------------------------------------------------------------------------------- /demo/src/app/shared/clipboard-button/clipboard-button.component.scss: -------------------------------------------------------------------------------- 1 | .btn-clipboard { 2 | &.mat-mdc-icon-button { 3 | height: 30px; 4 | width: 30px; 5 | padding: 0; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/src/marked-extensions.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { MarkedExtension } from 'marked'; 3 | 4 | export const MARKED_EXTENSIONS = new InjectionToken('MARKED_EXTENSIONS'); 5 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/headers.md: -------------------------------------------------------------------------------- 1 | # H1 2 | ## H2 3 | ### H3 4 | #### H4 5 | ##### H5 6 | ###### H6 7 | 8 | Alternatively, for H1 and H2, an underline-ish style: 9 | 10 | Alt-H1 11 | ====== 12 | 13 | Alt-H2 14 | ------ -------------------------------------------------------------------------------- /lib/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /lib/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../dist/lib", 4 | "deleteDestPath": true, 5 | "lib": { 6 | "entryFile": "public_api.ts" 7 | }, 8 | "allowedNonPeerDependencies": ["."] 9 | } -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav/scrollspy-nav.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/ng-package.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../dist/lib", 4 | "deleteDestPath": true, 5 | "lib": { 6 | "entryFile": "public_api.ts" 7 | }, 8 | "allowedNonPeerDependencies": ["."] 9 | } -------------------------------------------------------------------------------- /lib/src/marked-options.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { MarkedOptions } from 'marked'; 3 | 4 | export type { MarkedOptions } from 'marked'; 5 | 6 | export const MARKED_OPTIONS = new InjectionToken('MARKED_OPTIONS'); 7 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/emphasis.md: -------------------------------------------------------------------------------- 1 | Emphasis, aka italics, with *asterisks* or _underscores_. 2 | 3 | Strong emphasis, aka bold, with **asterisks** or __underscores__. 4 | 5 | Combined emphasis with **asterisks and _underscores_**. 6 | 7 | Strikethrough uses two tildes. ~~Scratch this.~~ -------------------------------------------------------------------------------- /demo/src/app/app.models.ts: -------------------------------------------------------------------------------- 1 | export enum Theme { 2 | Light = 'light', 3 | Dark = 'dark', 4 | } 5 | 6 | export function isTheme(value: unknown): value is Theme { 7 | return value != null 8 | && typeof value === 'string' 9 | && Object.values(Theme).includes(value as Theme); 10 | } 11 | -------------------------------------------------------------------------------- /demo/src/app/get-started/get-started.component.html: -------------------------------------------------------------------------------- 1 | 2 |

Get Started

3 | 4 |
-------------------------------------------------------------------------------- /lib/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "marked" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.d.ts", 12 | "src/**/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [ 6 | "node" 7 | ], 8 | }, 9 | "include": [ 10 | "src/**/*.ts" 11 | ], 12 | "exclude": [ 13 | "src/**/*.spec.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/scss/_typography.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | $font-family: "Google Sans", "Helvetica Neue", sans-serif; 4 | $body-1: mat.m2-define-typography-level($font-size: 15px); 5 | 6 | $mat-typography-config: mat.m2-define-typography-config( 7 | $font-family, 8 | $body-1, 9 | ); 10 | -------------------------------------------------------------------------------- /demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import './prism'; 2 | import 'hammerjs'; 3 | import { bootstrapApplication } from '@angular/platform-browser'; 4 | import { appConfig } from '@app/app.config'; 5 | import { AppComponent } from './app/app.component'; 6 | 7 | bootstrapApplication(AppComponent, appConfig) 8 | .catch(err => console.error(err)); 9 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/non-root-user-with-output.bash: -------------------------------------------------------------------------------- 1 | pwd 2 | /usr/home/chris/bin 3 | ls -la 4 | total 2 5 | drwxr-xr-x 2 chris chris 11 Jan 10 16:48 . 6 | drwxr--r-x 45 chris chris 92 Feb 14 11:10 .. 7 | -rwxr-xr-x 1 chris chris 444 Aug 25 2013 backup 8 | -rwxr-xr-x 1 chris chris 642 Jan 17 14:42 deploy -------------------------------------------------------------------------------- /demo/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'gumshoejs' { 2 | namespace Gumshoe {} 3 | 4 | class Gumshoe { 5 | constructor(selector: string, options: GumshoeOptions); 6 | destroy(): void; 7 | setup(): void; 8 | } 9 | 10 | class GumshoeOptions { 11 | offset?: number; 12 | reflow?: boolean; 13 | } 14 | 15 | export = Gumshoe; 16 | } 17 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/images.md: -------------------------------------------------------------------------------- 1 | Here's our logo (hover to see the title text): 2 | 3 | Inline-style: 4 | ![alt text](https://github.com/adam-p/markdown-here/raw/master/src/common/images/icon48.png "Logo Title Text 1") 5 | 6 | Reference-style: 7 | ![alt text][logo] 8 | 9 | [logo]: https://github.com/adam-p/markdown-here/raw/master/src/common/images/icon48.png "Logo Title Text 2" -------------------------------------------------------------------------------- /demo/src/app/shared/clipboard-button/clipboard-button.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/blockquotes.md: -------------------------------------------------------------------------------- 1 | > Blockquotes are very handy in email to emulate reply text. 2 | > This line is part of the same quote. 3 | 4 | Quote break. 5 | 6 | > This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/code-and-synthax-highlighting.md: -------------------------------------------------------------------------------- 1 | Inline `code` has `back-ticks around` it. 2 | 3 | ```javascript 4 | var s = "JavaScript syntax highlighting"; 5 | alert(s); 6 | ``` 7 | 8 | ```python 9 | s = "Python syntax highlighting" 10 | print s 11 | ``` 12 | 13 | ``` 14 | No language indicated, so no syntax highlighting. 15 | But let's throw in a tag. 16 | ``` -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | ij_typescript_use_double_quotes = false 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /demo/public/icon-get-started.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/public/icon-chevron-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/public/icon-re-render.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/src/clipboard-options.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken, TemplateRef, Type } from '@angular/core'; 2 | 3 | export interface ClipboardOptions { 4 | buttonComponent?: Type; 5 | } 6 | 7 | export interface ClipboardRenderOptions extends ClipboardOptions { 8 | buttonTemplate?: TemplateRef; 9 | } 10 | 11 | export const CLIPBOARD_OPTIONS = new InjectionToken('CLIPBOARD_OPTIONS'); 12 | -------------------------------------------------------------------------------- /lib/src/sanitize-options.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken, SecurityContext } from '@angular/core'; 2 | 3 | export const SANITIZE = new InjectionToken('SANITIZE'); 4 | 5 | export type SanitizeFunction = (html: string) => string; 6 | 7 | export function isSanitizeFunction(sanitize: SecurityContext | SanitizeFunction | null): sanitize is SanitizeFunction { 8 | return typeof sanitize === 'function'; 9 | } 10 | -------------------------------------------------------------------------------- /demo/src/app/app.animation.ts: -------------------------------------------------------------------------------- 1 | import { animate, query, style, transition, trigger } from '@angular/animations'; 2 | 3 | export const ROUTE_ANIMATION = trigger('routeAnimation', [ 4 | transition('* <=> *', [ 5 | query(':enter', [ 6 | style({ opacity: 0, transform: 'translateY(32px)' }), 7 | animate('550ms cubic-bezier(0.35, 0, 0.25, 1)', style({ opacity: 1, transform: 'translateY(0)' })), 8 | ], { optional: true }), 9 | ]), 10 | ]); 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit" 4 | }, 5 | 6 | "eslint.useFlatConfig": true, 7 | 8 | "files.associations": { 9 | "*.json": "jsonc", 10 | "package.json": "json" 11 | }, 12 | 13 | "files.trimTrailingWhitespace": true, 14 | 15 | "[markdown]": { 16 | "files.trimTrailingWhitespace": false 17 | }, 18 | 19 | "typescript.tsdk": "node_modules\\typescript\\lib", 20 | 21 | "vsicons.presets.angular": true 22 | } -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav/scrollspy-nav.component.scss: -------------------------------------------------------------------------------- 1 | ul.scrollspy-nav { 2 | padding: 0; 3 | 4 | li { 5 | border-radius: 2px; 6 | font-size: 14px; 7 | font-weight: 500; 8 | list-style: none; 9 | padding: 4px 0 4px 16px; 10 | 11 | > a, 12 | > a:active, 13 | > a:focus, 14 | > a:hover { 15 | text-decoration: none; 16 | } 17 | 18 | &:not(.active) { 19 | border-color: transparent; 20 | opacity: 0.6; 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /demo/public/icon-syntax-highlight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav-layout/scrollspy-nav-layout.component.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | margin: 32px 0 8px; 3 | 4 | &-text { 5 | display: block; 6 | font-size: 13px; 7 | padding-top: 4px; 8 | } 9 | } 10 | 11 | .sticky { 12 | position: sticky; 13 | top: 80px; 14 | } 15 | 16 | .scrollup-button { 17 | margin:14px; 18 | 19 | &--fixed { 20 | position: fixed; 21 | bottom: 16px; 22 | right: 16px; 23 | } 24 | 25 | img { 26 | display: flex; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ngx-markdown | Demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/src/app/shared/http-raw-loader/http-raw-loader.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | import { share } from 'rxjs/operators'; 5 | 6 | @Injectable({ providedIn: 'root' }) 7 | export class HttpRawLoaderService { 8 | private httpClient = inject(HttpClient); 9 | 10 | get(url: string): Observable { 11 | return this.httpClient 12 | .get(url, { responseType: 'text' }) 13 | .pipe(share()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/scss/_dark-theme.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @use 'typography' as typography; 3 | 4 | $dark-primary: mat.m2-define-palette(mat.$m2-grey-palette, 900, 300, 700); 5 | $dark-accent: mat.m2-define-palette(mat.$m2-light-blue-palette); 6 | $dark-warn: mat.m2-define-palette(mat.$m2-red-palette); 7 | 8 | $theme: mat.m2-define-dark-theme(( 9 | color: ( 10 | primary: $dark-primary, 11 | accent: $dark-accent, 12 | warn: $dark-warn, 13 | ), 14 | typography: typography.$mat-typography-config, 15 | density: 0, 16 | )); 17 | -------------------------------------------------------------------------------- /demo/public/icon-bindings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clipboard-button.component'; 2 | export * from './clipboard-options'; 3 | export * from './katex-options'; 4 | export * from './language.pipe'; 5 | export * from './markdown.component'; 6 | export * from './markdown.module'; 7 | export * from './markdown.pipe'; 8 | export * from './markdown.service'; 9 | export * from './marked-extensions'; 10 | export * from './marked-options'; 11 | export * from './marked-renderer'; 12 | export * from './mermaid-options'; 13 | export * from './prism-plugin'; 14 | export * from './provide-markdown'; 15 | export * from './sanitize-options'; 16 | -------------------------------------------------------------------------------- /demo/public/icon-light-on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/tables.md: -------------------------------------------------------------------------------- 1 | Colons can be used to align columns. 2 | 3 | | Tables | Are | Cool | 4 | | ------------- |:-------------:| -----:| 5 | | col 3 is | right-aligned | $1600 | 6 | | col 2 is | centered | $12 | 7 | | zebra stripes | are neat | $1 | 8 | 9 | There must be at least 3 dashes separating each header cell. 10 | The outer pipes (|) are optional, and you don't need to make the 11 | raw Markdown line up prettily. You can also use inline Markdown. 12 | 13 | Markdown | Less | Pretty 14 | --- | --- | --- 15 | *Still* | `renders` | **nicely** 16 | 1 | 2 | 3 -------------------------------------------------------------------------------- /demo/src/scss/_utils.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | @use 'sass:math'; 3 | 4 | @function darken-color($color, $percent) { 5 | @return color.adjust($color, $lightness: -$percent); 6 | } 7 | 8 | @function lighten-color($color, $percent) { 9 | @return color.adjust($color, $lightness: $percent); 10 | } 11 | 12 | @function get-lightness($color) { 13 | @return color.channel($color, 'lightness', $space: hsl); 14 | } 15 | 16 | @function soften-color($color, $percent) { 17 | @if get-lightness($color) < 50 { 18 | @return lighten-color($color, $percent); 19 | } 20 | @return darken-color($color, math.div($percent, 2)); 21 | } 22 | -------------------------------------------------------------------------------- /lib/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/lib", 5 | "target": "es2015", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "inlineSources": true, 9 | "inlineSourceMap": true, 10 | "types": [ 11 | "marked" 12 | ], 13 | }, 14 | "angularCompilerOptions": { 15 | "skipTemplateCodegen": true, 16 | "strictMetadataEmit": true, 17 | "enableResourceInlining": true 18 | }, 19 | "include": [ 20 | "src/**/*.ts", 21 | "public_api.ts" 22 | ], 23 | "exclude": [ 24 | "**/*.spec.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/provide-markdown.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@angular/core'; 2 | import { MarkdownModuleConfig } from './markdown.module'; 3 | import { MarkdownService } from './markdown.service'; 4 | 5 | export function provideMarkdown(markdownModuleConfig?: MarkdownModuleConfig): Provider[] { 6 | return [ 7 | MarkdownService, 8 | markdownModuleConfig?.loader ?? [], 9 | markdownModuleConfig?.clipboardOptions ?? [], 10 | markdownModuleConfig?.markedOptions ?? [], 11 | markdownModuleConfig?.mermaidOptions ?? [], 12 | markdownModuleConfig?.markedExtensions ?? [], 13 | markdownModuleConfig?.sanitize ?? [], 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav-layout/scrollspy-nav-layout.animation.ts: -------------------------------------------------------------------------------- 1 | import { animate, style, transition, trigger } from '@angular/animations'; 2 | 3 | export const ZOOM_ANIMATION = trigger('zoomAnimation', [ 4 | transition('void => *', [ 5 | style({ opacity: 0, transform: 'translateY(32px) scale(0)' }), 6 | animate('400ms cubic-bezier(0.35, 0, 0.25, 1)', style({ opacity: 1, transform: 'translateY(0) scale(1)' })), 7 | ]), 8 | transition('* => void', [ 9 | style({ opacity: 1, transform: 'translateY(0)' }), 10 | animate('300ms cubic-bezier(0.35, 0, 0.25, 1)', style({ opacity: 0, transform: 'translateY(32px)' })), 11 | ]), 12 | ]); 13 | -------------------------------------------------------------------------------- /demo/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.dev/reference/versions#browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 2 Chrome versions 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | Safari >= 13.2 15 | iOS >= 13.2 16 | last 2 Android major versions 17 | Firefox ESR 18 | -------------------------------------------------------------------------------- /demo/src/app/app.component.theme.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use '@angular/material' as mat; 3 | @use '../scss/utils' as utils; 4 | 5 | @mixin theme($theme) { 6 | $color-config: mat.m2-get-color-config($theme); 7 | 8 | $primary-palette: map.get($color-config, 'primary'); 9 | $primary-color: mat.m2-get-color-from-palette($primary-palette, 'default'); 10 | 11 | .mat-toolbar.mat-primary { 12 | background: linear-gradient(90deg, $primary-color 15%, utils.darken-color($primary-color, 4%) 100%); 13 | } 14 | 15 | .mat-mdc-tab-nav-bar.mat-background-primary { 16 | background: $primary-color; 17 | } 18 | 19 | @include mat.tabs-overrides(( 20 | foreground-color: #ffffff, 21 | )); 22 | } 23 | -------------------------------------------------------------------------------- /demo/public/icon-light-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://www.buymeacoffee.com/jfcere', 'https://www.paypal.me/jfcere'] 13 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/lists.md: -------------------------------------------------------------------------------- 1 | 1. First ordered list item 2 | 2. Another item 3 | * Unordered sub-list. 4 | 1. Actual numbers don't matter, just that it's a number 5 | 1. Ordered sub-list 6 | 4. And another item. 7 | 8 | You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). 9 | 10 | To have a line break without a paragraph, you will need to use two trailing spaces. 11 | Note that this line is separate, but within the same paragraph. 12 | (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) 13 | 14 | 15 | * Unordered list can use asterisks 16 | - Or minuses 17 | + Or pluses -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/lists-dot.md: -------------------------------------------------------------------------------- 1 | 1. First ordered list item 2 | 2. Another item 3 | ⋅⋅⋅* Unordered sub-list. 4 | 1. Actual numbers don't matter, just that it's a number 5 | ⋅⋅⋅1. Ordered sub-list 6 | 4. And another item. 7 | 8 | ⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). 9 | 10 | ⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅ 11 | ⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅ 12 | ⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) 13 | 14 | 15 | * Unordered list can use asterisks 16 | - Or minuses 17 | + Or pluses -------------------------------------------------------------------------------- /demo/public/icon-cheat-sheet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/src/language.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'language', 5 | }) 6 | export class LanguagePipe implements PipeTransform { 7 | 8 | transform(value: string | null, language: string): string { 9 | if (value == null) { 10 | value = ''; 11 | } 12 | if (language == null) { 13 | language = ''; 14 | } 15 | if (typeof value !== 'string') { 16 | console.error(`LanguagePipe has been invoked with an invalid value type [${typeof value}]`); 17 | return value; 18 | } 19 | if (typeof language !== 'string') { 20 | console.error(`LanguagePipe has been invoked with an invalid parameter [${typeof language}]`); 21 | return value; 22 | } 23 | return '```' + language + '\n' + value + '\n```'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav/scrollspy-nav.component.theme.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use '@angular/material' as mat; 3 | 4 | @mixin theme($theme) { 5 | $color-config: mat.m2-get-color-config($theme); 6 | 7 | $accent-palette: map.get($color-config, 'accent'); 8 | $accent-color: mat.m2-get-color-from-palette($accent-palette, 'default'); 9 | $foreground-palette: map.get($color-config, 'foreground'); 10 | $foreground-text: map.get($foreground-palette, 'text'); 11 | 12 | ul.scrollspy-nav { 13 | 14 | li { 15 | border-left: 2px solid $accent-color; 16 | box-shadow: inset 1px 0 0 $accent-color; 17 | 18 | &:not(.active) { 19 | box-shadow: inset 1px 0 0 rgba($foreground-text, .21); 20 | 21 | a:not(:hover) { 22 | color: $foreground-text; 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /demo/src/app/plugins/remote/windows-powershell-with-output.powershell: -------------------------------------------------------------------------------- 1 | dir 2 | 3 | 4 | Directory: C:\Users\Chris 5 | 6 | 7 | Mode LastWriteTime Length Name 8 | ---- ------------- ------ ---- 9 | d-r-- 10/14/2015 5:06 PM Contacts 10 | d-r-- 12/12/2015 1:47 PM Desktop 11 | d-r-- 11/4/2015 7:59 PM Documents 12 | d-r-- 10/14/2015 5:06 PM Downloads 13 | d-r-- 10/14/2015 5:06 PM Favorites 14 | d-r-- 10/14/2015 5:06 PM Links 15 | d-r-- 10/14/2015 5:06 PM Music 16 | d-r-- 10/14/2015 5:06 PM Pictures 17 | d-r-- 10/14/2015 5:06 PM Saved Games 18 | d-r-- 10/14/2015 5:06 PM Searches 19 | d-r-- 10/14/2015 5:06 PM Videos -------------------------------------------------------------------------------- /demo/src/app/shared/clipboard-button/clipboard-button.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; 2 | import { MatButtonModule } from '@angular/material/button'; 3 | import { MatSnackBar } from '@angular/material/snack-bar'; 4 | 5 | @Component({ 6 | selector: 'app-clipboard-button', 7 | templateUrl: './clipboard-button.component.html', 8 | styleUrls: ['./clipboard-button.component.scss'], 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | imports: [MatButtonModule], 11 | }) 12 | export class ClipboardButtonComponent { 13 | private snackbar = inject(MatSnackBar); 14 | 15 | onCopyToClipboard(): void { 16 | this.snackbar.open('Copied to clipboard via component!', undefined, { 17 | duration: 3000, 18 | horizontalPosition: 'right', 19 | verticalPosition: 'bottom', 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/public/icon-plugins.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/remote/links.md: -------------------------------------------------------------------------------- 1 | There are two ways to create links. 2 | 3 | [I'm an inline-style link](https://www.google.com) 4 | 5 | [I'm an inline-style link with title](https://www.google.com "Google's Homepage") 6 | 7 | [I'm a reference-style link][Arbitrary case-insensitive reference text] 8 | 9 | [I'm a relative reference to a repository file](../blob/master/LICENSE) 10 | 11 | [You can use numbers for reference-style link definitions][1] 12 | 13 | Or leave it empty and use the [link text itself]. 14 | 15 | URLs and URLs in angle brackets will automatically get turned into links. 16 | http://www.example.com or and sometimes 17 | example.com (but not on GitHub, for example). 18 | 19 | Some text to show that the reference links can follow later. 20 | 21 | [arbitrary case-insensitive reference text]: https://www.mozilla.org 22 | [1]: http://slashdot.org 23 | [link text itself]: http://www.reddit.com -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /documentation 6 | /tmp 7 | /out-tsc 8 | 9 | # Node 10 | **/node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # profiling files 15 | chrome-profiler-events*.json 16 | 17 | # IDEs and editors 18 | .idea/ 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # Visual Studio Code 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # Miscellaneous 35 | /.angular/cache 36 | .nx/cache 37 | .nx/workspace-data 38 | .sass-cache/ 39 | /connect.lock 40 | /coverage 41 | /libpeerconnection.log 42 | /package 43 | /typings 44 | __screenshots__/ 45 | yarn.lock 46 | test-results.xml 47 | eslint.xml 48 | *.log 49 | *.tar 50 | *.tgz 51 | 52 | # System files 53 | .DS_Store 54 | Thumbs.db 55 | -------------------------------------------------------------------------------- /demo/public/icon-github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/src/app/app-routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const appRoutes: Routes = [ 4 | { 5 | path: 'get-started', 6 | loadComponent: () => import('./get-started/get-started.component'), 7 | data: { label: 'Get Started' }, 8 | }, 9 | { 10 | path: 'cheat-sheet', 11 | loadComponent: () => import('./cheat-sheet/cheat-sheet.component'), 12 | data: { label: 'Cheat Sheet' }, 13 | }, 14 | { 15 | path: 'syntax-highlight', 16 | loadComponent: () => import('./syntax-highlight/syntax-highlight.component'), 17 | data: { label: 'Syntax Highlight' }, 18 | }, 19 | { 20 | path: 'bindings', 21 | loadComponent: () => import('./bindings/bindings.component'), 22 | data: { label: 'Bindings' }, 23 | }, 24 | { 25 | path: 'plugins', 26 | loadComponent: () => import('./plugins/plugins.component'), 27 | data: { label: 'Plugins' }, 28 | }, 29 | { 30 | path: 're-render', 31 | loadComponent: () => import('./rerender/rerender.component'), 32 | data: { label: 'Re-render' }, 33 | }, 34 | { 35 | path: '**', 36 | redirectTo: 'get-started', 37 | }, 38 | ]; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2025 Jean-Francois Cere 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav-layout/scrollspy-nav-layout.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 10 | @if (showScrollUpButton) { 11 | 14 | } 15 |
16 |
17 | 18 | @if (showScrollUpButton) { 19 | 22 | } 23 |
24 |
-------------------------------------------------------------------------------- /demo/src/app/syntax-highlight/syntax-highlight.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit } from '@angular/core'; 3 | import { LanguagePipe, MarkdownComponent, MarkdownPipe } from 'ngx-markdown'; 4 | import { ScrollspyNavLayoutComponent } from '@shared/scrollspy-nav-layout'; 5 | 6 | @Component({ 7 | selector: 'app-syntax-highlight', 8 | templateUrl: './syntax-highlight.component.html', 9 | styleUrls: ['./syntax-highlight.component.scss'], 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | imports: [ 12 | AsyncPipe, 13 | LanguagePipe, 14 | MarkdownComponent, 15 | MarkdownPipe, 16 | ScrollspyNavLayoutComponent, 17 | ], 18 | }) 19 | export default class SyntaxHighlightComponent implements OnInit { 20 | private elementRef = inject>(ElementRef); 21 | 22 | headings: Element[] | undefined; 23 | 24 | myValue = 'print(\'hello-world\')'; 25 | 26 | ngOnInit(): void { 27 | this.setHeadings(); 28 | } 29 | 30 | private setHeadings(): void { 31 | const headings: Element[] = []; 32 | this.elementRef.nativeElement 33 | .querySelectorAll('h2') 34 | .forEach(x => headings.push(x)); 35 | this.headings = headings; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/src/clipboard-button.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; 2 | import { toSignal } from '@angular/core/rxjs-interop'; 3 | import { merge, of, Subject, timer } from 'rxjs'; 4 | import { distinctUntilChanged, mapTo, shareReplay, switchMap } from 'rxjs/operators'; 5 | 6 | const BUTTON_TEXT_COPY = 'Copy'; 7 | const BUTTON_TEXT_COPIED = 'Copied'; 8 | 9 | @Component({ 10 | selector: 'markdown-clipboard', 11 | template: ` 12 | 17 | `, 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | }) 20 | export class ClipboardButtonComponent { 21 | private _buttonClick$ = new Subject(); 22 | 23 | readonly copied = toSignal( 24 | this._buttonClick$.pipe( 25 | switchMap(() => merge( 26 | of(true), 27 | timer(3000).pipe(mapTo(false)), 28 | )), 29 | distinctUntilChanged(), 30 | shareReplay(1), 31 | ) 32 | ); 33 | 34 | readonly copiedText = computed(() => 35 | this.copied() 36 | ? BUTTON_TEXT_COPIED 37 | : BUTTON_TEXT_COPY 38 | ); 39 | 40 | onCopyToClipboardClick(): void { 41 | this._buttonClick$.next(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-markdown", 3 | "version": "21.0.1", 4 | "description": "Angular library that uses marked to parse markdown to html combined with Prism.js for synthax highlights", 5 | "homepage": "https://github.com/jfcere/ngx-markdown", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Jean-Francois Cere", 9 | "email": "jfcere@hotmail.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/jfcere/ngx-markdown" 14 | }, 15 | "keywords": [ 16 | "angular", 17 | "ngx", 18 | "markdown", 19 | "parser", 20 | "marked", 21 | "marked.js", 22 | "prism", 23 | "prism.js", 24 | "katex", 25 | "emoji", 26 | "clipboard", 27 | "clipboard.js" 28 | ], 29 | "dependencies": { 30 | "tslib": "^2.3.0" 31 | }, 32 | "peerDependencies": { 33 | "@angular/common": "^21.0.0", 34 | "@angular/core": "^21.0.0", 35 | "@angular/platform-browser": "^21.0.0", 36 | "marked": "^17.0.0", 37 | "rxjs": "^6.5.3 || ^7.4.0", 38 | "zone.js": "~0.15.0 || ~0.16.0" 39 | }, 40 | "optionalDependencies": { 41 | "clipboard": "^2.0.11", 42 | "emoji-toolkit": ">= 8.0.0 < 10.0.0", 43 | "katex": "^0.16.0", 44 | "mermaid": ">= 10.6.0 < 12.0.0", 45 | "prismjs": "^1.30.0" 46 | }, 47 | "sideEffects": false 48 | } 49 | -------------------------------------------------------------------------------- /demo/src/app/get-started/get-started.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ElementRef, inject } from '@angular/core'; 2 | import { MarkdownComponent } from 'ngx-markdown'; 3 | import { ScrollspyNavLayoutComponent } from '@shared/scrollspy-nav-layout'; 4 | 5 | @Component({ 6 | selector: 'app-get-started', 7 | templateUrl: './get-started.component.html', 8 | styleUrls: ['./get-started.component.scss'], 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | imports: [ 11 | MarkdownComponent, 12 | ScrollspyNavLayoutComponent, 13 | ], 14 | }) 15 | export default class GetStartedComponent { 16 | private elementRef = inject>(ElementRef); 17 | 18 | headings: Element[] | undefined; 19 | 20 | onLoad(): void { 21 | this.stripContent(); 22 | this.setHeadings(); 23 | } 24 | 25 | private setHeadings(): void { 26 | const headings: Element[] = []; 27 | this.elementRef.nativeElement 28 | .querySelectorAll('h2') 29 | .forEach(x => headings.push(x)); 30 | this.headings = headings; 31 | } 32 | 33 | private stripContent(): void { 34 | this.elementRef.nativeElement 35 | .querySelector('markdown')! 36 | .querySelectorAll('markdown > p:nth-child(-n + 2), #ngx-markdown, #table-of-contents + ul, #table-of-contents') 37 | .forEach(x => x.remove()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /demo/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

ngx-markdown

4 | 5 | 11 | 12 | 13 | 14 |
15 |
16 | 17 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /demo/src/scss/_light-theme.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use '@angular/material' as mat; 3 | @use 'typography' as typography; 4 | 5 | $dark-text: map.get(mat.$m2-light-theme-foreground-palette, text); 6 | $light-text: map.get(mat.$m2-dark-theme-foreground-palette, text); 7 | 8 | $primary-palette: ( 9 | 50 : #d6ebff, 10 | 100 : #8ac4ff, 11 | 200 : #52a8ff, 12 | 300 : #0a85ff, 13 | 400 : #0075eb, 14 | 500 : #0066cc, 15 | 600 : #0057ad, 16 | 700 : #00478f, 17 | 800 : #003870, 18 | 900 : #002952, 19 | A100 : #cce5ff, 20 | A200 : #66b2ff, 21 | A400 : #007fff, 22 | A700 : #0073e6, 23 | contrast: ( 24 | 50 : $dark-text, 25 | 100 : $dark-text, 26 | 200 : $dark-text, 27 | 300 : $light-text, 28 | 400 : $light-text, 29 | 500 : $light-text, 30 | 600 : $light-text, 31 | 700 : $light-text, 32 | 800 : $light-text, 33 | 900 : $light-text, 34 | A100 : $dark-text, 35 | A200 : $dark-text, 36 | A400 : $light-text, 37 | A700 : $light-text, 38 | ) 39 | ); 40 | 41 | $light-primary: mat.m2-define-palette($primary-palette); 42 | $light-accent: mat.m2-define-palette($primary-palette); 43 | $light-warn: mat.m2-define-palette(mat.$m2-red-palette); 44 | 45 | $theme: mat.m2-define-light-theme(( 46 | color: ( 47 | primary: $light-primary, 48 | accent: $light-accent, 49 | warn: $light-warn, 50 | ), 51 | typography: typography.$mat-typography-config, 52 | density: 0, 53 | )); 54 | -------------------------------------------------------------------------------- /lib/src/markdown.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef, inject, NgZone, Pipe, PipeTransform, ViewContainerRef } from '@angular/core'; 2 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; 3 | import { first } from 'rxjs/operators'; 4 | import { MarkdownService, ParseOptions, RenderOptions } from './markdown.service'; 5 | 6 | export type MarkdownPipeOptions = ParseOptions & RenderOptions; 7 | 8 | @Pipe({ 9 | name: 'markdown', 10 | }) 11 | export class MarkdownPipe implements PipeTransform { 12 | private domSanitizer = inject(DomSanitizer); 13 | private elementRef = inject>(ElementRef); 14 | private markdownService = inject(MarkdownService); 15 | private viewContainerRef = inject(ViewContainerRef); 16 | private zone = inject(NgZone); 17 | 18 | async transform(value: string, options?: MarkdownPipeOptions): Promise { 19 | if (value == null) { 20 | return ''; 21 | } 22 | 23 | if (typeof value !== 'string') { 24 | console.error(`MarkdownPipe has been invoked with an invalid value type [${typeof value}]`); 25 | return value; 26 | } 27 | 28 | const markdown = await this.markdownService.parse(value, options); 29 | 30 | this.zone.onStable 31 | .pipe(first()) 32 | .subscribe(() => this.markdownService.render(this.elementRef.nativeElement, options, this.viewContainerRef)); 33 | 34 | return this.domSanitizer.bypassSecurityTrustHtml(markdown); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav-layout/scrollspy-nav-layout.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, HostListener, Input } from '@angular/core'; 2 | import { ExtendedModule } from '@angular/flex-layout/extended'; 3 | import { FlexModule } from '@angular/flex-layout/flex'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatDividerModule } from '@angular/material/divider'; 6 | import { MarkdownComponent } from 'ngx-markdown'; 7 | import { ScrollspyNavComponent } from '@shared/scrollspy-nav'; 8 | import { ZOOM_ANIMATION } from './scrollspy-nav-layout.animation'; 9 | 10 | @Component({ 11 | animations: [ZOOM_ANIMATION], 12 | selector: 'app-scrollspy-nav-layout', 13 | templateUrl: './scrollspy-nav-layout.component.html', 14 | styleUrls: ['./scrollspy-nav-layout.component.scss'], 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | imports: [ 17 | ExtendedModule, 18 | FlexModule, 19 | MarkdownComponent, 20 | MatButtonModule, 21 | MatDividerModule, 22 | ScrollspyNavComponent, 23 | ], 24 | }) 25 | export class ScrollspyNavLayoutComponent { 26 | 27 | @Input() 28 | headings: Element[] | undefined; 29 | 30 | showScrollUpButton = false; 31 | 32 | @HostListener('window:scroll') 33 | onWindowScroll(): void { 34 | this.showScrollUpButton = Math.ceil(window.pageYOffset) > 64; 35 | } 36 | 37 | onScrollUp(): void { 38 | window.scrollTo(0, 0); 39 | location.hash = ''; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "baseUrl": "./", 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "experimentalDecorators": true, 16 | "importHelpers": true, 17 | "target": "ES2022", 18 | "module": "preserve", 19 | "useDefineForClassFields": false, 20 | "paths": { 21 | "@shared/*": ["demo/src/app/shared/*"], 22 | "@app/*": ["demo/src/app/*"], 23 | "ngx-markdown": ["lib/src"] 24 | } 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "fullTemplateTypeCheck": true, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "typeCheckHostBindings": true, 32 | "strictTemplates": true 33 | }, 34 | "files": [], 35 | "references": [ 36 | { 37 | "path": "./demo/tsconfig.app.json" 38 | }, 39 | { 40 | "path": "./lib/tsconfig.lib.json" 41 | }, 42 | { 43 | "path": "./lib/tsconfig.spec.json" 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /demo/src/styles.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | body { 9 | font-size: 15px; 10 | } 11 | 12 | blockquote { 13 | overflow: auto; 14 | } 15 | 16 | p, ul { 17 | line-height: 1.5; 18 | } 19 | 20 | pre { 21 | &::-webkit-scrollbar { 22 | height: 8px; 23 | width: 8px; 24 | } 25 | 26 | &::-webkit-scrollbar-thumb { 27 | background: rgba(255,255,255,0.4); 28 | border-radius: 4px; 29 | } 30 | 31 | &::-webkit-scrollbar-thumb:hover { 32 | background: rgba(255,255,255,0.7); 33 | } 34 | 35 | &::-webkit-scrollbar-track { 36 | background: rgba(255,255,255,0.2); 37 | border-radius: 4px; 38 | } 39 | } 40 | 41 | section { 42 | margin-top: 40px; 43 | } 44 | 45 | table { 46 | width: 100%; 47 | 48 | th { 49 | color: rgba(0,0,0,.54); 50 | font-size: 12px; 51 | font-weight: 500; 52 | height: 56px; 53 | } 54 | 55 | th[align=""], 56 | th:not([align]) { 57 | text-align: left; 58 | } 59 | 60 | td { 61 | font-size: 15px; 62 | height: 48px; 63 | } 64 | 65 | td, 66 | th { 67 | padding: 0; 68 | border-bottom-width: 1px; 69 | border-bottom-style: solid; 70 | border-bottom-color: rgba(0,0,0,.12); 71 | } 72 | } 73 | 74 | input, 75 | textarea { 76 | font-family: monospace !important; 77 | font-size: 14px !important; 78 | line-height: 1.25 !important; 79 | } 80 | 81 | [hidden] { 82 | display: none !important; 83 | } 84 | 85 | * { 86 | box-sizing: border-box; 87 | } 88 | -------------------------------------------------------------------------------- /demo/src/app/plugins/plugins.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | textarea { 6 | min-height: 180px; 7 | } 8 | 9 | // Clipboard toolbar styling 10 | 11 | .btn-clipboard-toolbar ::ng-deep .markdown-clipboard-toolbar { 12 | top: 16px; 13 | right: 16px; 14 | opacity: 0; 15 | transition: opacity 250ms ease-out; 16 | 17 | &.hover { 18 | opacity: 1; 19 | } 20 | } 21 | 22 | // Clipboard default button styling 23 | 24 | .btn-clipboard-default ::ng-deep .markdown-clipboard-button { 25 | background-color: rgba(255, 255, 255, 0.07); 26 | border: none; 27 | border-radius: 4px; 28 | color: #ffffff; 29 | cursor: pointer; 30 | font-family: 'Google Sans', Helvetica, sans-serif; 31 | font-size: 11px; 32 | padding: 4px 0; 33 | width: 50px; 34 | transition: all 250ms ease-out; 35 | 36 | &:hover, 37 | &:focus { 38 | background-color: rgba(255, 255, 255, 0.14); 39 | } 40 | 41 | &:active { 42 | transform: scale(0.95); 43 | } 44 | 45 | &.copied { 46 | background-color: rgba(0, 255, 0, 0.1); 47 | color: #00ff00; 48 | } 49 | } 50 | 51 | // Clipboard template styling 52 | 53 | .btn-clipboard { 54 | display: flex; 55 | justify-content: center; 56 | align-items: center; 57 | background-color: #1e1e1e; 58 | border: 1px solid #666666; 59 | border-radius: 4px; 60 | padding: 6px; 61 | cursor: pointer; 62 | transition: all 200ms ease-out; 63 | 64 | &:active, 65 | &:hover { 66 | border-color: #888888; 67 | } 68 | 69 | &:active { 70 | background-color: #3e3e3e; 71 | transform: scale(0.95); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /demo/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const tseslint = require("typescript-eslint"); 3 | const angular = require("angular-eslint"); 4 | 5 | const baseConfig = require("../eslint.config.js"); 6 | 7 | module.exports = tseslint.config( 8 | { 9 | extends: [...baseConfig], 10 | }, 11 | { 12 | ignores: ["!**/*"], 13 | }, 14 | { 15 | files: ["**/*.ts"], 16 | 17 | languageOptions: { 18 | ecmaVersion: 5, 19 | sourceType: "script", 20 | 21 | parserOptions: { 22 | project: "tsconfig.app.json", 23 | tsconfigRootDir: __dirname, 24 | createDefaultProgram: true, 25 | }, 26 | }, 27 | 28 | processor: angular.processInlineTemplates, 29 | 30 | rules: { 31 | "@angular-eslint/component-selector": ["error", { 32 | "type": "element", 33 | "prefix": "app", 34 | "style": "kebab-case", 35 | }], 36 | 37 | "@angular-eslint/directive-selector": ["error", { 38 | "type": "attribute", 39 | "prefix": "app", 40 | "style": "camelCase", 41 | }], 42 | 43 | "@angular-eslint/no-output-native": "off", 44 | "@typescript-eslint/ban-types": "off", 45 | "@typescript-eslint/dot-notation": "off", 46 | "@typescript-eslint/no-non-null-assertion": "off", 47 | "@typescript-eslint/no-unused-vars": "error", 48 | "@typescript-eslint/no-var-requires": "off", 49 | "comma-dangle": ["error", "always-multiline"], 50 | "import/order": "error", 51 | "object-shorthand": "off", 52 | }, 53 | }, 54 | { 55 | files: ["**/*.html"], 56 | rules: {}, 57 | }, 58 | ); 59 | -------------------------------------------------------------------------------- /lib/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-coverage'), 12 | require('karma-jasmine-html-reporter'), 13 | require('karma-junit-reporter'), 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, '../coverage'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'lcovonly' }, 33 | { type: 'text-summary' } 34 | ] 35 | }, 36 | reporters: ['progress', 'kjhtml', 'junit'], 37 | junitReporter: { 38 | outputFile: '../test-results.xml', 39 | useBrowserName: false 40 | }, 41 | port: 9876, 42 | colors: true, 43 | logLevel: config.LOG_INFO, 44 | autoWatch: true, 45 | browsers: ['Chrome'], 46 | singleRun: false, 47 | restartOnFileChange: true 48 | }); 49 | }; -------------------------------------------------------------------------------- /demo/src/app/shared/scrollspy-nav/scrollspy-nav.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ElementRef, inject, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; 2 | import { RouterLink } from '@angular/router'; 3 | import Gumshoe from 'gumshoejs'; 4 | import { first } from 'rxjs/operators'; 5 | 6 | @Component({ 7 | selector: 'app-scrollspy-nav', 8 | templateUrl: './scrollspy-nav.component.html', 9 | styleUrls: ['./scrollspy-nav.component.scss'], 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | imports: [ 12 | RouterLink, 13 | ], 14 | }) 15 | export class ScrollspyNavComponent implements OnChanges, OnDestroy { 16 | private elementRef = inject>(ElementRef); 17 | private zone = inject(NgZone); 18 | 19 | @Input() 20 | headings: Element[] | undefined; 21 | 22 | private scrollSpy: Gumshoe | undefined; 23 | 24 | ngOnChanges(changes: SimpleChanges): void { 25 | if (changes['headings']?.currentValue) { 26 | this.setScrollSpy(); 27 | } 28 | } 29 | 30 | ngOnDestroy(): void { 31 | this.destroyScrollSpy(); 32 | } 33 | 34 | destroyScrollSpy(): void { 35 | if (this.scrollSpy) { 36 | this.scrollSpy.destroy(); 37 | } 38 | } 39 | 40 | setScrollSpy(): void { 41 | if (this.scrollSpy) { 42 | this.scrollSpy.setup(); 43 | return; 44 | } 45 | this.zone.onStable 46 | .pipe(first()) 47 | .subscribe(() => { 48 | const hostElement = this.elementRef.nativeElement; 49 | const linkSelector = `${hostElement.tagName}.${hostElement.className} a`; 50 | this.scrollSpy = new Gumshoe(linkSelector, { offset: 64, reflow: true }); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /demo/src/app/app.marked-config.ts: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | import { MarkedOptions, MarkedRenderer } from 'ngx-markdown'; 3 | import { AnchorService } from '@shared/anchor'; 4 | 5 | export function markedOptionsFactory(anchorService: AnchorService): MarkedOptions { 6 | const renderer = new MarkedRenderer(); 7 | 8 | // fix `href` for absolute link with fragments so that _copy-paste_ urls are correct 9 | renderer.link = ({ href, text }) => { 10 | return `${text}`; 11 | }; 12 | 13 | return { renderer }; 14 | } 15 | 16 | export function sanitizeHtml(html: string): string { 17 | // configure DOMPurify to allow... 18 | // - `class` as it is safe by default 19 | // - `href` as its content is validated by DOMPurify 20 | // - `style` as its content is validated by DOMPurify 21 | // - `id` to be validated by the hook 22 | DOMPurify.setConfig({ 23 | ALLOWED_ATTR: ['class', 'href', 'style'], 24 | ADD_ATTR: ['id'], 25 | }); 26 | 27 | // hook to validate and restrict `id` usage on header elements only 28 | // and ensure they do not containt javascript or other unsafe characters 29 | // to prevent potential XSS attacks through `id` attributes 30 | DOMPurify.addHook('uponSanitizeElement', (node: Node) => { 31 | const isNodeElement = node instanceof Element; 32 | if (!isNodeElement) { 33 | return; 34 | } 35 | 36 | const isHeader = /^(h[1-6])$/i.test(node.tagName); 37 | if (isHeader) { 38 | const idValue = node.getAttribute('id') ?? ''; 39 | const isValidId = /^[a-zA-Z][\w\-:.]*$/.test(idValue); 40 | if (!isValidId) { 41 | node.removeAttribute('id'); 42 | } 43 | } else { 44 | node.removeAttribute('id'); 45 | } 46 | }); 47 | 48 | return DOMPurify.sanitize(html); 49 | } 50 | -------------------------------------------------------------------------------- /demo/src/prism.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | 4 | /** 5 | * Prism language extension 6 | * https://prismjs.com/extending.html 7 | */ 8 | 9 | declare let Prism: any; 10 | 11 | Prism.languages.typescript = Prism.languages.extend('typescript', { 12 | 13 | 'class-name': [ 14 | // existing pattern 15 | Prism.languages.typescript['class-name'], 16 | 17 | // constructor(private foo:Foo, bar: Bar) { } 18 | // function foo(): Bar {} 19 | // const foo = (): Bar => {}; 20 | // const foo = (): void => {}; 21 | // foo: Bar = {}; 22 | { 23 | pattern: /(:)([^,()={][A-Z]{1}[^,()={]+)/, 24 | lookbehind: true, 25 | inside: Prism.languages.typescript, 26 | }, 27 | 28 | // new Foo(); 29 | // new Foo.Bar(); 30 | { 31 | pattern: /\b(new\s.*\.|new\s)([^(]+)/, 32 | lookbehind: true, 33 | }, 34 | 35 | // import { foo, bar } from 'baz'; 36 | { 37 | pattern: /(import\s*{)\s*([^}]*)/, 38 | lookbehind: true, 39 | inside: { 40 | 'import-member': /([^,]+)/, 41 | punctuation: /(,)/, 42 | }, 43 | }, 44 | 45 | // TODO: not correctly highlighted 46 | // - `Baz` in `export class Foo implements Bar, Baz` 47 | // - `void` in `func: (foo: string) => void = (foo) => {};` 48 | // - `Foo` in `Foo.bar()` when Foo is a class 49 | ], 50 | 51 | function: [ 52 | // existing pattern 53 | Prism.languages.typescript.function, 54 | 55 | // foo: () => Bar; 56 | { 57 | pattern: /\b\S+\s*[=]\s*\(.*\).*/, 58 | inside: Prism.languages.typescript, 59 | }, 60 | ], 61 | 62 | keyword: [ 63 | // existing pattern 64 | ...Prism.languages.typescript.keyword, 65 | 66 | // constructor() 67 | /\b(?:constructor)\b/, 68 | ], 69 | }); 70 | 71 | Prism.languages.ts = Prism.languages.typescript; 72 | -------------------------------------------------------------------------------- /demo/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, provideHttpClient } from '@angular/common/http'; 2 | import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 3 | import { provideAnimations } from '@angular/platform-browser/animations'; 4 | import { provideRouter, withInMemoryScrolling } from '@angular/router'; 5 | import { gfmHeadingId } from 'marked-gfm-heading-id'; 6 | import { CLIPBOARD_OPTIONS, MARKED_EXTENSIONS, MARKED_OPTIONS, MERMAID_OPTIONS, provideMarkdown, SANITIZE } from 'ngx-markdown'; 7 | import { appRoutes } from '@app/app-routes'; 8 | import { markedOptionsFactory, sanitizeHtml } from '@app/app.marked-config'; 9 | import { AnchorService } from '@shared/anchor/anchor.service'; 10 | import { ClipboardButtonComponent } from '@shared/clipboard-button'; 11 | 12 | export const appConfig: ApplicationConfig = { 13 | providers: [ 14 | provideAnimations(), 15 | provideHttpClient(), 16 | provideZoneChangeDetection(), 17 | provideRouter( 18 | appRoutes, 19 | withInMemoryScrolling({ 20 | anchorScrolling: 'enabled', 21 | scrollPositionRestoration: 'enabled', 22 | }), 23 | ), 24 | provideMarkdown({ 25 | loader: HttpClient, 26 | clipboardOptions: { 27 | provide: CLIPBOARD_OPTIONS, 28 | useValue: { buttonComponent: ClipboardButtonComponent }, 29 | }, 30 | markedOptions: { 31 | provide: MARKED_OPTIONS, 32 | useFactory: markedOptionsFactory, 33 | deps: [AnchorService], 34 | }, 35 | markedExtensions: [ 36 | { 37 | provide: MARKED_EXTENSIONS, 38 | useFactory: gfmHeadingId, 39 | multi: true, 40 | }, 41 | ], 42 | mermaidOptions: { 43 | provide: MERMAID_OPTIONS, 44 | useValue: { 45 | darkMode: true, 46 | look: 'handDrawn', 47 | }, 48 | }, 49 | sanitize: { 50 | provide: SANITIZE, 51 | useValue: sanitizeHtml, 52 | }, 53 | }), 54 | ], 55 | }; 56 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/cheat-sheet.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit } from '@angular/core'; 3 | import { MarkdownComponent } from 'ngx-markdown'; 4 | import { HttpRawLoaderService } from '@shared/http-raw-loader'; 5 | import { ScrollspyNavLayoutComponent } from '@shared/scrollspy-nav-layout'; 6 | 7 | @Component({ 8 | selector: 'app-cheat-sheet', 9 | templateUrl: './cheat-sheet.component.html', 10 | styleUrls: ['./cheat-sheet.component.scss'], 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | imports: [ 13 | AsyncPipe, 14 | MarkdownComponent, 15 | ScrollspyNavLayoutComponent, 16 | ], 17 | }) 18 | export default class CheatSheetComponent implements OnInit { 19 | private elementRef = inject>(ElementRef); 20 | private rawLoaderService = inject(HttpRawLoaderService); 21 | 22 | blockquotes$ = this.rawLoaderService.get('app/cheat-sheet/remote/blockquotes.md'); 23 | codeAndSynthaxHighlighting$ = this.rawLoaderService.get('app/cheat-sheet/remote/code-and-synthax-highlighting.md'); 24 | emphasis$ = this.rawLoaderService.get('app/cheat-sheet/remote/emphasis.md'); 25 | headers$ = this.rawLoaderService.get('app/cheat-sheet/remote/headers.md'); 26 | horizontalRule$ = this.rawLoaderService.get('app/cheat-sheet/remote/horizontal-rule.md'); 27 | images$ = this.rawLoaderService.get('app/cheat-sheet/remote/images.md'); 28 | links$ = this.rawLoaderService.get('app/cheat-sheet/remote/links.md'); 29 | lists$ = this.rawLoaderService.get('app/cheat-sheet/remote/lists.md'); 30 | listsDot$ = this.rawLoaderService.get('app/cheat-sheet/remote/lists-dot.md'); 31 | tables$ = this.rawLoaderService.get('app/cheat-sheet/remote/tables.md'); 32 | 33 | headings: Element[] | undefined; 34 | 35 | ngOnInit(): void { 36 | this.setHeadings(); 37 | } 38 | 39 | private setHeadings(): void { 40 | const headings: Element[] = []; 41 | this.elementRef.nativeElement 42 | .querySelectorAll('h2') 43 | .forEach(x => headings.push(x)); 44 | this.headings = headings; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /demo/src/app/rerender/rerender.component.html: -------------------------------------------------------------------------------- 1 | 2 |

Re-render

3 | 4 | 5 | In some situations, you might need to re-render markdown after making changes. If you've updated the text this would be done automatically, however if the changes are internal to the library such as rendering options, you will need to inform the `MarkdownService` that it needs to update. 6 | 7 | To do so, inject the `MarkdownService` and call the `reload()` function as shown below. 8 | 9 | ```typescript 10 | import { MarkdownService } from 'ngx-markdown'; 11 | 12 | constructor( 13 | private markdownService: MarkdownService, 14 | ) { } 15 | 16 | update() { 17 | this.markdownService.reload(); 18 | } 19 | ``` 20 | 21 | 22 |
23 |

Example

24 | 25 | 26 | The example below will apply the `style` attribute on heading elements to customize their colors. This requires markdown to be reloaded because it updates the renderer programmatically to override the `heading` token. 27 | 28 | Although this could be done simply with CSS variables, this is only for demo purposes. 29 | 30 | 31 |
32 |
33 |
34 | 35 | CSS Color 36 | 37 | 38 | 39 | 40 | 41 | 42 |
43 | 44 | 45 |
46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /lib/src/language.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 2 | 3 | import { LanguagePipe } from './language.pipe'; 4 | 5 | describe('LanguagePipe', () => { 6 | let pipe: LanguagePipe; 7 | 8 | beforeEach(() => { 9 | pipe = new LanguagePipe(); 10 | }); 11 | 12 | it('should replace value with empty string when null/undefined', () => { 13 | const markdowns: any[] = [null, undefined]; 14 | const language = 'language'; 15 | 16 | markdowns.forEach(markdown => { 17 | const result = pipe.transform(markdown, language); 18 | expect(result).toBe('```' + language + '\n\n```'); 19 | }); 20 | }); 21 | 22 | it('should replace language with empty string when null/undefined', () => { 23 | const markdown = '# Markdown'; 24 | const languages: any[] = [null, undefined]; 25 | 26 | languages.forEach(language => { 27 | const result = pipe.transform(markdown, language); 28 | expect(result).toBe('```\n' + markdown + '\n```'); 29 | }); 30 | }); 31 | 32 | it('should log error and return value when value is not a string', () => { 33 | const markdowns: any[] = [0, {}, [], /regex/]; 34 | 35 | spyOn(console, 'error'); 36 | 37 | markdowns.forEach(markdown => { 38 | const result = pipe.transform(markdown, markdown); 39 | 40 | expect(result).toBe(markdown); 41 | expect(console.error).toHaveBeenCalledWith(`LanguagePipe has been invoked with an invalid value type [${typeof markdown}]`); 42 | }); 43 | }); 44 | 45 | it('should log error and return value when parameter is not a string', () => { 46 | const markdown = '# Markdown'; 47 | const languages: any[] = [0, {}, [], /regex/]; 48 | 49 | spyOn(console, 'error'); 50 | 51 | languages.forEach(language => { 52 | const result = pipe.transform(markdown, language); 53 | 54 | expect(result).toBe(markdown); 55 | expect(console.error).toHaveBeenCalledWith(`LanguagePipe has been invoked with an invalid parameter [${typeof language}]`); 56 | }); 57 | }); 58 | 59 | it('should append language to value', () => { 60 | const markdown = '# Markdown'; 61 | const language = 'language'; 62 | 63 | const result = pipe.transform(markdown, language); 64 | 65 | expect(result).toBe('```' + language + '\n' + markdown + '\n```'); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /demo/src/app/cheat-sheet/cheat-sheet.component.html: -------------------------------------------------------------------------------- 1 | 2 |

Cheat Sheet

3 | 4 | 5 | The following examples are intended as a quick markdown reference and showcase. It is based on Adam Pritchard work of [Markdown Cheat Sheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet). 6 | 7 | 8 |
9 |

Headers

10 |
{{ headers$ | async }}
11 | 12 |
13 | 14 |
15 |

Emphasis

16 |
{{ emphasis$ | async }}
17 | 18 |
19 | 20 |
21 |

Lists

22 |

23 | In this example, leading and trailing spaces are shown with with dots (⋅) 24 |

25 |
{{ listsDot$ | async }}
26 | 27 |
28 | 29 |
30 | 31 |
{{ links$ | async }}
32 | 33 |
34 | 35 |
36 |

Images

37 |
{{ images$ | async }}
38 | 39 |
40 | 41 |
42 |

Code and Syntax Highlighting

43 |
{{ codeAndSynthaxHighlighting$ | async }}
44 | 45 |
46 | 47 |
48 |

Tables

49 |
{{ tables$ | async }}
50 | 51 |
52 | 53 |
54 |

Blockquotes

55 |
{{ blockquotes$ | async }}
56 | 57 |
58 | 59 |
60 |

Horizontal Rule

61 |
{{ horizontalRule$ | async }}
62 | 63 |
64 |
-------------------------------------------------------------------------------- /demo/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | // variables 4 | 5 | $viewport-max-width: 960px; 6 | $viewport-offset-x: 16px; 7 | 8 | // mixins 9 | 10 | @mixin viewport-width($padding: true) { 11 | margin: 0 auto; 12 | max-width: $viewport-max-width; 13 | 14 | @if ($padding) { 15 | padding-left: $viewport-offset-x; 16 | padding-right: $viewport-offset-x; 17 | } 18 | } 19 | 20 | // style 21 | 22 | :host { 23 | display: block; 24 | } 25 | 26 | .github-icon { 27 | --mat-icon-button-icon-size: 28px; 28 | position: relative; 29 | top: -2px; 30 | left: -2px; 31 | } 32 | 33 | .mat-mdc-tab-nav-bar--sticky { 34 | @include mat.elevation(6); 35 | transition: box-shadow .3s ease-out; 36 | } 37 | 38 | .mat-mdc-tab-nav-bar ::ng-deep { 39 | position: sticky; 40 | top: 0; 41 | z-index: 24; 42 | 43 | .mdc-tab-indicator__content--underline { 44 | border-radius: 3px 3px 0 0; 45 | border-top-width: 3px; 46 | transition-duration: 500ms; 47 | } 48 | 49 | .mat-mdc-tab-link { 50 | font-size: 14px; 51 | font-weight: 500; 52 | letter-spacing: normal; 53 | margin: 0 $viewport-offset-x; 54 | min-width: 0; 55 | opacity: 0.6; 56 | padding: 0; 57 | text-decoration: none; 58 | transition: all 0.2s ease-out; 59 | } 60 | 61 | .mat-mdc-tab-link:not(.mdc-tab--active):hover { 62 | transform: translateY(-1px); 63 | } 64 | 65 | .mat-mdc-tab-link:hover, 66 | .mdc-tab--active { 67 | opacity: 1; 68 | } 69 | 70 | .mat-mdc-tab-link-container { 71 | @include viewport-width($padding: false); 72 | overflow: auto; 73 | } 74 | 75 | .mdc-tab__ripple { 76 | opacity: 0; 77 | } 78 | 79 | // start - workaround for responsive tabs 80 | .mat-mdc-tab-header-pagination { 81 | display: none !important; 82 | } 83 | 84 | .mat-mdc-tab-list { 85 | transform: unset !important; 86 | } 87 | // end - workaround 88 | } 89 | 90 | .mat-toolbar ::ng-deep { 91 | 92 | .mat-toolbar-row { 93 | @include viewport-width(); 94 | } 95 | 96 | .mat-icon-button { 97 | transition: all 0.2s ease-out; 98 | 99 | &:hover { 100 | transform: translateY(-2px); 101 | } 102 | } 103 | } 104 | 105 | .outlet-wrapper { 106 | @include viewport-width(); 107 | display: block; 108 | margin-top: $viewport-offset-x; 109 | margin-bottom: $viewport-offset-x; 110 | position: relative; 111 | } 112 | -------------------------------------------------------------------------------- /demo/src/app/bindings/bindings.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit } from '@angular/core'; 3 | import { FlexModule } from '@angular/flex-layout/flex'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { MatFormFieldModule } from '@angular/material/form-field'; 6 | import { MatInputModule } from '@angular/material/input'; 7 | import { LanguagePipe, MarkdownComponent, MarkdownPipe } from 'ngx-markdown'; 8 | import { HttpRawLoaderService } from '@shared/http-raw-loader'; 9 | import { ScrollspyNavLayoutComponent } from '@shared/scrollspy-nav-layout'; 10 | 11 | @Component({ 12 | selector: 'app-bindings', 13 | templateUrl: './bindings.component.html', 14 | styleUrls: ['./bindings.component.scss'], 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | imports: [ 17 | AsyncPipe, 18 | FlexModule, 19 | FormsModule, 20 | LanguagePipe, 21 | MarkdownComponent, 22 | MarkdownPipe, 23 | MatFormFieldModule, 24 | MatInputModule, 25 | ScrollspyNavLayoutComponent, 26 | ], 27 | }) 28 | export default class BindingsComponent implements OnInit { 29 | private elementRef = inject>(ElementRef); 30 | private rawLoaderService = inject(HttpRawLoaderService); 31 | 32 | // remote url 33 | demoPython$ = this.rawLoaderService.get('app/bindings/remote/demo.py'); 34 | 35 | // variable-binding 36 | markdown = 37 | `### Markdown example 38 | --- 39 | This is an **example** where we bind a variable to the \`markdown\` component that is also bound to a textarea. 40 | 41 | #### example.component.ts 42 | \`\`\`typescript 43 | public markdown = "# Markdown"; 44 | \`\`\` 45 | 46 | #### example.component.html 47 | \`\`\`html 48 | 49 | 50 | \`\`\``; 51 | 52 | // pipe 53 | typescriptMarkdown = 54 | `import { Component } from '@angular/core'; 55 | 56 | @Component({ 57 | selector: 'markdown-demo', 58 | templateUrl: './markdown-demo.component.html', 59 | styleUrls: ['./markdown-demo.component.scss'], 60 | }) 61 | export class MarkdownDemoComponent { 62 | public pipeMarkdown = '# Markdown'; 63 | }`; 64 | 65 | headings: Element[] | undefined; 66 | 67 | ngOnInit(): void { 68 | this.setHeadings(); 69 | } 70 | 71 | private setHeadings(): void { 72 | const headings: Element[] = []; 73 | this.elementRef.nativeElement 74 | .querySelectorAll('h2') 75 | .forEach(x => headings.push(x)); 76 | this.headings = headings; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/src/clipboard-button.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; 2 | import { ClipboardButtonComponent } from './clipboard-button.component'; 3 | 4 | describe('ClipboardButtonComponent', () => { 5 | let fixture: ComponentFixture; 6 | let nativeElement: HTMLElement; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | imports: [ClipboardButtonComponent], 11 | }).compileComponents(); 12 | 13 | fixture = TestBed.createComponent(ClipboardButtonComponent); 14 | nativeElement = fixture.nativeElement; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | describe('button', () => { 19 | it('should have class `markdown-clipboard-button`', () => { 20 | 21 | const buttonElement = nativeElement.querySelector('.markdown-clipboard-button'); 22 | 23 | expect(buttonElement).toBeDefined(); 24 | }); 25 | 26 | it('should have class `copied` applied for 3 seconds when clicked', fakeAsync(() => { 27 | 28 | const buttonElement = nativeElement.querySelector('.markdown-clipboard-button'); 29 | 30 | expect(buttonElement?.classList).not.toContain('copied'); 31 | 32 | buttonElement?.click(); 33 | fixture.detectChanges(); 34 | 35 | expect(buttonElement?.classList).toContain('copied'); 36 | 37 | tick(2999); 38 | fixture.detectChanges(); 39 | 40 | expect(buttonElement?.classList).toContain('copied'); 41 | 42 | tick(1); 43 | fixture.detectChanges(); 44 | 45 | expect(buttonElement?.classList).not.toContain('copied'); 46 | })); 47 | 48 | it('should display text `copy`', () => { 49 | 50 | const buttonElement = nativeElement.querySelector('.markdown-clipboard-button'); 51 | 52 | expect(buttonElement?.innerText).toBe('Copy'); 53 | }); 54 | 55 | it('should display text `copied` for 3 seconds when clicked', fakeAsync(() => { 56 | 57 | const buttonElement = nativeElement.querySelector('.markdown-clipboard-button'); 58 | 59 | expect(buttonElement?.innerText).toBe('Copy'); 60 | 61 | buttonElement?.click(); 62 | fixture.detectChanges(); 63 | 64 | expect(buttonElement?.innerText).toBe('Copied'); 65 | 66 | tick(2999); 67 | fixture.detectChanges(); 68 | 69 | expect(buttonElement?.innerText).toBe('Copied'); 70 | 71 | tick(1); 72 | fixture.detectChanges(); 73 | 74 | expect(buttonElement?.innerText).toBe('Copy'); 75 | })); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /lib/src/markdown.module.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken, ModuleWithProviders, NgModule, Provider } from '@angular/core'; 2 | import { ClipboardButtonComponent } from './clipboard-button.component'; 3 | import { CLIPBOARD_OPTIONS } from './clipboard-options'; 4 | import { LanguagePipe } from './language.pipe'; 5 | import { MarkdownComponent } from './markdown.component'; 6 | import { MarkdownPipe } from './markdown.pipe'; 7 | import { MARKED_EXTENSIONS } from './marked-extensions'; 8 | import { MARKED_OPTIONS } from './marked-options'; 9 | import { MERMAID_OPTIONS } from './mermaid-options'; 10 | import { provideMarkdown } from './provide-markdown'; 11 | import { SANITIZE } from './sanitize-options'; 12 | 13 | type InjectionTokenType> = T extends InjectionToken ? R : unknown; 14 | 15 | interface TypedValueProvider> { 16 | provide: T; 17 | useValue: InjectionTokenType; 18 | }; 19 | 20 | interface TypedFactoryProvider> { 21 | provide: T; 22 | useFactory: (...args: any[]) => InjectionTokenType; 23 | deps?: any[]; 24 | }; 25 | 26 | type TypedProvider> = TypedValueProvider | TypedFactoryProvider; 27 | 28 | type MultiTypedProvider> = TypedProvider & { multi: true }; 29 | 30 | // having a dependency on `HttpClientModule` within a library 31 | // breaks all the interceptors from the app consuming the library 32 | // here, we explicitely ask the user to pass a provider with 33 | // their own instance of `HttpClientModule` 34 | export interface MarkdownModuleConfig { 35 | loader?: Provider; 36 | clipboardOptions?: TypedProvider; 37 | markedOptions?: TypedProvider; 38 | markedExtensions?: MultiTypedProvider[]; 39 | mermaidOptions?: TypedProvider; 40 | sanitize?: TypedProvider; 41 | } 42 | 43 | const sharedDeclarations = [ 44 | ClipboardButtonComponent, 45 | LanguagePipe, 46 | MarkdownComponent, 47 | MarkdownPipe, 48 | ]; 49 | 50 | @NgModule({ 51 | imports: sharedDeclarations, 52 | exports: sharedDeclarations, 53 | }) 54 | export class MarkdownModule { 55 | static forRoot(markdownModuleConfig?: MarkdownModuleConfig): ModuleWithProviders { 56 | return { 57 | ngModule: MarkdownModule, 58 | providers: [ 59 | provideMarkdown(markdownModuleConfig), 60 | ], 61 | }; 62 | } 63 | 64 | static forChild(): ModuleWithProviders { 65 | return { 66 | ngModule: MarkdownModule, 67 | }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /demo/src/app/plugins/plugins.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit } from '@angular/core'; 2 | import { FlexModule } from '@angular/flex-layout/flex'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatFormFieldModule } from '@angular/material/form-field'; 5 | import { MatInputModule } from '@angular/material/input'; 6 | import { MatSnackBar } from '@angular/material/snack-bar'; 7 | import { CLIPBOARD_OPTIONS, MarkdownComponent, MermaidAPI, provideMarkdown, SANITIZE } from 'ngx-markdown'; 8 | import { sanitizeHtml } from '@app/app.marked-config'; 9 | import { ClipboardButtonComponent } from '@shared/clipboard-button'; 10 | import { ScrollspyNavLayoutComponent } from '@shared/scrollspy-nav-layout'; 11 | 12 | @Component({ 13 | selector: 'app-plugins', 14 | templateUrl: './plugins.component.html', 15 | styleUrls: ['./plugins.component.scss'], 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [ 18 | FlexModule, 19 | FormsModule, 20 | MarkdownComponent, 21 | MatFormFieldModule, 22 | MatInputModule, 23 | ScrollspyNavLayoutComponent, 24 | ], 25 | providers: [ 26 | provideMarkdown({ 27 | clipboardOptions: { 28 | provide: CLIPBOARD_OPTIONS, 29 | useValue: {}, 30 | }, 31 | sanitize: { 32 | provide: SANITIZE, 33 | useValue: sanitizeHtml, 34 | }, 35 | }), 36 | ], 37 | }) 38 | export default class PluginsComponent implements OnInit { 39 | private elementRef = inject>(ElementRef); 40 | private snackbar = inject(MatSnackBar); 41 | 42 | readonly clipboardButton = ClipboardButtonComponent; 43 | 44 | emojiMarkdown = '# I :heart: ngx-markdown'; 45 | 46 | katexMarkdown = 47 | `#### \`katex\` directive example 48 | 49 | \`\`\`latex 50 | f(x) = \\int_{-\\infty}^\\infty \\hat f(\\xi) e^{2 \\pi i \\xi x} d\\xi 51 | \`\`\` 52 | 53 | $f(x) = \\int_{-\\infty}^\\infty \\hat f(\\xi) e^{2 \\pi i \\xi x} d\\xi$`; 54 | 55 | mermaidMarkdown = 56 | `\`\`\`mermaid 57 | graph TD; 58 | A-->B; 59 | A-->C; 60 | B-->D; 61 | C-->D; 62 | \`\`\``; 63 | 64 | mermaidOptions: MermaidAPI.MermaidConfig = { 65 | fontFamily: 'inherit', 66 | theme: 'dark', 67 | }; 68 | 69 | headings: Element[] | undefined; 70 | 71 | ngOnInit(): void { 72 | this.setHeadings(); 73 | } 74 | 75 | onCopyToClipboard(): void { 76 | this.snackbar.open('Copied to clipboard via ng-template!', undefined, { 77 | duration: 3000, 78 | horizontalPosition: 'right', 79 | verticalPosition: 'bottom', 80 | }); 81 | } 82 | 83 | private setHeadings(): void { 84 | const headings: Element[] = []; 85 | this.elementRef.nativeElement 86 | .querySelectorAll('h2') 87 | .forEach(x => headings.push(x)); 88 | this.headings = headings; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /demo/src/app/syntax-highlight/syntax-highlight.component.html: -------------------------------------------------------------------------------- 1 | 2 |

Syntax Highlight

3 | 4 |
5 |

Auto-Detect

6 | 7 | 8 | When using the `src` input property to load file remotely, language for syntax highlight will be auto-detected based on the loaded file extension. 9 | 10 | The following example... 11 | 12 | ```html 13 | <markdown [src]="'app/syntax-highlight/remote/for-loop.js'"></markdown> 14 | ``` 15 | 16 | Would render with Javascript syntax highlight based on the `js` file extension. 17 | 18 | 19 | 20 |
21 | 22 |
23 |

Interpolation

24 | 25 | 26 | > :bulb: Using interpolation requires the uses of `ngPreserveWhitespaces` to keep indentation and spaces untouched during compilation. 27 | 28 | When using [interpolation](https://angular.io/guide/template-syntax#interpolation-), the language for code block must be specified after the first three backticks. 29 | 30 | ````html 31 | <markdown ngPreserveWhitespaces> 32 | ```typescript 33 | export function greetings(name: string): string &#123; 34 | return 'Hello ' + name; 35 | } 36 | ``` 37 | </markdown> 38 | ```` 39 | ##### _* Characters such as `<, >, {, }` directly written in the HTML template file must be escaped so that the compiler doesn't try to bind it as regular Angular code_. 40 | 41 | Would render with TypeScript syntax highlight based on the specified `typescript` language. 42 | 43 | 44 | 45 | ```typescript 46 | export function greetings(name: string): string { 47 | return 'Hello ' + name; 48 | } 49 | ``` 50 | 51 |
52 | 53 |
54 |

Language Pipe

55 | 56 | 57 | When using the `markdown` pipe, you can specify the syntax highlight language by chaining the `language` pipe. 58 | 59 | For example, having the python code `print('hello world')` into the `myValue` variable could be parsed specifying the language as follow... 60 | 61 | ```` 62 | ```html 63 | <div [innerHTML]="myValue | language : 'python' | markdown | async"><div> 64 | ``` 65 | ```` 66 | 67 | Would render with Python syntax highlight as specified with the `language` pipe in front of the `markdown` pipe. 68 | 69 | 70 |
71 |
72 |
-------------------------------------------------------------------------------- /demo/src/app/bindings/bindings.component.html: -------------------------------------------------------------------------------- 1 | 2 |

Bindings

3 | 4 |
5 |

Remote Url

6 | 7 | 8 | Using component with `src` property to fetch remote markdown file `app/bindings/remote/demo.md` 9 | 10 | 11 | 12 | 13 | 14 | Using component with static `python` code block 15 | 16 | 17 | 18 | 19 | 20 | Using directive with `src` property to fetch remote html file `app/bindings/remote/demo.html` 21 | 22 | 23 |
24 | 25 | 26 | Using directive with `src` property to fetch remote C++ file `app/bindings/remote/demo.cpp` 27 | 28 | 29 |
30 |
31 | 32 |
33 |

Variable Binding

34 | 35 | 36 | Using component or directive with `data` property allow to bind a variable that will update the DOM when value changes 37 | 38 | 39 |
40 | 41 | 42 | 43 | 44 | 45 |
46 | 47 | 48 | Using `language` pipe you can specify the language of the variable content for synthax highlights 49 | 50 | 51 | 52 |
53 | 54 |
55 |

Pipe Usage

56 | 57 | 58 | Using `markdown` pipe to transform markdown to HTML allow you to chain pipe transformations and will update the DOM when value changes. It is important to note that, because the `marked` parsing method returns a `Promise`, it requires the use of the `async` pipe. 59 | 60 | 61 | 62 | 63 | 64 | In the following example using the synthax above, `typescriptMarkdown` property does not contain any `back-ticks` to set the content language but will be chain with `language` pipe instead to specify synthax highlights language along with `markdown` pipe for conversion 65 | 66 | 67 |
68 | 69 | 70 | 71 | 72 |
73 |
74 |
75 |
-------------------------------------------------------------------------------- /demo/src/app/rerender/rerender.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'; 2 | import { FlexModule } from '@angular/flex-layout/flex'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatFormFieldModule } from '@angular/material/form-field'; 5 | import { MatInputModule } from '@angular/material/input'; 6 | import { MarkdownComponent, MarkdownService } from 'ngx-markdown'; 7 | import { ScrollspyNavLayoutComponent } from '@shared/scrollspy-nav-layout'; 8 | 9 | @Component({ 10 | selector: 'app-rerender', 11 | templateUrl: './rerender.component.html', 12 | styleUrls: ['./rerender.component.scss'], 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | imports: [ 15 | FlexModule, 16 | FormsModule, 17 | MarkdownComponent, 18 | MatFormFieldModule, 19 | MatInputModule, 20 | ScrollspyNavLayoutComponent, 21 | ], 22 | }) 23 | export default class RerenderComponent implements OnInit, OnDestroy { 24 | private elementRef = inject>(ElementRef); 25 | private markdownService = inject(MarkdownService); 26 | 27 | // property to handle override as per marked documentation, if a renderer 28 | // function returns `false` it will fallback to previous implementation 29 | // https://marked.js.org/using_pro#renderer 30 | private overrideEnabled = false; 31 | 32 | private _accentColor = ''; 33 | 34 | get accentColor(): string { 35 | return this._accentColor; 36 | } 37 | set accentColor(value: string) { 38 | if (this._accentColor === value) { 39 | return; 40 | } 41 | this._accentColor = value; 42 | this.changeAccentColor(); 43 | } 44 | 45 | headings: Element[] | undefined; 46 | 47 | markdown = `## Markdown rulez! 48 | --- 49 | 50 | ### Syntax highlight 51 | \`\`\`typescript 52 | const language = 'typescript'; 53 | \`\`\` 54 | 55 | ### Lists 56 | 1. Ordered list 57 | 2. Another bullet point 58 | - Unordered list 59 | - Another unordered bullet point 60 | 61 | ### Blockquote 62 | > Blockquote to the max`; 63 | 64 | ngOnInit(): void { 65 | this.setHeadings(); 66 | } 67 | 68 | ngOnDestroy(): void { 69 | this.resetRenderer(); 70 | } 71 | 72 | private changeAccentColor(): void { 73 | const styleAttribute = this.accentColor 74 | ? ` style="color: ${this.accentColor}"` 75 | : ''; 76 | 77 | this.overrideRenderer(styleAttribute); 78 | 79 | this.markdownService.reload(); 80 | } 81 | 82 | private overrideRenderer(styleAttribute: string): void { 83 | this.overrideEnabled = true; 84 | 85 | this.markdownService.renderer.heading = ({ text, depth }): string => { 86 | return this.overrideEnabled 87 | ? `${text}` 88 | : false as unknown as string; 89 | }; 90 | } 91 | 92 | private resetRenderer(): void { 93 | this.overrideEnabled = false; 94 | } 95 | 96 | private setHeadings(): void { 97 | const headings: Element[] = []; 98 | this.elementRef.nativeElement 99 | .querySelectorAll('h2') 100 | .forEach(x => headings.push(x)); 101 | this.headings = headings; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /demo/src/scss/material-theme.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use '@angular/material' as mat; 3 | 4 | @use 'typography' as typography; 5 | @use 'utils' as utils; 6 | 7 | @use 'light-theme' as light-theme; 8 | @use 'dark-theme' as dark-theme; 9 | 10 | @use '../app/app.component.theme' as app-component; 11 | @use '../app/shared/scrollspy-nav/scrollspy-nav.component.theme' as scrollspy-nav-component; 12 | 13 | @mixin native-element-theme($theme) { 14 | $color-config: mat.m2-get-color-config($theme); 15 | 16 | $primary-palette: map.get($color-config, 'primary'); 17 | $accent-palette: map.get($color-config, 'accent'); 18 | $foreground-palette: map.get($color-config, 'foreground'); 19 | 20 | a, 21 | a:active, 22 | a:focus, 23 | a:visited { 24 | color: mat.m2-get-color-from-palette($accent-palette, A400); 25 | text-decoration: none; 26 | } 27 | 28 | a:hover { 29 | text-decoration: underline; 30 | } 31 | 32 | blockquote { 33 | background: mat.m2-get-color-from-palette($primary-palette, 300, 0.14); 34 | border-left: 4px solid mat.m2-get-color-from-palette($accent-palette, 'default'); 35 | border-radius: 4px; 36 | color: utils.soften-color(mat.m2-get-color-from-palette($foreground-palette, 'base'), 40%); 37 | margin: 20px 0; 38 | padding: 1px 20px; 39 | } 40 | 41 | code:not([class*="language-"]) { 42 | background: mat.m2-get-color-from-palette($foreground-palette, 'secondary-text', 0.07); 43 | border-radius: 3px; 44 | font-size: 0.94em; 45 | padding: 0px 6px 2px; 46 | overflow-wrap: break-word; 47 | } 48 | 49 | hr { 50 | border-color: mat.m2-get-color-from-palette($foreground-palette, 'divider'); 51 | border-style: solid; 52 | border-width: 1px 0 0 0; 53 | } 54 | 55 | table { 56 | th { 57 | color: mat.m2-get-color-from-palette($foreground-palette, 'secondary-text'); 58 | } 59 | 60 | td, 61 | th { 62 | border-bottom-color: mat.m2-get-color-from-palette($foreground-palette, 'divider'); 63 | } 64 | } 65 | } 66 | 67 | @mixin material-element-themes($theme) { 68 | @include mat.core-color($theme); 69 | @include mat.divider-theme($theme); 70 | @include mat.fab-theme($theme); 71 | @include mat.form-field-theme($theme); 72 | @include mat.icon-button-theme($theme); 73 | @include mat.icon-theme($theme); 74 | @include mat.input-theme($theme); 75 | @include mat.snack-bar-theme($theme); 76 | @include mat.tabs-theme($theme); 77 | @include mat.toolbar-theme($theme); 78 | } 79 | 80 | @mixin app-component-themes($theme) { 81 | @include app-component.theme($theme); 82 | @include scrollspy-nav-component.theme($theme); 83 | } 84 | 85 | @mixin theme($theme, $name) { 86 | .#{$name}-theme { 87 | @include native-element-theme($theme); 88 | @include material-element-themes($theme); 89 | @include app-component-themes($theme); 90 | } 91 | } 92 | 93 | // common theme styling 94 | body { 95 | font-family: typography.$font-family; 96 | } 97 | 98 | // material core 99 | @include mat.elevation-classes(); 100 | @include mat.app-background(); 101 | 102 | // themes 103 | @include theme(light-theme.$theme, 'light'); 104 | @include theme(dark-theme.$theme, 'dark'); 105 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const eslint = require("@eslint/js"); 3 | const tseslint = require("typescript-eslint"); 4 | const importPlugin = require("eslint-plugin-import"); 5 | const angular = require("angular-eslint"); 6 | 7 | module.exports = tseslint.config( 8 | { 9 | ignores: ["projects/**/*"], 10 | }, 11 | { 12 | files: ["**/*.ts"], 13 | 14 | extends: [ 15 | eslint.configs.recommended, 16 | ...tseslint.configs.recommendedTypeChecked, 17 | ...tseslint.configs.stylistic, 18 | ...angular.configs.tsRecommended, 19 | ], 20 | 21 | plugins: { 22 | "import": importPlugin, 23 | }, 24 | 25 | settings: { 26 | "import/resolver": { 27 | node: true, 28 | typescript: "eslint-import-resolver-typescript" 29 | } 30 | }, 31 | 32 | languageOptions: { 33 | ecmaVersion: 5, 34 | sourceType: "script", 35 | 36 | parserOptions: { 37 | project: "tsconfig.json", 38 | tsconfigRootDir: __dirname, 39 | createDefaultProgram: true, 40 | }, 41 | }, 42 | 43 | processor: angular.processInlineTemplates, 44 | 45 | rules: { 46 | "@angular-eslint/directive-selector": [ 47 | "error", 48 | { 49 | "type": "attribute", 50 | "prefix": "app", 51 | "style": "camelCase", 52 | }, 53 | ], 54 | "@angular-eslint/component-selector": [ 55 | "error", 56 | { 57 | "type": "element", 58 | "prefix": "app", 59 | "style": "kebab-case", 60 | }, 61 | ], 62 | 63 | "@angular-eslint/no-output-native": "off", 64 | "@typescript-eslint/ban-types": "off", 65 | "@typescript-eslint/dot-notation": "off", 66 | "@typescript-eslint/no-non-null-assertion": "off", 67 | "@typescript-eslint/no-unused-vars": "error", 68 | "@typescript-eslint/no-unsafe-assignment": "off", 69 | "@typescript-eslint/no-unsafe-member-access": "off", 70 | "@typescript-eslint/no-floating-promises": "off", 71 | "@typescript-eslint/no-wrapper-object-types": "off", 72 | "comma-dangle": ["error", "always-multiline"], 73 | 74 | "comma-spacing": ["error", { 75 | "before": false, 76 | "after": true, 77 | }], 78 | 79 | "import/order": ["error", { 80 | "alphabetize": { 81 | "order": "asc", 82 | "caseInsensitive": true, 83 | }, 84 | 85 | "newlines-between": "never", 86 | 87 | "pathGroups": [{ 88 | "pattern": "@*/**", 89 | "group": "parent", 90 | }, { 91 | "pattern": "ngx-markdown", 92 | "group": "external", 93 | }], 94 | }], 95 | 96 | "import/no-duplicates": "error", 97 | "object-curly-spacing": ["error", "always"], 98 | "object-shorthand": "off", 99 | "quotes": ["error", "single"], 100 | "semi": ["error", "always"], 101 | "semi-spacing": "error", 102 | 103 | "sort-imports": ["error", { 104 | "ignoreCase": true, 105 | "ignoreDeclarationSort": true, 106 | }], 107 | }, 108 | }, 109 | { 110 | files: ["**/*.html"], 111 | 112 | extends: [ 113 | ...angular.configs.templateRecommended, 114 | ], 115 | 116 | rules: {}, 117 | } 118 | ); 119 | -------------------------------------------------------------------------------- /lib/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const tseslint = require("typescript-eslint"); 3 | const angular = require("angular-eslint"); 4 | 5 | const baseConfig = require("../eslint.config.js"); 6 | 7 | module.exports = tseslint.config( 8 | { 9 | extends: [...baseConfig], 10 | }, 11 | { 12 | ignores: ["!**/*"], 13 | }, 14 | { 15 | files: ["**/*.ts"], 16 | 17 | languageOptions: { 18 | ecmaVersion: 5, 19 | sourceType: "script", 20 | 21 | parserOptions: { 22 | project: "tsconfig.lib.json", 23 | tsconfigRootDir: __dirname, 24 | createDefaultProgram: true, 25 | }, 26 | }, 27 | 28 | processor: angular.processInlineTemplates, 29 | 30 | rules: { 31 | "@angular-eslint/component-selector": ["error", { 32 | "type": "element", 33 | "prefix": "markdown", 34 | "style": "kebab-case", 35 | }], 36 | 37 | "@angular-eslint/directive-selector": ["error", { 38 | "type": "attribute", 39 | "prefix": "markdown", 40 | "style": "camelCase", 41 | }], 42 | 43 | "@angular-eslint/no-output-native": "off", 44 | "@typescript-eslint/ban-types": "off", 45 | "@typescript-eslint/dot-notation": "off", 46 | "@typescript-eslint/no-empty-function": "off", 47 | "@typescript-eslint/no-explicit-any": "off", 48 | "@typescript-eslint/no-non-null-assertion": "off", 49 | "@typescript-eslint/no-unsafe-call": "off", 50 | "@typescript-eslint/no-unused-vars": [ "error", { "args": "none" }], 51 | "@typescript-eslint/restrict-template-expressions": "off", 52 | "@typescript-eslint/unbound-method": "off", 53 | "comma-dangle": ["error", "always-multiline"], 54 | "import/order": "error", 55 | "object-shorthand": "off", 56 | }, 57 | }, 58 | { 59 | files: ["**/*.spec.ts"], 60 | 61 | languageOptions: { 62 | ecmaVersion: 5, 63 | sourceType: "script", 64 | 65 | parserOptions: { 66 | project: "tsconfig.spec.json", 67 | tsconfigRootDir: __dirname, 68 | createDefaultProgram: true, 69 | }, 70 | }, 71 | 72 | rules: { 73 | "@angular-eslint/component-selector": ["error", { 74 | type: "element", 75 | prefix: "markdown", 76 | style: "kebab-case", 77 | }], 78 | 79 | "@angular-eslint/directive-selector": ["error", { 80 | type: "attribute", 81 | prefix: "markdown", 82 | style: "camelCase", 83 | }], 84 | 85 | "@angular-eslint/no-output-native": "off", 86 | "@typescript-eslint/ban-types": "off", 87 | "@typescript-eslint/dot-notation": "off", 88 | "@typescript-eslint/no-empty-function": "off", 89 | "@typescript-eslint/no-explicit-any": "off", 90 | "@typescript-eslint/no-non-null-assertion": "off", 91 | "@typescript-eslint/no-unsafe-call": "off", 92 | 93 | "@typescript-eslint/no-unused-vars": ["error", { 94 | args: "none", 95 | }], 96 | 97 | "@typescript-eslint/restrict-template-expressions": "off", 98 | "@typescript-eslint/unbound-method": "off", 99 | "comma-dangle": ["error", "always-multiline"], 100 | "import/order": "error", 101 | "object-shorthand": "off", 102 | }, 103 | }, 104 | { 105 | files: ["**/*.html"], 106 | rules: {}, 107 | }, 108 | ); 109 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | browser-tools: circleci/browser-tools@1.4.8 4 | jobs: 5 | build: 6 | docker: 7 | - image: cimg/node:20.19-browsers 8 | steps: 9 | # Install chrome via browser tools 10 | - browser-tools/install-chrome 11 | - browser-tools/install-chromedriver 12 | # Checkout the code from the branch into the working_directory 13 | - checkout 14 | # Restore dependencies from cache 15 | - restore_cache: 16 | key: dependency-cache-{{ checksum "package-lock.json" }} 17 | # Install dependencies 18 | - run: 19 | name: Install dependencies 20 | command: npm ci 21 | # Cache dependencies if they don't exist 22 | - save_cache: 23 | key: dependency-cache-{{ checksum "package-lock.json" }} 24 | paths: 25 | - ~/.npm 26 | - ./node_modules 27 | # Test the source code 28 | - run: 29 | name: Test 30 | command: npm run test -- --watch=false --code-coverage --no-progress 31 | - store_artifacts: 32 | path: test-results.xml 33 | prefix: tests 34 | - store_artifacts: 35 | path: coverage 36 | prefix: coverage 37 | # Upload coverage artifacts to Coveralls 38 | - run: 39 | name: Push coverage artifacts 40 | command: npm run coveralls 41 | # Type check the source code 42 | - run: 43 | name: Type-Check 44 | command: npm run type-check:lib 45 | # Lint the source code 46 | - run: 47 | name: Lint 48 | command: npm run lint:ci 49 | - store_artifacts: 50 | path: eslint.xml 51 | prefix: lint 52 | # Build the source code 53 | - run: 54 | name: Build 55 | command: npm run build:lib 56 | deploy: 57 | docker: 58 | - image: cimg/node:20.19 59 | steps: 60 | # Checkout the code from the branch into the working_directory 61 | - checkout 62 | # Restore dependencies from cache 63 | - restore_cache: 64 | key: dependency-cache-{{ checksum "package-lock.json" }} 65 | # Install dependencies 66 | - run: 67 | name: Install dependencies 68 | command: npm ci 69 | # Cache local dependencies if they don't exist 70 | - save_cache: 71 | key: dependency-cache-{{ checksum "package-lock.json" }} 72 | paths: 73 | - ~/.npm 74 | - ./node_modules 75 | # Build demo 76 | - run: 77 | name: Build 78 | command: | 79 | npm run gh-pages:build 80 | npm run gh-pages:postbuild 81 | # Deploy demo to Github Pages 82 | - run: 83 | name: Deploy to gh-pages 84 | command: npm run gh-pages:deploy 85 | 86 | workflows: 87 | version: 2 88 | build_and_deploy: 89 | jobs: 90 | # Build for all branches other than gh-pages and for all tags 91 | - build: 92 | filters: 93 | tags: 94 | only: /.*/ 95 | branches: 96 | ignore: gh-pages 97 | # Deploy for tags marked by a version number only on master branch 98 | - deploy: 99 | requires: 100 | - build 101 | filters: 102 | tags: 103 | only: /v[0-9]+(\.[0-9]+)*/ 104 | branches: 105 | only: master 106 | -------------------------------------------------------------------------------- /lib/src/markdown.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 2 | 3 | import { ElementRef, NgZone, ViewContainerRef } from '@angular/core'; 4 | import { fakeAsync, TestBed, tick } from '@angular/core/testing'; 5 | import { DomSanitizer } from '@angular/platform-browser'; 6 | import { MarkdownModule } from './markdown.module'; 7 | import { MarkdownPipe, MarkdownPipeOptions } from './markdown.pipe'; 8 | import { MarkdownService } from './markdown.service'; 9 | 10 | describe('MarkdownPipe', () => { 11 | let domSanitizer: DomSanitizer; 12 | let elementRef: ElementRef; 13 | let markdownService: MarkdownService; 14 | let pipe: MarkdownPipe; 15 | let viewContainerRef: ViewContainerRef; 16 | let zone: NgZone; 17 | 18 | const elementRefSpy = jasmine.createSpyObj([], { nativeElement: document.createElement('div') }); 19 | const viewContainerRefSpy = jasmine.createSpyObj(['createComponent']); 20 | 21 | beforeEach(() => { 22 | TestBed.configureTestingModule({ 23 | imports: [ 24 | MarkdownModule.forRoot(), 25 | ], 26 | providers: [ 27 | MarkdownPipe, 28 | { provide: ElementRef, useValue: elementRefSpy }, 29 | { provide: ViewContainerRef, useValue: viewContainerRefSpy }, 30 | ], 31 | }); 32 | 33 | pipe = TestBed.inject(MarkdownPipe); 34 | 35 | elementRef = TestBed.inject(ElementRef); 36 | domSanitizer = TestBed.inject(DomSanitizer); 37 | markdownService = TestBed.inject(MarkdownService); 38 | viewContainerRef = TestBed.inject(ViewContainerRef); 39 | zone = TestBed.inject(NgZone); 40 | }); 41 | 42 | it('should return empty string when value is null/undefined', async () => { 43 | 44 | const markdowns: any[] = [undefined, null]; 45 | 46 | for (const markdown of markdowns) { 47 | const result = await pipe.transform(markdown); 48 | expect(result).toBe(''); 49 | } 50 | }); 51 | 52 | it('should log error and return value when parameter is not a string', async () => { 53 | 54 | const markdowns: any[] = [0, {}, [], /regex/]; 55 | 56 | spyOn(console, 'error'); 57 | 58 | for (const markdown of markdowns) { 59 | const result = await pipe.transform(markdown); 60 | 61 | expect(result).toBe(markdown); 62 | expect(console.error).toHaveBeenCalledWith(`MarkdownPipe has been invoked with an invalid value type [${typeof markdown}]`); 63 | } 64 | }); 65 | 66 | it('should render element through MarkdownService when zone is stable', fakeAsync(() => { 67 | 68 | const markdown = '# Markdown'; 69 | const mockPipeOptions: MarkdownPipeOptions = { mermaid: true, mermaidOptions: { darkMode: true } }; 70 | 71 | spyOn(markdownService, 'render'); 72 | 73 | pipe.transform(markdown, mockPipeOptions); 74 | tick(); 75 | 76 | expect(markdownService.render).not.toHaveBeenCalled(); 77 | 78 | zone.onStable.emit(null); 79 | 80 | expect(markdownService.render).toHaveBeenCalledWith(elementRef.nativeElement, mockPipeOptions, viewContainerRef); 81 | })); 82 | 83 | it('should return parsed markdown', async () => { 84 | 85 | const markdown = '# Markdown'; 86 | const mockParsed = 'compiled-x'; 87 | const mockBypassSecurity = 'bypass-x'; 88 | const mockPipeOptions: MarkdownPipeOptions = { inline: true, emoji: true, disableSanitizer: true }; 89 | 90 | spyOn(markdownService, 'parse').and.returnValue(mockParsed); 91 | spyOn(domSanitizer, 'bypassSecurityTrustHtml').and.returnValue(mockBypassSecurity); 92 | 93 | const result = await pipe.transform(markdown, mockPipeOptions); 94 | 95 | expect(markdownService.parse).toHaveBeenCalledWith(markdown, mockPipeOptions); 96 | expect(domSanitizer.bypassSecurityTrustHtml).toHaveBeenCalledWith(mockParsed); 97 | expect(result).toBe(mockBypassSecurity); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /demo/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, ElementRef, HostListener, inject, OnInit, ViewChild } from '@angular/core'; 3 | import { FlexModule } from '@angular/flex-layout/flex'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatIconModule } from '@angular/material/icon'; 6 | import { MatTabsModule } from '@angular/material/tabs'; 7 | import { MatToolbarModule } from '@angular/material/toolbar'; 8 | import { Route, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; 9 | import { AnchorService } from '@shared/anchor'; 10 | import { ROUTE_ANIMATION } from './app.animation'; 11 | import { DEFAULT_THEME, LOCAL_STORAGE_THEME_KEY } from './app.constant'; 12 | import { isTheme, Theme } from './app.models'; 13 | 14 | @Component({ 15 | animations: [ROUTE_ANIMATION], 16 | selector: 'app-root', 17 | templateUrl: './app.component.html', 18 | styleUrls: ['./app.component.scss'], 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | imports: [ 21 | FlexModule, 22 | MatButtonModule, 23 | MatIconModule, 24 | MatTabsModule, 25 | MatToolbarModule, 26 | RouterLink, 27 | RouterLinkActive, 28 | RouterOutlet, 29 | ], 30 | }) 31 | export class AppComponent implements OnInit { 32 | private document = inject(DOCUMENT); 33 | private anchorService = inject(AnchorService); 34 | private router = inject(Router); 35 | 36 | private readonly stickyClassName = 'mat-mdc-tab-nav-bar--sticky'; 37 | 38 | routes: Route[]; 39 | theme = DEFAULT_THEME; 40 | 41 | @ViewChild('tabHeader', { read: ElementRef, static: true }) 42 | tabHeader: ElementRef | undefined; 43 | 44 | @HostListener('document:click', ['$event']) 45 | onDocumentClick(event: Event): void { 46 | this.anchorService.interceptClick(event); 47 | } 48 | 49 | @HostListener('window:scroll') 50 | onWindowScroll(): void { 51 | if (this.tabHeader == null) { 52 | return; 53 | } 54 | const tabHeader = this.tabHeader.nativeElement; 55 | const tabHeaderOffset = Math.ceil(tabHeader.offsetTop); 56 | const windowOffset = Math.ceil(window.pageYOffset); 57 | const hasStickyClass = tabHeader.classList.contains(this.stickyClassName); 58 | if (!hasStickyClass && windowOffset >= tabHeaderOffset) { 59 | tabHeader.classList.add(this.stickyClassName); 60 | } 61 | if (hasStickyClass && windowOffset < tabHeaderOffset) { 62 | tabHeader.classList.remove(this.stickyClassName); 63 | } 64 | } 65 | 66 | constructor() { 67 | this.routes = this.router.config.filter(route => route.data && route.data['label']); 68 | } 69 | 70 | ngOnInit(): void { 71 | this.anchorService.setOffset([0, 64]); 72 | 73 | const storedTheme = localStorage.getItem(LOCAL_STORAGE_THEME_KEY); 74 | this.setTheme( 75 | isTheme(storedTheme) 76 | ? storedTheme 77 | : DEFAULT_THEME, 78 | ); 79 | } 80 | 81 | handleFragment(): void { 82 | this.anchorService.scrollToAnchor(); 83 | } 84 | 85 | setTheme(theme: Theme): void { 86 | this.theme = theme; 87 | const bodyClassList = this.document.querySelector('body')!.classList; 88 | const removeClassList = /\w*-theme\b/.exec(bodyClassList.value); 89 | if (removeClassList) { 90 | bodyClassList.remove(...removeClassList); 91 | } 92 | bodyClassList.add(`${this.theme}-theme`); 93 | localStorage.setItem(LOCAL_STORAGE_THEME_KEY, this.theme); 94 | } 95 | 96 | getRouteAnimation(outlet: RouterOutlet): string { 97 | return outlet 98 | && outlet.activatedRouteData 99 | && outlet.activatedRouteData['label'] as string; 100 | } 101 | 102 | toggleTheme(): void { 103 | this.setTheme( 104 | this.theme === Theme.Light 105 | ? Theme.Dark 106 | : Theme.Light, 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /demo/src/scss/prism-theme.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * prism.js Visual Studio Code Theme 3 | * @author Visual Studio Code 4 | */ 5 | 6 | code[class*="language-"], 7 | pre[class*="language-"] { 8 | color: #9CDCFE; 9 | background: none; 10 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 11 | text-align: left; 12 | white-space: pre; 13 | word-spacing: normal; 14 | word-break: normal; 15 | word-wrap: normal; 16 | line-height: 1.5; 17 | 18 | -moz-tab-size: 4; 19 | -o-tab-size: 4; 20 | tab-size: 4; 21 | 22 | -webkit-hyphens: none; 23 | -moz-hyphens: none; 24 | -ms-hyphens: none; 25 | hyphens: none; 26 | } 27 | 28 | /* Code blocks */ 29 | 30 | pre[class*="language-"] { 31 | padding: 1em; 32 | margin: .5em 0; 33 | overflow: auto; 34 | } 35 | 36 | :not(pre) > code[class*="language-"], 37 | pre[class*="language-"] { 38 | border-radius: 4px; 39 | background: #1E1E1E; 40 | font-size: 14px; 41 | } 42 | 43 | /* Inline code */ 44 | 45 | :not(pre) > code[class*="language-"] { 46 | padding: .1em; 47 | border-radius: .3em; 48 | white-space: normal; 49 | } 50 | 51 | .token.comment, 52 | .token.block-comment, 53 | .token.prolog, 54 | .token.doctype, 55 | .token.cdata { 56 | color: #6A9955; 57 | } 58 | 59 | .token.punctuation { 60 | color: #CCC; 61 | } 62 | 63 | .token.tag, 64 | .token.namespace, 65 | .token.deleted { 66 | color: #569CD6; 67 | } 68 | 69 | .token.attr-name { 70 | color: #9CDCFE; 71 | } 72 | 73 | .token.function-name { 74 | color: #6196cc; 75 | } 76 | 77 | .token.boolean { 78 | color: #569CD6; 79 | } 80 | 81 | .token.number { 82 | color: #B5CEA8; 83 | } 84 | 85 | .token.function { 86 | color: #DCDCAA; 87 | } 88 | 89 | .token.property, 90 | .token.constant, 91 | .token.symbol { 92 | color: #51b6c4; 93 | } 94 | 95 | .token.builtin, 96 | .token.class-name { 97 | color: #4EC9B0; 98 | } 99 | 100 | .token.selector, 101 | .token.important, 102 | .token.atrule, 103 | .token.keyword { 104 | color: #C586C0; 105 | } 106 | 107 | .token.variable, 108 | .token.string, 109 | .token.char, 110 | .token.attr-value, 111 | .token.variable { 112 | color: #CE9169; 113 | } 114 | 115 | .token.regex { 116 | color: #d16969; 117 | } 118 | 119 | .token.operator { 120 | color: #D4D4D4; 121 | } 122 | 123 | .token.entity, 124 | .token.url { 125 | color: #67cdcc; 126 | } 127 | 128 | .token.important, 129 | .token.bold { 130 | font-weight: bold; 131 | } 132 | 133 | .token.italic { 134 | font-style: italic; 135 | } 136 | 137 | .token.entity { 138 | cursor: help; 139 | } 140 | 141 | /* diff */ 142 | 143 | .language-diff { 144 | 145 | .token.inserted { 146 | color: #8fce00; 147 | } 148 | } 149 | 150 | /* Html */ 151 | 152 | .language-html :not(.token) { 153 | color: #d4d4d4; 154 | } 155 | 156 | .language-html .token.punctuation { 157 | color: #808080; 158 | } 159 | 160 | /* TypeScript, Javascript */ 161 | 162 | .language-ts, 163 | .language-typescript, 164 | .language-js, 165 | .language-javascript { 166 | color: #9CDCFE; 167 | 168 | .token.string { 169 | color: #CE9169; 170 | } 171 | 172 | .token.punctuation { 173 | color: #D4D4D4; 174 | } 175 | 176 | .script-punctuation+.token.punctuation+.token.punctuation { 177 | color: #D4D4D4; 178 | } 179 | 180 | .script-punctuation+.token.punctuation+.token.punctuation~.token.punctuation { 181 | color: #D4D4D4; 182 | } 183 | 184 | .script-punctuation+.token.punctuation+.token.punctuation~.token.punctuation+.token.punctuation { 185 | color: #3F9CD6; 186 | } 187 | 188 | .keyword-class, 189 | .keyword-const, 190 | .keyword-constructor, 191 | .keyword-function, 192 | .keyword-implements, 193 | .keyword-new, 194 | .keyword-private, 195 | .keyword-public, 196 | .keyword-readonly, 197 | .keyword-this { 198 | color: #569CD6; 199 | } 200 | 201 | .keyword-void { 202 | color: #4EC9B0; 203 | } 204 | 205 | .import-member { 206 | color: #9CDCFE; 207 | } 208 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-markdown", 3 | "version": "21.0.1", 4 | "description": "Angular library that uses marked to parse markdown to html combined with Prism.js for synthax highlights", 5 | "homepage": "https://github.com/jfcere/ngx-markdown", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Jean-Francois Cere", 9 | "email": "jfcere@hotmail.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/jfcere/ngx-markdown" 14 | }, 15 | "keywords": [ 16 | "angular", 17 | "ngx", 18 | "markdown", 19 | "parser", 20 | "marked", 21 | "marked.js", 22 | "prism", 23 | "prism.js", 24 | "katex", 25 | "emoji", 26 | "clipboard", 27 | "clipboard.js" 28 | ], 29 | "scripts": { 30 | "ng": "ng", 31 | "start": "npm run link:lib && ng serve", 32 | "build:demo": "ng build demo --configuration production", 33 | "build:lib": "ng build lib --configuration production", 34 | "postbuild:lib": "cpy ./README.md ./dist/lib --flat && cpy ./LICENSE ./dist/lib --flat", 35 | "link:lib": "cd ./demo && npm link ../lib", 36 | "lint": "npm run lint:lib && npm run lint:demo", 37 | "lint:demo": "ng lint demo", 38 | "lint:lib": "ng lint lib", 39 | "lint:ci": "ng lint lib --format checkstyle > eslint.xml", 40 | "type-check:demo": "tsc --project ./demo/tsconfig.app.json --inlineSourceMap --noEmit", 41 | "type-check:lib": "tsc --project ./lib/tsconfig.lib.json --inlineSourceMap --noEmit", 42 | "test": "ng test", 43 | "coveralls": "cat \"./coverage/lcov.info\" | \"./node_modules/coveralls/bin/coveralls.js\"", 44 | "gh-pages:build": "npm run build:demo -- --aot --base-href /ngx-markdown/", 45 | "gh-pages:postbuild": "cpy ./dist/demo/3rdpartylicenses.txt ./dist/demo/browser --flat", 46 | "gh-pages:deploy": "angular-cli-ghpages --dir=\"dist/demo/browser\" --name=\"CircleCI\" --email=\"circleci@email.com\" --no-silent", 47 | "publish:lib": "npm publish ./dist/lib", 48 | "publish:beta": "npm publish ./dist/lib --tag beta" 49 | }, 50 | "packageManager": "npm@10.9.4", 51 | "dependencies": { 52 | "@angular/animations": "^21.0.0", 53 | "@angular/cdk": "^21.0.0", 54 | "@angular/common": "^21.0.0", 55 | "@angular/compiler": "^21.0.0", 56 | "@angular/core": "^21.0.0", 57 | "@angular/flex-layout": "15.0.0-beta.42", 58 | "@angular/forms": "^21.0.0", 59 | "@angular/material": "^21.0.0", 60 | "@angular/platform-browser": "^21.0.0", 61 | "@angular/router": "^21.0.0", 62 | "clipboard": "^2.0.11", 63 | "dompurify": "^3.2.6", 64 | "emoji-toolkit": "^9.0.0", 65 | "gumshoejs": "^5.1.2", 66 | "hammerjs": "~2.0.8", 67 | "katex": "^0.16.2", 68 | "marked": "^17.0.0", 69 | "marked-gfm-heading-id": "^4.1.3", 70 | "mermaid": "^11.2.1", 71 | "ngx-markdown": "file:lib", 72 | "prismjs": "^1.30.0", 73 | "rxjs": "~6.5.3", 74 | "tslib": "^2.3.0", 75 | "zone.js": "~0.15.0" 76 | }, 77 | "devDependencies": { 78 | "@angular-eslint/schematics": "^21.0.1", 79 | "@angular/build": "^21.0.0", 80 | "@angular/cli": "^21.0.0", 81 | "@angular/compiler-cli": "^21.0.0", 82 | "@angular/language-service": "^21.0.0", 83 | "@types/jasmine": "~5.1.0", 84 | "angular-cli-ghpages": "^2.0.3", 85 | "angular-eslint": "^21.0.1", 86 | "coveralls": "^3.1.1", 87 | "cpy-cli": "^5.0.0", 88 | "eslint": "^9.28.0", 89 | "eslint-formatter-checkstyle": "^8.40.0", 90 | "eslint-import-resolver-typescript": "^3.6.3", 91 | "eslint-plugin-import": "^2.31.0", 92 | "jasmine-core": "~5.7.0", 93 | "karma": "~6.4.0", 94 | "karma-chrome-launcher": "~3.2.0", 95 | "karma-coverage": "~2.2.0", 96 | "karma-jasmine": "~5.1.0", 97 | "karma-jasmine-html-reporter": "~2.1.0", 98 | "karma-junit-reporter": "^2.0.1", 99 | "ng-packagr": "^21.0.0", 100 | "rimraf": "^6.0.0", 101 | "typescript": "~5.9.2", 102 | "typescript-eslint": "^8.33.1" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/src/katex-options.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export class KatexSpecificOptions { 3 | /** 4 | * If `true`, math will be rendered in display mode 5 | * (math in display style and center math on page) 6 | * 7 | * If `false`, math will be rendered in inline mode 8 | * @default false 9 | */ 10 | displayMode?: boolean; 11 | /** 12 | * If `true`, KaTeX will throw a `ParseError` when 13 | * it encounters an unsupported command or invalid LaTex 14 | * 15 | * If `false`, KaTeX will render unsupported commands as 16 | * text, and render invalid LaTeX as its source code with 17 | * hover text giving the error, in color given by errorColor 18 | * @default true 19 | */ 20 | throwOnError?: boolean; 21 | /** 22 | * A Color string given in format `#XXX` or `#XXXXXX` 23 | */ 24 | errorColor?: string; 25 | /** 26 | * A collection of custom macros. 27 | * 28 | * See `src/macros.js` for its usage 29 | */ 30 | macros?: any; 31 | /** 32 | * If `true`, `\color` will work like LaTeX's `\textcolor` 33 | * and takes 2 arguments 34 | * 35 | * If `false`, `\color` will work like LaTeX's `\color` 36 | * and takes 1 argument 37 | * 38 | * In both cases, `\textcolor` works as in LaTeX 39 | * 40 | * @default false 41 | */ 42 | colorIsTextColor?: boolean; 43 | /** 44 | * All user-specified sizes will be caped to `maxSize` ems 45 | * 46 | * If set to Infinity, users can make elements and space 47 | * arbitrarily large 48 | * 49 | * @default Infinity 50 | */ 51 | maxSize?: number; 52 | /** 53 | * Limit the number of macro expansions to specified number 54 | * 55 | * If set to `Infinity`, marco expander will try to fully expand 56 | * as in LaTex 57 | * 58 | * @default 1000 59 | */ 60 | maxExpand?: number; 61 | /** 62 | * Allowed protocols in `\href` 63 | * 64 | * Use `_relative` to allow relative urls 65 | * 66 | * Use `*` to allow all protocols 67 | */ 68 | allowedProtocols?: string[]; 69 | /** 70 | * If `false` or `"ignore"`, allow features that make 71 | * writing in LaTex convenient but not supported by LaTex 72 | * 73 | * If `true` or `"error"`, throw an error for such transgressions 74 | * 75 | * If `"warn"`, warn about behavior via `console.warn` 76 | * 77 | * @default "warn" 78 | */ 79 | strict?: boolean | string | Function; 80 | } 81 | 82 | export interface RenderMathInElementSpecificOptionsDelimiters { 83 | /** 84 | * A string which starts the math expression (i.e. the left delimiter) 85 | */ 86 | left: string; 87 | /** 88 | * A string which ends the math expression (i.e. the right delimiter) 89 | */ 90 | right: string; 91 | /** 92 | * A boolean of whether the math in the expression should be rendered in display mode or not 93 | */ 94 | display: boolean 95 | } 96 | 97 | export interface RenderMathInElementSpecificOptions { 98 | /** 99 | * A list of delimiters to look for math 100 | * 101 | * @default [ 102 | * {left: "$$", right: "$$", display: true}, 103 | * {left: "\\(", right: "\\)", display: false}, 104 | * {left: "\\[", right: "\\]", display: true} 105 | * ] 106 | */ 107 | delimiters?: ReadonlyArray | undefined; 108 | /** 109 | * A list of DOM node types to ignore when recursing through 110 | * 111 | * @default ["script", "noscript", "style", "textarea", "pre", "code"] 112 | */ 113 | ignoredTags?: ReadonlyArray | undefined; 114 | /** 115 | * A list of DOM node class names to ignore when recursing through 116 | * 117 | * @default [] 118 | */ 119 | ignoredClasses?: string[] | undefined; 120 | 121 | /** 122 | * A callback method returning a message and an error stack in case of an critical error during rendering 123 | * @param msg Message generated by KaTeX 124 | * @param err Caught error 125 | * 126 | * @default console.error 127 | */ 128 | errorCallback?(msg: string, err: Error): void; 129 | } 130 | 131 | /** 132 | * renderMathInElement options contain KaTeX render options and renderMathInElement specific options 133 | */ 134 | export type KatexOptions = KatexSpecificOptions & RenderMathInElementSpecificOptions; 135 | -------------------------------------------------------------------------------- /demo/src/app/shared/anchor/anchor.service.ts: -------------------------------------------------------------------------------- 1 | import { LocationStrategy, ViewportScroller } from '@angular/common'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { ActivatedRoute, Router, UrlTree } from '@angular/router'; 4 | 5 | /** 6 | * Service to handle links generated through markdown parsing. 7 | * #### Using `RouterModule` 8 | * The following `RouterModule` configuration is required to enabled anchors 9 | * to be scrolled to when URL has a fragment via the Angular router: 10 | * ``` 11 | * RouterModule.forRoot(routes, { 12 | * anchorScrolling: 'enabled', 13 | * scrollOffset: [0, 64], // (optional) 14 | * scrollPositionRestoration: 'enabled', 15 | * }) 16 | * ``` 17 | * #### Using `provideRouter` 18 | * The following `provideRouter` configuration is required to enabled anchors 19 | * to be scrolled to when URL has a fragment via the Angular router: 20 | * ``` 21 | * provideRouter(appRoutes, withInMemoryScrolling({ 22 | * anchorScrolling: 'enabled', 23 | * scrollPositionRestoration: 'enabled', 24 | * })) 25 | * ``` 26 | * To set the `scrollOffset` when scrolling to an element use the 27 | * `AnchorService.setOffset()` in your `AppComponent` (optional): 28 | * ``` 29 | * constructor(private anchorService: AnchorService) { 30 | * this.anchorService.setOffset([0, 64]); 31 | * } 32 | * ``` 33 | */ 34 | @Injectable({ providedIn: 'root' }) 35 | export class AnchorService { 36 | private locationStrategy = inject(LocationStrategy); 37 | private route = inject(ActivatedRoute); 38 | private router = inject(Router); 39 | private viewportScroller = inject(ViewportScroller); 40 | 41 | /** 42 | * Intercept clicks on `HTMLAnchorElement` to use `Router.navigate()` 43 | * when `href` is an internal URL not handled by `routerLink` directive. 44 | * @param event The event to evaluated for link click. 45 | */ 46 | interceptClick(event: Event): void { 47 | const element = event.target; 48 | if (!(element instanceof HTMLAnchorElement)) { 49 | return; 50 | } 51 | const href = element.getAttribute('href') || ''; 52 | if (this.isExternalUrl(href) || this.isRouterLink(element)) { 53 | return; 54 | } 55 | this.navigate(href); 56 | event.preventDefault(); 57 | } 58 | 59 | /** 60 | * Navigate to URL using angular `Router`. 61 | * @param url Destination path to navigate to. 62 | * @param replaceUrl If `true`, replaces current state in browser history. 63 | */ 64 | navigate(url: string, replaceUrl = false): void { 65 | const urlTree = this.getUrlTree(url); 66 | this.router.navigated = false; 67 | void this.router.navigateByUrl(urlTree, { replaceUrl }); 68 | } 69 | 70 | /** 71 | * Transform a relative URL to its absolute representation according to current router state. 72 | * @param url Relative URL path. 73 | * @return Absolute URL based on the current route. 74 | */ 75 | normalizeExternalUrl(url: string): string { 76 | if (this.isExternalUrl(url)) { 77 | return url; 78 | } 79 | const urlTree = this.getUrlTree(url); 80 | const serializedUrl = this.router.serializeUrl(urlTree); 81 | return this.locationStrategy.prepareExternalUrl(serializedUrl); 82 | } 83 | 84 | /** 85 | * Scroll view to the anchor corresponding to current route fragment. 86 | */ 87 | scrollToAnchor(): void { 88 | const url = this.router.parseUrl(this.router.url); 89 | if (url.fragment) { 90 | this.navigate(this.router.url, true); 91 | } 92 | } 93 | 94 | /** 95 | * Configures the top offset used when scrolling to an anchor. 96 | * @param offset A position in screen coordinates (a tuple with x and y values) 97 | * or a function that returns the top offset position. 98 | */ 99 | setOffset(...params: Parameters): void { 100 | this.viewportScroller.setOffset(...params); 101 | } 102 | 103 | private getUrlTree(url: string): UrlTree { 104 | const urlPath = this.stripFragment(url) || this.stripFragment(this.router.url); 105 | const urlFragment = this.router.parseUrl(url).fragment || undefined; 106 | return this.router.createUrlTree([urlPath], { relativeTo: this.route, fragment: urlFragment }); 107 | } 108 | 109 | private isExternalUrl(url: string): boolean { 110 | return /^(?!http(s?):\/\/).+$/.exec(url) == null; 111 | } 112 | 113 | private isRouterLink(element: HTMLAnchorElement): boolean { 114 | return element.getAttributeNames().some(n => n.startsWith('_ngcontent')); 115 | } 116 | 117 | private stripFragment(url: string): string { 118 | return /[^#]*/.exec(url)![0]; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "", 5 | "projects": { 6 | "demo": { 7 | "root": "demo/", 8 | "sourceRoot": "demo/src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": { 12 | "@schematics/angular:component": { 13 | "style": "scss" 14 | } 15 | }, 16 | "architect": { 17 | "build": { 18 | "builder": "@angular/build:application", 19 | "options": { 20 | "browser": "demo/src/main.ts", 21 | "polyfills": [ 22 | "zone.js" 23 | ], 24 | "tsConfig": "demo/tsconfig.app.json", 25 | "assets": [ 26 | { 27 | "glob": "**/*", 28 | "input": "demo/public" 29 | }, 30 | "demo/src/app/bindings/remote", 31 | "demo/src/app/cheat-sheet/remote", 32 | "demo/src/app/syntax-highlight/remote", 33 | "demo/src/app/plugins/remote" 34 | ], 35 | "styles": [ 36 | "demo/src/styles.scss", 37 | "demo/src/scss/material-theme.scss", 38 | "demo/src/scss/prism-theme.scss", 39 | "node_modules/prismjs/plugins/command-line/prism-command-line.css", 40 | "node_modules/prismjs/plugins/line-highlight/prism-line-highlight.css", 41 | "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css", 42 | "node_modules/katex/dist/katex.min.css" 43 | ], 44 | "scripts": [ 45 | "node_modules/prismjs/prism.js", 46 | "node_modules/prismjs/plugins/command-line/prism-command-line.js", 47 | "node_modules/prismjs/plugins/highlight-keywords/prism-highlight-keywords.min.js", 48 | "node_modules/prismjs/plugins/line-highlight/prism-line-highlight.js", 49 | "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.js", 50 | "node_modules/prismjs/components/prism-bash.min.js", 51 | "node_modules/prismjs/components/prism-c.min.js", 52 | "node_modules/prismjs/components/prism-clike.min.js", 53 | "node_modules/prismjs/components/prism-cpp.min.js", 54 | "node_modules/prismjs/components/prism-css.min.js", 55 | "node_modules/prismjs/components/prism-diff.min.js", 56 | "node_modules/prismjs/components/prism-javascript.min.js", 57 | "node_modules/prismjs/components/prism-latex.min.js", 58 | "node_modules/prismjs/components/prism-markup.min.js", 59 | "node_modules/prismjs/components/prism-markdown.min.js", 60 | "node_modules/prismjs/components/prism-powershell.min.js", 61 | "node_modules/prismjs/components/prism-python.min.js", 62 | "node_modules/prismjs/components/prism-typescript.min.js", 63 | "node_modules/emoji-toolkit/lib/js/joypixels.js", 64 | "node_modules/katex/dist/katex.min.js", 65 | "node_modules/katex/dist/contrib/auto-render.min.js", 66 | "node_modules/mermaid/dist/mermaid.min.js", 67 | "node_modules/clipboard/dist/clipboard.min.js" 68 | ], 69 | "allowedCommonJsDependencies": [ 70 | "gumshoejs", 71 | "hammerjs" 72 | ] 73 | }, 74 | "configurations": { 75 | "production": { 76 | "budgets": [ 77 | { 78 | "type": "initial", 79 | "maximumWarning": "2MB", 80 | "maximumError": "5MB" 81 | }, 82 | { 83 | "type": "anyComponentStyle", 84 | "maximumWarning": "6kB", 85 | "maximumError": "10kB" 86 | } 87 | ], 88 | "outputHashing": "all" 89 | }, 90 | "development": { 91 | "optimization": false, 92 | "extractLicenses": false, 93 | "sourceMap": true 94 | } 95 | }, 96 | "defaultConfiguration": "production" 97 | }, 98 | "serve": { 99 | "builder": "@angular/build:dev-server", 100 | "configurations": { 101 | "production": { 102 | "buildTarget": "demo:build:production" 103 | }, 104 | "development": { 105 | "buildTarget": "demo:build:development" 106 | } 107 | }, 108 | "defaultConfiguration": "development" 109 | }, 110 | "lint": { 111 | "builder": "@angular-eslint/builder:lint", 112 | "options": { 113 | "eslintConfig": "demo/eslint.config.js", 114 | "lintFilePatterns": [ 115 | "demo/**/*.ts", 116 | "demo/**/*.html" 117 | ] 118 | } 119 | } 120 | } 121 | }, 122 | "lib": { 123 | "root": "lib", 124 | "sourceRoot": "lib/src", 125 | "projectType": "library", 126 | "prefix": "lib", 127 | "architect": { 128 | "build": { 129 | "builder": "@angular/build:ng-packagr", 130 | "configurations": { 131 | "production": { 132 | "tsConfig": "lib/tsconfig.lib.prod.json" 133 | }, 134 | "development": { 135 | "tsConfig": "lib/tsconfig.lib.json" 136 | } 137 | }, 138 | "defaultConfiguration": "production" 139 | }, 140 | "test": { 141 | "builder": "@angular/build:karma", 142 | "options": { 143 | "tsConfig": "lib/tsconfig.spec.json", 144 | "karmaConfig": "lib/karma.conf.js", 145 | "polyfills": [ 146 | "zone.js", 147 | "zone.js/testing" 148 | ] 149 | } 150 | }, 151 | "lint": { 152 | "builder": "@angular-eslint/builder:lint", 153 | "options": { 154 | "eslintConfig": "lib/eslint.config.js", 155 | "lintFilePatterns": [ 156 | "lib/**/*.ts", 157 | "lib/**/*.html" 158 | ] 159 | } 160 | } 161 | } 162 | } 163 | }, 164 | "cli": { 165 | "analytics": false, 166 | "packageManager": "npm", 167 | "schematicCollections": [ 168 | "@angular-eslint/schematics" 169 | ] 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /lib/src/markdown.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, Output, TemplateRef, Type, ViewContainerRef } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | import { takeUntil } from 'rxjs/operators'; 4 | import { ClipboardRenderOptions } from './clipboard-options'; 5 | import { KatexOptions } from './katex-options'; 6 | import { MarkdownService, ParseOptions, RenderOptions } from './markdown.service'; 7 | import { MermaidAPI } from './mermaid-options'; 8 | import { PrismPlugin } from './prism-plugin'; 9 | 10 | @Component({ 11 | // eslint-disable-next-line @angular-eslint/component-selector 12 | selector: 'markdown, [markdown]', 13 | template: '', 14 | }) 15 | export class MarkdownComponent implements OnChanges, AfterViewInit, OnDestroy { 16 | element = inject>(ElementRef); 17 | markdownService = inject(MarkdownService); 18 | viewContainerRef = inject(ViewContainerRef); 19 | 20 | protected static ngAcceptInputType_clipboard: boolean | ''; 21 | protected static ngAcceptInputType_emoji: boolean | ''; 22 | protected static ngAcceptInputType_katex: boolean | ''; 23 | protected static ngAcceptInputType_mermaid: boolean | ''; 24 | protected static ngAcceptInputType_lineHighlight: boolean | ''; 25 | protected static ngAcceptInputType_lineNumbers: boolean | ''; 26 | protected static ngAcceptInputType_commandLine: boolean | ''; 27 | 28 | @Input() data: string | null | undefined; 29 | @Input() src: string | null | undefined; 30 | 31 | @Input() 32 | get disableSanitizer(): boolean { return this._disableSanitizer; } 33 | set disableSanitizer(value: boolean) { this._disableSanitizer = this.coerceBooleanProperty(value); } 34 | 35 | @Input() 36 | get inline(): boolean { return this._inline; } 37 | set inline(value: boolean) { this._inline = this.coerceBooleanProperty(value); } 38 | 39 | // Plugin - clipboard 40 | @Input() 41 | get clipboard(): boolean { return this._clipboard; } 42 | set clipboard(value: boolean) { this._clipboard = this.coerceBooleanProperty(value); } 43 | 44 | @Input() clipboardButtonComponent: Type | undefined; 45 | @Input() clipboardButtonTemplate: TemplateRef | undefined; 46 | 47 | // Plugin - emoji 48 | @Input() 49 | get emoji(): boolean { return this._emoji; } 50 | set emoji(value: boolean) { this._emoji = this.coerceBooleanProperty(value); } 51 | 52 | // Plugin - katex 53 | @Input() 54 | get katex(): boolean { return this._katex; } 55 | set katex(value: boolean) { this._katex = this.coerceBooleanProperty(value); } 56 | 57 | @Input() katexOptions: KatexOptions | undefined; 58 | 59 | // Plugin - mermaid 60 | @Input() 61 | get mermaid(): boolean { return this._mermaid; } 62 | set mermaid(value: boolean) { this._mermaid = this.coerceBooleanProperty(value); } 63 | 64 | @Input() mermaidOptions: MermaidAPI.MermaidConfig | undefined; 65 | 66 | // Plugin - lineHighlight 67 | @Input() 68 | get lineHighlight(): boolean { return this._lineHighlight; } 69 | set lineHighlight(value: boolean) { this._lineHighlight = this.coerceBooleanProperty(value); } 70 | 71 | @Input() line: string | string[] | undefined; 72 | @Input() lineOffset: number | undefined; 73 | 74 | // Plugin - lineNumbers 75 | @Input() 76 | get lineNumbers(): boolean { return this._lineNumbers; } 77 | set lineNumbers(value: boolean) { this._lineNumbers = this.coerceBooleanProperty(value); } 78 | 79 | @Input() start: number | undefined; 80 | 81 | // Plugin - commandLine 82 | @Input() 83 | get commandLine(): boolean { return this._commandLine; } 84 | set commandLine(value: boolean) { this._commandLine = this.coerceBooleanProperty(value); } 85 | 86 | @Input() filterOutput: string | undefined; 87 | @Input() host: string | undefined; 88 | @Input() prompt: string | undefined; 89 | @Input() output: string | undefined; 90 | @Input() user: string | undefined; 91 | 92 | // Event emitters 93 | @Output() error = new EventEmitter(); 94 | @Output() load = new EventEmitter(); 95 | @Output() ready = new EventEmitter(); 96 | 97 | private _clipboard = false; 98 | private _commandLine = false; 99 | private _disableSanitizer = false; 100 | private _emoji = false; 101 | private _inline = false; 102 | private _katex = false; 103 | private _lineHighlight = false; 104 | private _lineNumbers = false; 105 | private _mermaid = false; 106 | 107 | private readonly destroyed$ = new Subject(); 108 | 109 | ngOnChanges(): void { 110 | this.loadContent(); 111 | } 112 | 113 | loadContent(): void { 114 | if (this.data != null) { 115 | this.handleData(); 116 | return; 117 | } 118 | if (this.src != null) { 119 | this.handleSrc(); 120 | return; 121 | } 122 | } 123 | 124 | ngAfterViewInit(): void { 125 | if (!this.data && !this.src) { 126 | this.handleTransclusion(); 127 | } 128 | 129 | this.markdownService.reload$ 130 | .pipe(takeUntil(this.destroyed$)) 131 | .subscribe(() => this.loadContent()); 132 | } 133 | 134 | ngOnDestroy(): void { 135 | this.destroyed$.next(); 136 | this.destroyed$.complete(); 137 | } 138 | 139 | async render(markdown: string, decodeHtml = false): Promise { 140 | const parsedOptions: ParseOptions = { 141 | decodeHtml, 142 | inline: this.inline, 143 | emoji: this.emoji, 144 | mermaid: this.mermaid, 145 | disableSanitizer: this.disableSanitizer, 146 | }; 147 | 148 | const renderOptions: RenderOptions = { 149 | clipboard: this.clipboard, 150 | clipboardOptions: this.getClipboardOptions(), 151 | katex: this.katex, 152 | katexOptions: this.katexOptions, 153 | mermaid: this.mermaid, 154 | mermaidOptions: this.mermaidOptions, 155 | }; 156 | 157 | const parsed = await this.markdownService.parse(markdown, parsedOptions); 158 | 159 | this.element.nativeElement.innerHTML = parsed; 160 | 161 | this.handlePlugins(); 162 | 163 | this.markdownService.render(this.element.nativeElement, renderOptions, this.viewContainerRef); 164 | 165 | this.ready.emit(); 166 | } 167 | 168 | private coerceBooleanProperty(value: boolean | ''): boolean { 169 | return value != null && `${String(value)}` !== 'false'; 170 | } 171 | 172 | private getClipboardOptions(): ClipboardRenderOptions | undefined { 173 | if (this.clipboardButtonComponent || this.clipboardButtonTemplate) { 174 | return { 175 | buttonComponent: this.clipboardButtonComponent, 176 | buttonTemplate: this.clipboardButtonTemplate, 177 | }; 178 | } 179 | return undefined; 180 | } 181 | 182 | private handleData(): void { 183 | this.render(this.data!); 184 | } 185 | 186 | private handleSrc(): void { 187 | this.markdownService 188 | .getSource(this.src!) 189 | .subscribe({ 190 | next: markdown => { 191 | this.render(markdown).then(() => { 192 | this.load.emit(markdown); 193 | }); 194 | }, 195 | error: (error: string | Error) => this.error.emit(error), 196 | }); 197 | } 198 | 199 | private handleTransclusion(): void { 200 | this.render(this.element.nativeElement.innerHTML, true); 201 | } 202 | 203 | private handlePlugins(): void { 204 | if (this.commandLine) { 205 | this.setPluginClass(this.element.nativeElement, PrismPlugin.CommandLine); 206 | this.setPluginOptions(this.element.nativeElement, { 207 | dataFilterOutput: this.filterOutput, 208 | dataHost: this.host, 209 | dataPrompt: this.prompt, 210 | dataOutput: this.output, 211 | dataUser: this.user, 212 | }); 213 | } 214 | if (this.lineHighlight) { 215 | this.setPluginOptions(this.element.nativeElement, { dataLine: this.line, dataLineOffset: this.lineOffset }); 216 | } 217 | if (this.lineNumbers) { 218 | this.setPluginClass(this.element.nativeElement, PrismPlugin.LineNumbers); 219 | this.setPluginOptions(this.element.nativeElement, { dataStart: this.start }); 220 | } 221 | } 222 | 223 | private setPluginClass(element: HTMLElement, plugin: string | string[]): void { 224 | const preElements = element.querySelectorAll('pre'); 225 | for (let i = 0; i < preElements.length; i++) { 226 | const classes = plugin instanceof Array ? plugin : [plugin]; 227 | preElements.item(i).classList.add(...classes); 228 | } 229 | } 230 | 231 | private setPluginOptions(element: HTMLElement, options: Record): void { 232 | const preElements = element.querySelectorAll('pre'); 233 | for (let i = 0; i < preElements.length; i++) { 234 | Object.keys(options).forEach(option => { 235 | const attributeValue = options[option]; 236 | if (attributeValue) { 237 | const attributeName = this.toLispCase(option); 238 | preElements.item(i).setAttribute(attributeName, attributeValue.toString()); 239 | } 240 | }); 241 | } 242 | } 243 | 244 | private toLispCase(value: string): string { 245 | const upperChars = value.match(/([A-Z])/g); 246 | if (!upperChars) { 247 | return value; 248 | } 249 | let str = value.toString(); 250 | for (let i = 0, n = upperChars.length; i < n; i++) { 251 | str = str.replace(new RegExp(upperChars[i]), '-' + upperChars[i].toLowerCase()); 252 | } 253 | if (str.slice(0, 1) === '-') { 254 | str = str.slice(1); 255 | } 256 | return str; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /lib/src/markdown.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpClientModule } from '@angular/common/http'; 2 | import { createEnvironmentInjector, EnvironmentInjector, importProvidersFrom, ModuleWithProviders, SecurityContext } from '@angular/core'; 3 | import { TestBed } from '@angular/core/testing'; 4 | import { MarkedExtension } from 'marked'; 5 | import { CLIPBOARD_OPTIONS, ClipboardOptions } from './clipboard-options'; 6 | import { MarkdownModule } from './markdown.module'; 7 | import { MARKED_EXTENSIONS } from './marked-extensions'; 8 | import { MARKED_OPTIONS, MarkedOptions } from './marked-options'; 9 | import { SANITIZE } from './sanitize-options'; 10 | 11 | describe('MarkdownModule', () => { 12 | 13 | function createInjectorForModule(markdownModule: ModuleWithProviders): EnvironmentInjector { 14 | const environmentProviders = importProvidersFrom(markdownModule); 15 | const parentInjector = undefined as unknown as EnvironmentInjector; 16 | 17 | return createEnvironmentInjector([environmentProviders], parentInjector); 18 | } 19 | 20 | describe('forRoot', () => { 21 | 22 | it('should provide HttpClient when MarkdownModuleConfig.loader is provided', () => { 23 | 24 | TestBed.configureTestingModule({ 25 | imports: [ 26 | HttpClientModule, 27 | MarkdownModule.forRoot({ loader: HttpClient }), 28 | ], 29 | }); 30 | 31 | const httpClient = TestBed.inject(HttpClient); 32 | 33 | expect(httpClient instanceof HttpClient).toBeTruthy(); 34 | }); 35 | 36 | it('should not provide HttpClient when MarkdownModuleConfig is provided without loader', () => { 37 | 38 | const injector = createInjectorForModule( 39 | MarkdownModule.forRoot({ 40 | markedOptions: { 41 | provide: MARKED_OPTIONS, 42 | useValue: {}, 43 | }, 44 | }), 45 | ); 46 | 47 | const httpClient = injector.get(HttpClient, null, { optional: true }); 48 | 49 | expect(httpClient).toBeNull(); 50 | }); 51 | 52 | it('should not provide HttpClient when MarkdownModuleConfig is not provided', () => { 53 | 54 | const injector = createInjectorForModule( 55 | MarkdownModule.forRoot(), 56 | ); 57 | 58 | const httpClient = injector.get(HttpClient, null, { optional: true }); 59 | 60 | expect(httpClient).toBeNull(); 61 | }); 62 | 63 | it('should provide ClipboardOptions when MarkdownModuleConfig is provided with clipboardOptions', () => { 64 | 65 | const mockClipboardOptions: ClipboardOptions = { buttonComponent: class mockClipboardButtonComponent {} }; 66 | 67 | TestBed.configureTestingModule({ 68 | imports: [ 69 | MarkdownModule.forRoot({ 70 | clipboardOptions: { 71 | provide: CLIPBOARD_OPTIONS, 72 | useValue: mockClipboardOptions, 73 | }, 74 | }), 75 | ], 76 | }); 77 | 78 | const clipboardOptions = TestBed.inject(CLIPBOARD_OPTIONS); 79 | 80 | expect(clipboardOptions).toEqual(mockClipboardOptions); 81 | }); 82 | 83 | it('should not provide ClipboardOptions when MarkdownModuleConfig is provided without clipboardOptions', () => { 84 | 85 | TestBed.configureTestingModule({ 86 | imports: [ 87 | MarkdownModule.forRoot({ loader: HttpClient }), 88 | ], 89 | }); 90 | 91 | const clipboardOptions = TestBed.inject(CLIPBOARD_OPTIONS, null, { optional: true }); 92 | 93 | expect(clipboardOptions).toBeNull(); 94 | }); 95 | 96 | it('should provide MarkedOptions when MarkdownModuleConfig is provided with markedOptions', () => { 97 | 98 | const mockMarkedOptions: MarkedOptions = { breaks: true, gfm: false }; 99 | 100 | TestBed.configureTestingModule({ 101 | imports: [ 102 | MarkdownModule.forRoot({ 103 | markedOptions: { 104 | provide: MARKED_OPTIONS, 105 | useValue: mockMarkedOptions, 106 | }, 107 | }), 108 | ], 109 | }); 110 | 111 | const markedOptions = TestBed.inject(MARKED_OPTIONS); 112 | 113 | expect(markedOptions).toEqual(mockMarkedOptions); 114 | }); 115 | 116 | it('should not provide MarkedOptions when MarkdownModuleConfig is provided without markedOptions', () => { 117 | 118 | TestBed.configureTestingModule({ 119 | imports: [ 120 | MarkdownModule.forRoot({ loader: HttpClient }), 121 | ], 122 | }); 123 | 124 | const markedOptions = TestBed.inject(MARKED_OPTIONS, null, { optional: true }); 125 | 126 | expect(markedOptions).toBeNull(); 127 | }); 128 | 129 | it('should not provide MarkedOptions when MarkdownModuleConfig is not provided', () => { 130 | 131 | TestBed.configureTestingModule({ 132 | imports: [ 133 | MarkdownModule.forRoot(), 134 | ], 135 | }); 136 | 137 | const markedOptions = TestBed.inject(MARKED_OPTIONS, null, { optional: true }); 138 | 139 | expect(markedOptions).toBeNull(); 140 | }); 141 | 142 | it('should provide MarkedExtensions when MarkdownModuleConfig is provided with markedExtension providers', () => { 143 | const mockExtensionOne = { name: 'mock-extension-one' } as MarkedExtension; 144 | const mockExtensionTwo = { name: 'mock-extension-two' } as MarkedExtension; 145 | 146 | TestBed.configureTestingModule({ 147 | imports: [ 148 | MarkdownModule.forRoot({ 149 | markedExtensions: [ 150 | { 151 | provide: MARKED_EXTENSIONS, 152 | useValue: mockExtensionOne, 153 | multi: true, 154 | }, 155 | { 156 | provide: MARKED_EXTENSIONS, 157 | useFactory: () => mockExtensionTwo, 158 | multi: true, 159 | }, 160 | ], 161 | }), 162 | ], 163 | }); 164 | 165 | const markedExtensions = TestBed.inject(MARKED_EXTENSIONS); 166 | 167 | expect(markedExtensions).toEqual([mockExtensionOne, mockExtensionTwo]); 168 | }); 169 | 170 | it('should provide null when MarkdownModuleConfig is provided without markedExtensions', () => { 171 | 172 | TestBed.configureTestingModule({ 173 | imports: [ 174 | MarkdownModule.forRoot({ 175 | markedOptions: { 176 | provide: MARKED_OPTIONS, 177 | useValue: {}, 178 | }, 179 | }), 180 | ], 181 | }); 182 | 183 | const markedExtensions = TestBed.inject(MARKED_EXTENSIONS, null, { optional: true }); 184 | 185 | expect(markedExtensions).toBeNull(); 186 | }); 187 | 188 | it('should provide null when MarkdownModuleConfig is not provided', () => { 189 | 190 | TestBed.configureTestingModule({ 191 | imports: [ 192 | MarkdownModule.forRoot(), 193 | ], 194 | }); 195 | 196 | const markedExtensions = TestBed.inject(MARKED_EXTENSIONS, null, { optional: true }); 197 | 198 | expect(markedExtensions).toBeNull(); 199 | }); 200 | 201 | it('should provide SecurityContext when MarkdownModuleConfig is provided with sanitize', () => { 202 | 203 | TestBed.configureTestingModule({ 204 | imports: [ 205 | MarkdownModule.forRoot({ sanitize: { provide: SANITIZE, useValue: SecurityContext.NONE } }), 206 | ], 207 | }); 208 | 209 | const sanitize = TestBed.inject(SANITIZE); 210 | 211 | expect(sanitize).toBe(SecurityContext.NONE); 212 | }); 213 | 214 | it('should not provide SecurityContext when MarkdownModuleConfig is provided without sanitize', () => { 215 | 216 | TestBed.configureTestingModule({ 217 | imports: [ 218 | MarkdownModule.forRoot({ 219 | markedOptions: { 220 | provide: MARKED_OPTIONS, 221 | useValue: {}, 222 | }, 223 | }), 224 | ], 225 | }); 226 | 227 | const sanitize = TestBed.inject(SANITIZE, null, { optional: true }); 228 | 229 | expect(sanitize).toBeNull(); 230 | }); 231 | 232 | it('should not provide SecurityContext when MarkdownModuleConfig is not provided', () => { 233 | 234 | TestBed.configureTestingModule({ 235 | imports: [ 236 | MarkdownModule.forRoot(), 237 | ], 238 | }); 239 | 240 | const sanitize = TestBed.inject(SANITIZE, null, { optional: true }); 241 | 242 | expect(sanitize).toBeNull(); 243 | }); 244 | }); 245 | 246 | describe('forChild', () => { 247 | 248 | it('should not provide any providers', () => { 249 | 250 | const forChildModule = MarkdownModule.forChild(); 251 | 252 | expect(forChildModule.providers).toBeUndefined(); 253 | }); 254 | 255 | it('should inherit from forRoot providers', () => { 256 | 257 | const mockMarkedOptions: MarkedOptions = { breaks: true, gfm: false }; 258 | const mockClipboardOptions: ClipboardOptions = { buttonComponent: class mockClipboardButtonComponent { } }; 259 | 260 | TestBed.configureTestingModule({ 261 | imports: [ 262 | HttpClientModule, 263 | MarkdownModule.forRoot({ 264 | loader: HttpClient, 265 | clipboardOptions: { provide: CLIPBOARD_OPTIONS, useValue: mockClipboardOptions }, 266 | markedOptions: { provide: MARKED_OPTIONS, useValue: mockMarkedOptions }, 267 | sanitize: { provide: SANITIZE, useValue: SecurityContext.NONE }, 268 | }), 269 | MarkdownModule.forChild(), 270 | ], 271 | }); 272 | 273 | const httpClient = TestBed.inject(HttpClient); 274 | const clipboardOptions = TestBed.inject(CLIPBOARD_OPTIONS); 275 | const markedOptions = TestBed.inject(MARKED_OPTIONS); 276 | const sanitize = TestBed.inject(SANITIZE); 277 | 278 | expect(httpClient instanceof HttpClient).toBeTruthy(); 279 | expect(clipboardOptions).toEqual(mockClipboardOptions); 280 | expect(markedOptions).toEqual(mockMarkedOptions); 281 | expect(sanitize).toBe(SecurityContext.NONE); 282 | }); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /lib/src/markdown.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef, TemplateRef } from '@angular/core'; 2 | import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; 3 | import { of, throwError } from 'rxjs'; 4 | import { first } from 'rxjs/operators'; 5 | import { ClipboardRenderOptions } from './clipboard-options'; 6 | import { KatexOptions } from './katex-options'; 7 | import { MarkdownComponent } from './markdown.component'; 8 | import { MarkdownModule } from './markdown.module'; 9 | import { MarkdownService } from './markdown.service'; 10 | import { MermaidAPI } from './mermaid-options'; 11 | 12 | describe('MarkdownComponent', () => { 13 | let fixture: ComponentFixture; 14 | let component: MarkdownComponent; 15 | let markdownService: MarkdownService; 16 | 17 | beforeEach(async () => { 18 | await TestBed.configureTestingModule({ 19 | imports: [ 20 | MarkdownModule.forRoot(), 21 | ], 22 | }).compileComponents(); 23 | 24 | markdownService = TestBed.inject(MarkdownService); 25 | fixture = TestBed.createComponent(MarkdownComponent); 26 | component = fixture.componentInstance; 27 | fixture.detectChanges(); 28 | }); 29 | 30 | describe('data', () => { 31 | 32 | it('should call render with provided data when set', () => { 33 | 34 | const spyRender = spyOn(component, 'render'); 35 | 36 | const useCases = [ 37 | '', 38 | '# Markdown', 39 | '

Html

', 40 | ]; 41 | 42 | useCases.forEach(data => { 43 | component.data = data; 44 | component.ngOnChanges(); 45 | expect(component.render).toHaveBeenCalledWith(data); 46 | spyRender.calls.reset(); 47 | }); 48 | }); 49 | 50 | it('should return value correctly when get', () => { 51 | 52 | const mockData = '# Markdown'; 53 | 54 | component.data = mockData; 55 | 56 | expect(component.data).toBe(mockData); 57 | }); 58 | }); 59 | 60 | describe('src', () => { 61 | 62 | it('should call render with retreived content when set', () => { 63 | 64 | const mockSrc = './src-example/file.md'; 65 | const mockContent = 'source-content'; 66 | 67 | spyOn(component, 'render').and.returnValue(Promise.resolve()); 68 | spyOn(markdownService, 'getSource').and.returnValue(of(mockContent)); 69 | 70 | component.src = mockSrc; 71 | 72 | component.ngOnChanges(); 73 | 74 | expect(markdownService.getSource).toHaveBeenCalledWith(mockSrc); 75 | expect(component.render).toHaveBeenCalledWith(mockContent); 76 | }); 77 | 78 | it('should return value correctly when get', () => { 79 | 80 | const mockSrc = './src-example/file.md'; 81 | 82 | spyOn(markdownService, 'getSource').and.returnValue(of()); 83 | 84 | component.src = mockSrc; 85 | 86 | expect(component.src).toBe(mockSrc); 87 | }); 88 | 89 | it('should emit load when get', fakeAsync(() => { 90 | 91 | const mockSrc = './src-example/file.md'; 92 | const mockSrcReturn = 'src-return-value'; 93 | 94 | spyOn(markdownService, 'getSource').and.returnValue(of(mockSrcReturn)); 95 | spyOn(component.load, 'emit'); 96 | 97 | component.src = mockSrc; 98 | 99 | component.ngOnChanges(); 100 | tick(); 101 | 102 | expect(component.load.emit).toHaveBeenCalledWith(mockSrcReturn); 103 | })); 104 | 105 | it('should emit error when and error occurs', () => { 106 | 107 | const mockSrc = './src-example/file.md'; 108 | const mockError = 'error-x'; 109 | 110 | spyOn(markdownService, 'getSource').and.returnValue(throwError(mockError)); 111 | spyOn(component.error, 'emit'); 112 | 113 | component.src = mockSrc; 114 | 115 | component.ngOnChanges(); 116 | 117 | expect(component.error.emit).toHaveBeenCalledWith(mockError); 118 | }); 119 | }); 120 | 121 | describe('ngAfterViewInit', () => { 122 | 123 | it('should call render method and decodeHtml when neither data or src input property is provided', () => { 124 | 125 | const mockHtmlElement = document.createElement('div'); 126 | mockHtmlElement.innerHTML = 'inner-html'; 127 | 128 | spyOn(markdownService, 'getSource').and.returnValue(of()); 129 | 130 | component.element = new ElementRef(mockHtmlElement); 131 | component.data = undefined; 132 | component.src = undefined; 133 | 134 | spyOn(component, 'render'); 135 | 136 | component.ngAfterViewInit(); 137 | 138 | expect(component.render).toHaveBeenCalledWith(mockHtmlElement.innerHTML, true); 139 | }); 140 | 141 | it('should not call render method when src is provided', () => { 142 | 143 | const mockHtmlElement = document.createElement('div'); 144 | mockHtmlElement.innerHTML = 'inner-html'; 145 | 146 | spyOn(markdownService, 'getSource').and.returnValue(of()); 147 | 148 | component.element = new ElementRef(mockHtmlElement); 149 | component.src = './src-example/file.md'; 150 | 151 | spyOn(component, 'render'); 152 | 153 | component.ngAfterViewInit(); 154 | 155 | expect(component.render).not.toHaveBeenCalled(); 156 | }); 157 | 158 | it('should not call render method when data is provided', () => { 159 | 160 | const mockHtmlElement = document.createElement('div'); 161 | mockHtmlElement.innerHTML = 'inner-html'; 162 | 163 | component.element = new ElementRef(mockHtmlElement); 164 | component.data = '# Markdown'; 165 | 166 | spyOn(component, 'render'); 167 | 168 | component.ngAfterViewInit(); 169 | 170 | expect(component.render).not.toHaveBeenCalled(); 171 | }); 172 | 173 | it('should rerender content on demand', () => { 174 | 175 | spyOn(component, 'loadContent'); 176 | 177 | markdownService.reload(); 178 | 179 | expect(component.loadContent).toHaveBeenCalled(); 180 | }); 181 | }); 182 | 183 | describe('render', () => { 184 | 185 | it('should parse markdown through MarkdownService', async () => { 186 | 187 | const raw = '### Raw'; 188 | 189 | spyOn(markdownService, 'parse'); 190 | 191 | component.inline = true; 192 | component.emoji = false; 193 | component.mermaid = false; 194 | component.disableSanitizer = true; 195 | await component.render(raw, true); 196 | 197 | expect(markdownService.parse).toHaveBeenCalledWith(raw, { 198 | decodeHtml: true, 199 | inline: true, 200 | emoji: false, 201 | mermaid: false, 202 | disableSanitizer: true, 203 | }); 204 | }); 205 | 206 | it('should set innerHTML with parsed markdown', async () => { 207 | 208 | const raw = '### Raw'; 209 | const parsed = '

Compiled

'; 210 | 211 | spyOn(markdownService, 'parse').and.returnValue(parsed); 212 | 213 | await component.render(raw, true); 214 | 215 | expect(component.element.nativeElement.innerHTML).toBe(parsed); 216 | }); 217 | 218 | it('should handle commandline plugin correctly', async () => { 219 | 220 | const markdown = '```powershell\nGet-Date\n\nSunday, November 7, 2021 8:19:21 PM\n\n```'; 221 | const getHTMLPreElement = () => (fixture.nativeElement as HTMLElement).querySelector('pre'); 222 | 223 | component.commandLine = true; 224 | await component.render(markdown); 225 | 226 | expect(getHTMLPreElement()?.classList).toContain('command-line'); 227 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-start')).toBeNull(); 228 | 229 | component.filterOutput = '(out)'; 230 | await component.render(markdown); 231 | 232 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-filter-output')?.value).toBe('(out)'); 233 | 234 | component.host = 'localhost'; 235 | await component.render(markdown); 236 | 237 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-host')?.value).toBe('localhost'); 238 | 239 | component.prompt = 'PS C:\\Users\\Chris>'; 240 | await component.render(markdown); 241 | 242 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-prompt')?.value).toBe('PS C:\\Users\\Chris>'); 243 | 244 | component.output = '2-4'; 245 | await component.render(markdown); 246 | 247 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-output')?.value).toBe('2-4'); 248 | 249 | component.user = 'root'; 250 | await component.render(markdown); 251 | 252 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-user')?.value).toBe('root'); 253 | }); 254 | 255 | it('should handle lineNumbers plugin correctly', async () => { 256 | 257 | const markdown = '```javascript\nconst random = \'Math.random();\n```'; 258 | const getHTMLPreElement = () => (fixture.nativeElement as HTMLElement).querySelector('pre'); 259 | 260 | component.lineNumbers = true; 261 | await component.render(markdown); 262 | 263 | expect(getHTMLPreElement()?.classList).toContain('line-numbers'); 264 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-start')).toBeNull(); 265 | 266 | component.start = 5; 267 | await component.render(markdown); 268 | 269 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-start')?.value).toBe('5'); 270 | }); 271 | 272 | it('should handle lineHighlight plugin correctly', async () => { 273 | 274 | const markdown = '```javascript\nconst random = \'Math.random();\n```'; 275 | const getHTMLPreElement = () => (fixture.nativeElement as HTMLElement).querySelector('pre'); 276 | 277 | component.lineHighlight = true; 278 | component.line = '6, 10-16'; 279 | await component.render(markdown); 280 | 281 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-line')?.value).toBe('6, 10-16'); 282 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-line-offset')).toBeNull(); 283 | 284 | component.lineOffset = 5; 285 | await component.render(markdown); 286 | 287 | expect(getHTMLPreElement()?.attributes.getNamedItem('data-line-offset')?.value).toBe('5'); 288 | }); 289 | 290 | it('should render html element through MarkdownService', async () => { 291 | const raw = '### Raw'; 292 | const parsed = '

Compiled

'; 293 | const clipboardOptions: ClipboardRenderOptions = { 294 | buttonComponent: class mockButtonComponent { 295 | }, 296 | buttonTemplate: new class mockTemplateRef { 297 | } as TemplateRef, 298 | }; 299 | const katexOptions: KatexOptions = { displayMode: true }; 300 | const mermaidOptions: MermaidAPI.MermaidConfig = { darkMode: true }; 301 | 302 | spyOn(markdownService, 'parse').and.returnValue(parsed); 303 | spyOn(markdownService, 'render'); 304 | 305 | component.clipboard = true; 306 | component.clipboardButtonComponent = clipboardOptions.buttonComponent; 307 | component.clipboardButtonTemplate = clipboardOptions.buttonTemplate; 308 | component.katex = true; 309 | component.katexOptions = katexOptions; 310 | component.mermaid = true; 311 | component.mermaidOptions = mermaidOptions; 312 | await component.render(raw); 313 | 314 | expect(markdownService.parse).toHaveBeenCalledWith(raw, { 315 | decodeHtml: false, 316 | inline: false, 317 | emoji: false, 318 | mermaid: true, 319 | disableSanitizer: false, 320 | }); 321 | 322 | expect(markdownService.render).toHaveBeenCalledWith( 323 | component.element.nativeElement, 324 | { 325 | clipboard: true, 326 | clipboardOptions: clipboardOptions, 327 | katex: true, 328 | katexOptions: katexOptions, 329 | mermaid: true, 330 | mermaidOptions: mermaidOptions, 331 | }, 332 | component.viewContainerRef); 333 | }); 334 | 335 | it('should not overwrite `clipboardButtonComponent` and `clipboardButtonTemplate` when not provided', async () => { 336 | 337 | const raw = '### Raw'; 338 | const parsed = '

Compiled

'; 339 | 340 | spyOn(markdownService, 'parse').and.returnValue(parsed); 341 | spyOn(markdownService, 'render'); 342 | 343 | component.clipboard = true; 344 | await component.render(raw); 345 | 346 | expect(markdownService.parse).toHaveBeenCalledWith(raw, { 347 | decodeHtml: false, 348 | inline: false, 349 | emoji: false, 350 | mermaid: false, 351 | disableSanitizer: false, 352 | }); 353 | 354 | expect(markdownService.render).toHaveBeenCalledWith( 355 | component.element.nativeElement, 356 | { 357 | clipboard: true, 358 | clipboardOptions: undefined, 359 | katex: false, 360 | katexOptions: undefined, 361 | mermaid: false, 362 | mermaidOptions: undefined, 363 | }, 364 | component.viewContainerRef); 365 | }); 366 | 367 | it('should emit `ready` when parsing and rendering is done', async () => { 368 | 369 | const markdown = '# Markdown'; 370 | const parsed = '

Markdown

'; 371 | 372 | spyOn(markdownService, 'parse').and.returnValue(parsed); 373 | spyOn(markdownService, 'render'); 374 | 375 | component.ready 376 | .pipe(first()) 377 | .subscribe(() => { 378 | expect(markdownService.parse).toHaveBeenCalled(); 379 | expect(component.element.nativeElement.innerHTML).toBe(parsed); 380 | expect(markdownService.render).toHaveBeenCalled(); 381 | }); 382 | 383 | await component.render(markdown); 384 | }); 385 | }); 386 | }); 387 | -------------------------------------------------------------------------------- /lib/src/markdown.service.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser } from '@angular/common'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { EmbeddedViewRef, inject, Injectable, PLATFORM_ID, SecurityContext, ViewContainerRef } from '@angular/core'; 4 | import { DomSanitizer } from '@angular/platform-browser'; 5 | import { marked, MarkedExtension, Renderer } from 'marked'; 6 | import { Observable, Subject } from 'rxjs'; 7 | import { map } from 'rxjs/operators'; 8 | import { ClipboardButtonComponent } from './clipboard-button.component'; 9 | import { CLIPBOARD_OPTIONS, ClipboardOptions, ClipboardRenderOptions } from './clipboard-options'; 10 | import { KatexOptions } from './katex-options'; 11 | import { MARKED_EXTENSIONS } from './marked-extensions'; 12 | import { MARKED_OPTIONS, MarkedOptions } from './marked-options'; 13 | import { MarkedRenderer } from './marked-renderer'; 14 | import { MERMAID_OPTIONS, MermaidAPI } from './mermaid-options'; 15 | import { isSanitizeFunction, SANITIZE } from './sanitize-options'; 16 | 17 | // clipboard 18 | declare let ClipboardJS: { 19 | new( 20 | selector: string | Element | NodeListOf, 21 | options?: { text?: (elem: Element) => string }, 22 | ): typeof ClipboardJS; 23 | destroy(): void; 24 | }; 25 | 26 | // emoji 27 | declare let joypixels: { 28 | shortnameToUnicode(input: string): string; 29 | }; 30 | 31 | // katex 32 | declare let katex: unknown; 33 | declare function renderMathInElement(elem: HTMLElement, options?: KatexOptions): void; 34 | 35 | // mermaid 36 | declare let mermaid: { 37 | initialize: (options: MermaidAPI.MermaidConfig) => void; 38 | run: (runOptions: MermaidAPI.RunOptions) => void; 39 | }; 40 | 41 | // prism 42 | declare let Prism: { 43 | highlightAllUnder: (element: Element | Document) => void; 44 | }; 45 | 46 | export const errorJoyPixelsNotLoaded = '[ngx-markdown] When using the `emoji` attribute you *have to* include Emoji-Toolkit files to `angular.json` or use imports. See README for more information'; 47 | export const errorKatexNotLoaded = '[ngx-markdown] When using the `katex` attribute you *have to* include KaTeX files to `angular.json` or use imports. See README for more information'; 48 | export const errorMermaidNotLoaded = '[ngx-markdown] When using the `mermaid` attribute you *have to* include Mermaid files to `angular.json` or use imports. See README for more information'; 49 | export const errorClipboardNotLoaded = '[ngx-markdown] When using the `clipboard` attribute you *have to* include Clipboard files to `angular.json` or use imports. See README for more information'; 50 | export const errorClipboardViewContainerRequired = '[ngx-markdown] When using the `clipboard` attribute you *have to* provide the `viewContainerRef` parameter to `MarkdownService.render()` function'; 51 | export const errorSrcWithoutHttpClient = '[ngx-markdown] When using the `src` attribute you *have to* pass the `HttpClient` as a parameter of the `forRoot` method. See README for more information'; 52 | 53 | export interface ParseOptions { 54 | decodeHtml?: boolean; 55 | inline?: boolean; 56 | emoji?: boolean; 57 | mermaid?: boolean; 58 | markedOptions?: MarkedOptions; 59 | disableSanitizer?: boolean; 60 | } 61 | 62 | export interface RenderOptions { 63 | clipboard?: boolean; 64 | clipboardOptions?: ClipboardRenderOptions; 65 | katex?: boolean; 66 | katexOptions?: KatexOptions; 67 | mermaid?: boolean; 68 | mermaidOptions?: MermaidAPI.MermaidConfig; 69 | } 70 | 71 | export class ExtendedRenderer extends Renderer { 72 | ɵNgxMarkdownRendererExtendedForExtensions = false; 73 | ɵNgxMarkdownRendererExtendedForMermaid = false; 74 | } 75 | 76 | @Injectable() 77 | export class MarkdownService { 78 | private clipboardOptions = inject(CLIPBOARD_OPTIONS, { optional: true }); 79 | private extensions = inject(MARKED_EXTENSIONS, { optional: true }); 80 | private http = inject(HttpClient, { optional: true }); 81 | private mermaidOptions = inject(MERMAID_OPTIONS, { optional: true }); 82 | private platform = inject(PLATFORM_ID); 83 | private sanitize = inject(SANITIZE, { optional: true }); 84 | private sanitizer = inject(DomSanitizer); 85 | 86 | private readonly DEFAULT_MARKED_OPTIONS: MarkedOptions = { 87 | renderer: new MarkedRenderer(), 88 | }; 89 | 90 | private readonly DEFAULT_KATEX_OPTIONS: KatexOptions = { 91 | delimiters: [ 92 | { left: '$$', right: '$$', display: true }, 93 | { left: '$', right: '$', display: false }, 94 | { left: '\\(', right: '\\)', display: false }, 95 | { left: '\\begin{equation}', right: '\\end{equation}', display: true }, 96 | { left: '\\begin{align}', right: '\\end{align}', display: true }, 97 | { left: '\\begin{alignat}', right: '\\end{alignat}', display: true }, 98 | { left: '\\begin{gather}', right: '\\end{gather}', display: true }, 99 | { left: '\\begin{CD}', right: '\\end{CD}', display: true }, 100 | { left: '\\[', right: '\\]', display: true }, 101 | ], 102 | }; 103 | 104 | private readonly DEFAULT_MERMAID_OPTIONS: MermaidAPI.MermaidConfig = { 105 | startOnLoad: false, 106 | }; 107 | 108 | private readonly DEFAULT_CLIPBOARD_OPTIONS: ClipboardOptions = { 109 | buttonComponent: undefined, 110 | }; 111 | 112 | private readonly DEFAULT_PARSE_OPTIONS: ParseOptions = { 113 | decodeHtml: false, 114 | inline: false, 115 | emoji: false, 116 | mermaid: false, 117 | markedOptions: undefined, 118 | disableSanitizer: false, 119 | }; 120 | 121 | private readonly DEFAULT_RENDER_OPTIONS: RenderOptions = { 122 | clipboard: false, 123 | clipboardOptions: undefined, 124 | katex: false, 125 | katexOptions: undefined, 126 | mermaid: false, 127 | mermaidOptions: undefined, 128 | }; 129 | 130 | private readonly DEFAULT_SECURITY_CONTEXT = SecurityContext.HTML; 131 | 132 | private _options: MarkedOptions | null = null; 133 | 134 | get options(): MarkedOptions { return this._options!; } 135 | set options(value: MarkedOptions | null) { 136 | this._options = { ...this.DEFAULT_MARKED_OPTIONS, ...value }; 137 | } 138 | 139 | get renderer(): MarkedRenderer { return this.options.renderer!; } 140 | set renderer(value: MarkedRenderer) { 141 | this.options.renderer = value; 142 | } 143 | 144 | private readonly _reload$ = new Subject(); 145 | readonly reload$ = this._reload$.asObservable(); 146 | 147 | constructor() { 148 | this.options = inject(MARKED_OPTIONS, { optional: true }); 149 | } 150 | 151 | parse(markdown: string, parseOptions: ParseOptions = this.DEFAULT_PARSE_OPTIONS): string | Promise { 152 | const { 153 | decodeHtml, 154 | inline, 155 | emoji, 156 | mermaid, 157 | disableSanitizer, 158 | } = parseOptions; 159 | 160 | const markedOptions = { 161 | ...this.options, 162 | ...parseOptions.markedOptions, 163 | }; 164 | 165 | const renderer = markedOptions.renderer || this.renderer || new Renderer(); 166 | 167 | if (this.extensions) { 168 | this.renderer = this.extendsRendererForExtensions(renderer); 169 | } 170 | 171 | if (mermaid) { 172 | this.renderer = this.extendsRendererForMermaid(renderer); 173 | } 174 | 175 | const trimmed = this.trimIndentation(markdown); 176 | const decoded = decodeHtml ? this.decodeHtml(trimmed) : trimmed; 177 | const emojified = emoji ? this.parseEmoji(decoded) : decoded; 178 | const marked = this.parseMarked(emojified, markedOptions, inline); 179 | const sanitized = disableSanitizer ? marked : this.sanitizeHtml(marked); 180 | 181 | return sanitized; 182 | } 183 | 184 | render(element: HTMLElement, options: RenderOptions = this.DEFAULT_RENDER_OPTIONS, viewContainerRef?: ViewContainerRef): void { 185 | const { 186 | clipboard, 187 | clipboardOptions, 188 | katex, 189 | katexOptions, 190 | mermaid, 191 | mermaidOptions, 192 | } = options; 193 | 194 | if (katex) { 195 | this.renderKatex(element, { 196 | ...this.DEFAULT_KATEX_OPTIONS, 197 | ...katexOptions, 198 | }); 199 | } 200 | if (mermaid) { 201 | this.renderMermaid(element, { 202 | ...this.DEFAULT_MERMAID_OPTIONS, 203 | ...this.mermaidOptions, 204 | ...mermaidOptions, 205 | }); 206 | } 207 | if (clipboard) { 208 | this.renderClipboard(element, viewContainerRef, { 209 | ...this.DEFAULT_CLIPBOARD_OPTIONS, 210 | ...this.clipboardOptions, 211 | ...clipboardOptions, 212 | }); 213 | } 214 | 215 | this.highlight(element); 216 | } 217 | 218 | reload(): void { 219 | this._reload$.next(); 220 | } 221 | 222 | getSource(src: string): Observable { 223 | if (!this.http) { 224 | throw new Error(errorSrcWithoutHttpClient); 225 | } 226 | return this.http 227 | .get(src, { responseType: 'text' }) 228 | .pipe(map(markdown => this.handleExtension(src, markdown))); 229 | } 230 | 231 | highlight(element?: Element | Document): void { 232 | if (!isPlatformBrowser(this.platform)) { 233 | return; 234 | } 235 | if (typeof Prism === 'undefined' || typeof Prism.highlightAllUnder === 'undefined') { 236 | return; 237 | } 238 | if (!element) { 239 | element = document; 240 | } 241 | const noLanguageElements = element.querySelectorAll('pre code:not([class*="language-"])'); 242 | Array.prototype.forEach.call(noLanguageElements, (x: Element) => x.classList.add('language-none')); 243 | Prism.highlightAllUnder(element); 244 | } 245 | 246 | private decodeHtml(html: string): string { 247 | if (!isPlatformBrowser(this.platform)) { 248 | return html; 249 | } 250 | const textarea = document.createElement('textarea'); 251 | textarea.innerHTML = html; 252 | return textarea.value; 253 | } 254 | 255 | private extendsRendererForExtensions(renderer: Renderer): Renderer { 256 | const extendedRenderer = renderer as ExtendedRenderer; 257 | if (extendedRenderer.ɵNgxMarkdownRendererExtendedForExtensions === true) { 258 | return renderer; 259 | } 260 | 261 | if (this.extensions && this.extensions.length > 0) { 262 | marked.use(...this.extensions); 263 | } 264 | 265 | extendedRenderer.ɵNgxMarkdownRendererExtendedForExtensions = true; 266 | 267 | return renderer; 268 | } 269 | 270 | private extendsRendererForMermaid(renderer: Renderer): Renderer { 271 | const extendedRenderer = renderer as ExtendedRenderer; 272 | if (extendedRenderer.ɵNgxMarkdownRendererExtendedForMermaid === true) { 273 | return renderer; 274 | } 275 | 276 | const defaultCode = renderer.code; 277 | renderer.code = (token) => { 278 | return token.lang === 'mermaid' 279 | ? `
${token.text}
` 280 | : defaultCode(token); 281 | }; 282 | 283 | extendedRenderer.ɵNgxMarkdownRendererExtendedForMermaid = true; 284 | 285 | return renderer; 286 | } 287 | 288 | private handleExtension(src: string, markdown: string): string { 289 | const urlProtocolIndex = src.lastIndexOf('://'); 290 | const urlWithoutProtocol = urlProtocolIndex > -1 291 | ? src.substring(urlProtocolIndex + 4) 292 | : src; 293 | 294 | const lastSlashIndex = urlWithoutProtocol.lastIndexOf('/'); 295 | const lastUrlSegment = lastSlashIndex > -1 296 | ? urlWithoutProtocol.substring(lastSlashIndex + 1).split('?')[0] 297 | : ''; 298 | 299 | const lastDotIndex = lastUrlSegment.lastIndexOf('.'); 300 | const extension = lastDotIndex > -1 301 | ? lastUrlSegment.substring(lastDotIndex + 1) 302 | : ''; 303 | 304 | return !!extension && extension !== 'md' 305 | ? '```' + extension + '\n' + markdown + '\n```' 306 | : markdown; 307 | } 308 | 309 | private parseMarked(html: string, markedOptions: MarkedOptions, inline = false): string | Promise { 310 | if (markedOptions.renderer) { 311 | // clone renderer and remove extended flags otherwise 312 | // marked throws an error thinking it is a renderer prop 313 | const renderer = { ...markedOptions.renderer } as Partial; 314 | delete renderer.ɵNgxMarkdownRendererExtendedForExtensions; 315 | delete renderer.ɵNgxMarkdownRendererExtendedForMermaid; 316 | 317 | // remove renderer from markedOptions because if renderer is 318 | // passed to marked.parse method, it will ignore all extensions 319 | delete markedOptions.renderer; 320 | 321 | marked.use({ renderer }); 322 | } 323 | 324 | return inline 325 | ? marked.parseInline(html, markedOptions) 326 | : marked.parse(html, markedOptions); 327 | } 328 | 329 | private parseEmoji(html: string): string { 330 | if (!isPlatformBrowser(this.platform)) { 331 | return html; 332 | } 333 | if (typeof joypixels === 'undefined' || typeof joypixels.shortnameToUnicode === 'undefined') { 334 | throw new Error(errorJoyPixelsNotLoaded); 335 | } 336 | return joypixels.shortnameToUnicode(html); 337 | } 338 | 339 | private renderKatex(element: HTMLElement, options: KatexOptions): void { 340 | if (!isPlatformBrowser(this.platform)) { 341 | return; 342 | } 343 | if (typeof katex === 'undefined' || typeof renderMathInElement === 'undefined') { 344 | throw new Error(errorKatexNotLoaded); 345 | } 346 | renderMathInElement(element, options); 347 | } 348 | 349 | private renderClipboard(element: HTMLElement, viewContainerRef: ViewContainerRef | undefined, options: ClipboardRenderOptions): void { 350 | if (!isPlatformBrowser(this.platform)) { 351 | return; 352 | } 353 | if (typeof ClipboardJS === 'undefined') { 354 | throw new Error(errorClipboardNotLoaded); 355 | } 356 | if (!viewContainerRef) { 357 | throw new Error(errorClipboardViewContainerRequired); 358 | } 359 | 360 | const { 361 | buttonComponent, 362 | buttonTemplate, 363 | } = options; 364 | 365 | // target every
 elements
366 |     const preElements = element.querySelectorAll('pre');
367 |     for (let i = 0; i < preElements.length; i++) {
368 |       const preElement = preElements.item(i);
369 | 
370 |       // create 
 wrapper element
371 |       const preWrapperElement = document.createElement('div');
372 |       preWrapperElement.style.position = 'relative';
373 |       preElement.parentNode!.insertBefore(preWrapperElement, preElement);
374 |       preWrapperElement.appendChild(preElement);
375 | 
376 |       // create toolbar element
377 |       const toolbarWrapperElement = document.createElement('div');
378 |       toolbarWrapperElement.classList.add('markdown-clipboard-toolbar');
379 |       toolbarWrapperElement.style.position = 'absolute';
380 |       toolbarWrapperElement.style.top = '.5em';
381 |       toolbarWrapperElement.style.right = '.5em';
382 |       toolbarWrapperElement.style.zIndex = '1';
383 |       preWrapperElement.insertAdjacentElement('beforeend', toolbarWrapperElement);
384 | 
385 |       // register listener to show/hide toolbar
386 |       preWrapperElement.onmouseenter = () => toolbarWrapperElement.classList.add('hover');
387 |       preWrapperElement.onmouseleave = () => toolbarWrapperElement.classList.remove('hover');
388 | 
389 |       // declare embeddedViewRef holding variable
390 |       let embeddedViewRef: EmbeddedViewRef;
391 | 
392 |       // use provided component via input property
393 |       // or provided via ClipboardOptions provider
394 |       if (buttonComponent) {
395 |         const componentRef = viewContainerRef.createComponent(buttonComponent);
396 |         embeddedViewRef = componentRef.hostView as EmbeddedViewRef;
397 |         componentRef.changeDetectorRef.markForCheck();
398 |       }
399 |       // use provided template via input property
400 |       else if (buttonTemplate) {
401 |         embeddedViewRef = viewContainerRef.createEmbeddedView(buttonTemplate);
402 |       }
403 |       // use default component
404 |       else {
405 |         const componentRef = viewContainerRef.createComponent(ClipboardButtonComponent);
406 |         embeddedViewRef = componentRef.hostView as EmbeddedViewRef;
407 |         componentRef.changeDetectorRef.markForCheck();
408 |       }
409 | 
410 |       // declare clipboard instance variable
411 |       let clipboardInstance: typeof ClipboardJS;
412 | 
413 |       // attach clipboard.js to root node
414 |       embeddedViewRef.rootNodes.forEach((node: HTMLElement) => {
415 |         toolbarWrapperElement.appendChild(node);
416 |         clipboardInstance = new ClipboardJS(node, { text: () => preElement.innerText });
417 |       });
418 | 
419 |       // destroy clipboard instance when view is destroyed
420 |       embeddedViewRef.onDestroy(() => clipboardInstance.destroy());
421 |     }
422 |   }
423 | 
424 |   private renderMermaid(element: HTMLElement, options: MermaidAPI.MermaidConfig = this.DEFAULT_MERMAID_OPTIONS): void {
425 |     if (!isPlatformBrowser(this.platform)) {
426 |       return;
427 |     }
428 |     if (typeof mermaid === 'undefined' || typeof mermaid.initialize === 'undefined') {
429 |       throw new Error(errorMermaidNotLoaded);
430 |     }
431 |     const mermaidElements = element.querySelectorAll('.mermaid');
432 |     if (mermaidElements.length === 0) {
433 |       return;
434 |     }
435 |     mermaid.initialize(options);
436 |     mermaid.run({ nodes: mermaidElements });
437 |   }
438 | 
439 |   private trimIndentation(markdown: string): string {
440 |     if (!markdown) {
441 |       return '';
442 |     }
443 |     let indentStart: number;
444 |     return markdown
445 |       .split('\n')
446 |       .map(line => {
447 |         let lineIdentStart = indentStart;
448 |         if (line.length > 0) {
449 |           lineIdentStart = isNaN(lineIdentStart)
450 |             ? line.search(/\S|$/)
451 |             : Math.min(line.search(/\S|$/), lineIdentStart);
452 |         }
453 |         if (isNaN(indentStart)) {
454 |           indentStart = lineIdentStart;
455 |         }
456 |         return lineIdentStart
457 |           ? line.substring(lineIdentStart)
458 |           : line;
459 |       }).join('\n');
460 |   }
461 | 
462 |   private async sanitizeHtml(html: string | Promise): Promise {
463 |     if (isSanitizeFunction(this.sanitize)) {
464 |       return this.sanitize(await html);
465 |     }
466 |     if (this.sanitize !== SecurityContext.NONE) {
467 |       return this.sanitizer.sanitize(this.sanitize ?? this.DEFAULT_SECURITY_CONTEXT, html) ?? '';
468 |     }
469 |     return html;
470 |   }
471 | }
472 | 


--------------------------------------------------------------------------------
/demo/src/app/plugins/plugins.component.html:
--------------------------------------------------------------------------------
  1 | 
  2 |   

Plugins

3 | 4 | 5 | 6 | Before to use any plugin, make sure you've installed the required libraries by following the [installation](/get-started#installation) section of the __Get Started__ page. 7 | 8 | 9 | 10 |
11 |

Emoji plugin

12 | 13 | 14 | #### Emoji-Toolkit file to include 15 | ```javascript 16 | node_modules/emoji-toolkit/lib/js/joypixels.min.js 17 | ``` 18 | 19 | #### Directive 20 | `emoji` - activate emoji plugin 21 | 22 | ### Example 23 | 24 | 25 | 26 | Using `emoji` input property on `markdown` component, directive or pipe allows you to convert shortnames to native unicode emojis. 27 | 28 | 29 | 30 | 31 | 32 | The example below illustrate `emoji` directive in action. 33 | 34 | 35 |
36 | 37 | 38 | 39 | 40 | 41 |
42 | 43 | 44 | > :blue_book: You can refer to this [Emoji Cheat Sheet](https://github.com/ikatyang/emoji-cheat-sheet/blob/master/README.md) for a complete list of _shortnames_. 45 | 46 |
47 | 48 | 49 |
50 |

Line Numbers plugin

51 | 52 | 53 | #### Prism files to include 54 | ```javascript 55 | node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css 56 | node_modules/prismjs/plugins/line-numbers/prism-line-numbers.js 57 | ``` 58 | 59 | #### Directive 60 | `lineNumbers` - activate line numbers plugin 61 | 62 | #### Attributes 63 | `start` - offset number for the first display line 64 | 65 | ### Example 66 | 67 | 68 | 69 | Using `lineNumbers` input property on `markdown` component, directive or pipe allows you to add line number at the beginning of each lines of code block. 70 | 71 | 72 | 73 | 74 | 75 | The example below uses `lineNumbers` directive which uses default line offset of 1. 76 | 77 | 78 | 79 | ```javascript 80 | var result = square(2); 81 | 82 | function square(number) { 83 | return number * number; 84 | } 85 | ``` 86 | 87 | 88 | 89 | Optionally you can use `start` to specify the offset number for the first display line. 90 | 91 | 92 | 93 | In the example below line offset is set to 5 using `start` input property. 94 | 95 | 96 | 97 | ```javascript 98 | var result = root(2); 99 | 100 | function root(x, n) { 101 | try { 102 | var negate = n % 2 == 1 && x < 0; 103 | if (negate) 104 | x = -x; 105 | var possible = Math.pow(x, 1 / n); 106 | n = Math.pow(possible, n); 107 | if (Math.abs(x - n) < 1 && (x > 0 == n > 0)) 108 | return negate ? -possible : possible; 109 | } catch (e) { } 110 | } 111 | ``` 112 | 113 |
114 | 115 | 116 |
117 |

Line Highlight plugin

118 | 119 | 120 | #### Prism files to include 121 | ```javascript 122 | node_modules/prismjs/plugins/line-highlight/prism-line-highlight.css 123 | node_modules/prismjs/plugins/line-highlight/prism-line-highlight.js 124 | ``` 125 | 126 | #### Directive 127 | `lineHighlight` - activate line highlight plugin 128 | 129 | #### Attributes 130 | `line` - lines to highlight (i.e.: 6, 11-15)
131 | `lineOffset` - starting offset for line numbers
132 | 133 | ### Example 134 |
135 | 136 | 137 | You can highlight different lines by adding `lineHighlight` directive on the `markdown` component/directive. 138 | 139 | Use `line` input property to specify the line(s) to highlight and optionally there is a `lineOffset` property to specify the starting line of code your snippet represents. 140 | 141 | 142 | 143 | 144 | 145 | In the example below `line` 6 and 10 to 16 are highlight using a `lineOffset` of 5. 146 | 147 | 148 | 149 | ```javascript 150 | var result = root(2); 151 | 152 | function root(x, n) { 153 | try { 154 | var negate = n % 2 == 1 && x < 0; 155 | if (negate) 156 | x = -x; 157 | var possible = Math.pow(x, 1 / n); 158 | n = Math.pow(possible, n); 159 | if (Math.abs(x - n) < 1 && (x > 0 == n > 0)) 160 | return negate ? -possible : possible; 161 | } catch (e) { } 162 | } 163 | ``` 164 | 165 |
166 | 167 | 168 |
169 |

Command Line plugin

170 | 171 | 172 | #### Prism file(s) to include 173 | ```javascript 174 | node_modules/prismjs/plugins/command-line/prism-command-line.css 175 | node_modules/prismjs/plugins/command-line/prism-command-line.min.js 176 | ``` 177 | 178 | #### Directive 179 | `commandLine` - activate command-line display 180 | 181 | #### Attributes 182 | `host` - host name
183 | `output` - lines to be presented as output (optional)
184 | `filterOutput` - prefix to automatically present lines as output (optional)
185 | `prompt` - data prompt
186 | `user` - user name
187 | 188 | ### Example 189 |
190 | 191 | 192 | Root user without output 193 | 194 | ```html 195 | <markdown 196 | commandLine 197 | [user]="'root'" 198 | [host]="'localhost'" 199 | [src]="'path/to/file.bash'"> 200 | </markdown> 201 | ``` 202 | 203 | 204 | 209 | 210 | 211 | 212 | Non-Root User With Output 213 | 214 | ```html 215 | <markdown 216 | commandLine 217 | [user]="'chris'" 218 | [host]="'remotehost'" 219 | [output]="'2, 4-8'" 220 | [src]="'path/to/file.bash'"> 221 | </markdown> 222 | ``` 223 | 224 | 225 | 230 | 231 | 232 | 233 | Windows PowerShell With Output 234 | 235 | ```html 236 | <markdown 237 | commandLine 238 | [prompt]="'PS C:\Users\Chris>'" 239 | [output]="'2-19'" 240 | [src]="'path/to/file.bash'"> 241 | </markdown> 242 | ``` 243 | 244 | 245 | 250 | 251 | 252 | 253 | Windows PowerShell With Filter Output 254 | 255 | ```html 256 | <markdown 257 | commandLine 258 | [prompt]="'PS C:\Users\Chris>'" 259 | [filterOutput]="'(out)'"> 260 | ```powershell 261 | Get-Date 262 | (out) 263 | (out)Sunday, November 7, 2021 8:19:21 PM 264 | (out) 265 | `​`` 266 | </markdown> 267 | ``` 268 | 269 | 270 | 275 | 276 |
277 | 278 | 279 |
280 |

KaTeX plugin

281 | 282 | 283 | #### KaTeX files to include 284 | ```javascript 285 | node_modules/katex/dist/katex.min.css 286 | node_modules/katex/dist/katex.min.js 287 | node_modules/katex/dist/contrib/auto-render.min.js 288 | ``` 289 | 290 | #### Directive 291 | `katex` - activate KaTeX plugin 292 | 293 | #### Attributes 294 | `katexOptions` - combine [KaTeX options](https://katex.org/docs/options.html) and [Auto-Renderer options](https://katex.org/docs/autorender.html#api)
295 | 296 | ### Example 297 |
298 | 299 | 300 | You can render KaTex expression by adding `katex` directive on the `markdown` component/directive. 301 | 302 | 303 | 304 | 305 | 306 | The example below illustrate `katex` directive in action. 307 | 308 | 309 |
310 | 311 | 312 | 313 | 314 | 315 |
316 | 317 | 318 | Optionally, you can specify both [KaTeX options](https://katex.org/docs/options.html) and [Auto-Renderer options](https://katex.org/docs/autorender.html#api) using `katexOptions` property. 319 | 320 | **example.component.ts** 321 | ```typescript 322 | import { KatexOptions } from 'ngx-markdown'; 323 | 324 | public options: KatexOptions = { 325 | displayMode: true, 326 | throwOnError: false, 327 | errorColor: '#cc0000', 328 | delimiters: [...], 329 | ... 330 | }; 331 | ``` 332 | 333 | **example.component.html** 334 | 335 | 336 | 337 |
338 | 339 | 340 |
341 |

Mermaid plugin

342 | 343 | 344 | #### Mermaid file to include 345 | ```javascript 346 | node_modules/mermaid/dist/mermaid.min.js 347 | ``` 348 | 349 | #### Directive 350 | `mermaid` - activate mermaid plugin 351 | 352 | #### Attributes 353 | `mermaidOptions` - mermaid [configuration options](https://mermaid.js.org/config/schema-docs/config.html#mermaid-config-properties)
354 | 355 | ### Example 356 |
357 | 358 | 359 | Using `mermaid` input property on `markdown` component, directive or pipe allows you to use [mermaid](https://mermaid-js.github.io/) syntax to generate diagrams and flowcharts. 360 | 361 | 362 | 363 | 364 | 365 | The example below illustrate `mermaid` directive in action. 366 | 367 | 368 |
369 | 370 | 371 | 372 | 373 | 374 |
375 | 376 | 377 | #### Global configuration 378 | 379 | You can provide a global configuration for mermaid [configuration options](https://mermaid.js.org/config/schema-docs/config.html#mermaid-config-properties) to use across your application with the `mermaidOptions` in the `MarkdownModuleConfig` either with `provideMarkdown` provide-function for standalone components or `MarkdownModule.forRoot()` for module configuration. 380 | 381 | ```typescript 382 | // using the `provideMarkdown` function 383 | provideMarkdown({ 384 | mermaidOptions: { 385 | provide: MERMAID_OPTIONS, 386 | useValue: { 387 | darkMode: true, 388 | look: 'handDrawn', 389 | ... 390 | }, 391 | }, 392 | }), 393 | 394 | // using the `MarkdownModule` import 395 | MarkdownModule.forRoot({ 396 | mermaidOptions: { 397 | provide: MERMAID_OPTIONS, 398 | useValue: { 399 | darkMode: true, 400 | look: 'handDrawn', 401 | ... 402 | }, 403 | }, 404 | }), 405 | ``` 406 | 407 | #### Component configuration 408 | 409 | Additionally, you can specify mermaid [configuration options](https://mermaid.js.org/config/schema-docs/config.html#mermaid-config-properties) on component directly using `mermaidOptions` property. 410 | 411 | **example.component.ts** 412 | ```typescript 413 | import { MermaidAPI } from 'ngx-markdown'; 414 | 415 | public options: MermaidAPI.MermaidConfig = { 416 | darkMode: true, 417 | look: 'handDrawn', 418 | ... 419 | }; 420 | ``` 421 | 422 | **example.component.html** 423 | 424 | 425 | 426 | 427 | 428 | > :blue_book: You can refer to this [Mermaid](https://mermaid-js.github.io/) documentation for complete usage syntax. 429 | 430 |
431 | 432 | 433 |
434 |

Clipboard plugin

435 | 436 | 437 | #### Clipboard file(s) to include 438 | 439 | ```javascript 440 | node_modules/clipboard/dist/clipboard.min.js 441 | ``` 442 | 443 | #### Directive 444 | `clipboard` - activate copy-to-clipboard plugin 445 | 446 | #### Attributes 447 | `clipboardButtonComponent` - component `Type<any>` to use as copy-to-clipboard button
448 | `clipboardButtonTemplate` - template reference `TemplateRef<T>` to use as copy-to-clipboard button
449 | 450 | #### CSS Selectors 451 | `markdown-clipboard-toolbar` - toolbar wrapper
452 | `markdown-clipboard-toolbar.hover` - toolbar wrapper during mouse hover
453 | `markdown-clipboard-button` - default button
454 | `markdown-clipboard-button.copied` - default button during "copied" state
455 | 456 | ### Example 457 |
458 | 459 | 460 | #### Default button 461 | 462 | The `clipboard` plugin provide an unstyled default button with a default behavior out of the box if no alternative is used. 463 | 464 | ```javascript 465 | const example = 'the default clipboard button with default behavior'; 466 | ``` 467 | 468 | 469 | 470 | #### Customize toolbar 471 | 472 | The clipboard button is placed inside a wrapper element that can be customize using the `.markdown-clipboard-toolbar` CSS selector in your global `styles.css/scss` file. 473 | 474 | This allows to override the default positionning of the clipboard button and play with the visibility of the button using the `.hover` CSS selector that is applied on the toolbar when the mouse cursor enters and leaves the code block element. 475 | 476 | ```css 477 | .markdown-clipboard-toolbar { 478 | top: 16px; 479 | right: 16px; 480 | opacity: 0; 481 | transition: opacity 250ms ease-out; 482 | } 483 | 484 | .markdown-clipboard-toolbar.hover { 485 | opacity: 1; 486 | } 487 | ``` 488 | 489 | 490 | 491 | #### Customize default button 492 | 493 | The default button can be customized using the `.markdown-clipboard-button` CSS selector in your global `styles.css/scss` file. You can also customized the "copied" state happening after the button is clicked using the `.copied` CSS selector. 494 | 495 | ```css 496 | .markdown-clipboard-button { 497 | background-color: rgba(255, 255, 255, 0.07); 498 | border: none; 499 | border-radius: 4px; 500 | color: #ffffff; 501 | cursor: pointer; 502 | font-size: 11px; 503 | padding: 4px 0; 504 | width: 50px; 505 | transition: all 250ms ease-out; 506 | } 507 | 508 | .markdown-clipboard-button:hover { 509 | background-color: rgba(255, 255, 255, 0.14); 510 | } 511 | 512 | .markdown-clipboard-button:active { 513 | transform: scale(0.95); 514 | } 515 | 516 | .markdown-clipboard-button.copied { 517 | background-color: rgba(0, 255, 0, 0.1); 518 | color: #00ff00; 519 | } 520 | ``` 521 | 522 | 523 | 524 | #### Using global configuration 525 | 526 | You can provide a custom component to use globaly across your application with the `clipboardOptions` in the `MarkdownModuleConfig` either with `provideMarkdown` provide-function for standalone components or `MarkdownModule.forRoot()` for module configuration. 527 | 528 | ```typescript 529 | // using the `provideMarkdown` function 530 | provideMarkdown({ 531 | clipboardOptions: { 532 | provide: CLIPBOARD_OPTIONS, 533 | useValue: { 534 | buttonComponent: ClipboardButtonComponent, 535 | }, 536 | }, 537 | }) 538 | 539 | // using `MarkdownModule` import 540 | MarkdownModule.forRoot({ 541 | clipboardOptions: { 542 | provide: CLIPBOARD_OPTIONS, 543 | useValue: { 544 | buttonComponent: ClipboardButtonComponent, 545 | }, 546 | }, 547 | }), 548 | ``` 549 | 550 | 551 | 552 | #### Using a component 553 | 554 | You can also provide your custom component using the `clipboardButtonComponent` input property when using the `clipboard` directive. 555 | 556 | ```typescript 557 | import { Component } from '@angular/core'; 558 | 559 | @Component({ 560 | selector: 'app-clipboard-button', 561 | template: `<button (click)="onClick()">Copy</button>`, 562 | }) 563 | export class ClipboardButtonComponent { 564 | onClick() { 565 | alert('Copied to clipboard!'); 566 | } 567 | } 568 | ``` 569 | 570 | ```typescript 571 | import { ClipboardButtonComponent } from './clipboard-button-component'; 572 | 573 | @Component({ ... }) 574 | export class ExampleComponent { 575 | readonly clipboardButton = ClipboardButtonComponent; 576 | } 577 | ``` 578 | 579 | ```html 580 | <markdown clipboard [clipboardButtonComponent]="clipboardButton"></markdown> 581 | ``` 582 | 583 | 584 | 585 | 590 | 591 | 592 | 593 | #### Using ng-template 594 | 595 | Alternatively, the `clipboard` directive can be used in conjonction with `ng-template` to provide a custom button implementation via the `clipboardButtonTemplate` input property on the `markdown` component. 596 | 597 | ```html 598 | <ng-template #buttonTemplate> 599 | <button (click)="onCopyToClipboard()">...</button> 600 | </ng-template> 601 | 602 | <markdown clipboard [clipboardButtonTemplate]="buttonTemplate"></markdown> 603 | ``` 604 | 605 |
606 |
--------------------------------------------------------------------------------