Our XPath implementation in TypeScript.
- XPath 1.0: âś… Fully implemented and tested (390 tests passing)
- XPath 2.0/3.0/3.1: đź”§ Infrastructure prepared, awaiting implementation
- âś… Complete XPath 1.0 specification support
- âś… Pure TypeScript implementation with strong typing
- âś… XSLT Extensions API for XSLT 1.0 functions
- âś… Version infrastructure for future XPath 2.0+ support
- âś… Custom function support
- âś… Flexible context system
- âś… Comprehensive test coverage
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 { XPathParser, XPathLexer, createContext } from '@designliquido/xpath';
// Create parser and lexer
const parser = new XPathParser();
const lexer = new XPathLexer();
// 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);- 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
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 { XPathParser } from './parser';
import { createContext } from './context';
import { XPathNode } from './node';
export class CustomXPathSelector {
private lexer: XPathLexer;
private parser: XPathParser;
private nodeCache: WeakMap<YourNodeType, XPathNode> = new WeakMap();
constructor() {
this.lexer = new XPathLexer();
this.parser = new XPathParser();
}
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:
XPathParseracceptsoptions.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 {
XPathParser,
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
const parser = new XPathParser({ extensions });
// Create lexer and register extension functions
const lexer = new XPathLexer();
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 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