Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 4 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -61,7 +60,7 @@ async function run(xslt: string, xmls: Array<string>, 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)));
}
Expand Down Expand Up @@ -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(
Expand Down
142 changes: 120 additions & 22 deletions src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ import {
Statement,
} from "estree";
import * as slimdom from "slimdom";
import * as xjslt from "./xjslt";
import {
compileXPathToJavaScript,
evaluateXPath,
evaluateXPathToBoolean,
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";
Expand All @@ -75,6 +77,7 @@ import {
Rule,
TemplateForCompilation,
TemplateIndex,
StylesheetTransform,
} from "./definitions";
import {
compareSortable,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"), [
Expand Down Expand Up @@ -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(),
Expand All @@ -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")],
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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",
Expand All @@ -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;
Expand All @@ -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<string, OutputResult>`.
* 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(`
* <xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
* <xsl:template match="/">
* <result><xsl:value-of select="/doc/title"/></result>
* </xsl:template>
* </xsl:stylesheet>
* `);
*
* const transform = await compile(xslt);
*
* const input = slimdom.parseXmlDocument("<doc><title>Hello</title></doc>");
* const output = transform(input).get("#default");
* console.log(serialize(output)); // <result>Hello</result>
* ```
*/
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<slimdom.Document> {
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(
"/",
Expand All @@ -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)),
);
Expand All @@ -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<StylesheetTransform> {
return compile(
await readAndParseXml(xsltPath),
mkFsReadDocument(),
pathToFileURL(xsltPath),
);
}

export function compileFromPathSync(xsltPath: string): StylesheetTransform {
return compile(
readAndParseXmlSync(xsltPath),
mkFsReadDocument(),
pathToFileURL(xsltPath),
);
}
12 changes: 12 additions & 0 deletions src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface TransformParams {
outputDocument?: slimdom.Document;
outputNode?: slimdom.Node;
inputURL?: string;
readDocument?: (uri: string) => slimdom.Document;
initialMode?: string;
stylesheetParams?: object;
}
Expand All @@ -191,6 +192,16 @@ export type OutputResult = OutputDefinition & {
document: slimdom.Document;
};

export interface TransformResultMap extends Map<string, OutputResult> {
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 {
Expand All @@ -202,6 +213,7 @@ export interface DynamicContext {
variableScopes: Array<VariableScope>;
nextMatches?: Generator<Template>;
inputURL: URL;
readDocument: (uri: string) => slimdom.Document;
currentGroup?: NodeGroup;
keys: Map<String, Key>;
patternMatchCache: PatternMatchCache;
Expand Down
Loading