Generating HTML Pages with Templates

Templating functionality has changed considerably from LiTScript version 1.x. Starting from version 2.0, templates are no longer external projects/libraries. Templates reside now under the <baseDir>/site/pages directory. Any LiTScript project can define its own templates or just use the built-in ones.

A template does not need implement any interfaces. It is simply a function that returns the HTML for a given input page.

template.ts imports
import * as path from 'path'
import * as fs from 'fs'
import * as toc from './toc'
import * as fm from './front-matter'
import * as utils from '../utils'
import { HtmlTemplate, saveHtmlTemplate } from './html';

Template Context

A template function is passed a context object that contains data needed for generating HTML and methods to add new files for the bundler.

export class TemplateContext {

All the modules referenced by calling require method are stored in this property.

    modules: string[] = []

The constructor takes all the required data as a parameter and stores them in read-only properties.

    constructor(

Front matter that comes from the configuration or from the source file.

        readonly frontMatter: fm.FrontMatter,

Table of contents data structure generated by the weaver.

        readonly toc: toc.Toc,

The actual documentation contents of the file in HTML format.

        readonly contents: string,

The relative path of the output file. The path is relative to outDir.

        readonly relFilePath: string,

The fully resolved path of the output file.

        readonly fullFilePath: string,

The site directory of the project where templates reside.

        readonly siteSrcDir: string,

The site directory where the compiled templates reside.

        readonly siteOutDir: string,

The output directory for HTML files.

        readonly outDir: string,

Style <link> tags to be added to the head section.

        readonly styles: string,

<script> tags to be added at the end of the body.

        readonly scripts: string) { }

Importing Script and Style Files

Modules and style files required by the template can be imported with the require method. It adds the referred TS, JS, or CSS file into the modules array, if it's not already there.

The method takes directory and module path as parameter and resolves them to an absolute path. Reason why the path is given in two parts is to make referring to modules in different source directories more convenient.

We also check that the given path exists. This makes debugging templates easier as missing modules are noticed before bundling phase. If the file has no extension, .js is appended to the path. As the final transformation we convert the module path to be relative to the main directory under the <siteDir>. See below why we do that.

    require(dir: string, modPath: string) {
        let module = path.resolve(dir, modPath)
        if (path.extname(module) == "")
            module += ".js"
        checkModuleExists(this)
        let mainDir = path.resolve(this.siteOutDir, "main/")
        let modpath = utils.toPosixPath(path.relative(mainDir, module))
        if (!this.modules.includes(modpath))
            this.modules.push(modpath)

Checking that the module exists is a bit involved operation because TypeScript compiler does not copy CSS files to the output directory. This makes referring to them from templates tricky. We would like to specify a relative path starting from the module's location directory available in the __dirname variable. But when the compiled JS code evaluates this variable it gets the compiler's ouput directory, not the template source directory.

We solve the problem like this: If the module is not found under the given path, we check whether it resides under the siteOutDir. If so, we replace the siteOutDir with siteSrcDir, which is the corresponding source directory and check again. If still cannot find the module, we throw an exception.

        function checkModuleExists(ctx: TemplateContext) {
            if (fs.existsSync(module)) 
                return
            else if (utils.isInsideDir(module, ctx.siteOutDir)) {
                let relPath = path.relative(ctx.siteOutDir, module)
                module = path.resolve(ctx.siteSrcDir, relPath)
                checkModuleExists(ctx)
            }
            else
                throw new Error(`Cannot find module "${module}"`)
        }
    }
}

Template Function

A template is a function that gets a TemplateContext object as an argument and returns a HTML template literal. The HtmlTemplate object can be then efficiently outputted to a file.

export type Template = (ctx: TemplateContext) => HtmlTemplate

Page templates live under the <baseDir>/site directory. They are loaded dynamically when a page is generated. To prevent unnecessary reloads, we cache the loedded templates in a dictionary. Its keys are template names and values are template functions.

let templates: Record<string, Template> = {}

Initializing Templates

Before we can generate pages with a template, we must clean up the working directory at <siteDir>/main. It contains the bundler entry/root files that import the required modules.

export function initialize(siteDir: string) {
    let mainDir = path.resolve(siteDir, "main/")
    if (fs.existsSync(mainDir))
        utils.clearDir(mainDir)
}

When template source code changes, we need to recompile and reload them. The function below clears the template cache as well as node.js module cache.

export function clearCache(siteDir: string) {
    templates = {}
    for (let mod in require.cache)
        if (utils.isInsideDir(mod, siteDir))
            delete require.cache[mod]
}

Generating Pages

generate function takes as an argument all the needed properties and:

  1. constructs the TemplateContext class,
  2. loads the template,
  3. calls the template function which returns the HTML template literal,
  4. saves the generated page to the output directory, and
  5. returns the name of the used template along with the path to the main module which is used as the entry for the bundler.
export function generate(fm: fm.FrontMatter, toc: toc.Toc, contents: string, 
    styles: string, scripts: string, fullFilePath: string, relFilePath: string,
    siteSrcDir: string, siteOutDir: string, outDir: string): [string, string] {
    let ctx = new TemplateContext(fm, toc, contents, relFilePath, fullFilePath,
        siteSrcDir, siteOutDir, outDir, styles, scripts)
    let template = loadTemplate(siteOutDir, fm.pageTemplate)
    let htmlTemp = template(ctx)
    saveHtmlTemplate(htmlTemp, fullFilePath);
    return [fm.pageTemplate, saveMain(siteOutDir, fm.pageTemplate, ctx)]
}

Loading Template

The module that contains the named <template> must be located at <siteDir>/pages/<template>.js or <siteDir>/pages/<template>/index.js. The template function must be the default export of that module. We use node.js require function to load the module and get its detault export. If that succeeds, we store the template in the cache dictionary.

function loadTemplate(siteDir: string, tempName: string): Template {
    let temp = templates[tempName]
    if (!temp) {
        let tempFile = path.resolve(siteDir, "pages/", tempName)   
        temp = require(tempFile).default as Template
        templates[tempName] = temp
    }
    return temp
}

Saving the Main Module

The main module is used as the entry file for the bundler. It imports all required modules which bundler subsequently combines into a single JS or CSS file. The function below outputs the main module into <siteDir>/main/<template>.js and returns the path to it.

function saveMain(siteDir: string, tempName: string, ctx: TemplateContext): 
    string {
    let jsPath = path.resolve(siteDir, `main/${tempName}.js`)
    if (!fs.existsSync(jsPath)) {
        utils.ensureDirExist(jsPath)
        let fd = fs.openSync(jsPath, 'w')
        try {
            fs.writeSync(fd, '"use strict";\n' +
                'Object.defineProperty(exports, "__esModule", { value: true });\n', 
                null, 'utf8')
            for (let mod of ctx.modules)
                fs.writeSync(fd, `require("${mod}");\n`, null, 'utf8')
        }
        finally {
            fs.closeSync(fd)
        }
    }
    return jsPath
}

Helper Functions

The relLink function returns a relative path from an URL to another.

export function relLink(from: string, to: string): string {
    return to.match(/^https?:\/\//) ? to :
        path.relative(path.dirname(from), to).replace(/\\/g, "/")
}