diff --git a/src/app/core/shared/math.directive.ts b/src/app/core/shared/math.directive.ts deleted file mode 100644 index 3702369ae9..0000000000 --- a/src/app/core/shared/math.directive.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - Directive, - ElementRef, - Input, - OnChanges, - OnDestroy, - OnInit, - SimpleChanges, -} from '@angular/core'; -import { Subject } from 'rxjs'; -import { - take, - takeUntil, -} from 'rxjs/operators'; - -import { MathService } from './math.service'; - -@Directive({ - selector: '[dsMath]', - standalone: true, -}) -export class MathDirective implements OnInit, OnChanges, OnDestroy { - @Input() dsMath: string; - private alive$ = new Subject(); - private readonly el: HTMLElement; - - constructor(private mathService: MathService, private elementRef: ElementRef) { - this.el = elementRef.nativeElement; - } - - ngOnInit() { - this.render(); - } - - ngOnChanges(changes: SimpleChanges) { - if (changes?.dsMath?.currentValue) { - this.render(); - } - } - - private render() { - this.mathService.ready().pipe( - take(1), - takeUntil(this.alive$), - ).subscribe(() => { - // if this.dsMath begins with "The observation of the" - if (this.dsMath.startsWith('The observation of the')) { - console.warn('rendering math after ready'); - console.warn('this.dsMath', this.dsMath); - console.warn('this.el', this.el); - } - this.mathService.render(this.el, this.dsMath); - }); - } - - ngOnDestroy() { - this.alive$.next(false); - } - -} diff --git a/src/app/core/shared/math.service.ts b/src/app/core/shared/math.service.ts index 794f3b16e5..7549b09680 100644 --- a/src/app/core/shared/math.service.ts +++ b/src/app/core/shared/math.service.ts @@ -7,16 +7,9 @@ import { interface MathJaxConfig { source: string; - integrity: string; id: string; } -declare global { - interface Window { - MathJax: any; - } -} - @Injectable({ providedIn: 'root', }) @@ -38,12 +31,10 @@ export class MathService { private mathJax: MathJaxConfig = { source: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js', - integrity: 'sha256-CnzfCXjFj1REmPHgWvm/OQv8gFaxwbLKUi41yCU7N2s=', id: 'MathJaxScript', }; private mathJaxFallback: MathJaxConfig = { source: 'assets/mathjax/mml-chtml.js', - integrity: 'sha256-CnzfCXjFj1REmPHgWvm/OQv8gFaxwbLKUi41yCU7N2s=', id: 'MathJaxBackupScript', }; @@ -62,6 +53,7 @@ export class MathService { private async registerMathJaxAsync(config: MathJaxConfig): Promise { return new Promise((resolve, reject) => { + const optionsScript: HTMLScriptElement = document.createElement('script'); optionsScript.type = 'text/javascript'; optionsScript.text = `MathJax = ${JSON.stringify(this.mathJaxOptions)};`; @@ -83,11 +75,7 @@ export class MathService { return this.signal; } - render(element: HTMLElement, value: string) { - // Take initial typesetting which MathJax performs into account - // window.MathJax.startup.promise.then(() => { - element.innerHTML = value; - window.MathJax.typesetPromise([element]); - // }); + render(element: HTMLElement) { + (window as any).MathJax.typesetPromise([element]); } } diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.html b/src/app/item-page/field-components/metadata-values/metadata-values.component.html index 17eb4e42e3..44a3657fa5 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.html +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.html @@ -12,7 +12,7 @@ - + diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index 8c319c6ad2..abc26c0d82 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -24,7 +24,7 @@ import { MetadataValue } from '../../../core/shared/metadata.models'; import { VALUE_LIST_BROWSE_DEFINITION } from '../../../core/shared/value-list-browse-definition.resource-type'; import { hasValue } from '../../../shared/empty.util'; import { MetadataFieldWrapperComponent } from '../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; -import { MarkdownPipe as MarkdownPipe_1 } from '../../../shared/utils/markdown.pipe'; +import { MarkdownDirective } from '../../../shared/utils/markdown.directive'; import { ImageField } from '../../simple/field-components/specific-field/image-field'; /** @@ -36,7 +36,7 @@ import { ImageField } from '../../simple/field-components/specific-field/image-f styleUrls: ['./metadata-values.component.scss'], templateUrl: './metadata-values.component.html', standalone: true, - imports: [MetadataFieldWrapperComponent, NgFor, NgTemplateOutlet, NgIf, RouterLink, AsyncPipe, MarkdownPipe_1, TranslateModule], + imports: [MetadataFieldWrapperComponent, NgFor, NgTemplateOutlet, NgIf, RouterLink, AsyncPipe, TranslateModule, MarkdownDirective], }) export class MetadataValuesComponent implements OnChanges { @@ -61,7 +61,7 @@ export class MetadataValuesComponent implements OnChanges { @Input() label: string; /** - * Whether the {@link MarkdownPipe} should be used to render these metadata values. + * Whether the {@link MarkdownDirective} should be used to render these metadata values. * This will only have effect if {@link MarkdownConfig#enabled} is true. * Mathjax will only be rendered if {@link MarkdownConfig#mathjax} is true. */ diff --git a/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts b/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts index 6a4ec85862..1453427db6 100644 --- a/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts @@ -47,7 +47,7 @@ export class ItemPageAbstractFieldComponent extends ItemPageFieldComponent { label = 'item.page.abstract'; /** - * Use the {@link MarkdownPipe} to render dc.description.abstract values + * Use the {@link MarkdownDirective} to render dc.description.abstract values */ enableMarkdown = true; } diff --git a/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts b/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts index 52c07cca16..768608fd77 100644 --- a/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts @@ -43,7 +43,7 @@ export class GenericItemPageFieldComponent extends ItemPageFieldComponent { @Input() label: string; /** - * Whether the {@link MarkdownPipe} should be used to render this metadata. + * Whether the {@link MarkdownDirective} should be used to render this metadata. */ @Input() enableMarkdown = false; diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts index 89d4be2459..39faae5ca4 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts @@ -39,7 +39,7 @@ export class ItemPageFieldComponent { @Input() item: Item; /** - * Whether the {@link MarkdownPipe} should be used to render this metadata. + * Whether the {@link MarkdownDirective} should be used to render this metadata. */ enableMarkdown = false; diff --git a/src/app/shared/utils/markdown.directive.spec.ts b/src/app/shared/utils/markdown.directive.spec.ts new file mode 100644 index 0000000000..621fdd45b6 --- /dev/null +++ b/src/app/shared/utils/markdown.directive.spec.ts @@ -0,0 +1,8 @@ +import { MarkdownDirective } from './markdown.directive'; + +describe('MarkdownDirective', () => { + it('should create an instance', () => { + const directive = new MarkdownDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/app/shared/utils/markdown.directive.ts b/src/app/shared/utils/markdown.directive.ts new file mode 100644 index 0000000000..cece57ae11 --- /dev/null +++ b/src/app/shared/utils/markdown.directive.ts @@ -0,0 +1,85 @@ +import { + Directive, + ElementRef, + Inject, + InjectionToken, + Input, + OnDestroy, + OnInit, + SecurityContext, +} from '@angular/core'; +import { + DomSanitizer, + SafeHtml, +} from '@angular/platform-browser'; +import { Subject } from 'rxjs'; +import { + take, + takeUntil, +} from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { MathService } from '../../core/shared/math.service'; +import { isEmpty } from '../empty.util'; + +const markdownItLoader = async () => (await import('markdown-it')).default; +type LazyMarkdownIt = ReturnType; +const MARKDOWN_IT = new InjectionToken( + 'Lazily loaded MarkdownIt', + { providedIn: 'root', factory: markdownItLoader }, +); + +@Directive({ + selector: '[dsMarkdown]', + standalone: true, +}) +export class MarkdownDirective implements OnInit, OnDestroy { + + @Input() dsMarkdown: string; + private alive$ = new Subject(); + + el: HTMLElement; + + constructor( + protected sanitizer: DomSanitizer, + @Inject(MARKDOWN_IT) private markdownIt: LazyMarkdownIt, + private mathService: MathService, + private elementRef: ElementRef) { + this.el = elementRef.nativeElement; + } + + ngOnInit() { + this.render(this.dsMarkdown); + } + + async render(value: string, forcePreview = false): Promise { + if (isEmpty(value) || (!environment.markdown.enabled && !forcePreview)) { + return value; + } + const MarkdownIt = await this.markdownIt; + const md = new MarkdownIt({ + html: true, + linkify: true, + }); + + const html = this.sanitizer.sanitize(SecurityContext.HTML, md.render(value)); + this.el.innerHTML = html; + + if (environment.markdown.mathjax) { + this.renderMathjax(); + } + } + + private renderMathjax() { + this.mathService.ready().pipe( + take(1), + takeUntil(this.alive$), + ).subscribe(() => { + this.mathService.render(this.el); + }); + } + + ngOnDestroy() { + this.alive$.next(false); + } +} diff --git a/src/app/shared/utils/markdown.pipe.spec.ts b/src/app/shared/utils/markdown.pipe.spec.ts deleted file mode 100644 index 58a5c70e64..0000000000 --- a/src/app/shared/utils/markdown.pipe.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { APP_CONFIG } from '../../../config/app-config.interface'; -import { environment } from '../../../environments/environment'; -import { MarkdownPipe } from './markdown.pipe'; - -describe('Markdown Pipe', () => { - - let markdownPipe: MarkdownPipe; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - MarkdownPipe, - { - provide: APP_CONFIG, - useValue: Object.assign(environment, { - markdown: { - enabled: true, - mathjax: true, - }, - }), - }, - ], - }).compileComponents(); - - markdownPipe = TestBed.inject(MarkdownPipe); - }); - - it('should render markdown', async () => { - await testTransform( - '# Header', - '

Header

', - ); - }); - - it('should render mathjax', async () => { - await testTransform( - '$\\sqrt{2}^2$', - '.*', - ); - }); - - it('should render regular links', async () => { - await testTransform( - 'DSpace', - 'DSpace', - ); - }); - - it('should not render javascript links', async () => { - await testTransform( - 'exploit', - 'exploit', - ); - }); - - it('should render undefined value', async () => { - await testTransform( - undefined, - undefined, - ); - }); - - it('should render null value', async () => { - await testTransform( - null, - null, - ); - }); - - async function testTransform(input: string, output: string) { - expect( - await markdownPipe.transform(input), - ).toMatch( - new RegExp('.*' + output + '.*'), - ); - } -}); diff --git a/src/app/shared/utils/markdown.pipe.ts b/src/app/shared/utils/markdown.pipe.ts deleted file mode 100644 index 767d5f6a8b..0000000000 --- a/src/app/shared/utils/markdown.pipe.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - Inject, - InjectionToken, - Pipe, - PipeTransform, - SecurityContext, -} from '@angular/core'; -import { - DomSanitizer, - SafeHtml, -} from '@angular/platform-browser'; -import { MathService } from 'src/app/core/shared/math.service'; - -import { environment } from '../../../environments/environment'; -import { isEmpty } from '../empty.util'; - -const markdownItLoader = async () => (await import('markdown-it')).default; -type LazyMarkdownIt = ReturnType; -const MARKDOWN_IT = new InjectionToken( - 'Lazily loaded MarkdownIt', - { providedIn: 'root', factory: markdownItLoader }, -); - -// const mathjaxLoader = async () => (await import('markdown-it-mathjax3')).default; -// type Mathjax = ReturnType; -// const MATHJAX = new InjectionToken( -// 'Lazily loaded mathjax', -// { providedIn: 'root', factory: mathjaxLoader }, -// ); - -const sanitizeHtmlLoader = async () => (await import('sanitize-html') as any).default; -type SanitizeHtml = ReturnType; -const SANITIZE_HTML = new InjectionToken( - 'Lazily loaded sanitize-html', - { providedIn: 'root', factory: sanitizeHtmlLoader }, -); - -/** - * Pipe for rendering markdown and mathjax. - * - markdown will only be rendered if {@link MarkdownConfig#enabled} is true - * - mathjax will only be rendered if both {@link MarkdownConfig#enabled} and {@link MarkdownConfig#mathjax} are true - * - * This pipe should be used on the 'innerHTML' attribute of a component, in combination with an async pipe. - * Example usage: - * - * Result: - * - *

title

- *
- */ -@Pipe({ - name: 'dsMarkdown', - standalone: true, -}) -export class MarkdownPipe implements PipeTransform { - - constructor( - protected sanitizer: DomSanitizer, - @Inject(MARKDOWN_IT) private markdownIt: LazyMarkdownIt, - // @Inject(MATHJAX) private mathjax: Mathjax, - @Inject(SANITIZE_HTML) private sanitizeHtml: SanitizeHtml, - private mathService: MathService, - ) { - } - - async transform(value: string, forcePreview = false): Promise { - if (isEmpty(value) || (!environment.markdown.enabled && !forcePreview)) { - return value; - } - const MarkdownIt = await this.markdownIt; - const md = new MarkdownIt({ - html: true, - linkify: true, - }); - - let html: string; - if (environment.markdown.mathjax) { - const sanitizeHtml = await this.sanitizeHtml; - html = sanitizeHtml(md.render(value), { - // sanitize-html doesn't let through SVG by default, so we extend its allowlists to cover MathJax SVG - allowedTags: [ - ...sanitizeHtml.defaults.allowedTags, - 'mjx-container', 'svg', 'g', 'path', 'rect', 'text', - // Also let the mjx-assistive-mml tag (and it's children) through (for screen readers) - 'mjx-assistive-mml', 'math', 'mrow', 'mi', - ], - allowedAttributes: { - ...sanitizeHtml.defaults.allowedAttributes, - 'mjx-container': [ - 'class', 'style', 'jax', - ], - svg: [ - 'xmlns', 'viewBox', 'style', 'width', 'height', 'role', 'focusable', 'alt', 'aria-label', - ], - g: [ - 'data-mml-node', 'style', 'stroke', 'fill', 'stroke-width', 'transform', - ], - path: [ - 'd', 'style', 'transform', - ], - rect: [ - 'width', 'height', 'x', 'y', 'transform', 'style', - ], - text: [ - 'transform', 'font-size', - ], - 'mjx-assistive-mml': [ - 'unselectable', 'display', 'style', - ], - math: [ - 'xmlns', - ], - mrow: [ - 'data-mjx-texclass', - ], - }, - parser: { - lowerCaseAttributeNames: false, - }, - }); - } else { - html = this.sanitizer.sanitize(SecurityContext.HTML, md.render(value)); - } - - return this.sanitizer.bypassSecurityTrustHtml(html); - } -} diff --git a/src/config/markdown-config.interface.ts b/src/config/markdown-config.interface.ts index 6ba40f50fb..71c5e45547 100644 --- a/src/config/markdown-config.interface.ts +++ b/src/config/markdown-config.interface.ts @@ -1,20 +1,20 @@ import { Config } from './config.interface'; /** - * Config related to the {@link MarkdownPipe}. + * Config related to the {@link MarkdownDirective}. */ export interface MarkdownConfig extends Config { /** - * Enable Markdown (https://commonmark.org/) syntax for values passed to the {@link MarkdownPipe}. - * - If this is true, values passed to the MarkdownPipe will be transformed to html according to the markdown syntax + * Enable Markdown (https://commonmark.org/) syntax for values passed to the {@link MarkdownDirective}. + * - If this is true, values passed to the MarkdownDirective will be transformed to html according to the markdown syntax * rules. - * - If this is false, using the MarkdownPipe will have no effect. + * - If this is false, using the MarkdownDirective will have no effect. */ enabled: boolean; /** - * Enable MathJax (https://www.mathjax.org/) syntax for values passed to the {@link MarkdownPipe}. + * Enable MathJax (https://www.mathjax.org/) syntax for values passed to the {@link MarkdownDirective}. * Requires {@link enabled} to also be true before MathJax will display. */ mathjax: boolean;