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.
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.
🎯 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
Jinks Templates employs a sophisticated two-stage processing pipeline:
- Parser - Converts templates into an XML-based Abstract Syntax Tree (AST)
- 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.
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.
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.
The Jinks Templates library exposes one main function, tmpl:process, which takes 3 arguments:
$input(xs:string): the template to process as a string$context(map(*)): the context providing the information to be passed to templating expressions$config(map(*)): a configuration map with the following properties:plainText(xs:boolean?): should be true for plain text processing (default is false)resolver(function(xs:string)?): the resolver function to use (see below)modules(map(*)?): sequence of modules to import (see below)namespaces(map(*)?): namespace mappings (see below)debug(xs:boolean?): if true,tmpl:processreturns 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.
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 resourcecontent: 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)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.
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>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 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.
When a template extends another template:
- Named templates in the child fill the corresponding blocks in the parent
- Remaining content is injected into the
contentblock of the parent - Multiple levels of inheritance are supported
Here's a complete example using the test application templates:
<div>
<header>
<nav>
<ul>
<li>page.html</li>
[% block menu %][% endblock %]
</ul>
</nav>
</header>
<main>
[% block content %][% endblock %]
</main>
[% include "pages/footer.html" %]
</div><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>---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><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><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).
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>[% 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
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.
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 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.
When you specify "use": ["pages/blocks.html"] in your front matter:
- Template loading - The specified template file is loaded and parsed
- Block registration - Any
[% template name %]blocks in the imported file become available - Content injection - These blocks can then be used to fill corresponding
[% block name %]placeholders in the inheritance chain
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.
You can specify multiple files in the "use" array:
{
"templating": {
"extends": "pages/base.html",
"use": [
"pages/blocks.html",
"pages/components.html",
"pages/navigation.html"
]
}
}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
$replacewith valuetrueinto the map - if you rather want to replace certain properties of a map, but merge the rest,
$replacecan be an array listing the keys to be replaced. All other keys will be merged recursively.
- if you would instead like to entirely replace a map, add a property
- 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-valuesXPath function - if the values are maps and each map has an
idproperty, they will be deduplicated using the value of this property. - if the values are maps and at least one does not have an
idproperty, they will be serialized to JSON for deduplication
- if the array contains atomic values only, they are compared using the
In the case of arrays of maps, we recommend that each map has an id property for correct deduplication.
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.
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.
This project includes a Cypress test suite for the Jinks Templates API. As well as smoke test using bats.
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)
-
Prerequisites:
- Node.js 22.0.0 or higher
- Docker (for containerized testing)
- Ant (for compiling
.xarpackages)
-
Install dependencies:
npm install
-
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 antThen proceed to install both
.xarpackages into your local exist-db responding on ports8080and8443 -
Run tests:
npx cypress open # or npx cypress run
The project includes a GitHub Actions workflow (.github/workflows/test.yml) that automatically:
- Builds the Docker image containing eXist-db and the test application
- Starts the container and waits for eXist-db to be ready
- Runs the test suite against the running API
- Uploads test results and coverage reports as artifacts
- Cleans up containers and images
The workflow runs on:
- Every push
- Every pull request to
mainormasterbranches
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.
- master: Stable releases are published from this branch.
- beta: Pre-releases (e.g.,
1.0.0-beta.1) are published from this branch.
- Every push or pull request to
masterorbetatriggers 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)
- Commits pushed to the
betabranch will create pre-releases (e.g.,1.0.0-beta.1).
To simulate a release locally without publishing:
-
Obtain a GitHub token with
repopermissions. -
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.
- The build process creates a
.xarpackage in thebuild/directory. - This package is attached to each GitHub release automatically.
For more details, see the configuration in .releaserc and .github/workflows/deploy.yml.