mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
[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:

committed by
Giuseppe Digilio

parent
825308e223
commit
68c9ef1051
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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<any> {
|
||||
return new Promise<void>((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]);
|
||||
}
|
||||
}
|
||||
|
@@ -12,7 +12,7 @@
|
||||
|
||||
<!-- Render value as markdown -->
|
||||
<ng-template #markdown let-value="value">
|
||||
<span class="dont-break-out" [innerHTML]="value | dsMarkdown | async">
|
||||
<span class="dont-break-out" [dsMarkdown]="value">
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
|
@@ -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.
|
||||
*/
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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;
|
||||
|
||||
|
8
src/app/shared/utils/markdown.directive.spec.ts
Normal file
8
src/app/shared/utils/markdown.directive.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { MarkdownDirective } from './markdown.directive';
|
||||
|
||||
describe('MarkdownDirective', () => {
|
||||
it('should create an instance', () => {
|
||||
const directive = new MarkdownDirective();
|
||||
expect(directive).toBeTruthy();
|
||||
});
|
||||
});
|
85
src/app/shared/utils/markdown.directive.ts
Normal file
85
src/app/shared/utils/markdown.directive.ts
Normal 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);
|
||||
}
|
||||
}
|
@@ -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 + '.*'),
|
||||
);
|
||||
}
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
Reference in New Issue
Block a user