Our XPath implementation in TypeScript.
parse-json (with liberal mode support), json-to-xml, xml-to-json.? operator (e.g., $data?users?1?name).map { ... } and array { ... } (or square brackets []).=>): Chain function calls for cleaner code (e.g., $str => upper-case() => normalize-space()).`Hello {$name}`).function($x) { $x * 2 }).1 to 10.if/then/else, for, some/every quantifiers, and try/catch.instance of, castable as, and treat as.i, m, s, x) and analyze-string.fn:environment-variable.generate-id, path, innermost, and outermost.map, filter, fold-left, fold-right, and sort.Comprehensive documentation is available:
# Generate TypeDoc documentation
yarn docs
# Watch for changes and regenerate
yarn docs:watch
Documentation 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:
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);
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:
@designliquido/xpath): Provides type definitions, interfaces, and integration hooksThis approach keeps the XPath library pure while enabling XSLT functionality through a well-defined extension mechanism.
XSLTExtensions, XSLTExtensionFunction, XSLTFunctionMetadata interfacesXPathBaseParser accepts options.extensions parameterXPathLexer.registerFunctions() for dynamic function registrationXPathContext as first parameterHere'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 availabilityFor 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:
generate-id, system-property)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.