DEV Community

Cover image for AMP CMS: Finishing & deploying  to DigitalOcean
Valeria
Valeria

Posted on

AMP CMS: Finishing & deploying to DigitalOcean

One week ago AMP CMS was just an idea, and now look how much it grew! But let's postpone the celebration till it's actually ready.

Here's the plan for today:

  • Create CRUD resolvers for users, pages, templates, and styles
  • Implement AMP page renderer
  • Fix whatever pops up
  • Deploy an instance of AMP CMS to DigitalOcean APP Platform and add a self-deploy button

Generic Resolvers

Most of the data operating functionality is defined in the data source classes, making resolvers very slim and pretty much the same, so I'll go ahead and implement a generic one:

import { requiresPermission } from "modules/authorization/lib";
import { Permission } from "modules/authorization/Permissions";
import { CRUDLDataSource } from "./DataSource";
import { APIContext } from "./types";

export default function CRUDLResolver<
  T extends CRUDLDataSource<{ id: string }>
>(name: string, dataSource?: string, path?: string) {
  const endpoint = path ?? "/" + name;
  const ctxKey = dataSource ?? name;
  type CRUDLContext = APIContext & Record<string, T>;
  return {
    [endpoint]: {
      GET: requiresPermission(
        { scope: name, permissions: Permission.list },
        async (params, ctx: CRUDLContext) => {
          return ctx[ctxKey].list(params);
        }
      ),
      POST: requiresPermission(
        { scope: name, permissions: Permission.create },
        async ({ input }, ctx: CRUDLContext) => {
          return ctx[ctxKey].create(input);
        }
      ),
    },
    [`${endpoint}/:id`]: {
      GET: requiresPermission(
        { scope: "pages", permissions: Permission.read },
        async ({ id }, ctx: CRUDLContext) => {
          return ctx[ctxKey].get(id);
        }
      ),
      POST: requiresPermission(
        { scope: "pages", permissions: Permission.update },
        async ({ id, input }, ctx: CRUDLContext) => {
          return ctx[ctxKey].update(id, input);
        }
      ),
      DELETE: requiresPermission(
        { scope: "pages", permissions: Permission.delete },
        async ({ id }, ctx: CRUDLContext) => {
          return ctx[ctxKey].delete(id);
        }
      ),
    },
  };
}


Enter fullscreen mode Exit fullscreen mode

Now routes for a single entity will look like this:

import CRUDLResolver from "core/CRUDLResolver";
import Pages from "./Pages";

export const dataSources = {
  pages: Pages,
};

export const routes = CRUDLResolver<Pages>("pages");

Enter fullscreen mode Exit fullscreen mode

Mystical CRUDLDataSource is an abstract DataSource class with create, get, update, delete, and list methods:

export abstract class CRUDLDataSource<T, I=Partial<T>, P = I> extends DataSource {
  create(input: I): MaybePromise<T> {
    throw new NotImplementedError(this.constructor.name, "create");
  }
  update(id: string, input: P): MaybePromise<T> {
    throw new NotImplementedError(this.constructor.name, "update");
  }
  delete(id: string): MaybePromise<{ deleted: boolean }> {
    throw new NotImplementedError(this.constructor.name, "delete");
  }
  get(id: string): MaybePromise<T> {
    throw new NotImplementedError(this.constructor.name, "get");
  }
  list(params: ListParams): MaybePromise<ItemsList<T>> {
    throw new NotImplementedError(this.constructor.name, "get");
  }
}

// Utility types
export class NotImplementedError extends Error {
  constructor(className: string, methodName: string) {
    super(`${className}.${methodName}`);
    this.name = `NotImplemented`;
  }
}

export type ListParams = {
  offset?: number;
  limit?: number;
  search?: string;
};

export type ItemsList<T> = {
  items: T[];
  nextOffset?: number;
};
Enter fullscreen mode Exit fullscreen mode

First-time access

When CMS user launches the system for the first time it'll have an empty database and thus, no user to log in and start creating pages.

To solve this problem I've added configurable superuser: its credentials are simply set as environment variables SUPERUSER_EMAIL and SUPERUSER_PASSWORD. That's going to come in handy if the user deleted himself too.

AMP CMS in action

Surprisingly, the most important feature of the CMS got to be one of the last to finish. Well, let me show you first and explain after. Without further ado, I proudly present you the Template Editor:

Editor with "Body", "Head" and "Styles" tabs and a functional preview window

Here's what it can do:

  • Styles are in fact rendered by sass and you can import styles defined in the styles section with @import 'style_id' or @use 'style_id'

  • Body and Head markup is rendered by liquid engine, meaning that you can reuse existing templates or extend them. The tags are set to <% %> to keep amp-mustache untouched.

  • You can include additional fields using YAML-like format in the page markdown content, that will be passed down to the template.

The preview is server-side rendered on post request and then turned into a blob URL:

api.post("/template/preview", values).then((r) => {
          if (typeof r === "object") return showErrors(r.errors);
          const url = URL.createObjectURL(new Blob([r], { type: "text/html" }));
          if (!preview.current.window) {
            preview.current.window = window.open(url, "blank");
          } else {
            preview.current.window.document.location.href = url;
          }
        })
Enter fullscreen mode Exit fullscreen mode

Rendering pages

Under the hood, when template preview is rendered, liquidjs collects required templates and partials and renders them for an empty page. The actual page on the other hand has content and other defined parameters. Parameters (including content) are variables and can be used in tags:

<header><% title %></header>
<main><% content %></main>
Enter fullscreen mode Exit fullscreen mode

