Create Blogs with Scully

Matthias Baldi, 12/10/2022 (updated)

en

This blog was created with Scully because I generate my projects very often with the Angular CLI and I like the environment. The ecosystem is evolved and works out of the box pretty easy because the tooling is already here and many tools or libraries are helping you to reach your targets fast.

A long time ago, I wanted to setup a blog and I thought about different systems like Ghost and others. I do not write so many blog articles and I did not want to setup another system to just serve the blog, while I have my running website on Angular. When I saw the release news in early 2020 about Scully I was excited and I thought, now I have to build a blog, just to test Scully.

I started with Scully in a basic way to pre-generate my websites with Scully, to improve the initial loading speed. This generated page will then lazy load the Angular app to add the dynamic part to the webpage.

For the blog I needed some more knowhow to configure the routes correctly and handle images etc. in a correct way. First of all… it was not that easy as I expected to get Scully running in my current setup with the expected routing. And in this article I want to share the problems and the code how you could solve it in your case too.

Add Scully to your project

This steps are working similar as they are described on the Scully docs page.

ng add @scullyio/init
npm install @scullyio/init
nx g @scullyio/init:install -- --project=myProject
ng generate @scullyio/init:blog

The steps above are adding the required schematics to your Angular project and will generate a blog folder. After that we want not only a blog page. I wanted an overview page and a blog detail page.

Let’s generate the detail page with the command:

ng generate c --skip-tests blog/blog-detail

Additionally it was a requirement for me to structure the blog in a yearly manner to get better structured folders. That makes it easier for an overview and allows a better routing. You can even expand that to a monthly or daily base, depending on how many articles you write. To reach that goal I edited the blog-routing file with the following change:

const routes: Routes = [
    {
        path: ':year/:slug',
        component: BlogDetailComponent,
    },
    {
        path: ':year',
        component: BlogComponent,
    },
    {
        path: '',
        component: BlogComponent,
    },
];

Configure

Blog Overview

As I mentioned earlier it was a requirement for me to have one structural part in the URL to collect the articles by year. For the user it is also easier to filter the existing articles over a separate view. To handle the overview per year you can write a component like this:

// blog.component.ts

// fetching all articles over the scully service
blogEntries$: Observable<ScullyRoute[]> = combineLatest([this.scully.available$, this.route.params]).pipe(
    map(ctx => ({ routes: ctx[0], params: ctx[1] })),
    map(ctx => ctx.routes
        // filter them by multiple patterns, here you may have to optimize some stuff
        .filter(this.isMatchingBlogRoutes)
        .filter(entry => this.isMatchingYearIfSet(ctx, entry))
        // reverse the complete list to get the newest articles on top
        .reverse())
);

private isMatchingYearIfSet(ctx, entry) {
    if (ctx.params.year) {
        return entry.route.includes(`/${ctx.params.year}/`)
    } else {
        return true;
    }
}

private isMatchingBlogRoutes(entry) {
    // i.e. here I filter out all image paths because I'll store all images
    // under /:year/images/** and scully would also return that resources
    // over scully.available$ observable
    return entry.route.includes('/blog/') &&
        !entry.route.includes('/images/');
}

In the blog overview template we can generate cards or what you need in your case:

<div class="container" *ngIf="blogEntries$ | async as blogEntries">
    <section class="card-columns">
        <article class="card" *ngFor="let entry of blogEntries">
            <div class="card-body">
                <h4 class="pointer" [routerLink]="[entry.route]">{{entry.title}}</h4>
                {{entry.description}}
                <div class="blog-entry-footer">
                    <small>{{entry.date | date: 'dd.MM.yyyy'}}</small>
                    <small><a href="https://twitter.com/{{entry.authorTwitter}}">{{entry.authorName}}</a></small>
                </div>
            </div>
        </article>
    </section>
    <article *ngIf="(blogEntries$ | async)?.length === 0" class="card">
        <div class="card-body">
            <h4>We have not found an article for this URL. 😰</h4>
        </div>
    </article>
</div>

Blog Detail

As we have now configured the overview page, let’s start with the detail page. Basically the setup is similar to the overview page, but instead of loading the list of all route entries we want only to get the current one from the Scully service.

entry$: Observable<ScullyRoute> = this.scully.getCurrent();

Also this template has the same style as we used before in the overview. You can access over the entry on every property you have configured in the header of the markdown file.

<div class="container">
    <section class="card">
        <div class="card-body">
            <scully-content></scully-content>
            <div class="blog-entry-footer" *ngIf="(entry$ | async) as entry">
                <small>{{entry.date | date: 'dd.MM.yyyy'}}</small>
                <small><a href="https://twitter.com/{{entry.authorTwitter}}">{{entry.authorName}}</a></small>
            </div>
        </div>
    </section>
</div>

Scully Configuration

