XSLT 2.0 and 3.0: design decision

Status: released, @tradik/xslt3 1.0.0 alongside @tradik/xslt-processor 1.3.0. This page records how XSLT 2.0, XSLT 3.0 and XPath 3.1 were added, why, and how far the engine conforms.

Decision

PackageCoversDependencies
@tradik/xslt-processor (this package)XSLT 1.0, XPath 1.0, EXSLT: the browser XSLTProcessor with Chrome (libxslt) behaviournone
@tradik/xslt3 (new, packages/xslt3/ in this repository)XSLT 3.0 and XPath 3.1, which also run version="2.0" stylesheets and version="1.0" ones in backwards-compatible modenone at run time
  • One engine for 2.0 and 3.0. XSLT 3.0 is a superset of 2.0, and a 3.0 processor must accept 2.0 stylesheets (XSLT 3.0 section 3.9). A separate 2.0 engine would duplicate the data model, the function library and most instructions.
  • 1.0 stays separate and unchanged. Pages and applications that replace the browser's native XSLTProcessor need libxslt's XSLT 1.0 behaviour, not a 3.0 processor's backwards-compatible mode, which differs in details (errors, xsl:number, serialization). The 1.0 package keeps its zero dependencies, and nobody who uses 1.0 downloads or loads 2.0/3.0 code: the 1.0 bundles carry only the bridge that hands a 2.0/3.0 stylesheet over (about 4 kB of the minified browser bundle).
  • Opt-in bridge (done).XSLTProcessor takes xsltVersion: "auto": a stylesheet that declares version 2.0 or 3.0 is then run by @tradik/xslt3, loaded with import() only at that moment and only when it is installed (optional peer dependency). The asynchronous API loads it by itself; the synchronous W3C API needs await XSLTProcessor.preload() first. The CLI flag is --xslt-version auto. Without the option nothing changes: a version="2.0" stylesheet runs in XSLT 1.0 forwards-compatible mode, as in Chrome. The bridge lives in src/bridge/ of the 1.0 package; the API mapping is in API Reference. The standalone xslt executables embed @tradik/xslt3, so --xslt-version auto works there without installing anything.
  • Inside @tradik/xslt3, rarely used parts (regular expressions, date/number formatting, JSON) live in their own modules so bundlers and import() keep them out of programs that do not use them.

Scope

Target: a basic XSLT 3.0 processor (XSLT 3.0 section 27) with XPath 3.1, maps, arrays, higher-order functions and JSON, and Serialization 3.1.

Not planned: schema awareness (xsl:import-schema with a schema), streaming (xsl:mode streamable="yes" runs, without streaming guarantees), static typing and XQuery.

Implementation-defined limits

ItemValue
xs:decimal and xs:integerexact, unbounded (BigInt)
Decimal division (div)at least 18 fraction digits, more when an operand has more, rounded half-down
xs:double, xs:floatIEEE 754 (JavaScript numbers; float through Math.fround)
Years-999,999,999 to 999,999,999 (XSD 1.1: year 0 exists)
Implicit timezonean option, default UTC
xs:anyURIlexical form not validated

Milestones

MilestoneContent
0.1XPath 3.1 on its own: parser, data model (sequences, atomic types, casting), path expressions over DOM nodes, FLWOR, maps, arrays, inline and dynamic functions, the core function library
0.2XSLT 2.0 instructions: grouping, xsl:analyze-string, xsl:function, typed variables, tunnel parameters, xsl:result-document, character maps
0.3XSLT 3.0: xsl:iterate, xsl:try, text value templates, xsl:evaluate, accumulators, xsl:merge, static parameters and use-when, JSON and adaptive output
1.0Conformance against the W3C test suites published, the bridge in @tradik/xslt-processor, benchmarks in BENCHMARKS.md: the 1.0 package against the new engine on the same stylesheets, 2.0/3.0-only scenarios, and Saxon-JS as the reference 3.0 processor in JavaScript

Conformance

Measured with the W3C suites, fetched at test time and not committed: qt3tests for XPath 3.1 and its functions, and xslt30-test for XSLT 3.0 and 2.0. Pass rates per feature are published here as they come in.

Current results

SuiteStageApplicablePassRate
qt3tests (XPath 3.1)parsing and static analysis21,78721,75999.9%
qt3tests (XPath 3.1)evaluation, all families21,78721,77099.9%
xslt30-test (XSLT 3.0 and 2.0)transformation, all families7,9147,77298.2%

