Our XPath implementation in TypeScript.
- XPath 1.0: âś… Fully implemented and tested
- XPath 2.0/3.0/3.1: âś… Fully implemented, including maps, arrays, and JSON support (99.9% test pass rate)
- Pure TypeScript: Written in strictly typed TypeScript for robustness and ease of use.
- Supported Versions: Full support for XPath 1.0, 2.0, 3.0, and 3.1.
- Extensible: Custom function support and XSLT Extensions API.
- Isomorphic: Runs in Node.js and modern browsers.
- Maps & Arrays: Native support for XDM Maps and Arrays, fully interoperable with JSON.
- JSON Functions:
parse-json(with liberal mode support),json-to-xml,xml-to-json. - Lookup Operator: Drill down into data structures using the
?operator (e.g.,$data?users?1?name). - Constructors: Create maps and arrays using
map { ... }andarray { ... }(or square brackets[]).
- Arrow Operator (
=>): Chain function calls for cleaner code (e.g.,$str => upper-case() => normalize-space()). - String Templates: Interpolated strings using backticks (
`Hello {$name}`). - Inline Functions: Define anonymous functions/lambdas (e.g.,
function($x) { $x * 2 }). - Range Operator: Generate sequences easily with
1 to 10. - Control Flow: Support for
if/then/else,for,some/everyquantifiers, andtry/catch.
- Schema-Awareness: Full support for validating nodes against XML Schemas and handling typed values.
- Type Checking: Advanced type system handling
instance of,castable as, andtreat as. - Union Types: Support for complex type definitions and checking.
- Streaming Evaluation: Capable of processing large documents with low memory footprint using XSLT 3.0 capabilities (posture and sweep analysis).
- Profiler: Built-in expression profiler to analyze execution time and memory usage.
- Optimizer: Static analysis tools to suggest query optimizations.
- Regular Expressions: Full XPath regex support including flags (
i,m,s,x) andanalyze-string. - Date/Time: Comprehensive duration, date, and time manipulation.
- Environment: Access system environment variables via
fn:environment-variable. - Node Functions: Advanced node operations like
generate-id,path,innermost, andoutermost. - Higher-Order Functions: Functional programming with
map,filter,fold-left,fold-right, andsort.
Comprehensive documentation is available:
- API Documentation - Auto-generated TypeDoc API reference (published via GitHub Pages)
- Interactive XPath Tester - Test XPath expressions in your browser
- Custom Selectors - Integrate XPath with custom DOM implementations
- XSLT Extensions API - Extend XPath with XSLT functions
- XPath Version Support - Infrastructure for XPath 2.0/3.0/3.1
- Migration Guide - Upgrading from XPath 1.0 to 2.0
- Incompatibilities Guide - Known version differences
# Generate TypeDoc documentation
yarn docs
# Watch for changes and regenerate
yarn docs:watchDocumentation is automatically built and published to GitHub Pages on every push to the main branch.
We maintain another open source package called xslt-processor. The XPath component the project had became impossible to maintain due to a variety of reasons. xslt-processor uses this project as a submodule since its version 4.
This repository is intended to solve a particular problem in our packages, but it can be used by any other NPM package.
import { XPath10Parser, XPathLexer, createContext } from '@designliquido/xpath';
// Create parser and lexer for XPath 1.0
const parser = new XPath10Parser();
const lexer = new XPathLexer('1.0');
// Parse an XPath expression
const tokens = lexer.scan('//book[price > 30]/title');
const expression = parser.parse(tokens);
// Evaluate against your DOM
const context = createContext(documentNode);
const result = expression.evaluate(context);This library supports multiple XPath versions. Choose the appropriate parser and lexer configuration based on your needs.
For XPath 1.0 expressions (XSLT 1.0 compatibility):
import { XPath10Parser, XPathLexer, createContext } from '@designliquido/xpath';
// Explicit version
const lexer = new XPathLexer('1.0');
const parser = new XPath10Parser();
// Or use defaults (both default to 1.0)
const lexer = new XPathLexer();
const parser = new XPath10Parser();
const tokens = lexer.scan('//book[@price > 30]');
const ast = parser.parse(tokens);For XPath 2.0 expressions with conditionals, for expressions, and quantified expressions:
import { XPath20Parser, XPathLexer, createContext } from '@designliquido/xpath';
// IMPORTANT: Use matching versions for lexer and parser
const lexer = new XPathLexer('2.0'); // Recognizes 'if', 'then', 'else', 'for', etc.
const parser = new XPath20Parser();
// if-then-else expressions
const tokens1 = lexer.scan("if ($price > 100) then 'expensive' else 'affordable'");
const ast1 = parser.parse(tokens1);
// for expressions
const tokens2 = lexer.scan('for $x in (1, 2, 3) return $x * 2');
const ast2 = parser.parse(tokens2);
// quantified expressions
const tokens3 = lexer.scan('some $x in //item satisfies $x/@stock > 0');
const ast3 = parser.parse(tokens3);For automatic parser selection based on version:
import { createXPathParser, XPathLexer } from '@designliquido/xpath';
// Create parser for specific version
const parser10 = createXPathParser('1.0');
const parser20 = createXPathParser('2.0');
// With options
const parser = createXPathParser('1.0', {
enableNamespaceAxis: true,
});The lexer version determines how certain keywords are tokenized:
| Keyword | XPath 1.0 | XPath 2.0 |
|---|---|---|
if |
Identifier (element name) | Reserved word |
then |
Identifier (element name) | Reserved word |
else |
Identifier (element name) | Reserved word |
for |
Identifier (element name) | Reserved word |
return |
Identifier (element name) | Reserved word |
some |
Identifier (element name) | Reserved word |
every |
Identifier (element name) | Reserved word |
Important: Always match your lexer and parser versions. Using an XPath 1.0 lexer with an XPath 2.0 parser will cause parsing errors for 2.0-specific syntax.
For detailed information about version-specific features and the implementation roadmap, see:
- XPATH-VERSIONS.md - Version support infrastructure and feature flags
- Migration Guide - Upgrading from XPath 1.0 to 2.0
You can implement custom selectors by wrapping the XPath parser and lexer. This is useful when you need to integrate XPath with your own DOM implementation.
Here's how to create a custom selector class:
import { XPathLexer } from './lexer';
import { XPath10Parser } from './parser';
import { createContext } from './context';
import { XPathNode } from './node';
export class CustomXPathSelector {
private lexer: XPathLexer;
private parser: XPath10Parser;
private nodeCache: WeakMap<YourNodeType, XPathNode> = new WeakMap();
constructor() {
// Use XPath 1.0 for most DOM use cases
this.lexer = new XPathLexer('1.0');
this.parser = new XPath10Parser();
}
public select(expression: string, contextNode: YourNodeType): YourNodeType[] {
// 1. Tokenize the XPath expression
const tokens = this.lexer.scan(expression);
// 2. Parse tokens into an AST
const ast = this.parser.parse(tokens);
// 3. Clear cache for each selection
this.nodeCache = new WeakMap();
// 4. Convert your node to XPathNode
const xpathNode = this.convertToXPathNode(contextNode);
// 5. Create context and evaluate
const context = createContext(xpathNode);
const result = ast.evaluate(context);
// 6. Convert results back to your node type
return this.convertResult(result);
}
}The key to custom selectors is converting between your DOM nodes and XPathNode format:
private convertToXPathNode(node: YourNodeType): XPathNode {
// Check cache to avoid infinite recursion
const cached = this.nodeCache.get(node);
if (cached) return cached;
// Filter out attribute nodes (nodeType = 2) from children
const childNodes = node.childNodes || [];
const attributes = childNodes.filter(n => n.nodeType === 2);
const elementChildren = childNodes.filter(n => n.nodeType !== 2);
// Create XPathNode BEFORE converting children to prevent infinite recursion
const xpathNode: XPathNode = {
nodeType: this.getNodeType(node),
nodeName: node.nodeName || '#document',
localName: node.localName || node.nodeName,
namespaceUri: node.namespaceUri || null,
textContent: node.nodeValue,
parentNode: null, // Avoid cycles
childNodes: [], // Will be populated
attributes: [], // Will be populated
nextSibling: null,
previousSibling: null,
ownerDocument: null
};
// Cache BEFORE converting children
this.nodeCache.set(node, xpathNode);
// NOW convert children and attributes
xpathNode.childNodes = elementChildren.map(child =>
this.convertToXPathNode(child)
);
xpathNode.attributes = attributes.map(attr =>
this.convertToXPathNode(attr)
);
return xpathNode;
}Map your node types to standard DOM node types:
private getNodeType(node: YourNodeType): number {
if (node.nodeType !== undefined) return node.nodeType;
// Map node names to standard node types
switch (node.nodeName?.toLowerCase()) {
case '#text':
return 3; // TEXT_NODE
case '#comment':
return 8; // COMMENT_NODE
case '#document':
return 9; // DOCUMENT_NODE
case '#document-fragment':
return 11; // DOCUMENT_FRAGMENT_NODE
default:
return 1; // ELEMENT_NODE
}
}Convert XPath results back to your node type:
private convertResult(result: any): YourNodeType[] {
if (Array.isArray(result)) {
return result.map(node => this.convertFromXPathNode(node));
}
if (result && typeof result === 'object' && 'nodeType' in result) {
return [this.convertFromXPathNode(result)];
}
return [];
}
private convertFromXPathNode(xpathNode: XPathNode): YourNodeType {
return {
nodeType: xpathNode.nodeType,
nodeName: xpathNode.nodeName,
localName: xpathNode.localName,
namespaceUri: xpathNode.namespaceUri,
nodeValue: xpathNode.textContent,
parent: xpathNode.parentNode ?
this.convertFromXPathNode(xpathNode.parentNode) : undefined,
children: xpathNode.childNodes ?
Array.from(xpathNode.childNodes).map(child =>
this.convertFromXPathNode(child)) : undefined,
attributes: xpathNode.attributes ?
Array.from(xpathNode.attributes).map(attr =>
this.convertFromXPathNode(attr)) : undefined,
nextSibling: xpathNode.nextSibling ?
this.convertFromXPathNode(xpathNode.nextSibling) : undefined,
previousSibling: xpathNode.previousSibling ?
this.convertFromXPathNode(xpathNode.previousSibling) : undefined
} as YourNodeType;
}const selector = new CustomXPathSelector();
// Select all book elements
const books = selector.select('//book', documentNode);
// Select books with price > 30
const expensiveBooks = selector.select('//book[price > 30]', documentNode);
// Select first book title
const firstTitle = selector.select('//book[1]/title', documentNode);- Caching: Use WeakMap to cache node conversions and prevent memory leaks
- Recursion: Cache nodes BEFORE converting children to avoid infinite loops
- Attributes: Filter attributes (nodeType = 2) separately from element children
- Null Safety: Handle null/undefined values when converting between node types
- Performance: Clear the cache between selections to avoid stale references
For a complete working example, see the XPathSelector implementation in xslt-processor.
This library provides a pure XPath 1.0 implementation. However, it also includes a clean integration API for XSLT-specific functions, allowing the xslt-processor package (or any other XSLT implementation) to extend XPath with XSLT 1.0 functions like document(), key(), format-number(), generate-id(), and others.
The XSLT Extensions API follows a separation of concerns pattern:
- This package (
@designliquido/xpath): Provides type definitions, interfaces, and integration hooks - XSLT processor packages: Implement the actual XSLT function logic
This approach keeps the XPath library pure while enabling XSLT functionality through a well-defined extension mechanism.
- Type Definitions:
XSLTExtensions,XSLTExtensionFunction,XSLTFunctionMetadatainterfaces - Parser Integration:
XPathBaseParseracceptsoptions.extensionsparameter - Lexer Support:
XPathLexer.registerFunctions()for dynamic function registration - Context Integration: Extension functions receive
XPathContextas first parameter
Here's how to use XSLT extensions (typically done by the xslt-processor package):
import {
XPath10Parser,
XPathLexer,
XSLTExtensions,
XSLTFunctionMetadata,
getExtensionFunctionNames,
XPathContext,
} from '@designliquido/xpath';
// Define XSLT extension functions
const xsltFunctions: XSLTFunctionMetadata[] = [
{
name: 'generate-id',
minArgs: 0,
maxArgs: 1,
implementation: (context: XPathContext, nodeSet?: any[]) => {
const node = nodeSet?.[0] || context.node;
return `id-${generateUniqueId(node)}`;
},
description: 'Generate unique identifier for a node',
},
{
name: 'system-property',
minArgs: 1,
maxArgs: 1,
implementation: (context: XPathContext, propertyName: string) => {
const properties = {
'xsl:version': '1.0',
'xsl:vendor': 'Design Liquido XPath',
'xsl:vendor-url': 'https://github.com/designliquido/xpath',
};
return properties[String(propertyName)] || '';
},
description: 'Query XSLT processor properties',
},
];
// Create extensions bundle
const extensions: XSLTExtensions = {
functions: xsltFunctions,
version: '1.0',
};
// Create parser with extensions (XPath 1.0 for XSLT 1.0 compatibility)
const parser = new XPath10Parser({ extensions });
// Create lexer and register extension functions
const lexer = new XPathLexer('1.0');
lexer.registerFunctions(getExtensionFunctionNames(extensions));
// Parse expression
const tokens = lexer.scan('generate-id()');
const expression = parser.parse(tokens);
// Create context with extension functions
const context: XPathContext = {
node: rootNode,
functions: {
'generate-id': xsltFunctions[0].implementation,
'system-property': xsltFunctions[1].implementation,
},
};
// Evaluate
const result = expression.evaluate(context);XSLT extension functions receive the evaluation context as their first parameter:
type XSLTExtensionFunction = (context: XPathContext, ...args: any[]) => any;This allows extension functions to access:
context.node- current context nodecontext.position- position in node-set (1-based)context.size- size of current node-setcontext.variables- XPath variablescontext.functions- other registered functions
// Validate extensions bundle for errors
const errors = validateExtensions(extensions);
if (errors.length > 0) {
console.error('Extension validation errors:', errors);
}
// Extract function names for lexer registration
const functionNames = getExtensionFunctionNames(extensions);
lexer.registerFunctions(functionNames);
// Create empty extensions bundle
const emptyExtensions = createEmptyExtensions('1.0');The following XSLT 1.0 functions are designed to be implemented via this extension API:
document()- Load external XML documentskey()- Efficient node lookup using keysformat-number()- Number formatting with patternsgenerate-id()- Generate unique node identifiersunparsed-entity-uri()- Get URI of unparsed entitiessystem-property()- Query processor propertieselement-available()- Check XSLT element availabilityfunction-available()- Check function availability
For detailed implementation guidance, see TODO.md.
XSLT functions may require additional context data beyond standard XPath context:
const context: XPathContext = {
node: rootNode,
functions: {
'generate-id': generateIdImpl,
key: keyImpl,
'format-number': formatNumberImpl,
},
// XSLT-specific context extensions
xsltVersion: '1.0',
// For key() function
keys: {
'employee-id': { match: 'employee', use: '@id' },
},
// For document() function
documentLoader: (uri: string) => loadXmlDocument(uri),
// For format-number() function
decimalFormats: {
euro: { decimalSeparator: ',', groupingSeparator: '.' },
},
// For system-property() function
systemProperties: {
'xsl:version': '1.0',
'xsl:vendor': 'Design Liquido',
},
};For a complete implementation example, see the test suite at https://github.com/DesignLiquido/xpath/blob/main/tests/xslt-extensions.test.ts, which demonstrates:
- Creating and validating extension bundles
- Registering extensions with parser and lexer
- Implementing sample XSLT functions (
generate-id,system-property) - End-to-end evaluation with extension functions
This section documents changes to the API and how to migrate from older versions.
Prior versions used abstract or unversioned parser/lexer classes. The new API uses explicit versioned classes for better clarity and type safety.
// OLD (deprecated):
import { XPathBaseParser } from '@designliquido/xpath';
const parser = new XPathBaseParser(); // Error: XPathBaseParser is abstract
// NEW (recommended):
import { XPath10Parser } from '@designliquido/xpath';
const parser = new XPath10Parser();
// Or use the factory:
import { createXPathParser } from '@designliquido/xpath';
const parser = createXPathParser('1.0');// OLD (may have defaulted to 2.0):
import { XPathLexer } from '@designliquido/xpath';
const lexer = new XPathLexer(); // Was defaulting to '2.0'
// NEW (explicit version, defaults to 1.0):
import { XPathLexer } from '@designliquido/xpath';
const lexer = new XPathLexer('1.0'); // Explicit XPath 1.0
// Or with options object:
const lexer = new XPathLexer({ version: '1.0' });Important: The lexer default version has changed from '2.0' to '1.0' for backward compatibility with XPath 1.0/XSLT 1.0 use cases.
If your code relied on the old default and uses XPath 2.0 features, update your lexer instantiation:
// If you were using XPath 2.0 features with the old default:
const lexer = new XPathLexer(); // OLD: defaulted to 2.0
// Update to explicit 2.0:
const lexer = new XPathLexer('2.0');| Old API | New API |
|---|---|
new XPathBaseParser() |
new XPath10Parser() or createXPathParser('1.0') |
new XPathBaseParser({ version: '2.0' }) |
new XPath20Parser() or createXPathParser('2.0') |
new XPathLexer() (was 2.0) |
new XPathLexer('1.0') (now 1.0) |
new XPathLexer('2.0') |
new XPathLexer('2.0') (unchanged) |
For gradual migration, XPathParser is available as an alias for XPath10Parser:
import { XPathParser } from '@designliquido/xpath';
const parser = new XPathParser(); // Same as new XPath10Parser()This alias is deprecated and will be removed in a future major version. Prefer using XPath10Parser directly.