diff --git a/README.md b/README.md index e1e11ef..26491ee 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,39 @@ const xslt = new Xslt(options); - `name`: the parameter name; - `namespaceUri` (optional): the namespace; - `value`: the value. +- `fetchFunction` (`(uri: string) => Promise`, optional): a custom function for loading external resources referenced by `` and ``. Receives the URI and must return the fetched content as a string. Defaults to the global `fetch` API. This is useful for: + - Denying external loading entirely; + - Loading from the local filesystem or other non-HTTP sources; + - Transforming or remapping URIs before fetching. + +**`fetchFunction` examples:** + +```js +import { readFileSync } from 'fs'; + +// Deny all external loading +const xslt = new Xslt({ + fetchFunction: async (uri) => { + throw new Error(`External loading is not allowed: ${uri}`); + } +}); + +// Load from local filesystem +const xslt = new Xslt({ + fetchFunction: async (uri) => { + return readFileSync(uri, 'utf-8'); + } +}); + +// Remap URIs before fetching +const xslt = new Xslt({ + fetchFunction: async (uri) => { + const remapped = uri.replace('https://example.com/', '/local/stylesheets/'); + const response = await fetch(remapped); + return response.text(); + } +}); +``` #### JSON Output Format diff --git a/src/xslt/types.ts b/src/xslt/types.ts index a031e4c..17330b1 100644 --- a/src/xslt/types.ts +++ b/src/xslt/types.ts @@ -35,5 +35,6 @@ export type XsltOptions = { escape: boolean, selfClosingTags: boolean, outputMethod?: 'xml' | 'html' | 'text' | 'xhtml' | 'json' | 'adaptive', - parameters?: XsltParameter[] + parameters?: XsltParameter[], + fetchFunction?: (uri: string) => Promise } diff --git a/src/xslt/xslt.ts b/src/xslt/xslt.ts index 2b50528..4893e25 100644 --- a/src/xslt/xslt.ts +++ b/src/xslt/xslt.ts @@ -141,6 +141,13 @@ export class Xslt { decimalFormatSettings: XsltDecimalFormatSettings; warningsCallback: (...args: any[]) => void; + /** + * Custom fetch function for loading external resources (e.g. xsl:import, xsl:include). + * Takes a URI and returns the fetched content as a string. + * Defaults to using the global `fetch` API. + */ + fetchFunction: (uri: string) => Promise; + outputDocument: XDocument; outputMethod: 'xml' | 'html' | 'text' | 'name' | 'xhtml' | 'json' | 'adaptive'; outputOmitXmlDeclaration: string; @@ -307,6 +314,22 @@ export class Xslt { this.firstTemplateRan = false; this.forwardsCompatible = false; this.warningsCallback = console.warn.bind(console); + this.fetchFunction = options.fetchFunction || (async (uri: string) => { + const globalFetch = + typeof globalThis !== 'undefined' && typeof (globalThis as any).fetch === 'function' + ? (globalThis as any).fetch + : null; + + if (!globalFetch) { + throw new Error( + 'No global fetch implementation available. ' + + 'Please provide options.fetchFunction or use a runtime that exposes globalThis.fetch.' + ); + } + + const response = await globalFetch(uri); + return response.text(); + }); this.streamingProcessor = new StreamingProcessor({ xPath: this.xPath, version: '' @@ -2126,20 +2149,6 @@ export class Xslt { */ protected async xsltImportOrInclude(context: ExprContext, template: XNode, output: XNode | undefined, isImport: boolean) { const elementName = isImport ? 'xsl:import' : 'xsl:include'; - const [major, minor] = process.versions.node.split('.').map(Number); - if (major <= 17 && minor < 5) { - throw new Error(`Your Node.js version does not support \`<${elementName}>\`. If possible, please update your Node.js version to at least version 17.5.0.`); - } - - // We need to test here whether `window.fetch` is available or not. - // If it is a browser environemnt, it should be. - // Otherwise, we will need to import an equivalent library, like 'node-fetch'. - if (!global.globalThis.fetch) { - global.globalThis.fetch = fetch as any; - global.globalThis.Headers = Headers as any; - global.globalThis.Request = Request as any; - global.globalThis.Response = Response as any; - } const hrefAttributeFind = template.childNodes.filter(n => n.nodeName === 'href'); if (hrefAttributeFind.length <= 0) { @@ -2155,8 +2164,7 @@ export class Xslt { return; } - const fetchTest = await global.globalThis.fetch(href); - const fetchResponse = await fetchTest.text(); + const fetchResponse = await this.fetchFunction(href); const includedXslt = this.xmlParser.xmlParse(fetchResponse); // Track stylesheet metadata for apply-imports diff --git a/tests/xslt/fetch-function.test.tsx b/tests/xslt/fetch-function.test.tsx new file mode 100644 index 0000000..e7c86d1 --- /dev/null +++ b/tests/xslt/fetch-function.test.tsx @@ -0,0 +1,78 @@ +import assert from 'assert'; + +import { XmlParser } from "../../src/dom"; +import { Xslt } from "../../src/xslt"; + +describe('fetchFunction option', () => { + const xmlSource = ``; + + const includedStylesheet = ` + + included + + `; + + it('is called with the href URI on xsl:include', async () => { + const calledWith: string[] = []; + + const xsltSource = ` + + `; + + const xsltClass = new Xslt({ + fetchFunction: async (uri) => { + calledWith.push(uri); + return includedStylesheet; + } + }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlSource); + const xslt = xmlParser.xmlParse(xsltSource); + const result = await xsltClass.xsltProcess(xml, xslt); + + assert.deepStrictEqual(calledWith, ['https://example.com/my-stylesheet.xsl']); + assert.equal(result, 'included'); + }); + + it('is called with the href URI on xsl:import', async () => { + const calledWith: string[] = []; + + const xsltSource = ` + + `; + + const xsltClass = new Xslt({ + fetchFunction: async (uri) => { + calledWith.push(uri); + return includedStylesheet; + } + }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlSource); + const xslt = xmlParser.xmlParse(xsltSource); + const result = await xsltClass.xsltProcess(xml, xslt); + + assert.deepStrictEqual(calledWith, ['https://example.com/imported.xsl']); + assert.equal(result, 'included'); + }); + + it('propagates errors thrown from fetchFunction', async () => { + const xsltSource = ` + + `; + + const xsltClass = new Xslt({ + fetchFunction: async (uri) => { + throw new Error(`External loading denied: ${uri}`); + } + }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlSource); + const xslt = xmlParser.xmlParse(xsltSource); + + await assert.rejects( + async () => await xsltClass.xsltProcess(xml, xslt), + { message: 'External loading denied: https://example.com/denied.xsl' } + ); + }); +});