Hi there, today I’m going to show you how to build a dynamic page generator in Angular from static markdown files. The backbone of our system will be ngx-markdown to render the markdown content. You can find that project’s page here!

Why NgxMarkdown

I was looking for something that would allow me to do dynamic content updates without needing a backend application server. In the past, I would have reached for something like Wordpress or Ghost to do a CMS for my websites, but after working with Hugo I knew I could do something much simpler and secure. I started out my journey building my new consulting website by looking into Hugo. Hugo is a great static site generator and even is what powers this blog you’re reading! However, there are some serious limitations to what you can do with Hugo in terms of interactivity and I knew I wanted some interactive components in addition to a rich content management system for my IT consulting brochure site. Basically, I knew I didn’t want to maintain my own Hugo theme just to add interactive widgets. That has less to do with an aversion to heavy frontend development and more to do with my aversion to fussing over and baby-sitting git submodules. I looked on the internet and found a project that looked like it would fit my bill, but saw that Scully, the Angular static site generator I found, was undergoing a massive re-write/hadn’t been updated in two years. Ultimately, I was not happy with what Scully offered and I didn’t want to build on a foundation that was not keeping up with Angular releases. Ultimately, this state of affairs led me to the conclusion that I would have to build my ideal solution.

The ideal solution’s MVP

My MVP for the component was this: render markdown content that exists as a static asset in Angular’s assets folder. That way all I have to do to update the site is to publish a new static build which will include all the necessary assets in it’s built assets directory. As I set out to build this solution, I quickly came across several problems I had to solve:

  1. How can I show a list of all posts that have been written in the past in a nice menu without a backend server to list the data?
  2. How can I associate those posts with a specific markdown file so that, when clicked, the appropriate markdown file is rendered?
  3. How can I paginate posts so that when I have 1000+ posts I can chunk them up appropriately?

To solve this I just applied my same logic to my content as I did to my metadata. Just use files served from the assets directory. So I came up with a data structure to represent the list of posts. You can see an example of it below:

// index.json
{
  "pages": [
    {
      "pageNumber": 1,
      "pageJsonContent": "/page1.json"
    },
    {
      "pageNumber": 2,
      "pageJsonContent": "/page2.json"
    },
    {
      "pageNumber": 3,
      "pageJsonContent": "/page3.json"
    },
    {
      "pageNumber": 4,
      "pageJsonContent": "/page4.json"
    },
    {
      "pageNumber": 5,
      "pageJsonContent": "/page5.json"
    }
  ],
  "slugs": {
    "your-blog-post-slug": "/your-blog-post.md",
    // ...more posts until you're done
  }
}

This file is generated by a script I wrote to iterate over the files in created date order and then associate a slug based on file name with the file. More metadata is placed into the resulting pageX.json files with some markdown meta tags like so:

# test-post.md
[_metadata_:author]:- "colonelpopcorn"
[_metadata_:title]:- "Hello World"
[_metadata_:description]:- "Lorem ipsum dolor sit amet, consectetur adipiscing elit."

These tags generate structure of posts like this for each “page”:

// page1.json
{
  "postTitle": "Hello World",
  "postSlug": "test-post.md",
  "postDescription": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
  "contentPath": "/test-post.md",
  "dateCreated": "2024-06-07",
  "dateModified": "2024-06-07"
},

Finally, we feed all this into a component that renders a list from the pageX.json files.


@Component({
  selector: 'app-blog',
  template: `
    <ng-container>
      <ng-container *ngIf="(exampleJson | async) as posts">
        <mat-list class="post-list">
          <mat-list-item
            class="post"
            *ngFor="let post of posts"
            (click)="goToBlogPost(post)"
          >
            <span matListItemTitle>
              {{ post.postTitle }}
            </span>
            <span matListItemLine>{{ post.postDescription }}</span>
          </mat-list-item>
        </mat-list>
      </ng-container>
      <div class="page-number-container">
        <div
          (click)="previousPage()"
        >
          <<
        </div>
        <div
          class="page-number"
          *ngFor="let page of pageNumbers | async; let idx = index"
          (click)="goToPageNumber(idx + 1)"
        >
          {{ idx + 1 }}
        </div>
        <div
          (click)="nextPage()"
        >
          >>
        </div>
      </div>
    </ng-container>
  `,
  styles: [
    `
    /* styles go here */
    `,
  ],
})
export class BlogListComponent {
  maxPages: number;
  pageNumbers = this.blogContentService
    .getPagesIndex()
    .pipe(tap((pages) => (this.maxPages = pages.length)));
  exampleJson: Observable<Array<Post>>;
  pageNumber: number;
  constructor(
    private blogContentService: BlogContentService,
    private activeRoute: ActivatedRoute,
    private router: Router
  ) {
    this.exampleJson = this.activeRoute.queryParams.pipe(
      map((params: Params) => parseInt(params['page_num'] ?? 1)),
      tap((pageNumber: number) => (this.pageNumber = pageNumber)),
      mergeMap((pageNumber: number) =>
        this.blogContentService.getPageNumber(pageNumber)
      ),
      catchError((err) => of(err))
    );
  }
  goToPageNumber(pageNumber: number) {
    this.router.navigate(['/blog'], { queryParams: { page_num: pageNumber } });
  }

  nextPage() {
    if (this.pageNumber < this.maxPages) {
      this.router.navigate(['/blog'], {
        queryParams: { page_num: this.pageNumber + 1 },
      });
    }
  }

  previousPage() {
    if (this.pageNumber > 1) {
      this.router.navigate(['/blog'], {
        queryParams: { page_num: this.pageNumber - 1 },
      });
    }
  }

  goToBlogPost(post: Post) {
    this.router.navigate([`/blog/post/${post.postSlug}`]);
  }
}

This component renders out the title and description in a list of clickable components that then routes to a blog-post.component.ts file that renders the content with ng-markdown. Here’s the file below:

@Component({
  selector: 'sbdo-blog-post',
  template: `<markdown [src]="blogPostContentUrl$ | async"></markdown>`,
  styles: [``],
})
export class BlogPostComponent {
  blogPostContentUrl$: Observable<string>;

  constructor(private route: ActivatedRoute, private http: HttpClient) {
    this.blogPostContentUrl$ = this.route.paramMap.pipe(
      map((paramMap) => paramMap.get('slug')!),
      switchMap((slug) =>
        this.http
          .get<IndexFile>(`${getBaseAssetUrl()}/index.json`)
          .pipe(map((content) => `${getBaseAssetUrl()}/${content.slugs[slug]}`))
      )
    );
  }
}

This is a bare minimum example, and I want to move the http call out of the component itself and into the BlogContentService, but it works!. I was able to get a test post rendering and I’ve generated several test files to make sure we can paginate successfully! It doesn’t do everything yet, but the MVP is looking good and I can build upon this solution from here going forward!

Build vs. Buy (or npm install)

It’s always a hard choice to make when you’re evaluating building your own solution or pulling one off the shelf. I think what I’ve built will work for me, but solutions like this never survive first contact with users. I’ll keep you posted on whether or not this will work going forward, but I’m satisfied with how it works right now.