From 20d8c6d43997a3c3a3cef3484a59fdd9fbb15a0f Mon Sep 17 00:00:00 2001 From: Martin Middel Date: Wed, 6 May 2026 15:28:49 +0200 Subject: [PATCH 1/6] expose compiler directly This way external packages can use this package without needing any command line --- README.md | 31 +++++++++++++++++++++ package-lock.json | 9 +++++++ package.json | 5 +++- src/cli.ts | 6 ++--- src/compile.ts | 63 +++++++++++++++++++++++++++++++++++++------ test/xjslt.spec.ts | 67 +++++++++++++++++++++++++++++----------------- 6 files changed, 144 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 587e62e..d47cace 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,37 @@ XJSLT can compile XSLT stylesheets into executable JavaScript code, which can th - `npm run start` - Visit http://localhost:8787/?url=https://jats.nlm.nih.gov/publishing/tag-library/1.1/FullArticleSamples/bmj_sample.xml +## Programmatic API (Node.js) + +Use `compile` from `xjslt/compile` to build a transform function directly in +Node.js without writing any files to disk. The stylesheet is passed as a parsed +document, and the returned function can be called immediately. + +```ts +import * as slimdom from "slimdom"; +import { pathToFileURL } from "url"; +import { readFileSync } from "fs"; +import { compile } from "xjslt/compile"; +import { serialize } from "xjslt"; + +// Parse the stylesheet (pass its file URL so xsl:include/import can resolve) +const stylesheetPath = "jats-html.xsl"; +const xslt = slimdom.parseXmlDocument(readFileSync(stylesheetPath, "utf-8")); +const transform = await compile(xslt, pathToFileURL(stylesheetPath)); + +// Transform an XML document +const input = slimdom.parseXmlDocument(readFileSync("article.xml", "utf-8")); +const results = transform(input); +console.log(serialize(results.get("#default"))); +``` + +If the stylesheet has no `xsl:include` or `xsl:import` directives you can omit +the `inputURL` argument: + +```ts +const transform = await compile(xslt); +``` + # Supported features - `if`/`choose/when/otherwise` - conditional evaluation diff --git a/package-lock.json b/package-lock.json index e6e697e..7e7fe55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1248,6 +1249,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1715,6 +1717,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1761,6 +1764,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2046,6 +2050,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3047,6 +3052,7 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -4839,6 +4845,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4914,6 +4921,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5057,6 +5065,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index 5705ee9..717429a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "check": "jest && prettier -c src/ test/", "prepare": "tsc && chmod +x dist/cli.js" }, - "exports": "./dist/xjslt.js", + "exports": { + ".": "./dist/xjslt.js", + "./compile": "./dist/compile.js" + }, "bin": { "xjslt": "./dist/cli.js" }, diff --git a/src/cli.ts b/src/cli.ts index ca3ffba..d0bfadc 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,10 +31,10 @@ import * as path from "path"; import * as fs from "fs"; import * as process from "process"; -function run(xslt: string, xmls: Array, options: object) { +async function run(xslt: string, xmls: Array, options: object) { let transform; if (xslt.endsWith(".xsl") || xslt.endsWith(".xslt")) { - transform = buildStylesheet(xslt); + transform = await buildStylesheet(xslt); } else { let tmp = require(path.resolve(xslt)); transform = tmp.transform; @@ -125,7 +125,7 @@ async function compile(xslt: string, destination: string, options: object) { if (fs.existsSync(destinationAbs)) { throw new Error(`${destinationAbs} exists!`); } - const src = compileStylesheet(xslt); + const src = await compileStylesheet(xslt); try { if (options["web"] || options["standalone"]) { const compiler = webpack( diff --git a/src/compile.ts b/src/compile.ts index 88196fb..cc83e23 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -1330,7 +1330,7 @@ function compileAvt(avt: string | null) { } } -function preprocess(doc: slimdom.Document, path: string): slimdom.Document { +async function preprocess(doc: slimdom.Document, inputURL?: URL): Promise { if ( !evaluateXPathToBoolean( "/xsl:stylesheet|/xsl:transform", @@ -1356,10 +1356,10 @@ function preprocess(doc: slimdom.Document, path: string): slimdom.Document { ) ) { doc = preprocessInclude(doc, { - inputURL: pathToFileURL(path), + inputURL: inputURL, }).get("#default").document; doc = preprocessImport(doc, { - inputURL: pathToFileURL(path), + inputURL: inputURL, stylesheetParams: { "base-precedence": basePrecedence }, }).get("#default").document; basePrecedence += 100; @@ -1375,7 +1375,54 @@ function preprocess(doc: slimdom.Document, path: string): slimdom.Document { return doc; } -export function compileStylesheet(xsltPath: string) { +/** + * Compile an XSLT stylesheet document into a callable transform function. + * + * Unlike `buildStylesheet`, this API accepts an already-parsed document and + * executes the compiled JavaScript in-memory — no temporary files or symlinks + * are created. + * + * @param xslt - The XSLT stylesheet as a parsed slimdom Document. + * @param inputURL - Base URL used to resolve relative `xsl:include` / + * `xsl:import` hrefs. Pass `pathToFileURL(stylesheetPath)` when the + * stylesheet lives on disk, or omit it if the stylesheet has no includes. + * @returns A transform function with the signature + * `(document, params?) => Map`. + * The `"#default"` key holds the primary output document. + * + * @example + * ```ts + * import * as slimdom from "slimdom"; + * import { compile } from "xjslt/compile"; + * import { serialize } from "xjslt"; + * + * const xslt = slimdom.parseXmlDocument(` + * + * + * + * + * + * `); + * + * const transform = await compile(xslt); + * + * const input = slimdom.parseXmlDocument("Hello"); + * const output = transform(input).get("#default"); + * console.log(serialize(output)); // Hello + * ``` + */ +export async function compile( + xslt: slimdom.Document, + inputURL?: URL, +) { + const xsltDoc = await preprocess(xslt, inputURL); + const code = generate(compileStylesheetNode(xsltDoc.documentElement)); + const m: { exports: { transform?: Function } } = { exports: {} }; + new Function("require", "module", code)(require, m); + return m.exports.transform; +} + +export async function compileStylesheet(xsltPath: string) { let slimdom_path = require.resolve("slimdom").split(path.sep); let root_dir = path.join( "/", @@ -1392,9 +1439,9 @@ export function compileStylesheet(xsltPath: string) { ); symlinkSync(path.join(root_dir, "dist"), path.join(tempdir, "dist")); var tempfile = path.join(tempdir, "transform.js"); - let xsltDoc = preprocess( + const xsltDoc = await preprocess( slimdom.parseXmlDocument(readFileSync(xsltPath).toString()), - xsltPath, + pathToFileURL(xsltPath), ); writeFileSync( tempfile, @@ -1408,8 +1455,8 @@ export function compileStylesheet(xsltPath: string) { * Build a stylesheet. Returns a function that will take an input DOM * document and return an output DOM document. */ -export function buildStylesheet(xsltPath: string) { - const tempfile = compileStylesheet(xsltPath); +export async function buildStylesheet(xsltPath: string) { + const tempfile = await compileStylesheet(xsltPath); let transform = require(tempfile); // console.log(readFileSync(tempfile).toString()); return transform.transform; diff --git a/test/xjslt.spec.ts b/test/xjslt.spec.ts index 2e07c97..eff2cf7 100644 --- a/test/xjslt.spec.ts +++ b/test/xjslt.spec.ts @@ -31,6 +31,7 @@ import { } from "../src/xjslt"; import { buildStylesheet, + compile, compileAvtRaw, compileSequenceConstructorNode, compileTopLevelNode, @@ -63,14 +64,14 @@ declare module "expect" { } const serializer = new slimdom.XMLSerializer(); -function makeSimpleTransform(match: string, template: string) { +async function makeSimpleTransform(match: string, template: string) { return makeTransform(` ${template} `); } -function makeTransform(body: string) { +async function makeTransform(body: string) { const tempfile = path.join(tmpdir(), "temp.xsl"); writeFileSync( tempfile, @@ -83,7 +84,7 @@ xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> ${body} `, ); - const transform = buildStylesheet(tempfile); + const transform = await buildStylesheet(tempfile); unlinkSync(tempfile); return transform; } @@ -291,8 +292,8 @@ test("compileTemplateNode", () => { ); }); -test("compileStylesheetNode", () => { - const transform = buildStylesheet(`${__dirname}/simple2.xslt`); +test("compileStylesheetNode", async () => { + const transform = await buildStylesheet(`${__dirname}/simple2.xslt`); expect( slimdom.serializeToWellFormedString( transform( @@ -305,6 +306,22 @@ test("compileStylesheetNode", () => { ).toEqual(readFileSync(`${__dirname}/simple2.out`, "utf-8")); }); +test("compile", async () => { + const xslt = slimdom.parseXmlDocument( + readFileSync(`${__dirname}/simple2.xslt`, "utf-8"), + ); + const transform = await compile(xslt); + expect( + slimdom.serializeToWellFormedString( + transform( + slimdom.parseXmlDocument( + readFileSync(`${__dirname}/simple.xml`, "utf-8"), + ), + ).get("#default").document, + ), + ).toEqual(readFileSync(`${__dirname}/simple2.out`, "utf-8")); +}); + test("evaluateAttributeValueTemplate", () => { const nodes = evaluateXPathToNodes("//Author", document); const context = { @@ -339,8 +356,8 @@ test("evaluateAttributeValueTemplate", () => { ).toEqual(""); }); -test("elementNode", () => { - const transform = makeSimpleTransform( +test("elementNode", async () => { + const transform = await makeSimpleTransform( "//Author", "Hi!", ); @@ -352,8 +369,8 @@ test("elementNode", () => { ); }); -test("attributeNode", () => { - const transform = makeSimpleTransform( +test("attributeNode", async () => { + const transform = await makeSimpleTransform( "//Author", "", ); @@ -365,8 +382,8 @@ test("attributeNode", () => { ); }); -test("literalElementAttributeEvaluation", () => { - const transform = makeSimpleTransform( +test("literalElementAttributeEvaluation", async () => { + const transform = await makeSimpleTransform( "//Author", "", ); @@ -378,8 +395,8 @@ test("literalElementAttributeEvaluation", () => { ).toEqual("Mr. Foo"); }); -test("variableShadowing", () => { - const transform = makeSimpleTransform( +test("variableShadowing", async () => { + const transform = await makeSimpleTransform( "//Author", "", ); @@ -391,8 +408,8 @@ test("variableShadowing", () => { ); }); -test("call with param", () => { - const transform = makeTransform( +test("call with param", async () => { + const transform = await makeTransform( ` default @@ -411,8 +428,8 @@ test("call with param", () => { ); }); -test("param shadowed by variable", () => { - const transform = makeTransform( +test("param shadowed by variable", async () => { + const transform = await makeTransform( ` default @@ -432,8 +449,8 @@ test("param shadowed by variable", () => { ); }); -test("toplevel param", () => { - const transform = makeTransform( +test("toplevel param", async () => { + const transform = await makeTransform( ` toplevel @@ -450,8 +467,8 @@ test("toplevel param", () => { ); }); -test("call with param defaults", () => { - const transform = makeTransform( +test("call with param defaults", async () => { + const transform = await makeTransform( ` default @@ -469,8 +486,8 @@ test("call with param defaults", () => { ); }); -test("template mode", () => { - const transform = makeTransform( +test("template mode", async () => { + const transform = await makeTransform( ` FOO @@ -489,8 +506,8 @@ test("template mode", () => { expect(str).toMatch(/.*FOO Mr. Bar/); }); -test("text node", () => { - const transform = makeSimpleTransform( +test("text node", async () => { + const transform = await makeSimpleTransform( "//Author", `
  • -
  • `, From 55c246d6e4b4e43ce3797a4c731a5b483f685ebe Mon Sep 17 00:00:00 2001 From: Martin Middel Date: Thu, 7 May 2026 09:55:34 +0200 Subject: [PATCH 2/6] add prettier as a dev dependency --- package-lock.json | 17 +++++++++++++++++ package.json | 1 + 2 files changed, 18 insertions(+) diff --git a/package-lock.json b/package-lock.json index 7e7fe55..2666817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@types/jest": "*", "jest": "*", "jest-simple-dot-reporter": "*", + "prettier": "^3.8.3", "ts-jest": "*", "ts-node": "*", "typescript": "*" @@ -4159,6 +4160,22 @@ "node": ">=8" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", diff --git a/package.json b/package.json index 717429a..a248416 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/jest": "*", "jest": "*", "jest-simple-dot-reporter": "*", + "prettier": "^3.8.3", "ts-jest": "*", "ts-node": "*", "typescript": "*" From 8dca326cd39dec3c3d3f2c772e6eb2847db8108b Mon Sep 17 00:00:00 2001 From: Martin Middel Date: Thu, 7 May 2026 10:19:11 +0200 Subject: [PATCH 3/6] Allow injecting access to the filesystem from the runtime This way we can run theoretically run the transpiler in the browser, without any filesystem access --- README.md | 14 +- src/compile.ts | 49 +++- src/definitions.ts | 12 + src/preprocess/attribute-set.js | 224 +++++++----------- src/preprocess/error-analysis.js | 154 ++++--------- src/preprocess/import.js | 274 +++++++++------------- src/preprocess/include.js | 222 +++++++----------- src/preprocess/simplified.js | 214 ++++++------------ src/preprocess/stripWhitespace1.js | 120 +++------- src/preprocess/stripWhitespace2.js | 350 ++++++++++++----------------- src/preprocess/use-when.js | 194 ++++++---------- src/util.ts | 19 +- test/suite.spec.ts | 4 +- test/xjslt.spec.ts | 95 +++++++- 14 files changed, 755 insertions(+), 1190 deletions(-) diff --git a/README.md b/README.md index d47cace..1687741 100644 --- a/README.md +++ b/README.md @@ -54,27 +54,21 @@ document, and the returned function can be called immediately. ```ts import * as slimdom from "slimdom"; -import { pathToFileURL } from "url"; import { readFileSync } from "fs"; import { compile } from "xjslt/compile"; -import { serialize } from "xjslt"; -// Parse the stylesheet (pass its file URL so xsl:include/import can resolve) const stylesheetPath = "jats-html.xsl"; const xslt = slimdom.parseXmlDocument(readFileSync(stylesheetPath, "utf-8")); -const transform = await compile(xslt, pathToFileURL(stylesheetPath)); +const transform = await compile(xslt); // Transform an XML document const input = slimdom.parseXmlDocument(readFileSync("article.xml", "utf-8")); const results = transform(input); -console.log(serialize(results.get("#default"))); -``` +const resultDocument = results.get("#default"); -If the stylesheet has no `xsl:include` or `xsl:import` directives you can omit -the `inputURL` argument: +const xml = slimdom.serializeToWellFormedString(resultDocument); -```ts -const transform = await compile(xslt); +console.log(xml); ``` # Supported features diff --git a/src/compile.ts b/src/compile.ts index cc83e23..4ff3c7d 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -51,7 +51,7 @@ import { NamespaceResolver, } from "fontoxpath"; import { readFileSync, writeFileSync, symlinkSync } from "fs"; -import { pathToFileURL } from "url"; +import { pathToFileURL, fileURLToPath } from "url"; import * as path from "path"; import { tmpdir } from "os"; import { mkdtempSync } from "fs"; @@ -73,6 +73,7 @@ import { DecimalFormat, DEFAULT_DECIMAL_FORMAT, xpathstring, + StylesheetTransform, } from "./definitions"; import { isAlphanumeric, @@ -1106,6 +1107,7 @@ export function compileStylesheetNode(node: slimdom.Element): Program { templates: sortSortable(context.templates), variableScopes: [mkNew(mkIdentifier("Map"), [])], inputURL: mkMember("params", "inputURL"), + readDocument: mkMember("params", "readDocument"), keys: mkIdentifier("keys"), outputDefinitions: mkIdentifier("outputDefinitions"), decimalFormats: mkIdentifier("decimalFormats"), @@ -1330,7 +1332,11 @@ function compileAvt(avt: string | null) { } } -async function preprocess(doc: slimdom.Document, inputURL?: URL): Promise { +async function preprocess( + doc: slimdom.Document, + inputURL?: URL, + readDocument?: (uri: string) => slimdom.Document, +): Promise { if ( !evaluateXPathToBoolean( "/xsl:stylesheet|/xsl:transform", @@ -1355,11 +1361,18 @@ async function preprocess(doc: slimdom.Document, inputURL?: URL): Promise Map`. * The `"#default"` key holds the primary output document. @@ -1413,15 +1427,26 @@ async function preprocess(doc: slimdom.Document, inputURL?: URL): Promise slimdom.Document, +): Promise { + const xsltDoc = await preprocess(xslt, undefined, readDocument); const code = generate(compileStylesheetNode(xsltDoc.documentElement)); - const m: { exports: { transform?: Function } } = { exports: {} }; + const m: { exports: { transform?: StylesheetTransform } } = { exports: {} }; new Function("require", "module", code)(require, m); return m.exports.transform; } +function mkFsReadDocument(): (uri: string) => slimdom.Document { + return (uri: string) => { + if (uri.startsWith("file:")) { + return slimdom.parseXmlDocument( + readFileSync(fileURLToPath(new URL(uri))).toString(), + ); + } + return undefined; + }; +} + export async function compileStylesheet(xsltPath: string) { let slimdom_path = require.resolve("slimdom").split(path.sep); let root_dir = path.join( @@ -1439,9 +1464,11 @@ export async function compileStylesheet(xsltPath: string) { ); symlinkSync(path.join(root_dir, "dist"), path.join(tempdir, "dist")); var tempfile = path.join(tempdir, "transform.js"); + const xsltURL = pathToFileURL(xsltPath); const xsltDoc = await preprocess( slimdom.parseXmlDocument(readFileSync(xsltPath).toString()), - pathToFileURL(xsltPath), + xsltURL, + mkFsReadDocument(), ); writeFileSync( tempfile, @@ -1455,7 +1482,7 @@ export async function compileStylesheet(xsltPath: string) { * Build a stylesheet. Returns a function that will take an input DOM * document and return an output DOM document. */ -export async function buildStylesheet(xsltPath: string) { +export async function buildStylesheet(xsltPath: string): Promise { const tempfile = await compileStylesheet(xsltPath); let transform = require(tempfile); // console.log(readFileSync(tempfile).toString()); diff --git a/src/definitions.ts b/src/definitions.ts index c54fd76..9ae1c5e 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -157,6 +157,7 @@ export interface TransformParams { outputDocument?: slimdom.Document; outputNode?: slimdom.Node; inputURL?: string; + readDocument?: (uri: string) => slimdom.Document; initialMode?: string; stylesheetParams?: object; } @@ -181,6 +182,16 @@ export type OutputResult = OutputDefinition & { document: slimdom.Document; }; +export interface TransformResultMap extends Map { + get(key: "#default"): OutputResult; + get(key: string): OutputResult | undefined; +} + +export type StylesheetTransform = ( + document: slimdom.Document, + params?: TransformParams, +) => TransformResultMap; + export type Appender = (content: any) => Appender | undefined; export interface DynamicContext { @@ -192,6 +203,7 @@ export interface DynamicContext { variableScopes: Array; nextMatches?: Generator