Skip to content

eeditiones/jinks-templates

Repository files navigation

Jinks Templates

A modern templating engine for eXist-db that brings the full power of XPath and XQuery to template processing. Built for flexibility and performance, Jinks Templates handles HTML, XML, CSS, XPath, XQuery, and plain text files with a unified syntax.

Overview

Jinks Templates was developed as the core templating engine for Jinks, the new app generator for TEI Publisher. It extends beyond eXist's older HTML templating capabilities to provide a comprehensive solution for any templating task in the eXist ecosystem.

Key features

🎯 Universal processing - Handle any file type with a single templating engine

⚡ Native XPath & XQuery - Use familiar XPath expressions directly in templates

🏗️ Robust architecture - AST-based parsing and compilation for better performance

📝 Front matter support - Extend template context with embedded configuration

🔗 Template inheritance - Create a hierarchy of templates with block-based inheritance

🔧 Developer experience - Familiar syntax inspired by Nunjucks and JSX

Architecture

Jinks Templates employs a sophisticated two-stage processing pipeline:

  1. Parser - Converts templates into an XML-based Abstract Syntax Tree (AST)
  2. Compiler - Transforms the AST into optimized XQuery code

This architecture delivers superior performance, comprehensive error handling, and enhanced debugging capabilities compared to traditional regex-based solutions.

Expressions

The template syntax is similar to Nunjucks or Jinja, but it uses eXist-db's host language, XQuery, for all expressions, giving users the full power of XPath & XQuery.

The templating is passed a context map, which should contain all the information necessary for processing the template expressions. The entire context map can be accessed via variable $context. Additionally, each top-level property in the context map is made available as an XQuery variable. So if you have a context map like:

map {
    "title": "my title",
    "theme": map {
        "fonts": map {
            "content": "serif"
        }
    }
}

You can either use a map lookup expression referencing entries in the $content variable [[ $context?title ]] or a convenient short form [[ $title ]] to output the title. And to insert the content font, use [[ $context?theme?fonts?content ]] or [[ $theme?fonts?content ]].

Note: Trying to access an undefined context property via the short form, e.g., [[ $author ]], will result in an error. So in case you are unsure if a property is defined, use the long form, i.e., [[ $context?author ]].

Supported template expressions are:

