DEV Community

Cover image for Building a flat-file CMS with Angular
Peter Rauscher
Peter Rauscher

Posted on

Building a flat-file CMS with Angular

A flat-file CMS allows you to manage content for your website without a database backend. Instead, store your pages as regular text files (or in this case, Markdown files), build, and publish! If you're reading this right now, you know what the result of this looks like. I spent the last two days building my own flat-file CMS for my site, and now I'd love to walk you through how I did it.

Though I'll skip most of the stylistic choices (because they're mostly irrelevant) this framework can be used to build a custom blog on your own Angular website, without the need to individually create new components for each post, manage a database, or write your posts in HTML format. Let's get started!

Converting posts from Markdown to HTML

Writing in markdown is super convenient, and supported by just about any text editor. To convert these .md files to browser-ready HTML, I wrote a simple little Node.js script using two great npm packages called gray-matter and showdown.

The gray-matter package lets us define Jekyll-style front matter for our posts, so we can add metadata for each that we'll use later in the build script - like so:

---
title: "Some blog post title"
date: 2020-01-24
published: true
thumbnail: some-pic.jpg
permalink: some-post
---
Enter fullscreen mode Exit fullscreen mode

The showdown package converts the markdown content to HTML. For example, this Markdown:

Some blog post introduction...

## A sub-heading

Some section content...
Enter fullscreen mode Exit fullscreen mode

Will look like this:

<p>Some blog post introduction...</p>
<h2>A sub-heading</h2>
<p>Some section content...</p>
Enter fullscreen mode Exit fullscreen mode

First, the script finds all of the relevant files. It checks the posts folder, as defined by the MARKDOWN_DIRECTORYvariable:

const MARKDOWN_DIRECTORY = path.join(__dirname, "posts");
Enter fullscreen mode Exit fullscreen mode

Next, it runs the following code, which reads each of the markdown files from the post into a global array of objects called files:

let files = [];
files = fs
  .readdirSync(MARKDOWN_DIRECTORY)
  .map((f) => path.join(MARKDOWN_DIRECTORY, f))
  .filter((f) => fs.lstatSync(f).isFile())
  .map((file) => {
    // Read the whole file to a string
    let fileContent = fs.readFileSync(file).toString();
    // Parse it with gray-matter
    let post = matter(fileContent);
    post.filename = file;
    // Conver the content to HTML with showdown
    post.content = converter.makeHtml(post.content);
    return post;
  });
Enter fullscreen mode Exit fullscreen mode

The line let post = matter(fileContent) parses the file content with front matter into an object that looks like:

{
    "data": {
        "title":"Some blog post title",
        "date":"2020-01-24T00:00:00.000Z",
        "published":true,
        "thumbnail":"some-pic.jpg",
        "permalink":"some-post"
    },
    "content":"Some blog post introduction...\n## A sub-heading\nSome section content...",
}
Enter fullscreen mode Exit fullscreen mode

And the line post.content = converter.makeHtml(post.content) converts the content field to HTML, as you'd expect:

{
   ...
   "content":"<p>Some blog post introduction...</p>\n<h2>A sub-heading</h2>\n<p>Some section content...</p>",
}
Enter fullscreen mode Exit fullscreen mode

Since I wanted to have thumbnails for each post (the file it uses is defined by the thumbnail field in the front matter), I decided to add some code that copies the files from our posts folder to the assets folder in Angular. This way, we can easily reference them in our Angular code. However, some of the image files I would download from Unsplash.com (fantastic public domain images btw) were huge and would take a few seconds to load in the browser. So, I delegated this task to gulp, where the files would first be piped to imagemin and then copied to the assets folder. This optimized the images for faster load times. My gulpfile.mjs looks like this:

const THUMBNAIL_DIRECTORY = "posts/thumbnails";
const THUMBNAIL_OUTPUT_DIRECTORY = "src/assets/thumbnails";

export default () => gulp.src(`${THUMBNAIL_DIRECTORY}/*`).pipe(imagemin()).pipe(gulp.dest(THUMBNAIL_OUTPUT_DIRECTORY));
Enter fullscreen mode Exit fullscreen mode

Using the posts in Angular

Great! So our files array holds everything we need to write the HTML files, and our thumbnails are in place to be served by our website. Now, we just have to make this available to Angular in some way. Here are a couple of options I considered for achieving this:

  • Serve the posts over an API endpoint
  • Dynamically generate new components for each post

Ultimately, I decided against these two options because deploying a whole server for this purpose seemed overkill, and dynamically generating components (although cool) would be a fairly big task and go against Angular best practices.

Instead, I decided to leverage one of the coolest things about Angular: it builds everything into a static site and some plain vanilla JS, entirely in the build process. Cool! So let's have one generic post Component, and dynamically load the content into it using client-side JS. So, our build script writes our files array to a .json file called posts.json in the src folder of our Angular app. It also writes each post to its own HTML file, but this is just to allow me to easily inspect if the Markdown-to-HTML conversion went off without a hitch on my end:

// Write only the published: true posts to posts.json for Angular to serve
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(files.filter((p) => p.data.published)));

// For each file,
files.forEach((file) => {
  if (file) {
    // Change the extension to .html
    let filename = path.join(OUTPUT_DIRECTORY, path.basename(file.filename, ".md")) + ".html";
    // Write the file to the build directory
    fs.writeFileSync(filename, file.content);
  }
});
Enter fullscreen mode Exit fullscreen mode

Now, I generated two components: blog for the page displaying a list of all posts, and post for a template post page that can be loaded with whatever content.

But first, Routing

We'll keep this part simple. In our app-routing.module.ts file, we have the following routes:

const routes: Routes = [
    ...
    { path: 'blog', component: BlogComponent },
    { path: 'blog/:postlink', component: PostComponent },
    { path: 'not-found', component: PageNotFoundComponent },
    { path: '**', redirectTo: '/not-found' },
    ...
]
Enter fullscreen mode Exit fullscreen mode

The Post component

So, our post.component.html component is the generic page where all posts will have their content loaded. Here, the classes are from the Bulma CSS framework, and the template looks like this:

<div class="main section container pt-0">
  <div class="block is-fullwidth">
    <h1 class="title is-1">{{ meta.title }}</h1>
    <p class="has-text-grey">Published {{ meta.date }}</p>
  </div>
  <div class="content" [innerHTML]="content"></div>
</div>
Enter fullscreen mode Exit fullscreen mode

Note the inline directive, [innerHTML]="content" on the content div. This takes the component's content property and loads the raw HTML into it, creating the actual content of our post. The {{ meta.title }} and {{ meta.date }} properties define, the title and publishing date of the post (I know, shocking 😱).

And here's the cool part: the TypeScript code for the component:

// A library that provides syntax highlighting for <code> blocks
import hljs from "highlight.js";
// The posts.json file we generated in the build step
import posts from "src/posts.json";

interface Post {
  title: string;
  date: string;
  content: string;
  thumbnail: string;
  permalink: string;
}

// Iterate through each post object, filtering null/undefined items
let postData = posts.filter(Boolean).map((p) => {
  const post: Post = {
    title: p.data.title,
    // Format the date to something readable
    date: new Date(p.data.date).toLocaleDateString("en-US", {
      month: "long",
      day: "numeric",
      year: "numeric",
    }),
    content: p.content,
    // Create the actual link to the thumbnail as it's available on the server
    thumbnail: `/assets/thumbnails/${p.data.thumbnail}`,
    permalink: p.data.permalink,
  };
  return post;
});
Enter fullscreen mode Exit fullscreen mode

Now, we implement the OnInit interface so that when the component is initialized, we find the post with a permalink attribute matching the current route of the client. If no such post exists, we redirect to the site's 404 page:

