diff --git a/README.md b/README.md index eebe234..5f377da 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,31 @@ 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 = 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 All core features of XSLT 2.0. Roughly 50% of tests in the XSLT test suite (https://github.com/w3c/xslt30-test) pass - but many of these tests are for edge cases. @@ -125,7 +150,7 @@ The test suite includes both unit tests and a subset of the [W3C XSLT 3.0 test s npm install ``` -and then run: +and then run tests: ``` npm test diff --git a/package.json b/package.json index ef35828..bf5cd1c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,10 @@ "COPYING.LESSER" ], "main": "./dist/xjslt.js", - "exports": "./dist/xjslt.js", + "exports": { + ".": "./dist/xjslt.js", + "./compile": "./dist/compile.js" + }, "types": "./dist/xjslt.d.ts", "bin": { "xjslt": "./dist/cli.js" diff --git a/src/cli.ts b/src/cli.ts index b5f1cd3..c7f09fe 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,14 +21,13 @@ import * as slimdom from "slimdom"; import { Command, Option } from "commander"; -import { buildStylesheet, compileStylesheet } from "./compile"; +import { compileFromPath, compileToFile } from "./compile"; import { serialize } from "./xjslt"; -import { access, constants, readFile, writeFile } from "fs/promises"; +import { readFile, writeFile } from "fs/promises"; import { pathToFileURL } from "url"; import { webpack } from "webpack"; import * as url from "url"; import * as path from "path"; -import * as fs from "fs"; import * as process from "process"; async function processXml(xml: string, transform, options: object) { @@ -61,7 +60,7 @@ async function run(xslt: string, xmls: Array, options: object) { let tmp = require(path.resolve(xslt)); transform = tmp.transform; } else { - transform = buildStylesheet(xslt); + transform = await compileFromPath(xslt); } await Promise.all(xmls.map((xml) => processXml(xmls[0], transform, options))); } @@ -122,7 +121,7 @@ function mkStandaloneConfig(src, destinationAbs) { async function compile(xslt: string, destination: string, options: object) { const destinationAbs = path.resolve(destination); - const src = compileStylesheet(xslt); + const src = await compileToFile(xslt); try { if (options["web"] || options["standalone"]) { const compiler = webpack( diff --git a/src/compile.ts b/src/compile.ts index 9f54723..d342b33 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, @@ -50,7 +51,8 @@ import { evaluateXPathToNodes, } from "fontoxpath"; import { readFileSync, writeFileSync, symlinkSync } from "fs"; -import { pathToFileURL } from "url"; +import { readFile, symlink, writeFile } from "fs/promises"; +import { pathToFileURL, fileURLToPath } from "url"; import * as path from "path"; import { tmpdir } from "os"; import { mkdtempSync } from "fs"; @@ -75,6 +77,7 @@ import { Rule, TemplateForCompilation, TemplateIndex, + StylesheetTransform, } from "./definitions"; import { compareSortable, @@ -592,17 +595,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( @@ -983,7 +987,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"), [ @@ -1023,7 +1027,11 @@ function buildNamedTemplates( ); return toEstree(retval); } -export function compileStylesheetNode(node: slimdom.Element): Program { + +export function compileStylesheetNode( + node: slimdom.Element, + injectDeps = false, +): Program { let context: CompileContext = { declarationCounter: 0, namedTemplates: new Map(), @@ -1037,7 +1045,7 @@ export function compileStylesheetNode(node: slimdom.Element): Program { type: "Program", sourceType: "module", body: [ - ...mkImportsNode(), + ...(injectDeps ? [] : mkImportsNode()), mkFun( mkIdentifier("transform"), [mkIdentifier("document"), mkIdentifier("params")], @@ -1140,6 +1148,7 @@ export function compileStylesheetNode(node: slimdom.Element): Program { variableScopes: [mkNew(mkIdentifier("Map"), [])], inputURL: mkMember("params", "inputURL"), ruleTree: toEstree(buildRuleTree(context.rules)), + readDocument: mkMember("params", "readDocument"), keys: mkIdentifier("keys"), outputDefinitions: mkIdentifier("outputDefinitions"), decimalFormats: mkIdentifier("decimalFormats"), @@ -1447,7 +1456,11 @@ function compileAvt(avt: string | null) { } } -function preprocess(doc: slimdom.Document, path: string): slimdom.Document { +function preprocess( + doc: slimdom.Document, + inputURL: URL, + readDocument: (uri: string) => slimdom.Document, +): slimdom.Document { if ( !evaluateXPathToBoolean( "/xsl:stylesheet|/xsl:transform", @@ -1473,10 +1486,12 @@ function preprocess(doc: slimdom.Document, path: string): slimdom.Document { ) ) { 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; @@ -1492,7 +1507,77 @@ 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 `compileToFile`, 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 function compile( + xslt: slimdom.Document, + readDocument?: (uri: string) => slimdom.Document, + inputURL?: URL, +): StylesheetTransform { + const xsltDoc = preprocess(xslt, inputURL, 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(), + ); + } + throw new Error(`FODC0005: document ${uri} not found`); + }; +} + +async function readAndParseXml(path: string): Promise { + const str = (await readFile(path)).toString(); + return slimdom.parseXmlDocument(str); +} + +function readAndParseXmlSync(path: string): slimdom.Document { + const str = readFileSync(path).toString(); + return slimdom.parseXmlDocument(str); +} + +export async function compileToFile(xsltPath: string) { let slimdom_path = require.resolve("slimdom").split(path.sep); let root_dir = path.join( "/", @@ -1509,11 +1594,13 @@ 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( - slimdom.parseXmlDocument(readFileSync(xsltPath).toString()), - xsltPath, + const xsltURL = pathToFileURL(xsltPath); + const xsltDoc = await preprocess( + await readAndParseXml(xsltPath), + xsltURL, + mkFsReadDocument(), ); - writeFileSync( + await writeFile( tempfile, generate(compileStylesheetNode(xsltDoc.documentElement)), ); @@ -1525,9 +1612,20 @@ 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); - let transform = require(tempfile); - // console.log(readFileSync(tempfile).toString()); - return transform.transform; +export async function compileFromPath( + xsltPath: string, +): Promise { + return compile( + await readAndParseXml(xsltPath), + mkFsReadDocument(), + pathToFileURL(xsltPath), + ); +} + +export function compileFromPathSync(xsltPath: string): StylesheetTransform { + return compile( + readAndParseXmlSync(xsltPath), + mkFsReadDocument(), + pathToFileURL(xsltPath), + ); } diff --git a/src/definitions.ts b/src/definitions.ts index 1c3a0d9..ccc81e3 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -166,6 +166,7 @@ export interface TransformParams { outputDocument?: slimdom.Document; outputNode?: slimdom.Node; inputURL?: string; + readDocument?: (uri: string) => slimdom.Document; initialMode?: string; stylesheetParams?: object; } @@ -191,6 +192,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 { @@ -202,6 +213,7 @@ export interface DynamicContext { variableScopes: Array; nextMatches?: Generator