Generating HTML from Markdown at runtime is a great way to load content, but the generated HTML sits outside of the the Angular ecosystem. Here I detail a method for replacing the generated HTML elements in the DOM with dynamically created Angular components

Aim

In this tutorial we will add regular <a> elements to the Markdown with a custom data attribute, and render these as HTML at runtime. We will then query the DOM for the generated HTML, and replace any native <a> elements that contain our custom data attribute with Angular components, the templates of which will contain <a> elements with routerLink directives

Angular Component Template

template: `
      <a [routerLink]="model?.routerLinkPath" href="{{model?.href}}" title="{{model?.title}}">{{model?.text}}</a>
`

Why?

Adding a routerLink attribute in the Markdown would have no effect as the generated HTML is just formatted text which is outside the Angular framework. The routerLink attributes would have no functionality. Clicking on such a link would refresh the page and reload the application. We need to generate the anchor elements as Angular components with templates, so the routerLink directives are instantiated and navigation is handled by the Router

Markdown Content

Lets first create some Markdown content, and add the links with a custom data attribute so we can identify the elements in the generated HTML. We will be using marked.js to do the Markdown to HTML conversion

markdown.md

Dynamic and Regular Links to Data Page 
<a data-routerlink="/data" href="/data" title="Link to Data Page">Absolute routerLink Functional</a>
<a data-routerlink="../data" href="/data" title="Link to Data Page">Relative routerLink Functional</a>
[Native Link - Will Refresh](/data "This will Refersh")

The markdown contains some text, some HTML syntax links with custom data attributes, and a link written in markdown syntax. We have added our routerLink text to this data-routerlink attribute, and will process it at a later stage after the markdown has been rendered as HTML

