FLASH SALE!! LEFT

Arka Blog

Back to Main Blog
dev blog

Dynamically Generate Angular 2+ Components From External Html

This is a bit of a meta post about how we built this blog. This blog's content is entirely written in markdown. We have a markdown editor in an administrator panel that allows anyone to write an article easily and see what it will look like live.

A blog written in markdown is great for a bunch of reasons, like ease of writing. Storing all of the data in a common format. Converts nicely to html. Also since HTML is valid markdown we can include custom classes. That means we can define common styles like red and use them later on and repeatedly.

We also wanted to use custom angular components as widgets. Since we are already using some html in our markdown and some css classes we thought we could just do this automagically, but it turns out that it's not that easy.

Dynamic HTML

So the problem we run into goes like this.

We have some content, content, that contains all of our html, including a custom angular component. We then try the following in our component.

<div [innerHTML]="content"></div>

Unfortunately that doesn't work. While it adds all the html to the page including the custom angular component html, it does not detect and generate that component.

In order to do this we need to do a little more work. I started off by digging through ng-dynamic. Unfortunately it was broken when I got there. If it works for you, I hope that you enjoy using it, otherwise I'll help you extract the necessary parts.

At the end of this the goal is to have the following.

<dynamic-html [content]="content">

All of our rendered html and components should then be rendered inside the html tags.

Setting up the module

module.ts

import { NgModule, ModuleWithProviders, ANALYZE_FOR_ENTRY_COMPONENTS } from '@angular/core';
import { DynamicHTMLComponent } from './dynamic-html.component';
import { DynamicHTMLOptions } from './options';
import { DynamicHTMLRenderer } from './renderer';

@NgModule({
    declarations: [DynamicHTMLComponent],
    exports: [DynamicHTMLComponent],
})
export class DynamicHTMLModule {
    static forRoot(options: DynamicHTMLOptions): ModuleWithProviders {
        return {
            ngModule: DynamicHTMLModule,
            providers: [
                DynamicHTMLRenderer,
                { provide: DynamicHTMLOptions, useValue: options },
                { provide: ANALYZE_FOR_ENTRY_COMPONENTS, useValue: options.components, multi: true },
            ],
        };
    }
}

The module itself is pretty simple. It's just a wrapper around all the pieces that will be used internally. It also exports and declares the component for use.

The options

The options are a dead simple interface to allow us to specify which components can be used in the module to detect and render components.

options.ts

import { Type } from '@angular/core';

export interface ComponentWithSelector {
    selector: string;
    component: Type<any>;
}
export class DynamicHTMLOptions {
    components: Array<ComponentWithSelector>;
}

The component

The component get's a little more confusing, but the main thing to focus on is how we handle ngOnChanges.

dynamic-html.component.ts

import {
  Component,
  ElementRef,
  Input,
  SimpleChanges,
  OnChanges,
  OnDestroy,
  DoCheck,
} from '@angular/core';

import { DynamicHTMLRenderer, DynamicHTMLRef } from './renderer';

@Component({
  selector: 'dynamic-html',
  template: '',
})
export class DynamicHTMLComponent implements DoCheck, OnChanges, OnDestroy {
  @Input() content: string;

  private ref: DynamicHTMLRef = null;

  constructor(
    private renderer: DynamicHTMLRenderer,
    private elementRef: ElementRef,
  ) { }

  ngOnChanges(_: SimpleChanges) {
    if (this.ref) {
      this.ref.destroy();
      this.ref = null;
    }
    if (this.content && this.elementRef) {
      this.ref = this.renderer.renderInnerHTML(this.elementRef, this.content);
    }
  }

  ngDoCheck() {
    if (this.ref) {
      this.ref.check();
    }
  }

  ngOnDestroy() {
    if (this.ref) {
      this.ref.destroy();
      this.ref = null;
    }
  }
}

Other stuff before the renderer

We use the on mount interface so that we can send a callback before ngOnInit runs.

interfaces.ts

export abstract class OnMount {
    abstract dynamicOnMount(attrs?: Map<string, string>, content?: string, element?: Element): void;
}