Expression Description
[[ expr ]] Insert result of evaluating expr
[% if expr %] … [% endif %] Conditional evaluation of block
… [% elif expr %] … else if block after if
… [% else %] … [% endif %] else block after if or else if
[% for $var in expr %] … [% endfor %] Loop $var over sequence returned by expr
[% include expr %] Include a partial. expr should resolve to relative path.
[% block name %] … [% endblock %] Defines a named block, optionally containing default content to be displayed if there's no template addressing this block.
[% template name order? %] … [% endtemplate %] Contains content to be appended to the block with the same name. The optional order parameter is an integer by which blocks will be sorted
[% template! name %] … [% endtemplate %] Replace content of a block. This overwrites all other templates targeting the same block.
[% import "uri" as "prefix" at "path" %] Import an XQuery module so its functions/variables can be used in template expressions.
[% raw %]…[% endraw %] Include the contained text as is, without parsing for templating expressions
[# … #] Single or multi-line comment: content will be discarded

Here, expr must be a valid XPath expression.

For some real pages built with Jinks Templates, check the app manager of TEI Publisher, jinks. This app also includes a playground and demo for jinks-templates.

Output modes

The Jinks Templates library supports two output modes: XML/HTML and plain text. They differ in the XQuery code that templates are compiled into. While the first will always return XML – and fails if the result is not well-formed, the second uses XQuery string templates.

Use in XQuery

The Jinks Templates library exposes one main function, tmpl:process, which takes 3 arguments:

  1. $input (xs:string): the template to process as a string
  2. $context (map(*)): the context providing the information to be passed to templating expressions
  3. $config (map(*)): a configuration map with the following properties:
    1. plainText (xs:boolean?): should be true for plain text processing (default is false)
    2. resolver (function(xs:string)?): the resolver function to use (see below)
    3. modules (map(*)?): sequence of modules to import (see below)
    4. namespaces (map(*)?): namespace mappings (see below)
    5. debug (xs:boolean?): if true, tmpl:process returns a map with the result, ast and generated XQuery code (default is false)

A simple example:

xquery version "3.1";

import module namespace tmpl="http://e-editiones.org/xquery/templates";

let $input :=
    <body>
        <h1>[[$title]]</h1>
        <p>You are running eXist [[system:get-version()]]</p>
    </body>
    => serialize()
let $context := map {
    "title": "My app"
}
return
    tmpl:process($input, $context, map { "plainText": false() })

The input is constructed as XML, but serialized into a string for the call to tmpl:process. The context map in this example contains a single property, which will become available as variable $title (corresponding to its entry's name) within template expressions.

Specifying a resolver

Defining a resolver function in the configuration map is needed if you would like to use the [% include %], [% extends %] or [% import %] template expressions in your templates. Its value should be a function item that takes one parameter - the relative path to the resource - and that returns a map with two entries:

  • path: the absolute path to the resource
  • content: the content of the resource as a string

If the resource cannot be resolved, the empty sequence should be returned. In the following example, we're prepending the assumed application root ($config:app-root) to the supplied relative path to get an absolute path and load the resource:

import module namespace tmpl="http://e-editiones.org/xquery/templates";
import module namespace config=...;

declare function local:resolver($relPath as xs:string) as map(*)? {
    let $path := $config:app-root || "/" || $relPath
    let $content :=
        if (util:binary-doc-available($path)) then
            util:binary-doc($path) => util:binary-to-string()
        else if (doc-available($path)) then
            doc($path) => serialize()
        else
            ()
    return
        if ($content) then
            map {
                "path": $path,
                "content": $content
            }
        else
            ()
};

let $input :=
    <body>
        <h1>[[ $title ]]</h1>
        <p>You are running eXist [[ system:get-version() ]]</p>
    </body>
    => serialize()
let $context := map {
    "title": "My app"
}
let $config := map {
    "resolver": local:resolver#1
}
return
    tmpl:process($input, $context, $config)

Importing XQuery modules

To make the variables and functions of specific XQuery modules available in your templates, you have to explicitly list those in the configuration's modules entry. This is a map in which the key of each entry corresponds to the URI of the module and the value is a map with two properties: prefix and at, specifying the prefix to use and the location from which the module can be loaded:

let $config := map {
    "resolver": local:resolver#1,
    "modules": map {
        "http://www.tei-c.org/tei-simple/config": map {
            "prefix": "config",
            "at": $config:app-root || "/modules/config.xqm"
        }
    },
    "namespaces": map {
        "tei": "http://www.tei-c.org/ns/1.0"
    }
}
return
    tmpl:process($input, $context, $config)

This example also shows how namespaces can be declared in the namespaces entry, using the prefix as key and the namespace URI as value.

Use front matter to extend the context

Templates may start with a front matter block enclosed in ---. The purpose of front matter is to extend or overwrite the static context map provided in the second argument to tmpl:process. Currently only JSON syntax is supported. The front matter block will be parsed into an JSON object and merged with the static context passed to tmpl:process. For example, take the following template:

---json
{
    "title": "Lorem ipsum dolor sit amet",
    "author": "Hans"
}
---
<article>
    <h1>[[ $title ]]</h1>

    <p>Consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>

    <footer>Published [[ format-date(current-dateTime(), "[MNn] [D], [Y]", "en", (), ()) ]] by [[ $author ]].</footer>
</article>

This will overwrite the title and author properties of the static context map.

The front matter block should come first in the file with a newline after each of the two separators. However, to allow for well-formed XML, the front matter may come after one or more surrounding elements, e.g.:

<article>
---json
{
    "title": "Lorem ipsum dolor sit amet",
    "author": "Hans"
}
---
    <h1>[[ $title ]]</h1>
</article>

Configuring templating parameters in front matter

Some of the configuration parameters for the templating can also be set via the front matter instead of providing them to the tmpl:process XQuery function. These include declaring XQuery modules and namespaces that you want to reference within template expressions.

Additionally, you can enable template inheritance in the front matter using extends (see next section).

These templating configuration parameters should go into the front matter's block in a top-level entry named templating:

---json
{
    "templating": {
        "extends": "pages/demo/base.html",
        "namespaces": {
            "tei": "http://www.tei-c.org/ns/1.0"
        },
        "modules": {
            "https://tei-publisher.com/jinks/xquery/demo": {
                "prefix": "demo",
                "at": "modules/demo.xql"
            }
        }
    }
}
---

<article>
    [% let $data = demo:tei() %]
    <h1>[[ $data//tei:title/text() ]]</h1>
    <p>[[ $data//tei:body/tei:p/text() ]]</p>
    [% endlet %]
</article>

Template inheritance

Template inheritance allows you to create a hierarchy of templates where child templates can extend and customize parent templates. This is particularly useful for creating consistent layouts across multiple pages.

How it works

When a template extends another template:

  • Named templates in the child fill the corresponding blocks in the parent
  • Remaining content is injected into the content block of the parent
  • Multiple levels of inheritance are supported

Example: Multi-level template hierarchy

Here's a complete example using the test application templates:

1. Base layout (pages/page.html)

<div>
    <header>
        <nav>
            <ul>
                <li>page.html</li>
                [% block menu %][% endblock %]
            </ul>
        </nav>
    </header>
    <main>
        [% block content %][% endblock %]
    </main>
    [% include "pages/footer.html" %]
</div>

2. Intermediate template (pages/base.html)

<article>
    ---json
    {
        "templating": {
            "extends": "pages/page.html"
        }
    }
    ---
    [% template menu %]
    <li>base.html</li>
    [% endtemplate %]
    <section>
        [% block content %][% endblock %]
        <p>This paragraph was imported from the parent template.</p>
    </section>
</article>

3. Child template with additional blocks

---json
{
    "templating": {
        "extends": "pages/base.html",
        "use": ["pages/blocks.html"]
    }
}
---
[% template menu %]
<li>Extra menu item</li>
[% endtemplate %]

[% template copyright %]
<p>© e-editiones</p>
[% endtemplate %]

<div>
    <p>This is the main content of the page.</p>
    [% block foo %]
    <p>A block with default content not referenced by a template.</p>
    [% endblock %]
</div>

4. Footer template (pages/footer.html)

<footer style="border-top: 1px solid #a0a0a0; margin-top: 1rem;">
    <p>Generated by [[$context?app]] running on [[system:get-product-name()]] v[[system:get-version()]].</p>
    [% block copyright %][% endblock %]
</footer>

5. Additional blocks (pages/blocks.html)

<template>
    [% template menu %]
    <li>blocks.html</li>
    [% endtemplate %]
</template>

This is a file which contains only templates, no other content (see section on the use directive below).

Result

The final rendered output combines all templates:

<div>
    <header>
        <nav>
            <ul>
                <li>page.html</li>
                <li>base.html</li>
                <li>Extra menu item</li>
                <li>blocks.html</li>
            </ul>
        </nav>
    </header>
    <main>
        <article>
            <section>
                <div>
                    <p>This is the main content of the page.</p>
                    <p>A block with default content not referenced by a template.</p>
                </div>
                <p>This paragraph was imported from the parent template.</p>
            </section>
        </article>
    </main>
    <footer style="border-top: 1px solid #a0a0a0; margin-top: 1rem;">
        <p>Generated by TEI Publisher running on eXist v6.2.0.</p>
        <p>© e-editiones</p>
    </footer>
</div>

Key concepts

  • [% block name %] - Defines a named block that can be overridden
  • [% template name order? %] - Provides content for a specific block
  • [% include "path" %] - Includes another template file
  • "use": ["path"] - Imports additional template files for block definitions
  • Front matter - Configures inheritance and other templating options

Overwriting blocks

If there is more than one [% template %] with the same name, the content of all of them will be concatenated into the corresponding [% block %] placeholder. However, sometimes you may want to override content provided by earlier templates in the inheritance chain. To do so, use the special [% template! name %] directive.

Determining the position of templates in a block

By default, multiple templates will be appended to the target block in the sequence they appear in the inheritance hierarchy. To move a template's content to a specific position, use the optional order parameter to [% template %]. This should be an integer, which will be used to sort the templates before insertion. Templates with an order parameter will be output before templates without one. The latter are still rendered in the sequence they appear in.

The use front matter directive

The use front matter directive allows you to import additional files containing only template definitions, but no other content. This is particularly useful for adding features without modifying existing templates.

Use this directive to dynamically inject content into existing blocks, without having to specify an explicit include in the target template. If blocks are configured in the main context, additional templates will be picked up by any page which has the corresponding block placeholder. It does not need to know if additional templates are available or not.

TEI Publisher features use this to dynamically load specific views into the sidebars. For example, if the iiif feature is enabled, it will load a IIIF viewer into one of the sidebars.

How use works

When you specify "use": ["pages/blocks.html"] in your front matter:

  1. Template loading - The specified template file is loaded and parsed
  2. Block registration - Any [% template name %] blocks in the imported file become available
  3. Content injection - These blocks can then be used to fill corresponding [% block name %] placeholders in the inheritance chain

Example: Using pages/blocks.html

In our example, pages/blocks.html contains:

<template>
    [% template menu %]
    <li>blocks.html</li>
    [% endtemplate %]
</template>

When referenced with "use": ["pages/blocks.html"], the menu template becomes available and gets injected into the menu block in the inheritance chain, resulting in an additional menu item.

Note that we use the HTML <template> element to indicate that this file does not contain any content that should be rendered directly. This is not necessary, but it can help with clarity and organization.

Multiple "use" files

You can specify multiple files in the "use" array:

{
    "templating": {
        "extends": "pages/base.html",
        "use": [
            "pages/blocks.html",
            "pages/components.html",
            "pages/navigation.html"
        ]
    }
}

How context maps are merged

As the template inheritance examples may demonstrate, the library often has to merge different source maps into a single context map. This works as follows:

  • properties with an atomic value will overwrite earlier properties with the same key
  • maps will be processed recursively by merging the properties of each incoming into the outgoing map
    • if you would instead like to entirely replace a map, add a property $replace with value true into the map
    • if you rather want to replace certain properties of a map, but merge the rest, $replace can be an array listing the keys to be replaced. All other keys will be merged recursively.
  • arrays are merged by appending the values of each incoming array with duplicates removed. Duplicates are determined as follows:
    • if the array contains atomic values only, they are compared using the distinct-values XPath function
    • if the values are maps and each map has an id property, they will be deduplicated using the value of this property.
    • if the values are maps and at least one does not have an id property, they will be serialized to JSON for deduplication

In the case of arrays of maps, we recommend that each map has an id property for correct deduplication.

Caveats

When generating XQuery code from a template, do not use string constructors like

``[my long string with `{$variable}` interpolated]``

Jinks Templates uses string constructors in the compiled template and the XQuery parser will choke on other uses within the template text.

Testing

This project includes a integration test suite that validates the jinks-templates API functionality, as well as smoke tests for compiling and installing the application. The tests are automatically run on every push and pull request via GitHub Actions.

Test suite

This project includes a Cypress test suite for the Jinks Templates API. As well as smoke test using bats.

Test coverage

To execute the end-to-end test, a small test app located in test/app/ must be installed. It tests:

  • API contract: Basic endpoint accessibility and error handling (api.cy.js)
  • Template processing: HTML, CSS, and XQuery template rendering (templateHtml.cy.js, templateCss.cy.js, templateXquery.cy.js)
  • Security: XSS, XQuery injection, path traversal, header spoofing (api.security.cy.js)

Running tests locally

  1. Prerequisites:

    • Node.js 22.0.0 or higher
    • Docker (for containerized testing)
    • Ant (for compiling .xar packages)
  2. Install dependencies:

    npm install
  3. Deploy app

    • Using the provided Dockerfile:
    docker build -t jinks-templates-test .
    docker run -dit -p 8080:8080 -p 8443:8443 jinks-templates-test
    • Compile expath packages manually. From within the root of this repository:
    ant
    cd test/app
    ant

    Then proceed to install both .xar packages into your local exist-db responding on ports 8080 and 8443

  4. Run tests:

    npx cypress open
    # or
    npx cypress run

GitHub Actions workflow

The project includes a GitHub Actions workflow (.github/workflows/test.yml) that automatically:

  1. Builds the Docker image containing eXist-db and the test application
  2. Starts the container and waits for eXist-db to be ready
  3. Runs the test suite against the running API
  4. Uploads test results and coverage reports as artifacts
  5. Cleans up containers and images

The workflow runs on:

  • Every push
  • Every pull request to main or master branches

Release procedure

This project uses semantic-release to automate versioning and publishing of releases on GitHub. The process is fully automated and based on commit messages following the Conventional Commits specification.

Branches

  • master: Stable releases are published from this branch.
  • beta: Pre-releases (e.g., 1.0.0-beta.1) are published from this branch.

How releases are triggered

  • Every push or pull request to master or beta triggers the test workflow.
  • When the test workflow completes successfully, the release workflow runs.
  • The release workflow analyzes commit messages to determine the next version:
    • fix: triggers a patch release (e.g., 1.0.1)
    • feat: triggers a minor release (e.g., 1.1.0)
    • BREAKING CHANGE: triggers a major release (e.g., 2.0.0)

Pre-releases

  • Commits pushed to the beta branch will create pre-releases (e.g., 1.0.0-beta.1).

Local dry run of semantic release

To simulate a release locally without publishing:

  1. Obtain a GitHub token with repo permissions.

  2. Run the following command in your project root (replace your_token_here):

    GH_TOKEN=your_token_here npx semantic-release --dry-run

This previews what semantic-release will do, without making any changes or publishing a release.

Release artifacts

  • The build process creates a .xar package in the build/ directory.
  • This package is attached to each GitHub release automatically.

For more details, see the configuration in .releaserc and .github/workflows/deploy.yml.

About

Templating library for TEI Publisher's app manager, jinks

Resources

Stars

Watchers

Forks

Sponsor this project

  •  

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •