[DURACOM-240] refactor markdownPipe to markdownDirective

missing tests and splitting the service into a browser one and a server one
This commit is contained in:
Andrea Barbasso
2024-02-26 17:39:34 +01:00
committed by Giuseppe Digilio
parent 825308e223
commit 68c9ef1051
12 changed files with 108 additions and 293 deletions

View File

@@ -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<boolean>();
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);
}
}

View File

@@ -7,16 +7,9 @@ import {
interface MathJaxConfig { interface MathJaxConfig {
source: string; source: string;
integrity: string;
id: string; id: string;
} }
declare global {
interface Window {
MathJax: any;
}
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -38,12 +31,10 @@ export class MathService {
private mathJax: MathJaxConfig = { private mathJax: MathJaxConfig = {
source: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js', source: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js',
integrity: 'sha256-CnzfCXjFj1REmPHgWvm/OQv8gFaxwbLKUi41yCU7N2s=',
id: 'MathJaxScript', id: 'MathJaxScript',
}; };
private mathJaxFallback: MathJaxConfig = { private mathJaxFallback: MathJaxConfig = {
source: 'assets/mathjax/mml-chtml.js', source: 'assets/mathjax/mml-chtml.js',
integrity: 'sha256-CnzfCXjFj1REmPHgWvm/OQv8gFaxwbLKUi41yCU7N2s=',
id: 'MathJaxBackupScript', id: 'MathJaxBackupScript',
}; };
@@ -62,6 +53,7 @@ export class MathService {
private async registerMathJaxAsync(config: MathJaxConfig): Promise<any> { private async registerMathJaxAsync(config: MathJaxConfig): Promise<any> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const optionsScript: HTMLScriptElement = document.createElement('script'); const optionsScript: HTMLScriptElement = document.createElement('script');
optionsScript.type = 'text/javascript'; optionsScript.type = 'text/javascript';
optionsScript.text = `MathJax = ${JSON.stringify(this.mathJaxOptions)};`; optionsScript.text = `MathJax = ${JSON.stringify(this.mathJaxOptions)};`;
@@ -83,11 +75,7 @@ export class MathService {
return this.signal; return this.signal;
} }
render(element: HTMLElement, value: string) { render(element: HTMLElement) {
// Take initial typesetting which MathJax performs into account (window as any).MathJax.typesetPromise([element]);
// window.MathJax.startup.promise.then(() => {
element.innerHTML = value;
window.MathJax.typesetPromise([element]);
// });
} }
} }

View File

@@ -12,7 +12,7 @@
<!-- Render value as markdown --> <!-- Render value as markdown -->
<ng-template #markdown let-value="value"> <ng-template #markdown let-value="value">
<span class="dont-break-out" [innerHTML]="value | dsMarkdown | async"> <span class="dont-break-out" [dsMarkdown]="value">
</span> </span>
</ng-template> </ng-template>

View File

@@ -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 { VALUE_LIST_BROWSE_DEFINITION } from '../../../core/shared/value-list-browse-definition.resource-type';
import { hasValue } from '../../../shared/empty.util'; import { hasValue } from '../../../shared/empty.util';
import { MetadataFieldWrapperComponent } from '../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; 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'; 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'], styleUrls: ['./metadata-values.component.scss'],
templateUrl: './metadata-values.component.html', templateUrl: './metadata-values.component.html',
standalone: true, 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 { export class MetadataValuesComponent implements OnChanges {
@@ -61,7 +61,7 @@ export class MetadataValuesComponent implements OnChanges {
@Input() label: string; @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. * This will only have effect if {@link MarkdownConfig#enabled} is true.
* Mathjax will only be rendered if {@link MarkdownConfig#mathjax} is true. * Mathjax will only be rendered if {@link MarkdownConfig#mathjax} is true.
*/ */

View File

@@ -47,7 +47,7 @@ export class ItemPageAbstractFieldComponent extends ItemPageFieldComponent {
label = 'item.page.abstract'; 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; enableMarkdown = true;
} }

View File

@@ -43,7 +43,7 @@ export class GenericItemPageFieldComponent extends ItemPageFieldComponent {
@Input() label: string; @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; @Input() enableMarkdown = false;

View File

@@ -39,7 +39,7 @@ export class ItemPageFieldComponent {
@Input() item: Item; @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; enableMarkdown = false;

View File

@@ -0,0 +1,8 @@
import { MarkdownDirective } from './markdown.directive';
describe('MarkdownDirective', () => {
it('should create an instance', () => {
const directive = new MarkdownDirective();
expect(directive).toBeTruthy();
});
});

View File

@@ -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<typeof markdownItLoader>;
const MARKDOWN_IT = new InjectionToken<LazyMarkdownIt>(
'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<boolean>();
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<SafeHtml> {
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);
}
}

View File

@@ -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',
'<h1>Header</h1>',
);
});
it('should render mathjax', async () => {
await testTransform(
'$\\sqrt{2}^2$',
'<svg.*?>.*</svg>',
);
});
it('should render regular links', async () => {
await testTransform(
'<a href="https://www.dspace.com">DSpace</a>',
'<a href="https://www.dspace.com">DSpace</a>',
);
});
it('should not render javascript links', async () => {
await testTransform(
'<a href="javascript:window.alert(\'bingo!\');">exploit</a>',
'<a>exploit</a>',
);
});
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 + '.*'),
);
}
});

View File

@@ -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<typeof markdownItLoader>;
const MARKDOWN_IT = new InjectionToken<LazyMarkdownIt>(
'Lazily loaded MarkdownIt',
{ providedIn: 'root', factory: markdownItLoader },
);
// const mathjaxLoader = async () => (await import('markdown-it-mathjax3')).default;
// type Mathjax = ReturnType<typeof mathjaxLoader>;
// const MATHJAX = new InjectionToken<Mathjax>(
// 'Lazily loaded mathjax',
// { providedIn: 'root', factory: mathjaxLoader },
// );
const sanitizeHtmlLoader = async () => (await import('sanitize-html') as any).default;
type SanitizeHtml = ReturnType<typeof sanitizeHtmlLoader>;
const SANITIZE_HTML = new InjectionToken<SanitizeHtml>(
'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:
* <span class="example" [innerHTML]="'# title' | dsMarkdown | async"></span>
* Result:
* <span class="example">
* <h1>title</h1>
* </span>
*/
@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<SafeHtml> {
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);
}
}

View File

@@ -1,20 +1,20 @@
import { Config } from './config.interface'; import { Config } from './config.interface';
/** /**
* Config related to the {@link MarkdownPipe}. * Config related to the {@link MarkdownDirective}.
*/ */
export interface MarkdownConfig extends Config { export interface MarkdownConfig extends Config {
/** /**
* Enable Markdown (https://commonmark.org/) syntax for values passed to the {@link MarkdownPipe}. * Enable Markdown (https://commonmark.org/) syntax for values passed to the {@link MarkdownDirective}.
* - If this is true, values passed to the MarkdownPipe will be transformed to html according to the markdown syntax * - If this is true, values passed to the MarkdownDirective will be transformed to html according to the markdown syntax
* rules. * 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; 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. * Requires {@link enabled} to also be true before MathJax will display.
*/ */
mathjax: boolean; mathjax: boolean;