@covalent/markdown 0.0.0-COVALENT

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,471 @@
1
+ import {
2
+ Component,
3
+ AfterViewInit,
4
+ ElementRef,
5
+ Input,
6
+ Output,
7
+ EventEmitter,
8
+ Renderer2,
9
+ SecurityContext,
10
+ OnChanges,
11
+ SimpleChanges,
12
+ HostBinding,
13
+ NgZone,
14
+ OnDestroy,
15
+ } from '@angular/core';
16
+ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
17
+ import {
18
+ scrollToAnchor,
19
+ genHeadingId,
20
+ isAnchorLink,
21
+ removeTrailingHash,
22
+ rawGithubHref,
23
+ isGithubHref,
24
+ isRawGithubHref,
25
+ renderVideoElements,
26
+ isFileLink,
27
+ } from './markdown-utils/markdown-utils';
28
+
29
+ import * as showdown from 'showdown';
30
+
31
+ function isAbsoluteUrl(currentHref: string): boolean {
32
+ // Regular Expression to check url
33
+ const RgExp = new RegExp('^(?:[a-z]+:)?//', 'i');
34
+ return RgExp.test(currentHref);
35
+ }
36
+
37
+ // TODO: assumes it is a github url
38
+ // allow override somehow
39
+ function generateHref(currentHref: string, relativeHref: string): string {
40
+ if (currentHref && relativeHref) {
41
+ if (isAbsoluteUrl(currentHref)) {
42
+ const currentUrl: URL = new URL(currentHref);
43
+ const path: string = currentUrl.pathname
44
+ .split('/')
45
+ .slice(1, -1)
46
+ .join('/');
47
+ const correctUrl: URL = new URL(currentHref);
48
+
49
+ if (relativeHref.startsWith('/')) {
50
+ // url is relative to top level
51
+ const orgAndRepo: string = path.split('/').slice(0, 3).join('/');
52
+ correctUrl.pathname = `${orgAndRepo}${relativeHref}`;
53
+ } else {
54
+ correctUrl.pathname = `${path}/${relativeHref}`;
55
+ }
56
+ return correctUrl.href;
57
+ } else {
58
+ const path: string = currentHref.split('/').slice(0, -1).join('/');
59
+
60
+ if (relativeHref.startsWith('/')) {
61
+ return `${path}${relativeHref}`;
62
+ } else {
63
+ return `${path}/${relativeHref}`;
64
+ }
65
+ }
66
+ }
67
+ return '';
68
+ }
69
+
70
+ function normalizeHtmlHrefs(
71
+ html: string,
72
+ currentHref: string,
73
+ fileLinkExtensions?: string[]
74
+ ): string {
75
+ if (currentHref) {
76
+ const document: Document = new DOMParser().parseFromString(
77
+ html,
78
+ 'text/html'
79
+ );
80
+ document
81
+ .querySelectorAll<HTMLAnchorElement>('a[href]')
82
+ .forEach((link: HTMLAnchorElement) => {
83
+ const url: URL = new URL(link.href);
84
+ const originalHash: string = url.hash;
85
+ const isFileAnchorLink = isFileLink(link, fileLinkExtensions);
86
+ if (isAnchorLink(link)) {
87
+ if (originalHash) {
88
+ url.hash = genHeadingId(originalHash);
89
+ link.href = url.hash;
90
+ }
91
+ } else if (url.host === window.location.host) {
92
+ // hosts match, meaning URL MIGHT have been malformed by showdown
93
+ // url is a relative url or just a link to a part of the application
94
+ if (url.pathname.endsWith('.md') || isFileAnchorLink) {
95
+ // only check .md urls or urls ending with the fileLinkExtensions
96
+
97
+ const hrefWithoutHash: string = removeTrailingHash(
98
+ link.getAttribute('href')
99
+ );
100
+
101
+ url.href = generateHref(currentHref, hrefWithoutHash);
102
+
103
+ if (originalHash) {
104
+ url.hash = genHeadingId(originalHash);
105
+ }
106
+ link.href = url.href;
107
+ }
108
+ link.target = isFileAnchorLink ? '_self' : '_blank';
109
+ } else {
110
+ // url is absolute
111
+ if (url.pathname.endsWith('.md')) {
112
+ if (originalHash) {
113
+ url.hash = genHeadingId(originalHash);
114
+ }
115
+ link.href = url.href;
116
+ }
117
+ link.target = '_blank';
118
+ }
119
+ });
120
+
121
+ return new XMLSerializer().serializeToString(document);
122
+ }
123
+ return html;
124
+ }
125
+
126
+ function normalizeImageSrcs(html: string, currentHref: string): string {
127
+ if (currentHref) {
128
+ const document: Document = new DOMParser().parseFromString(
129
+ html,
130
+ 'text/html'
131
+ );
132
+ document
133
+ .querySelectorAll<HTMLImageElement>('img[src]')
134
+ .forEach((image: HTMLImageElement) => {
135
+ const src = image.getAttribute('src') ?? '';
136
+ try {
137
+ /* tslint:disable-next-line:no-unused-expression */
138
+ new URL(src);
139
+ if (isGithubHref(src)) {
140
+ image.src = rawGithubHref(src);
141
+ }
142
+ } catch {
143
+ image.src = generateHref(
144
+ isGithubHref(currentHref)
145
+ ? rawGithubHref(currentHref)
146
+ : currentHref,
147
+ src
148
+ );
149
+ }
150
+ // gh svgs need to have ?sanitize=true
151
+ if (isRawGithubHref(image.src)) {
152
+ const url: URL = new URL(image.src);
153
+ if (url.pathname.endsWith('.svg')) {
154
+ url.searchParams.set('sanitize', 'true');
155
+ image.src = url.href;
156
+ }
157
+ }
158
+ });
159
+
160
+ return new XMLSerializer().serializeToString(document);
161
+ }
162
+ return html;
163
+ }
164
+
165
+ function addIdsToHeadings(html: string): string {
166
+ if (html) {
167
+ const document: Document = new DOMParser().parseFromString(
168
+ html,
169
+ 'text/html'
170
+ );
171
+ document
172
+ .querySelectorAll('h1, h2, h3, h4, h5, h6')
173
+ .forEach((heading: Element) => {
174
+ const strippedInnerHTML = stripAllHtmlTags(heading.innerHTML);
175
+ const id: string = genHeadingId(strippedInnerHTML);
176
+ heading.setAttribute('id', id);
177
+ });
178
+ return new XMLSerializer().serializeToString(document);
179
+ }
180
+ return html;
181
+ }
182
+
183
+ function changeStyleAlignmentToClass(html: string) {
184
+ if (html) {
185
+ const document: Document = new DOMParser().parseFromString(
186
+ html,
187
+ 'text/html'
188
+ );
189
+ ['right', 'center', 'left'].forEach((alignment: string) => {
190
+ document
191
+ .querySelectorAll(`[style*='text-align:${alignment}']`)
192
+ .forEach((style: Element) => {
193
+ style.setAttribute('class', `markdown-align-${alignment}`);
194
+ });
195
+ });
196
+
197
+ return new XMLSerializer().serializeToString(document);
198
+ }
199
+
200
+ return html;
201
+ }
202
+
203
+ // Strips all the html tags in the html sand returns the text
204
+ function stripAllHtmlTags(input: string): string {
205
+ let sanitized = input;
206
+ let previous;
207
+
208
+ do {
209
+ previous = sanitized;
210
+ sanitized = sanitized.replace(/<[^>]+>/g, '');
211
+ } while (previous !== sanitized);
212
+
213
+ return sanitized;
214
+ }
215
+
216
+ @Component({
217
+ selector: 'td-markdown',
218
+ styleUrls: ['./markdown.component.scss'],
219
+ templateUrl: './markdown.component.html',
220
+ })
221
+ export class TdMarkdownComponent
222
+ implements OnChanges, AfterViewInit, OnDestroy
223
+ {
224
+ private _content!: string;
225
+ private _simpleLineBreaks = false;
226
+ private _hostedUrl!: string;
227
+ private _anchor!: string;
228
+ private _viewInit = false;
229
+ private _fileLinkExtensions!: string[] | undefined;
230
+ private _anchorListener?: VoidFunction;
231
+ /**
232
+ * .td-markdown class added to host so ::ng-deep gets scoped.
233
+ */
234
+ @HostBinding('class') class = 'td-markdown';
235
+
236
+ /**
237
+ * content?: string
238
+ *
239
+ * Markdown format content to be parsed as html markup.
240
+ *
241
+ * e.g. README.md content.
242
+ */
243
+ @Input()
244
+ set content(content: string) {
245
+ this._content = content;
246
+ }
247
+
248
+ /**
249
+ * simpleLineBreaks?: string
250
+ *
251
+ * Sets whether newline characters inside paragraphs and spans are parsed as <br/>.
252
+ * Defaults to false.
253
+ */
254
+ @Input()
255
+ set simpleLineBreaks(simpleLineBreaks: boolean) {
256
+ this._simpleLineBreaks = simpleLineBreaks;
257
+ }
258
+
259
+ /**
260
+ * hostedUrl?: string
261
+ *
262
+ * If markdown contains relative paths, this is required to generate correct urls.
263
+ *
264
+ */
265
+ @Input()
266
+ set hostedUrl(hostedUrl: string) {
267
+ this._hostedUrl = hostedUrl;
268
+ }
269
+
270
+ /**
271
+ * anchor?: string
272
+ *
273
+ * Anchor to jump to.
274
+ *
275
+ */
276
+ @Input()
277
+ set anchor(anchor: string) {
278
+ this._anchor = anchor;
279
+ }
280
+
281
+ /**
282
+ * The file extensions to monitor for in anchor tags. If an anchor's `href` ends
283
+ * with these extensions, an event will be emitted instead of performing the default click action.
284
+ * Example values: [".ipynb", ".zip", ".docx"]
285
+ */
286
+ @Input()
287
+ set fileLinkExtensions(extensions: string[] | undefined) {
288
+ this._fileLinkExtensions = extensions;
289
+ }
290
+
291
+ /**
292
+ * Event emitted when an anchor tag with an `href` matching the specified
293
+ * fileLinkExtensions is clicked. The emitted value is the URL of the clicked anchor.
294
+ */
295
+ @Output() fileLinkClicked = new EventEmitter<URL>();
296
+
297
+ /**
298
+ * contentReady?: function
299
+ * Event emitted after the markdown content rendering is finished.
300
+ */
301
+ @Output() contentReady: EventEmitter<void> = new EventEmitter<void>();
302
+
303
+ constructor(
304
+ private _renderer: Renderer2,
305
+ private _elementRef: ElementRef,
306
+ private _domSanitizer: DomSanitizer,
307
+ private _ngZone: NgZone
308
+ ) {}
309
+
310
+ ngOnChanges(changes: SimpleChanges): void {
311
+ // only anchor changed
312
+ if (
313
+ changes['anchor'] &&
314
+ !changes['content'] &&
315
+ !changes['simpleLineBreaks'] &&
316
+ !changes['hostedUrl']
317
+ ) {
318
+ scrollToAnchor(this._elementRef.nativeElement, this._anchor, true);
319
+ } else {
320
+ this.refresh();
321
+ }
322
+ }
323
+
324
+ ngAfterViewInit(): void {
325
+ if (!this._content) {
326
+ this._loadContent(
327
+ (<HTMLElement>this._elementRef.nativeElement).textContent
328
+ );
329
+ }
330
+ this._viewInit = true;
331
+
332
+ // Caretaker note: the `scrollToAnchor` calls `element.scrollIntoView`, a native synchronous DOM
333
+ // API and it doesn't require Angular running `ApplicationRef.tick()` each time the markdown component is clicked.
334
+ // Host listener (added through `@HostListener`) cause Angular to add an event listener within the Angular zone.
335
+ // It also calls `markViewDirty()` before calling the actual listener (the decorated class method).
336
+ this._ngZone.runOutsideAngular(() => {
337
+ this._anchorListener = this._renderer.listen(
338
+ this._elementRef.nativeElement,
339
+ 'click',
340
+ (event: MouseEvent) => {
341
+ const element: HTMLElement = <HTMLElement>event.srcElement;
342
+ if (
343
+ element.matches('a[href]') &&
344
+ isAnchorLink(<HTMLAnchorElement>element)
345
+ ) {
346
+ this.handleAnchorClicks(event);
347
+ } else if (
348
+ element.matches('a[href]') &&
349
+ isFileLink(<HTMLAnchorElement>element, this._fileLinkExtensions)
350
+ ) {
351
+ this.handleFileAnchorClicks(event);
352
+ }
353
+ }
354
+ );
355
+ });
356
+ }
357
+
358
+ ngOnDestroy(): void {
359
+ this._anchorListener?.();
360
+ }
361
+
362
+ refresh(): void {
363
+ if (this._content) {
364
+ this._loadContent(this._content);
365
+ } else if (this._viewInit) {
366
+ this._loadContent(
367
+ (<HTMLElement>this._elementRef.nativeElement).textContent
368
+ );
369
+ }
370
+ }
371
+
372
+ /**
373
+ * General method to parse a string markdown into HTML Elements and load them into the container
374
+ */
375
+ private _loadContent(markdown: string | null): void {
376
+ if (markdown && markdown.trim().length > 0) {
377
+ // Clean container
378
+ this._renderer.setProperty(
379
+ this._elementRef.nativeElement,
380
+ 'innerHTML',
381
+ ''
382
+ );
383
+ // Parse html string into actual HTML elements.
384
+ this._elementFromString(this._render(markdown));
385
+ }
386
+ // TODO: timeout required since resizing of html elements occurs which causes a change in the scroll position
387
+ this._ngZone.runOutsideAngular(() =>
388
+ setTimeout(
389
+ () =>
390
+ scrollToAnchor(this._elementRef.nativeElement, this._anchor, true),
391
+ 250
392
+ )
393
+ );
394
+ this.contentReady.emit();
395
+ }
396
+
397
+ private handleAnchorClicks(event: Event): void {
398
+ event.preventDefault();
399
+ const url: URL = new URL((<HTMLAnchorElement>event.target).href);
400
+ const hash: string = decodeURI(url.hash);
401
+ scrollToAnchor(this._elementRef.nativeElement, hash, true);
402
+ }
403
+
404
+ private handleFileAnchorClicks(event: Event): void {
405
+ event.preventDefault();
406
+ const url: URL = new URL((<HTMLAnchorElement>event.target).href);
407
+ this.fileLinkClicked.emit(url);
408
+ }
409
+
410
+ private _elementFromString(markupStr: string): HTMLDivElement {
411
+ // Renderer2 doesnt have a parsing method, so we have to sanitize and use [innerHTML]
412
+ // to parse the string into DOM element for now.
413
+ const div: HTMLDivElement = this._renderer.createElement('div');
414
+ this._renderer.appendChild(this._elementRef.nativeElement, div);
415
+
416
+ const html: string =
417
+ this._domSanitizer.sanitize(
418
+ SecurityContext.HTML,
419
+ changeStyleAlignmentToClass(markupStr)
420
+ ) ?? '';
421
+ const htmlWithAbsoluteHrefs: string = normalizeHtmlHrefs(
422
+ html,
423
+ this._hostedUrl,
424
+ this._fileLinkExtensions
425
+ );
426
+ const htmlWithAbsoluteImgSrcs: string = normalizeImageSrcs(
427
+ htmlWithAbsoluteHrefs,
428
+ this._hostedUrl
429
+ );
430
+ const htmlWithHeadingIds: string = addIdsToHeadings(
431
+ htmlWithAbsoluteImgSrcs
432
+ );
433
+ const htmlWithVideos: SafeHtml = renderVideoElements(htmlWithHeadingIds);
434
+ this._renderer.setProperty(div, 'innerHTML', htmlWithVideos);
435
+ return div;
436
+ }
437
+
438
+ private _render(markdown: string): string {
439
+ // Trim leading and trailing newlines
440
+ markdown = markdown
441
+ .replace(/^(\s|\t)*\n+/g, '')
442
+ .replace(/(\s|\t)*\n+(\s|\t)*$/g, '');
443
+ // Split markdown by line characters
444
+ let lines: string[] = markdown.split('\n');
445
+
446
+ // check how much indentation is used by the first actual markdown line
447
+ const firstLineWhitespaceMatch = lines[0].match(/^(\s|\t)*/);
448
+ const firstLineWhitespace: string = firstLineWhitespaceMatch
449
+ ? firstLineWhitespaceMatch[0]
450
+ : '';
451
+
452
+ // Remove all indentation spaces so markdown can be parsed correctly
453
+ const startingWhitespaceRegex = new RegExp('^' + firstLineWhitespace);
454
+ lines = lines.map(function (line: string): string {
455
+ return line.replace(startingWhitespaceRegex, '');
456
+ });
457
+
458
+ // Join lines again with line characters
459
+ const markdownToParse: string = lines.join('\n');
460
+
461
+ // Convert markdown into html
462
+ const converter: showdown.Converter = new showdown.Converter();
463
+ converter.setOption('ghCodeBlocks', true);
464
+ converter.setOption('tasklists', true);
465
+ converter.setOption('tables', true);
466
+ converter.setOption('literalMidWordUnderscores', true);
467
+ converter.setOption('simpleLineBreaks', this._simpleLineBreaks);
468
+ converter.setOption('emoji', true);
469
+ return converter.makeHtml(markdownToParse);
470
+ }
471
+ }
@@ -0,0 +1,23 @@
1
+ import { NgModule } from '@angular/core';
2
+
3
+ import {
4
+ provideHttpClient,
5
+ withInterceptorsFromDi,
6
+ } from '@angular/common/http';
7
+
8
+ import { TdMarkdownComponent } from './markdown.component';
9
+ import { TdMarkdownLoaderService } from './markdown-loader/markdown-loader.service';
10
+
11
+ /**
12
+ * @deprecated This module is deprecated and will be removed in future versions.
13
+ * Please migrate to using standalone components as soon as possible.
14
+ */
15
+ @NgModule({
16
+ exports: [TdMarkdownComponent],
17
+ imports: [TdMarkdownComponent],
18
+ providers: [
19
+ TdMarkdownLoaderService,
20
+ provideHttpClient(withInterceptorsFromDi()),
21
+ ],
22
+ })
23
+ export class CovalentMarkdownModule {}
@@ -0,0 +1,4 @@
1
+ export * from './lib/markdown.module';
2
+ export * from './lib/markdown.component';
3
+ export * from './lib/markdown-utils/markdown-utils';
4
+ export * from './lib/markdown-loader/markdown-loader.service';
@@ -0,0 +1 @@
1
+ import 'jest-preset-angular/setup-jest';
@@ -0,0 +1,13 @@
1
+ const { createGlobPatternsForDependencies } = require('@nx/angular/tailwind');
2
+ const { join } = require('path');
3
+
4
+ module.exports = {
5
+ content: [
6
+ join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'),
7
+ ...createGlobPatternsForDependencies(__dirname),
8
+ ],
9
+ theme: {
10
+ extend: {},
11
+ },
12
+ plugins: [],
13
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "files": [],
4
+ "include": [],
5
+ "references": [
6
+ {
7
+ "path": "./tsconfig.lib.json"
8
+ },
9
+ {
10
+ "path": "./tsconfig.lib.prod.json"
11
+ },
12
+ {
13
+ "path": "./tsconfig.spec.json"
14
+ }
15
+ ],
16
+ "compilerOptions": {
17
+ "forceConsistentCasingInFileNames": true,
18
+ "strict": true,
19
+ "noImplicitOverride": true,
20
+ "noPropertyAccessFromIndexSignature": true,
21
+ "noImplicitReturns": true,
22
+ "noFallthroughCasesInSwitch": true
23
+ },
24
+ "angularCompilerOptions": {
25
+ "strictInjectionParameters": true,
26
+ "strictInputAccessModifiers": true,
27
+ "strictTemplates": true
28
+ }
29
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "inlineSources": true,
8
+ "types": []
9
+ },
10
+ "exclude": ["src/test-setup.ts", "**/*.spec.ts", "**/*.test.ts"],
11
+ "include": ["**/*.ts"]
12
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./tsconfig.lib.json",
3
+ "compilerOptions": {
4
+ "declarationMap": false
5
+ },
6
+ "angularCompilerOptions": {
7
+ "compilationMode": "full"
8
+ }
9
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "types": ["jest", "node"]
7
+ },
8
+ "files": ["src/test-setup.ts"],
9
+ "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
10
+ }