Markdown content is parsed by gray-matter and then rendered to html by marked with an amp-image tag instead of a img. You can pass parameters (like width, height, layout and etc) to an image tag with the query parameters:

![Alt Text](https://picsum.photos/200/300?width=200&height=300 "Caption") 
Enter fullscreen mode Exit fullscreen mode

You can even include srcset, replacing whitespace with __.

The code for it is super tiny:


const renderer = {
  image(href: string, caption: string, text: string) {
    const [url, query] = href.split("?");
    const params = query ? querystring.parse(query.replace("__", " ")) : {};
    delete params["alt"];
    delete params["src"];
    const image = html`<amp-img
      alt="${text}"
      ${params2props(params)}
      src="${url}"
    ></amp-img>`;
    if (!caption) return image;
    return html`<figure>
      ${image}
      <figcaption>${caption}</figcaption>
    </figure>`;
  },
};
Enter fullscreen mode Exit fullscreen mode

Analytics

Analytics tracking consists of three parts: gathering, storing, and processing. AMP handles the first one with amp-analytics:

<amp-analytics>
  <script type="application/json">
      {
        "requests": {
          "event": "https://amp.dev/documentation/examples/components/amp-analytics/ping?user=0844f70b-3db7-4d06-a0d4-c84764577fd8&account=ampdev&event=${eventId}"
        },
        "triggers": {
          "trackPageview": {
            "on": "visible",
            "request": "event",
            "vars": {
              "eventId": "pageview"
            }
          }
        }
      }
    </script>
</amp-analytics>
Enter fullscreen mode Exit fullscreen mode

And Redis can handle storing it in streams:

import { flatten } from "core/DataSource";
import { APIContext } from "core/types";

export const routes = {
  "/_ping": {
    POST: async (
      { input, files, ...params }: any,
      { user, redis, headers, token, url, ip, ip_num }: APIContext
    ) => {
      const info = {
        ...params,
        ...headers,
        ip,
        ip_num,
        normalized_path: url.normalizedPath,
      };
      if (token) {
        info.token = token;
      }
      if (user) {
        info.user_id = user?.id;
        info.user_email = user?.email;
        info.user_name = user?.name;
      }
      redis.xadd("analytics", "*", flatten(info)).then(() => {});
      return { message: "OK" };
    },
  },
};


Enter fullscreen mode Exit fullscreen mode

I'm just dumping all the data we have into a Redis stream to handle it later. I have no idea what we might possibly want to analyze in the future, but guess what? We have all the data.

I've implemented a very naive pageviews counter:

import { APIContext } from "core/types";

export default async function pageviews(
  { from, to }: { from: number; to: number },
  { redis }: APIContext
) {
  // TODO: Limit returned values and scan
  const views = await redis.xrange("analytics", from, to ?? "+");
  const byDay = views.reduce((a, c) => {
    // Get date
    const recordedAt = parseInt(c[0].split("-").shift());
    const date = new Date(recordedAt);
    date.setMilliseconds(0);
    date.setSeconds(0);
    date.setMinutes(0);
    date.setHours(0);
    const ts = date.getTime();
    const views = a.get(ts) ?? 0;
    a.set(ts, views + 1);
    return a;
  }, new Map<number, number>());

  return byDay;
}

Enter fullscreen mode Exit fullscreen mode

And added it to the home page:

Pageviews graph updates on each page view

Deploying to a server

I've connected Github and the only thing I needed to set was environment variables:

Setting up REDIS_PORT, REDIS_HOST etc

My first five deployment attempts failed because:

  • there was one case sensitive path imported wrong
  • some dependencies were "dev" only and there was no server code bundler
  • there now was a server code bundler, but I forgot to uncomment the dashboard bundler after tests 🤪
  • code bundler was not the issue, but the node version I used on my machine was newer than the default LTS version platform assigns by default

If you're wondering, you can choose node version by adding this to your package.json:

{
....
"engines": {
    "node": "14.5.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

On the fifth attempt, I figured that my app doesn't install dependencies if I use a custom script. Duh!

Added yarn command to the scripts:

{
  "scripts": {
    "build": "yarn && ts-node -r tsconfig-paths/register src/build",
  }
}
Enter fullscreen mode Exit fullscreen mode

Now my app was running, but not responding to the health checks because it couldn't connect to Redis, so I added the existing Redis database as a "component":

Amp-cms and Redis running together

Finally, after several tries, I've figured that Redis uses a special REDIS_URL and not DATABASE_URL and it worked!

✅ Redis connection: OK

And then I've spent a lot of time tinkering things, finding bugs, refactoring... The "Fixing whatever pops up" stage took almost 4 hours and is not worthy of being documented.

There were a lot of bugs and there probably are some left. After all, AMP CMS is just a baby 🍼

But, nonetheless, It renders AMP and hence exists:

Welcome to AMP CMS Rendered

Same page in the dashboard

Take it for a test drive

You can go to https://amp-cms.dev/admin and enter these credentials:

  • email: clark.kent@daily.planet
  • password: 12345

This user has only list & view access, so you can look freely!
If you find any bugs or have feature requests and suggestions don't hesitate to file an issue on GitHub:

GitHub logo ValeriaVG / amp-cms

Content management system for blazingly fast AMP websites, written in TypeScript and powered by Redis

And feel free to deploy your very one AMP CMS with almost one-click deploy button!

By the way, adding this button was the easiest thing ever. I've used the spec generated for my app to create a .do/deploy.template.yaml and added a button to readme.

Unfortunately, this method does not create a database for you, as it supports only Postgres at the moment, so you'll need to create a database, create an app and then link those two together. I'll show you how to do it in my final post that's coming right after this one!

Now we can celebrate 🥳

Discussion (0)