The 20 remaining evaluation failures are collation-key with UCA collations (JavaScript's Intl exposes no sort keys), fn:transform and load-xquery-module (not offered by a basic processor), and three cases where the DOM implementation used by the test runner (@xmldom/xmldom) does not normalize xml:id, apply DTD default attributes or resolve external entities. The parse stage misses only XQuery-only errors and static typing (XPST0005).

XSLT per family:

FamilyApplicablePassRate
fn (XSLT functions)1,1171,10899.2%
misc1,8841,85498.4%
expr (expressions in stylesheets)65965098.6%
attr (attributes and AVTs)1,00298197.9%
insn (instructions)1,4121,38197.8%
decl (declarations, packages)1,0531,01996.8%
type (types and conversions)78777999.0%

Streaming (2,542 tests) and schema awareness are out of scope; stylesheets that ask for streaming run without it. Of the 142 failures, about 20 expect whitespace the test driver of the reference processor drops, some need what the DOM cannot do (external entities, XInclude, inherit-namespaces="no"), and about a dozen contradict the current specification (listed in tasks of the repository, not counted as bugs). Five tests are not run: they check warnings (assert-warning).

Using XSLT 3.0

import { compileStylesheet, serialize } from "@tradik/xslt3";

const parseXml = (text) =>
  new DOMParser().parseFromString(text, "application/xml");

const stylesheet = compileStylesheet(
  `<xsl:stylesheet version="3.0" expand-text="yes"
       xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
     <xsl:output method="html" html-version="5"/>
     <xsl:template match="orders">
       <ul>
         <xsl:for-each-group select="order" group-by="@city">
           <xsl:sort select="current-grouping-key()"/>
           <li>{current-grouping-key()}: {sum(current-group()/@total)}</li>
         </xsl:for-each-group>
       </ul>
     </xsl:template>
   </xsl:stylesheet>`,
  { parseXml },
);

const result = stylesheet.transform({ source: parseXml(ordersXml) });
serialize([result.principal].flat(), result.output);
// <ul><li>Gdańsk: 80</li><li>Kraków: 162.75</li></ul>

transform() returns the principal result, the secondary results of xsl:result-document (a Map by URI), the xsl:message output and the serialization parameters of xsl:output. Recursion runs on an explicit work stack, so templates nested 10,000 deep work (maxDepth sets the limit). An XSLTProcessor class with the browser's method names is also exported.

The website's playground has an XSLT 3.0 mode that runs compileStylesheet, transform and serialize in the browser. Its examples cover xsl:for-each-group (group-by, group-adjacent), xsl:analyze-string, typed xsl:functions, text value templates, xsl:iterate, maps written as JSON, xsl:try, xsl:merge, accumulators, xsl:result-document and a Muenchian 1.0 grouping rewritten; a link runs the same stylesheet with the 1.0 package for comparison.

Two XSLT 3.0 features are on by default, as the specification intends, and can be turned off per transformation:

  • xsl:evaluate compiles and runs XPath expressions built at run time, often from the source document. For untrusted input pass transform({ dynamicEvaluation: false }): xsl:evaluate then runs its xsl:fallback children or raises XTDE3175.
  • xsl:assert: transform({ assertions: false }) skips the assertions.

More transform() options: textLoader, xmlParser and collections (as for XPath: unparsed-text(), json-doc(), parse-xml(), collection() read nothing unless you pass them), buildTree: false or xsl:output method="json"/"adaptive" to get the raw result sequence (a map, an array) instead of a tree, and paramsAsUntyped: true to pass string parameters as xs:untypedAtomic so that as="xs:integer" converts them, as on a command line (untypedAtomic(value) is exported for single values). The loadStylesheet and documentLoader options receive (uri, baseUri). Errors carry the W3C code in code and, when known, location ({ module, line, column }), also appended to the message. fn:transform runs another stylesheet from inside one, and xsl:number/format-integer write English and German words and ordinals.

Without a source document (an initialTemplate run), results are built with the DOM of the stylesheet document, so createDocument is needed only when there is no DOM at all. For method="html" the default HTML version is implementation-defined (XSLT 3.0 section 26.1): stylesheets declaring version 1.0 or 2.0 get HTML 4.01 (no doctype, HTML 4 empty elements), as with @tradik/xslt-processor and XSLT 1.0/2.0 processors; version 3.0 stylesheets get HTML5 (<!DOCTYPE html>). Set html-version on xsl:output to choose.

Packages (xsl:use-package) are found through a resolver you pass to compileStylesheet; it returns the package as a document, a string or { source, baseUri }, and versionMatches(version, range) (exported) helps it pick a version:

const stylesheet = compileStylesheet(text, {
  parseXml,
  resolvePackage: (name, versionRange) => packages.get(name),
});

Using XPath 3.1

import { compileXPath, evaluateXPath } from "@tradik/xslt3";

evaluateXPath("sum((1, 2, 3)) * 2", null); // [xs:integer 12]

const query = compileXPath("//item[@price > $min]/@id ! string()", {
  variables: ["min"],
});
query.evaluate(document, { variables: { min: 10 } }); // strings

Expressions cannot reach outside the data you give them unless you allow it: doc() needs a documentLoader, unparsed-text() and json-doc() a textLoader, collection() a collections option. To let trusted expressions read local files in Node.js, pass the exported readFileUri:

import { evaluateXPath, readFileUri } from "@tradik/xslt3";

evaluateXPath("json-doc('file:///srv/data/config.json')?name", null, {
  textLoader: readFileUri,
});

The website's playground has an XPath 3.1 mode that runs this function in the browser, with examples of for/let, sort, maps, regular expressions, formatting, => and fold-left.

evaluateXPath keeps the 64 most recently compiled expressions (keyed by the expression text and the variable names; used only when no other static option is given), so calling it in a loop does not parse the expression each time. Use compileXPath to control compilation yourself.

An evaluation assumes the trees it reads do not change while it runs: results of //x and the document order are computed once per evaluation. Built-in functions only create new trees; an extension function written in JavaScript must not modify the DOM it is evaluated over.

Values are returned as data model items ({ type, value } for atomic values, DOM nodes, maps, arrays and functions). JavaScript values passed as variables are converted: string to xs:string, number to xs:double, BigInt to xs:integer, boolean, Date to xs:dateTime, arrays to sequences, plain objects and Maps to maps.

This page is generated from docs/XSLT3.md in the repository. Corrections are welcome as a pull request to that file.