ngOnInit(): void {
    this.postlink = this.route.snapshot.params['postlink'];
    let postMeta = postData.find((p) => p.permalink === this.postlink);
    if (postMeta) {
        this.meta = postMeta;
        this.content = postMeta.content;
    } else {
        this.router.navigate(['/not-found']);
    }
}
Enter fullscreen mode Exit fullscreen mode

We also implement the AfterViewInit interface, so that we can highlight our code blocks only after all of the content is done loading:

ngAfterViewInit(): void {
    this.document.querySelectorAll('code').forEach((el) => {
        hljs.highlightElement(el as HTMLElement);
    });
}
Enter fullscreen mode Exit fullscreen mode

The Blog component

The final piece of this puzzle is the Blog component, which lists all of the posts available on our site. Again, the CSS classes for this template come from Bulma:

<div class="main section container pt-0">
  <!-- For each post, create an entry -->
  <div *ngFor="let post of posts" class="box is-rounded p-0">
    <!-- Link to the post -->
    <a class="nostyle" [href]="'/blog/' + post.permalink">
      <div class="columns">
        <!-- Inline style directive to load the thumbnail as the background image for this div -->
        <div class="column is-one-fifth is-thumbnail is-hidden-mobile" [style]="post.thumbnail"></div>
        <div class="column is-four-fifths">
          <div class="content px-3 py-1">
            <!-- Display the post title -->
            <h3 class="title is-3 mb-2 no-border">{{ post.title }}</h3>
            <!-- Display the first 180 characters of the post as a preview -->
            <p class="mb-2" [innerHTML]="post.content.substring(0, 180) + '...'"></p>
            <!-- Display the published date -->
            <span class="tag is-info is-light">{{ post.date }}</span>
          </div>
        </div>
      </div>
    </a>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Our blog component pulls the posts.json data in much the same way as our post component does, but it makes the whole list available as a posts property, which is used by the *ngFor directive above:

let postData = posts.map((p) => {
  const post: Post = {
    title: p.data.title,
    date: new Date(p.data.date).toLocaleDateString("en-US", {
      month: "long",
      day: "numeric",
      year: "numeric",
    }),
    content: p.content,
    thumbnail: `background-image: url("/assets/thumbnails/${p.data.thumbnail}"); background-position: center; background-size: cover;`,
    permalink: p.data.permalink,
  };
  return post;
});

export class BlogComponent {
  posts = postData;
}
Enter fullscreen mode Exit fullscreen mode

Deployment

The final step is obviously to deploy the site, which I do with AWS. First, when I push to Github, any new posts trigger an AWS CodeBuild run which builds the site and copies the generated files to an S3 bucket. Luckily, the site is all static and can be served just over S3 with no need for a web server. I also have a CloudFront distribution set up for the bucket, so files can be cached in edge locations and available worldwide with low latency. My buildspec.yml looks like this:

version: 0.2
env:
  variables:
    BUCKET_NAME: peterrauscher.com
    DISTRIBUTION_ID: E34HJHH2D2T6HS
phases:
  pre_build:
    commands:
      - echo Installing source NPM dependencies...
      - npm install
      - npm install -g @angular/cli
      - npm install -g gulp-cli
  build:
    commands:
      - echo Build started on `date`
      - echo Compiling the dist folder...
      - npm run build
  post_build:
    commands:
      - echo Build completed on `date`
      - echo Deleting existing site files...
      - aws s3 sync dist s3://${BUCKET_NAME}/ --delete
      - echo Invalidating CloudFront cache...
      - aws cloudfront create-invalidation --distribution-id ${DISTRIBUTION_ID} --paths "/*"
artifacts:
  files:
    - "**/*"
  discard-paths: no
  base-directory: "dist/peterrauscher.com"
Enter fullscreen mode Exit fullscreen mode

Wrapping up

And that's about it! We've covered all of the logic needed to set up a flat-file blog on your own Angular site. Feel free to tweak things like the routing/paths, stylistic choices, or removing features you don't intend to use, but I'm quite happy with how this worked out and happy that I was able to implement it statically so I could still run it purely on S3. I hope you enjoyed the read and learned something you can apply on your own!

Top comments (0)