Introduction

A common architecture in Digital Humanities is to encode texts in TEI (Text Encoding Initiative) XML and transform them to HTML via XSLT for web publication.

Traditionally, this transformation is done client-side in the browser (using <?xml-stylesheet?> or JavaScript’s XSLTProcessor), but this approach has several drawbacks:

  • The browser must run the XSLT transformation on every page load, slowing down rendering
  • Poor SEO / crawler support
  • Browser-specific XSLT implementation differences

This article describes how to run XSLT transforms at build time on Vercel and serve pre-built HTML as a static site.

Project Structure

project/
├── docs/              # Vercel output directory
│   ├── index.html     # Top page
│   └── data/
│       ├── main.xsl       # XSLT stylesheet
│       ├── main.sef.json  # Compiled stylesheet
│       ├── source.xml     # TEI/XML source
│       └── source.html    # Generated HTML (built at deploy time)
├── build.js           # Build script
├── package.json
└── vercel.json

Comparing Node.js XSLT Libraries

Since native tools like xsltproc are not available in Vercel’s build environment, we need a Node.js XSLT library. Three options were tested.

xsltproc (reference: local environment)

A C-based XSLT processor that comes pre-installed on macOS.

xsltproc docs/data/main.xsl docs/data/source.xml > docs/data/source.html

Completes instantly, but cannot be used on Vercel (apt-get is not available).

xslt-processor (pure JavaScript)

npm install xslt-processor

A library based on Google’s AJAXSLT (2005), updated to ES2015+. Originally a polyfill for browsers that lacked XSLT support. Even for a ~1,400-line XML file, the transformation took several minutes and was impractical.

Reasons for the slow performance:

  • XPath expressions are parsed and evaluated at runtime on every call (no caching or pre-compilation)
  • No optimization strategy in the design, causing XPath evaluation overhead to accumulate
  • Pure JavaScript DOM implementation adds overhead for tree traversal

saxon-js (by Saxonica)

npm install saxon-js xslt3

A high-performance XSLT processor developed by Saxonica, the company led by Michael Kay, editor of the XSLT 3.0 specification. Its key advantage is a pre-compilation approach:

  1. Compile the XSLT stylesheet into SEF (Stylesheet Export File), a JSON-based format
  2. At runtime, simply load and execute the pre-compiled SEF
# Compile XSLT to SEF (one-time)
npx xslt3 -xsl:docs/data/main.xsl -export:docs/data/main.sef.json -nogo

The same transformation completed in 0.5 seconds.

Comparison

ToolImplementationTimeVercel Support
xsltprocC/native<0.1sNo
saxon-jsPre-compiled + JS execution0.5sYes
xslt-processorPure JS runtime processingMinutes+Yes (impractical)

Implementation

build.js

const fs = require('fs');
const path = require('path');
const SaxonJS = require('saxon-js');

const dataDir = path.join(__dirname, 'docs', 'data');
const sefPath = path.join(dataDir, 'main.sef.json');
const sefText = fs.readFileSync(sefPath, 'utf-8');
const sef = JSON.parse(sefText);

const xmlFiles = fs.readdirSync(dataDir).filter(f => f.endsWith('.xml'));

for (const file of xmlFiles) {
  const xmlPath = path.join(dataDir, file);
  const htmlPath = xmlPath.replace(/\.xml$/, '.html');
  const xmlText = fs.readFileSync(xmlPath, 'utf-8');
  console.log(`Processing: ${file}...`);
  const result = SaxonJS.transform({
    stylesheetInternal: sef,
    sourceText: xmlText,
    destination: 'serialized'
  });
  fs.writeFileSync(htmlPath, result.principalResult, 'utf-8');
  console.log(`Built: ${file} -> ${file.replace(/\.xml$/, '.html')}`);
}

package.json

{
  "scripts": {
    "build": "node build.js"
  },
  "dependencies": {
    "saxon-js": "^2.7.0"
  },
  "devDependencies": {
    "xslt3": "^2.7.0"
  }
}

vercel.json

{
  "buildCommand": "node build.js",
  "outputDirectory": "docs"
}

Vercel Project Settings

Check the following in the Vercel dashboard or via vercel project inspect:

  • Root Directory: Project root (not a subdirectory like docs)
  • Build Command: node build.js (specified in vercel.json)
  • Output Directory: docs (specified in vercel.json)

If the Root Directory is set to a subdirectory (e.g., docs), the build will fail because build.js and package.json won’t be found.

Workflow for XSLT Stylesheet Changes

When the XSLT stylesheet (main.xsl) is modified, the SEF must be recompiled:

# Recompile SEF
npx xslt3 -xsl:docs/data/main.xsl -export:docs/data/main.sef.json -nogo

# Test locally
node build.js

# Commit & push (Vercel auto-deploys)
git add -A && git commit -m "update" && git push

For XML data changes only, SEF recompilation is not needed — just push and Vercel will generate HTML at build time.

Summary

  • Moving XSLT transforms from client-side to build-time improves page load speed and SEO
  • Native tools (xsltproc) are not available in Vercel’s build environment, but saxon-js provides fast transforms
  • xslt-processor is a pure JavaScript implementation that is impractically slow for larger XML files
  • saxon-js’s pre-compilation (SEF) approach keeps build times under 1 second