Angular 4: Automatic Table of Contents – Part 2: Adding Links


In this short blog post, we will learn how to enrich the table of contents (we had added in the previous blog post) with links to the corresponding headline. The end result will look similar to the following picture and a click on one of the links will cause the browser to jump to the corresponding headline:

The method will be applied to dynamic content retrieved from the WordPress REST API. This poses some extra challenges caused by Angular’s security policies: per default, headline IDs are stripped, preventing us to successfully reference those IDs in the links. We will show below, how to make use of the SafeHtml module to successfully face those challenges.

Step 0: Download the Code and start and connect to the Server

Even though this is no prerequisite, I recommend to perform the steps within a Docker container. This way I can provide you with a Docker image that has all necessary software installed.

Fore more details on how to install a Docker host on Windows using Vagrant and Virtualbox, please see Step 1 of my Jenkins tutorial.

On a Docker host we run

(dockerhost)$ mkdir toc; cd toc
(dockerhost)$ docker run -it -p 8001:8000 -v $(pwd):/localdir oveits/angular_hello_world:centos bash
(container)# git clone https://github.com/oveits/ng-universal-demo
(container)# cd ng-universal-demo
(container)# git checkout 8b3948

Now we are up and running and we can start the server:

(container)# npm run watch &
(container)# npm run server

With that, any change of one of the files will cause the server to reload.

Open a browser and head to localhost:8001/blog

This is the end situation of the previous blog post: there is a table of contents, but the links to the headlines are missing. Those are the ones, will add today.

Note: if you are using Vagrant with a Docker host on a VirtualBox VM (as I do), per default, there is only a NAT-based interface and you need to create port-forwarding for any port you want to reach from outside (also the local machine you are working on is to be considered as outside). In this case, we need to add an entry in the port forwarding list of VirtualBox to map from Windows port 8001 to the VM’s port 8001 (in our case).

Step 1: Add IDs to the Headlines

First we need to make sure that each Headline has a unique ID we later can reference. For that, we assign some string “id394752934579″ concatenated by an increasing id_suffix within the getToc function we had defined in the previous blog post. This way we can be quite sure that the auto-created ID is unique on the page:

  private getToc(content: any) {
     // create div for holding the content
     var contentdiv = document.createElement("div");
     contentdiv.innerHTML = content;

     // create an array of headlines:
     var myArrayOfHeadlineNodes = [].slice.call(contentdiv.querySelectorAll("h1, h2"));

...

     // will be appended to the node id to make sure it is unique on the page:
     var id_suffix = 0;

     // loop through the array of headlines
     myArrayOfHeadlineNodes.forEach(
       function(value, key, listObj) {

...

           // if headline has no id, add a unique id
           if ("" == value.id) {
               value.id = "id394752934579" + ++id_suffix;
           }

...
       }
     );

...

     return(toc.innerHTML);
  }

Step 2: Apply the changed IDs to the dynamically retrieved Content

Since the myArrayOfHeadlineNodes is a list of references to the contentdiv’s headlines, the IDs of the headlines within the contentdiv variable has been manipulated in the previous step. However, the class variable “content” is unaffected by this change. Therefore, we need to apply the new enriched content to the class variable:

import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
...
  private getToc(content: any) {
     // create div for holding the content
     var contentdiv = document.createElement("div");
     contentdiv.innerHTML = content;

     // create an array of headlines:
     var myArrayOfHeadlineNodes = [].slice.call(contentdiv.querySelectorAll("h1, h2"));

...

     // update the content with the changed contentdiv, which contains IDs for every headline
     //   note that we need to use the saniztizer.bypassSecurityTrustHtml function in order to tell angular
     //   not to remove the ids, when used as [innerHtml] attribute in the HTML template
     this.content = this.sanitizer.bypassSecurityTrustHtml(contentdiv.innerHTML);

     return(toc.innerHTML);
  }

Just before returning, we write back the changed contentdiv variable’s content to the class variable this.content. But why did we apply this complicated bypass Security function?

 this.content = this.sanitizer.bypassSecurityTrustHtml(contentdiv.innerHTML);

Note that we apply the content as innerHTML in the HTML template (src/app/+blog/blog.module.html) like follows:

If the content is just a string containing HTML code, Angular will strip IDs and names from the innerHTML as a security measure. However, if we use the green saniziter code above instead, we explicitly tell Angular that it should trust the content, preventing Angular from stripping the IDs we are so keen on.