Most markdown converters allow for raw HTML blocks to be used along with specific markdown syntax like [I'm an inline-style link](https://www.google.com)

marked.js

To install marked.js run the following commands

npm install marked --save
npm install --save-dev @types/marked

// Add "allowJs": true to tsconfig.json
// Import using: import * as marked from 'marked';

Create a markdown pipe to use as either a service or a template pipe - the complete code can be seen in the StackBlitz demo section āš”ļø

transform(markdown: string, options?: marked.MarkedOptions): any {    
    let html = '';
    if (markdown) {
        html = marked(markdown, options); // options can be undefined, merged onto marked's defaults
    }
    return html;
}

Trusted HTML

The HTML generated by marked.js will be bound to an element's innerHTML property in the page's template:

<div #renderedDivContainer class="render-div" [innerHTML]="markdownHTML | trustedHTML"></div>

We need to use a pipe to let Angular know that the HTML being bound to the innerHTML property is trusted:

@Pipe({
    name: 'trustedHTML'
})
export class TrustedHtmlPipe implements PipeTransform {

    constructor(private sanitizer: DomSanitizer) {
    }

    transform(value: any, args?: any): any {
        return this.sanitizer.bypassSecurityTrustHtml(value);
    }

}

The generated HTML applied to the <div> creates the following elements:

<p>Dynamic and Regular Links to Data Page </p>
<p><a data-routerlink="/data" href="/data" title="Link to Data Page">Absolute routerLink Functional</a></p>
<p><a data-routerlink="../data" href="/data" title="Link to Data Page">Relative routerLink Functional</a></p>
<p><a href="/data" title="This will Refersh">Native Link - Will Refresh</a></p>

At this point, although the links will work, they would refersh the page and the application would reload. This is why we need to replace the native <a> elements with Angular components

Angular Component

Create the Angular Component

Firstly we need to create a component that will replace the native <a> elements in the DOM

anchor.component.ts

export interface IDynamicAnchor {
    routerLinkPath: string[];
    href: string;
    text: string;
    title?: string;
}

@Component({
    selector: 'bc-anchor',
    template: `
      <a [routerLink]="model?.routerLinkPath" href="{{model?.href}}" title="{{model?.title}}">{{model?.text}}</a>
    `,
    styles: [],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class AnchorComponent implements OnInit, OnDestroy {

    @Input() model: IDynamicAnchor;

    constructor() { }

    ngOnInit() {
    }

    ngOnDestroy(): void {
        console.log('Anchor Destroy');
    }

}

Entry Components

As the AnchorComponent will not be used directly in a template i.e. declared to be a required component ahead of time, Angular will ignore it during the build/AOT compilation and tree-shaking phase, and no associated ComponentFactory will be available to generate the component dynamically. For dynamic components wee add the AnchorComponent to the entryComponents array of the containing NgModule

@NgModule({
  imports: [
    CommonModule,
    PagesRoutingModule
  ],
...
entryComponents: [
    AnchorComponent
  ]
})
export class PagesModule { }

Dynamic Components

For this example, we will be query the DOM for links containing our custom data attribute [data-routerlink]. The attribute values will be used as inputs for the AnchorComponent. We will use a service to dynamically create the Angular components

Component Factory Service

I've created a service with a utility function to generate components based on the Angular component type

constructor(
    private componentFactoryResolver: ComponentFactoryResolver,
    private appRef: ApplicationRef,
    private injector: Injector
) {}

/**
 * Create a component, and attach to the viewContainerRef
 * If no index (index of embedded views) is supplied, adds the DOM element at the next available embedded view index (0)
 * e.g. the immediate sibling of the ViewContainerRef's rendered element
 *
 * @param component
 * @param viewContainerRef
 * @param index Index of embeddedView. If empty
 */
createComponent<T>(component: Type<T>, viewContainerRef: ViewContainerRef, index?: number): ComponentRef<T> {
    const componentFactory = this.componentFactoryResolver.resolveComponentFactory(component);
    const componentRef = viewContainerRef.createComponent(componentFactory, index);
    return componentRef;
}

Retrieve, Create, Replace

In the host component of the markdown/HTML we will call a function to retrieve the HTML elements that match our custom data attribute, generate AnchorComponent instances, and replace the current HTML elements with our dynamically generated components native elements. The inputs of the generated components will be initialized, and detectChanges() will be called to check and update the component templates

Note that before the addDynamicAnchors() function is called, there is a check to see that the application is running in the browser. If the page is being rendered server side, then the HTML would suffice until pre-rendered HTML is replaced with the template at runtime

To build the components, we need a reference to the ViewContainerRef instance of the targeted HTML element, which is retrieved using the @ViewChild() decorator. The process is detailed in the functions below...

home-page.component.ts

// ViewContainerRef: Represents a container where one or more Views can be attached
@ViewChild('renderedDivContainer', { read: ViewContainerRef })
private articleViewContainerRef: ViewContainerRef;

addDynamicComponents() {
  if (isPlatformBrowser(this.platformId)) {
    this.addDynamicAnchors();
    this.updateOutput();
  }
}

/**
   * Links can be in the form:
   * <a data-routerlink="/about" href="/about" >Absolute Link</a>
   *   data-routerlink="/blog/post/route-resolvers-rxjs-behaviorsubject-with-combinelatest"
   *   data-routerlink="../route-resolvers-rxjs-behaviorsubject-with-combinelatest"
   *   data-routerlink="/blog,post,route-resolvers-rxjs-behaviorsubject-with-combinelatest"
   */
addDynamicAnchors() {
  const nodeList = document.querySelectorAll('a[data-routerlink]');
  // TypeScript NodeList/NodeListOf doesn't have forEach (also browser support)
  const elementArray: HTMLAnchorElement[] = Array.prototype.slice.call(nodeList, 0);

  elementArray.forEach(anchorElement => {
    const angularCompRef = this.componentCreator.createComponent(AnchorComponent, this.articleViewContainerRef);
    const model: IDynamicAnchor = {
      routerLinkPath: anchorElement.getAttribute('data-routerlink').split(','),
      href: anchorElement.getAttribute('href'),
      title: anchorElement.getAttribute('title') || '',
      text: anchorElement.text
    };
    angularCompRef.instance.model = model;
    const newAnchorElement = (angularCompRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
    anchorElement.parentElement.replaceChild(newAnchorElement, anchorElement);
    // detectChanges could be invoked once on the parent component, but using here for demo purposes
    angularCompRef.changeDetectorRef.detectChanges();
  });
}

By default, the created components are added as immediate siblings of the ViewContainerRef. I commented out the replace lines of code so we can see what the default behaviour would look like. Here the ViewContainerRef is div.render-div. The two <bc-anchor> components we added are immediatlely below the closing </div> tag. You can also see the <a data-routerlink=".."> elements we want to replace

default component placement

In the forEach loop, the existing anchor elements are replaced with the Angular components hostView elements. If you inspect the image below, you can see that the <bc-anchor> elements are no longer siblings of the div.render-div, but have now been inserted where the <a data-routerlink=".."> elements were previously

elementArray.forEach(anchorElement => {
....
  const newAnchorElement = (angularCompRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
  anchorElement.parentElement.replaceChild(newAnchorElement, anchorElement);

image

Destroy

As a consequence of being added to the application via the ViewContainerRef createComponent() function, the generated component's ngOnDestroy() lifecycle hook will be invoked when the containing view is being destroyed. There is one caveat with this: if the created components are part of a page/route that is being reused, they will remain in memory and possibly remain visible in the DOM (depending on where the elements were placed). Further dynamic components will be appended to the ViewContainerRef along with the existing ones. To get around this, we clear the ViewContainerRef each time the page is reused

@ViewChild('renderedDivContainer', { read: ViewContainerRef })
private articleViewContainerRef: ViewContainerRef;
...
// Place inside of an ActivatedRoute events subscription handler 
// if route is being reused
this.articleViewContainerRef.clear();

StackBlitz Demo

It would be remiss of me not to inlcude one of those dynamically generated links I've been talking about! So here you go, a link to the main blog page - without refreshing the browser šŸ˜‰

Brian Bishop

Dublin, Ireland
v7.2.15+sha.e7897e1