A simple index to make importing everything easier.

index.ts

export * from './dynamic-html.component';
export * from './module';
export * from './interfaces';
export * from './options';
export * from './renderer';

The renderer

This is the final piece. It has all the interesting bits. Mainly it's just taking the content everytime it changes and looking for any components it recognizes. It uses the ComponentFactoryResolver to actually resolve the components. It sets up a map in the constructor that is used in the render function. Recall the the render function is called on any change so it needs to be quick.

It makes use of the OnMount class to call the dynamic on mount if it was setup in the options. There is also a guard here for angular-universal which is why you see the isPlatformBrowser() call.

renderer.ts

import { Injectable, Injector, ElementRef, ComponentFactoryResolver, ComponentFactory, ComponentRef } from '@angular/core';
import { DynamicHTMLOptions } from './options';
import { OnMount } from './interfaces';

export interface DynamicHTMLRef {
  check: () => void;
  destroy: () => void;
}

function isBrowserPlatform() {
  return window != null && window.document != null;
}

@Injectable()
export class DynamicHTMLRenderer {

  private componentFactories = new Map<string, ComponentFactory<any>>();

  private componentRefs = new Map<any, Array<ComponentRef<any>>>();

  constructor(private options: DynamicHTMLOptions, private cfr: ComponentFactoryResolver, private injector: Injector) {
    this.options.components.forEach(({ selector, component }) => {
      let cf: ComponentFactory<any>;
      cf = this.cfr.resolveComponentFactory(component);
      this.componentFactories.set(selector, cf);
    });
  }

  renderInnerHTML(elementRef: ElementRef, html: string): DynamicHTMLRef {
    if (!isBrowserPlatform()) {
      return {
        check: () => {},
        destroy: () => {},
      };
    }
    elementRef.nativeElement.innerHTML = html;

    const componentRefs: Array<ComponentRef<any>> = [];
    this.options.components.forEach(({ selector }) => {
      const elements = (elementRef.nativeElement as Element).querySelectorAll(selector);
      Array.prototype.forEach.call(elements, (el: Element) => {
        const content = el.innerHTML;
        const cmpRef = this.componentFactories.get(selector).create(this.injector, [], el);

        el.removeAttribute('ng-version');

        if (cmpRef.instance.dynamicOnMount) {
          const attrsMap = new Map<string, string>();
          if (el.hasAttributes()) {
            Array.prototype.forEach.call(el.attributes, (attr: Attr) => {
              attrsMap.set(attr.name, attr.value);
            });
          }
          (cmpRef.instance as OnMount).dynamicOnMount(attrsMap, content, el);
        }

        componentRefs.push(cmpRef);
      });
    });
    this.componentRefs.set(elementRef, componentRefs);
    return {
      check: () => componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges()),
      destroy: () => {
        componentRefs.forEach(ref => ref.destroy());
        this.componentRefs.delete(elementRef);
      },
    };
  }
}

Add to the module

You will need to include the newly created module in your app.module or whichever module you are intending to use it for. Add the following to the imports.

DynamicHTMLModule.forRoot({
    components: [
      {component: HelloComponent, selector: 'hello'}
    ]
 })

That's the complete setup. Now you are free to go build it for yourself.

Demo time

I started off by saying that this was implemented on the blog and now I want to show it to you.

We wanted to allow customers to signup for our email list and use it inside our blog posts. The markdown looks something like the following. You will be added to the list if you sign up.

### Signup for our email list
_BE SURE TO KNOW ABOUT THE LATEST FLASH SALES!_
<app-insider-signup></app-insider-signup>

Result :


Signup for our email list

BE SURE TO KNOW ABOUT THE LATEST FLASH SALES!


Try it yourself

I created a minimal example that you can play on stack blitz.

Limitations

We can't use @Input() or @Output on the components directly from the dynamic-html component.

In order to share data, you will need a shared service or a super component that encapsulates the rest of the logic.


Hope you enjoyed reading

Well that is everything I have to share today. Hopefully it is helpful or interesting.

If you are looking for a job, or perhaps just a change of scenery, check out our job postings on Angel List.