The initial setup process of Scully also generated a config file in the project root folder. In this configuration I had to change a few things to match my requirements:

  • Handle images and copy them to the right place
  • Parse the content folders correctly with my changed setup with the :year parameter
  • Docker build support, because of puppeteer
// scully.<yoursite>.config.ts

import { HandledRoute, registerPlugin, ScullyConfig, log } from '@scullyio/scully';
import { copyFileSync, existsSync, mkdirSync } from 'fs';
import * as path from 'path';

const outDir = './dist/static';

// register plugin to handle the file types
// otherwise scully will skip these files
registerPlugin('fileHandler', 'png', async () => '');
registerPlugin('fileHandler', 'jpg', async () => '');
registerPlugin('fileHandler', 'gif', async () => '');

export const config: ScullyConfig = {
    projectRoot: './src',
    projectName: 'myProject',
    outDir: outDir,
    routes: {
        '/blog/:year/:slug': {
            type: 'contentFolder',
            // handle the year instead of the slug
            year: {
                folder: './blog',
                property: 'year',
            },

            // handles images and copy them into a folder instead
            // of trying to parse the files :)
            preRenderer: async (handledRoute: HandledRoute) => {
                if (!existsSync(`${outDir}/images`)) {
                    mkdirSync(`${outDir}/images`);
                }
                const promise = new Promise((resolve, reject) => {
                    const fileExtension = path.extname(handledRoute.data.sourceFile);
                    // works only with this router config, may there is a better
                    // solution with a regex or with a Scully feature I don't know atm
                    const year = handledRoute.route.split('/')[2];
                    if (!existsSync(`${outDir}/images/${year}`)) {
                        mkdirSync(`${outDir}/images/${year}`);
                    }
                    if (['.jpg', '.png', '.gif'].includes(fileExtension)) {
                        const src = path.resolve(`./${handledRoute.route}${fileExtension}`);
                        const dest = path.resolve(`${outDir}/images/${year}/${handledRoute.data.sourceFile}`);
                        copyFileSync(src, dest);

                        // false is stopping the render process and Scully will skip the file
                        resolve(false);
                    } else {
                        resolve(true);
                    }
                });
                return promise;
            },
        },
    },

    // required for Docker build process
    puppeteerLaunchOptions: {
        args: ['--no-sandbox', '--disable-setuid-sandbox'],
    },
};

Sitemaps

For search engines like the Google or Bing bot it is recommended to serve sitemap.xml files too. To achieve this I used a plugin, called @gammastream/scully-plugin-sitemap. When you import the plugin and add the following code in the scully config, it should work out of the box.

You may have to change the 404 routes or the change frequency to your requirements.

// scully.<yoursite>.config.ts

const SitemapPlugin = getSitemapPlugin();
setPluginConfig(SitemapPlugin, {
    urlPrefix: 'https://maruba.ch',
    sitemapFilename: 'sitemap.xml',
    merge: false,
    trailingSlash: false,
    changeFreq: 'weekly',
    priority: ['1.0', '0.9', '0.8', '0.7', '0.6', '0.5', '0.4', '0.3', '0.2', '0.1', '0.0'],
    ignoredRoutes: ['/404'],
});

Generation Process

To generate now the webpage you have to to two steps.

  • npm run build -- --prod , to build your Angular app
  • npm run scully , to generate the static part from your app

When this is done, you can deploy the dist/static folder to your webserver.

In my case I use containers, so I only need to run a docker build .

FROM buildkite/puppeteer AS builder

COPY client /webpage
WORKDIR /webpage
RUN npm ci \
    && npm run build:prod \
    && npm run scully

### FINAL IMAGE
FROM node:lts-alpine
LABEL maintainer=<youremail>

WORKDIR /app
ENV NODE_ENV production

# add server part
COPY healthcheck.js server.js package.json package-lock.json ./

# add user and set user
RUN adduser -D myuser \
    && chown myuser:myuser -R ./ \
    && npm ci

# add client part
COPY --from=builder /webpage/dist/static ./public

USER myuser

# *opt add a healtcheck file to proof the it is up and running
HEALTHCHECK --interval=15s --timeout=15s --start-period=5s --retries=3 CMD node healthcheck.js

EXPOSE 3000

CMD ["node", "server.js"]

Conclusion

It is is pretty simple to deploy new articles with Scully as soon you have a working application/scully-config. But to customize i.e. the routes I had to invest some time because you find a lot of old documentation from the early versions of scully. A part of this old documentation is still with the old Javascript configuration file. And sometimes this stuff does now work as expected with the newer versions.

Great was the very easy implementation of new plugins like the shown sitemap plugin. When you only want to have a very primitive MD rendering, without customized routes etc. you can stay the happy path, as it is documented on their webpage. Over all I like Scully and I will proceed using it in my stack (switched to NextJS with Strapi or Astro, depending on the requirements) 😀 👍