Step 3: Verify changed IDs of the Content’s Headlines

Unfortunately, the usage of “document” is not compatible with server-side rendering, as we have pointed out in the previous blog post. Therefore, we will not find the IDs in the server-provided HTML source: if we right-click on the page choose to view the source code, the IDs will be missing:

However, the IDs can be verified by pressing F12 in a Chrome browser and by navigating to the “elements” section:

It displays the dynamic HTML code as seen by the browser and we see that the id is correctly applied.

Step 4: Add and test Links to the Table of Contents

Now, since call headlines are equipped with unique IDs, we can make use of it. Within the forEach loop above, we add the following list item to the table of contents list:

(we need to use a screenshot, since this blog is saved on WordPress and WordPress refuses to save the embedded HTML code correctly)

We place it within the forEach loop:

  private getToc(content: any) {
     // create div for holding the content
     var contentdiv = document.createElement("div");
     contentdiv.innerHTML = content;

     // create an array of headlines:
     var myArrayOfHeadlineNodes = [].slice.call(contentdiv.querySelectorAll("h1, h2"));

...

     // will be appended to the node id to make sure it is unique on the page:
     var id_suffix = 0;

     // loop through the array of headlines
     myArrayOfHeadlineNodes.forEach(
       function(value, key, listObj) {

...

           // if headline has no id, add a unique id
           if ("" == value.id) {
               value.id = "id394752934579" + ++id_suffix;
           }
...
           
       }
     );

     // debugging:
     console.log(toc.innerHTML);

     // update the content with the changed contentdiv, which contains IDs for every headline
     //   note that we need to use the saniztizer.bypassSecurityTrustHtml function in order to tell angular
     //   not to remove the IDs, when used as [innerHtml] attribute in the HTML template
     this.content = this.sanitizer.bypassSecurityTrustHtml(contentdiv.innerHTML);

     return(toc.innerHTML);
  }

Step 5: Verify the Links

With the changes of the previous step, the page will contain links in the table of contents after being reloaded:

Moreover, the page will jump to the desired position, if one of the links is clicked. E.g. after clicking on the Step 1 link, we will see:

Excellent! Thump up!

This is, what we have aimed at in this short session.

Caveat A: Error Message ‘document is not defined’ (workaround given)

In the window, where npm run server is running, we see following error message, when we access the /blog URL:

ERROR ReferenceError: document is not defined
    at BlogView.exports.modules.490.BlogView.getToc (/localdir/oveits__ng-universal-demo/dist/0.server.js:59:35)
    at SafeSubscriber._next (/localdir/oveits__ng-universal-demo/dist/0.server.js:52:31)
    at SafeSubscriber.__tryOrUnsub (/localdir/oveits__ng-universal-demo/dist/server.js:601:16)
    at SafeSubscriber.next (/localdir/oveits__ng-universal-demo/dist/server.js:548:22)
    at Subscriber._next (/localdir/oveits__ng-universal-demo/dist/server.js:488:26)
    at Subscriber.next (/localdir/oveits__ng-universal-demo/dist/server.js:452:18)
    at MapSubscriber._next (/localdir/oveits__ng-universal-demo/dist/server.js:29934:26)
    at MapSubscriber.Subscriber.next (/localdir/oveits__ng-universal-demo/dist/server.js:452:18)
    at ZoneTask.onComplete [as callback] (/localdir/oveits__ng-universal-demo/dist/server.js:32771:30)
    at ZoneDelegate.invokeTask (/localdir/oveits__ng-universal-demo/dist/server.js:90301:31)

Workaround:

The reason for the problem is that node.js (i.e. the server side) does not understand some of the javascript code we have used in getToc function. A workaround is found on the Readme of Universal (“Universal Gotchas”):

The difference is shown the output of a git diff:

$ git diff 8ca27d5..3225b9a
--- a/src/app/+blog/blog.module.ts
+++ b/src/app/+blog/blog.module.ts
@@ -7,6 +7,9 @@ import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
 import { Pipe, PipeTransform } from '@angular/core';
 import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { PLATFORM_ID } from '@angular/core';
+import { isPlatformBrowser, isPlatformServer } from '@angular/common';
+import { Inject } from '@angular/core';

 @Component({
   selector: 'blog-view',
@@ -18,7 +21,7 @@ export class BlogView implements OnInit {
   content: any = null;
   toc: any = null;

-  constructor(private http: Http, private sanitizer: DomSanitizer) {
+  constructor(private http: Http, private sanitizer: DomSanitizer, @Inject(PLATFORM_ID) private platformId: Object) {

   }

@@ -31,8 +34,11 @@ export class BlogView implements OnInit {
                 .map((res: Response) => res.json())
                  .subscribe(data => {
                        this.title = data.title;
-                       this.content = data.content;
-                        this.toc = this.getToc(this.content);
+                        this.content = data.content;
+                        if (isPlatformBrowser(this.platformId)) {
+                            // Client only code.
+                            this.toc = this.getToc(this.content);
+                        }
                         //console.log(data);
                         console.log("content = " + this.content.changingThisBreaksApplicationSecurity);
                 });

I call it “workaround” and not “resolution” because I would prefer the table of contents code to play well with the server. With this workaround, we just make sure that the code is not run on the server. This was, the table of contents is not shown in the HTML source.

Caveat B: ERROR TypeError: this.html.charCodeAt is not a function (resolved)

In the window, where npm run server is running, we see following error message, when we access the /blog URL:

ERROR TypeError: this.html.charCodeAt is not a function
    at Preprocessor.advance (/localdir/oveits__ng-universal-demo/dist/server.js:111990:24)
    at Tokenizer._consume (/localdir/oveits__ng-universal-demo/dist/server.js:22767:30)
    at Tokenizer.getNextToken (/localdir/oveits__ng-universal-demo/dist/server.js:22725:23)
    at Parser._runParsingLoop (/localdir/oveits__ng-universal-demo/dist/server.js:81548:36)
    at Parser.parseFragment (/localdir/oveits__ng-universal-demo/dist/server.js:81503:10)
    at Object.parseFragment (/localdir/oveits__ng-universal-demo/dist/server.js:35450:19)
    at Parse5DomAdapter.setInnerHTML (/localdir/oveits__ng-universal-demo/dist/server.js:33459:49)
    at Parse5DomAdapter.setProperty (/localdir/oveits__ng-universal-demo/dist/server.js:33100:18)
    at DefaultServerRenderer2.setProperty (/localdir/oveits__ng-universal-demo/dist/server.js:34618:109)
    at BaseAnimationRenderer.setProperty (/localdir/oveits__ng-universal-demo/dist/server.js:92084:23)

Resolution

I have found this issue on git, with some plunkr code that has helped me to sort this out: it seems like I need to define the content, title and toc with types as follows (instead of e.g. content: any = null):

 private title : SafeHtml|String = '';
 private toc : SafeHtml|String = '';
 private content : SafeHtml|String = '';

As a side effect, I had to exchange

this.content = this.sanitizer.bypassSecurityTrustHtml(data.content);

by the simpler expression

this.content = data.content;

and

console.log("content = " + this.content.changingThisBreaksApplicationSecurity);

by the simpler expression

console.log("content = " + this.content);

After those changes, the error messages disappeared.

The first commit, where this is implemented is 2adaa98  and can be reviewed with:

git clone https://github.com/oveits/ng-universal-demo
git checkout 2adaa98

Summary

In this short session, we have enriched the the automatic table of contents of the the previous blog post with links to the corresponding headline position. For that, we have

    • added IDs to all headlines
    • made sure the IDs are not stripped by Angular’s default security policy
    • added links to the table of contents
    • verified the results in Chrome’s debugger
    • tested the results

We only have used plain vanilla javascript functionality to add the IDs and links. Most probably, there exist more Angular’ish ways of performing the same (or better) result. We will explore this in future, most probably. However, it works well for my use case for now.

Download the Code

The code can be cloned via

# git clone https://github.com/oveits/ng-universal-demo
# cd ng-universal-demo
# git checkout 8ca27d5   # to be sure you are working with exact same that has been created in this blog

In addition to the features described, you will see that I have included a ScrollTo function from the bottom of the page to the top of the page.

View the Component Code

For your reference, see here the full code (changes highlighted in bold green) we have applied to the file

src/app/+blog/blog.module.ts

import {NgModule, Component, OnInit} from '@angular/core'
import {RouterModule} from '@angular/router'
import { Http, Response, Headers } from '@angular/http';
import 'rxjs/add/operator/map'
import { Observable } from 'rxjs/Observable';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
//import * as angular from "angular";

@Component({
  selector: 'blog-view',
  templateUrl: './blog.module.html'
})

export class BlogView implements OnInit {
  title: any = null;
  content: any = null;
  toc: any = null;

  constructor(private http: Http, private sanitizer: DomSanitizer) {

  }

  ngOnInit(){
    this.getMyBlog();
  }

  private getMyBlog() {
    return this.http.get('https://public-api.wordpress.com/rest/v1.1/sites/oliverveits.wordpress.com/posts/3078')
                .map((res: Response) => res.json())
                 .subscribe(data => {
                        this.title = data.title;
                        this.content = data.content;
                        this.toc = this.getToc(this.content);
                        //console.log(data);
                        console.log("content = " + this.content.changingThisBreaksApplicationSecurity);
                });
  }

  private getToc(content: any) {
     // create div for holding the content
     var contentdiv = document.createElement("div");
     contentdiv.innerHTML = content;

     // create an array of headlines:
     var myArrayOfHeadlineNodes = [].slice.call(contentdiv.querySelectorAll("h1, h2"));

     // initialize table of contents (toc):
     var toc = document.createElement("ul");

     // initialize a pointer that points to toc root:
     var pointer = toc;

     // will be appended to the node id to make sure it is unique on the page:
     var id_suffix = 0;

     // loop through the array of headlines
     myArrayOfHeadlineNodes.forEach(
       function(value, key, listObj) {

           // if we have detected a top level headline ...
           if ( "H1" == value.tagName ) {
               // ... reset the pointer to top level:
               pointer = toc;
           }

           // if we are at top level and we have detected a headline level 2 ...
           if ( "H2" == value.tagName && pointer == toc ) {
               // ... create a nested unordered list within the current list item:
               pointer = pointer.appendChild(document.createElement("ul"));
           }

           // if headline has no id, add a unique id
           if ("" == value.id) {
               value.id = "id934752938475" + ++id_suffix;
           }

           // for each headline, create a list item with the corresponding HTML content:
           var li = pointer.appendChild(document.createElement("li"));
           
       }
     );

     // debugging:
     console.log(toc.innerHTML);

     // update the content with the changed contentdiv, which contains IDs for every headline
     //   note that we need to use the sanitizer.bypassSecurityTrustHtml function in order to tell angular
     //   not to remove the IDs, when used as [innerHtml] attribute in the HTML template
     this.content = this.sanitizer.bypassSecurityTrustHtml(contentdiv.innerHTML);

     return(toc.innerHTML);
  }
}

@NgModule({
  declarations: [BlogView],
  imports: [
    ScrollToModule.forRoot(),
    RouterModule.forChild([
      { path: '', component: BlogView, pathMatch: 'full'}
    ])
  ]
})

export class BlogModule {

}

Next Steps + Improvement Potential

Do we have improvement potential? Lots of them:

  • Make the Code SSR compatible:
    We are running our code on basis of universal, a server-side rendering solution for Angular. However, we have made use of functions that are only defined in the browser and are not compatible with the node.js server. This leads to the fact that the table of contents is not visible in the server-provided HTML source code. I would like to improve that. This caveat is not relevant for pure client-side rendered solutions, which, I guess, will be the majority of the Angular projects.
  • Be more flexible on the headline levels displayed
    Today, we assume that H1 headlines are present and only H1 and H2 levels are displayed (fixed). A better solution should be more flexible. There might be situations, where H2 or H3 is the top-level headline and we do not want to rewrite your javascript/typescript code for those situations. For that we might also evaluate existing modules like this one from 2014.
  • Replace normal links by a scrollTo function
    nice to have feature

For achieving the first and the third topic, it might be helpful to place the table of contents in its own component with own html template
and use ng-repeat functionality similar to the answer of this StackOverflow Q&A. Why is this interesting? I have tried to make use of Nicky Lenaers ngx-scroll-to plugin, since I wanted the browser to scroll down instead of jumping to the headlines. However, the scroll-to plugin is ignored, if I just add it within the getToc function as a string:

By applying the sanitizer functions to the toc, I have succeeded that the ng-scroll-to function is not stripped, when the toc is applied as innerHTML. However, the ng-scroll-to just did not do anything. I guess, the situation changes, if the ng-scroll-to code is placed in a html template of a component. Even the SSR compatibility might be achieved more easily, if the toc is encapsulated in its own component, as I hope.

 

 

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s