Renderer classes

The Renderer is a base class from which one implement Renderer classes. For example, EJSRenderer renders EJS files, MarkdownRenderer renders Markdown files, and both are subclasses of Renderer.

To create your own Renderer, you start by subclassing the Renderer class. Doing so looks something like this:

import { Renderer, parseFrontmatter } from '@akashacms/renderers';
...
export class ExampleRenderer extends Renderer {
    ...
}

You then fill in a few methods, and voila you have a Renderer implementation. The methods are usually easy to code. The parseFrontmatter function is made available for files which can store metadata as frontmatter -- meaning a line of three dashes, a block of YAML data, and a closing line of three dashes.

The constructor method usually looks like this:

constructor() {
    super(".html.example", /^(.*\.html)\.(example)$/);
}

The first parameter is the name for the Renderer, and typically the name will be the file extension it handles. The second is a regular expression used in matching file names.

The pattern used in AkashaCMS, and carries over to @akashacms/renderers, is that input files have a double extension. The last extension indicates the format of the output file, for example .ejs means it is an EJS template. The next extension indicates the format of the output file. So, .html.ejs means a Renderer which converts EJS files to HTML, and .less.css means a Renderer for converting LESS files to CSS.

The regular expression has sub-expressions allowing us to capture the first extension, and the output file name. In other words, example.html.ejs would match as [ 'example.html.ejs', 'example.html', '.ejs' ].

The filePath method in the Renderer class relies on this policy of structuring regular expressions in this way. This function simply matches the regular expression against the input file name, then returns matches[1]. Through the magic of judiciously constructed file names and regular expressions, that is the correct output file name.

Next, one implements the render and renderSync methods. For these methods there must be some code which handles rendering that type of file. For example, with EJSRenderer that is the ejs package, and with LESSCSSRenderer that is the less package.

The precise details of the rendering code is outside the scope of this conversation. Suffice to say that there is likely to be a function which accepts a string as input, and produces a string as output. That function may allow a metadata object.

async render(context: RenderingContext): Promise<string> {
    return MODULE.render(
        context.body ? context.body : context.content,
        context.metadata);
}

renderSync(context: RenderingContext): string {
    return MODULE.renderSync(
        context.body ? context.body : context.content,
        context.metadata);
}

Some content rendering modules will only support asynchronous rendering. If that's the case, then renderSync must instead throw an Error explaining it cannot be used in a synchronous context.

The RenderingContext type is defined this way:

export type RenderingContext = {
    fspath?: string;   // Pathname that can be given to template engines for error messages
    content: string;   // Content to render
    body?: string;     // Content body after parsing frontmatter
    metadata: any;  // Data to be used for satisfying variables in templates
};

The fspath member is the filesystem path of the file. Since Renderer classes do not read the file they are rendering, fspath is primarily used for error messages.

The content and body members contain the input content. The content member contains the original content, and the body member contains the content left over after parsing metadata. Typically, metadata is represented as frontmatter (described earlier) prepended to the front of the file, and the body is the part after the frontmatter.

The metadata member contains an object representing the data extracted from the file.

The Renderer class provides a default implementation of parseMetadata which does nothing. Since we expect in most cases to represent metadata using frontmatter, the parseFrontmatter function is made available. It parses the YAML text in the format described above.

The parseMetadata method which uses parseFrontmatter looks like this:

parseMetadata(context: RenderingContext): RenderingContext {
    return parseFrontmatter(context);
}

Another function to implement is renderFormat, which indicates the format of the rendered output data. The package provides an enum describing the available formats:

export enum RenderingFormat {
    HTML = 'HTML',
    PHP  = 'PHP',
    JSON = 'JSON',
    CSS  = 'CSS',
    JS   = 'JS'
};

And a typical implementation looks like this:

renderFormat(context: RenderingContext) {
    if (!this.match(context.fspath)) {
        throw new Error(`ExampleRenderer does not render files with this extension ${context.fspath}`);
    }
    return RenderingFormat.HTML;
}

This inspects the fspath to see if it matches the file extensions supported by this Renderer. If not, the code calling this function did something wrong. In any case, for a Renderer that produces HTML, the RenderingFormat.HTML field is the correct value to return.

You may need to construct an options object to supply to the rendering engine. For example, NunjucksRenderer and EJSRenderer support a template command that loads and renders other templates. If true, you will need to supply an array of directory names where the engine can find template files, or a function that searches for the templates. How you do this depends on the rendering engine you're interfacing with.