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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@ const xslt = new Xslt(options);
- `name`: the parameter name;
- `namespaceUri` (optional): the namespace;
- `value`: the value.
- `fetchFunction` (`(uri: string) => Promise<string>`, optional): a custom function for loading external resources referenced by `<xsl:import>` and `<xsl:include>`. 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

Expand Down
3 changes: 2 additions & 1 deletion src/xslt/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
}
40 changes: 24 additions & 16 deletions src/xslt/xslt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@
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<string>;

outputDocument: XDocument;
outputMethod: 'xml' | 'html' | 'text' | 'name' | 'xhtml' | 'json' | 'adaptive';
outputOmitXmlDeclaration: string;
Expand Down Expand Up @@ -307,6 +314,22 @@
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;

Check warning on line 321 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

if (!globalFetch) {
throw new Error(
'No global fetch implementation available. ' +
'Please provide options.fetchFunction or use a runtime that exposes globalThis.fetch.'
);

Check warning on line 327 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 328 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

const response = await globalFetch(uri);
return response.text();
});
this.streamingProcessor = new StreamingProcessor({
xPath: this.xPath,
version: ''
Expand Down Expand Up @@ -2126,20 +2149,6 @@
*/
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) {
Expand All @@ -2155,8 +2164,7 @@
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
Expand Down
78 changes: 78 additions & 0 deletions tests/xslt/fetch-function.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import assert from 'assert';

import { XmlParser } from "../../src/dom";
import { Xslt } from "../../src/xslt";

describe('fetchFunction option', () => {
const xmlSource = `<root/>`;

const includedStylesheet = `<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<output>included</output>
</xsl:template>
</xsl:stylesheet>`;

it('is called with the href URI on xsl:include', async () => {
const calledWith: string[] = [];

const xsltSource = `<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:include href="https://example.com/my-stylesheet.xsl"/>
</xsl:stylesheet>`;

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, '<output>included</output>');
});

it('is called with the href URI on xsl:import', async () => {
const calledWith: string[] = [];

const xsltSource = `<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:import href="https://example.com/imported.xsl"/>
</xsl:stylesheet>`;

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, '<output>included</output>');
});

it('propagates errors thrown from fetchFunction', async () => {
const xsltSource = `<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:include href="https://example.com/denied.xsl"/>
</xsl:stylesheet>`;

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' }
);
});
});