diff --git a/README.md b/README.md index 587e62e..1687741 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,31 @@ 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 { readFileSync } from "fs"; +import { compile } from "xjslt/compile"; + +const stylesheetPath = "jats-html.xsl"; +const xslt = slimdom.parseXmlDocument(readFileSync(stylesheetPath, "utf-8")); +const transform = await compile(xslt); + +// Transform an XML document +const input = slimdom.parseXmlDocument(readFileSync("article.xml", "utf-8")); +const results = transform(input); +const resultDocument = results.get("#default"); + +const xml = slimdom.serializeToWellFormedString(resultDocument); + +console.log(xml); +``` + # Supported features - `if`/`choose/when/otherwise` - conditional evaluation diff --git a/package-lock.json b/package-lock.json index e6e697e..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": "*" @@ -61,6 +62,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 +1250,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 +1718,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 +1765,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 +2051,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3047,6 +3053,7 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -4153,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", @@ -4839,6 +4862,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 +4938,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5057,6 +5082,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..a248416 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" }, @@ -27,6 +30,7 @@ "@types/jest": "*", "jest": "*", "jest-simple-dot-reporter": "*", + "prettier": "^3.8.3", "ts-jest": "*", "ts-node": "*", "typescript": "*" 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..f3dda24 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -43,6 +43,7 @@ import { Statement, } from "estree"; import * as slimdom from "slimdom"; +import * as xjslt from "./xjslt"; import { compileXPathToJavaScript, evaluateXPath, @@ -51,7 +52,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 +74,7 @@ import { DecimalFormat, DEFAULT_DECIMAL_FORMAT, xpathstring, + StylesheetTransform, } from "./definitions"; import { isAlphanumeric, @@ -586,17 +588,18 @@ function compileSimpleElement(node: slimdom.Element, context: CompileContext) { function compileChooseNode(node: slimdom.Element, context: CompileContext) { let alternatives = []; for (let childNode of node.childNodes) { - if (childNode instanceof slimdom.Element) { - if (childNode.localName === "when") { + if (childNode.nodeType === node.ELEMENT_NODE) { + const childElement = childNode as slimdom.Element; + if (childElement.localName === "when") { alternatives.push( toEstree({ - test: hackXpath(childNode.getAttribute("test")), + test: hackXpath(childElement.getAttribute("test")), apply: mkArrowFun( - compileSequenceConstructor(childNode.childNodes, context), + compileSequenceConstructor(childElement.childNodes, context), ), }), ); - } else if (childNode.localName === "otherwise") { + } else if (childElement.localName === "otherwise") { alternatives.push( toEstree({ apply: mkArrowFun( @@ -974,7 +977,7 @@ function compileValueOf(node: slimdom.Element, context: CompileContext) { } function compileTextNode(node: slimdom.Element) { - if (node instanceof slimdom.Element && node.childElementCount > 0) { + if (node.nodeType === node.ELEMENT_NODE && node.childElementCount > 0) { throw new Error("XTSE0010 element found as child of xsl:text"); } return mkCallWithContext(mkMember("xjslt", "text"), [ @@ -1005,13 +1008,16 @@ function compileSequenceConstructor( return compileNodeArray(nodes, context, compileSequenceConstructorNode); } -export function compileStylesheetNode(node: slimdom.Element): Program { +export function compileStylesheetNode( + node: slimdom.Element, + injectDeps = false, +): Program { let context: CompileContext = { templates: [], whitespaceDeclarations: [] }; return { type: "Program", sourceType: "module", body: [ - ...mkImportsNode(), + ...(injectDeps ? [] : mkImportsNode()), mkFun( mkIdentifier("transform"), [mkIdentifier("document"), mkIdentifier("params")], @@ -1106,6 +1112,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 +1337,11 @@ function compileAvt(avt: string | null) { } } -function preprocess(doc: slimdom.Document, path: string): slimdom.Document { +async function preprocess( + doc: slimdom.Document, + inputURL?: URL, + readDocument?: (uri: string) => slimdom.Document, +): Promise { if ( !evaluateXPathToBoolean( "/xsl:stylesheet|/xsl:transform", @@ -1355,11 +1366,18 @@ function preprocess(doc: slimdom.Document, path: string): slimdom.Document { }, ) ) { + if (!inputURL && !readDocument) { + throw new Error( + "The transform contains xsl:include or xsl:import but no readDocument callback was provided. Pass a readDocument callback to compile() to resolve imports without the filesystem.", + ); + } doc = preprocessInclude(doc, { - inputURL: pathToFileURL(path), + inputURL: inputURL, + readDocument: readDocument, }).get("#default").document; doc = preprocessImport(doc, { - inputURL: pathToFileURL(path), + inputURL: inputURL, + readDocument: readDocument, stylesheetParams: { "base-precedence": basePrecedence }, }).get("#default").document; basePrecedence += 100; @@ -1375,7 +1393,66 @@ 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 readDocument - Optional callback to resolve `xsl:include` / + * `xsl:import` hrefs without touching the filesystem. Receives the resolved + * URI (absolute when a base is known, otherwise the raw href) and must + * return a parsed slimdom Document. Also used at runtime for `doc()` calls. + * @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, + readDocument?: (uri: string) => slimdom.Document, +): Promise { + const xsltDoc = await preprocess(xslt, undefined, readDocument); + const code = generate(compileStylesheetNode(xsltDoc.documentElement, true)); + const m: { exports: { transform?: StylesheetTransform } } = { exports: {} }; + new Function("xjslt", "module", code)(xjslt, 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( "/", @@ -1392,9 +1469,11 @@ 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 xsltURL = pathToFileURL(xsltPath); + const xsltDoc = await preprocess( slimdom.parseXmlDocument(readFileSync(xsltPath).toString()), - xsltPath, + xsltURL, + mkFsReadDocument(), ); writeFileSync( tempfile, @@ -1408,8 +1487,10 @@ 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, +): Promise { + const tempfile = await compileStylesheet(xsltPath); let transform = require(tempfile); // console.log(readFileSync(tempfile).toString()); return transform.transform; 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