diff --git a/package.json b/package.json index 1a428a5..7d6a2e2 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,12 @@ "fest": "^0.12.1", "jest": "^24.8.0", "memory-fs": "^0.4.1", - "webpack": "^4.36.1" + "webpack": "^4.36.1", + "xml2js": "^0.4.23" }, "peerDependencies": { - "fest": "^0.12.1" + "fest": "^0.12.1", + "xml2js": "^0.4.23" }, "engines": { "node": ">= 8.9.0" @@ -28,7 +30,7 @@ "scripts": { "pretest": "rm -rf test/.coverage && eslint src", "test": "jest", - "build": "babel src --out-dir=es", + "build": "rm -rf es && babel src --out-dir=es", "preversion": "npm test", "postversion": "git push && git push --tags", "prepublishOnly": "npm run build" diff --git a/src/filter-nodes.js b/src/filter-nodes.js new file mode 100644 index 0000000..4a9348a --- /dev/null +++ b/src/filter-nodes.js @@ -0,0 +1,58 @@ +import {NODE_ATTRIBUTES, NODE_TEXT} from './nodes'; + +/** + * @param {Object} object + * @param {string[]} nodeNames + * @param {string[]} ignoreNodes + * @returns {{name: string, item: {}[]}[]} + */ +export function filterNodes( + object, + nodeNames, + ignoreNodes = [NODE_ATTRIBUTES, NODE_TEXT] +) { + const stack = [object]; + const found = []; + + function isNotIgnored(key) { + return ignoreNodes.includes(key) === false; + } + + while (stack.length) { + const item = stack.shift(); + + const keys = Object.keys(item).filter(isNotIgnored); + + if (!keys.length) { + continue; + } + + for (let i = 0; i < keys.length; i++) { + const nodeName = keys[i]; + const {[nodeName]: nextItem} = item; + + if (nodeNames.includes(nodeName)) { + found.push({ + name: nodeName, + item: nextItem + }); + + continue; + } + + if (Array.isArray(nextItem)) { + stack.push(...nextItem); + + continue; + } + + if (typeof nextItem !== 'object') { + continue; + } + + stack.push(nextItem); + } + } + + return found; +} diff --git a/src/get-dependencies.js b/src/get-dependencies.js new file mode 100644 index 0000000..12863e3 --- /dev/null +++ b/src/get-dependencies.js @@ -0,0 +1,90 @@ +import path from 'path'; +import fs from 'fs'; +import util from 'util'; +import {parseStringPromise} from 'xml2js'; +import {NODE_ATTRIBUTES} from './nodes'; +import {filterNodes} from './filter-nodes'; + +const readFile = util.promisify(fs.readFile); + +/** + * @param {{attribute: {}[]}} node + * @return {boolean} + */ +function hasAttributes({item: [attributes]}) { + return attributes && NODE_ATTRIBUTES in attributes; +} + +function getSrcAttribute({item: [attributes]}) { + return attributes[NODE_ATTRIBUTES].src; +} + +/** + * @param {string} source + * @return {Promise>} + */ +async function getFileImports(source) { + const root = await parseStringPromise(source); + const nodes = filterNodes(root, [ + 'fest:include', + 'fest:insert' + ]); + + return new Set( + nodes.filter(hasAttributes).map(getSrcAttribute) + ); +} + +/** + * @param {string} parentFile + * @param {string} importedPath + * @return {string} + */ +function getAbsolutePath(parentFile, importedPath) { + return path.resolve(path.dirname(parentFile), importedPath); +} + +async function getAbsoluteFileImports(resourcePath, source) { + const toAbsolutePath = getAbsolutePath.bind(null, resourcePath); + const fileImports = await getFileImports(source); + + return [...fileImports].map(toAbsolutePath); +} + +export async function getDependencies(resourcePath, source) { + const dependencies = new Set(); + let stack = [ + [resourcePath, source] + ]; + + do { + const [resourcePath, source] = stack.shift(); + + let absoluteFileImports = await getAbsoluteFileImports( + resourcePath, + source + ); + absoluteFileImports = absoluteFileImports.filter((fileImport) => ( + !dependencies.has(fileImport) + )); + + for (const fileImport of absoluteFileImports) { + dependencies.add(fileImport); + + if (!fileImport.endsWith('.xml')) { + continue; + } + + let source; + try { + source = await readFile(fileImport, 'utf8'); + } catch (e) { + continue; + } + + stack.push([fileImport, source]); + } + } while (stack.length); + + return dependencies; +} diff --git a/src/loader.js b/src/loader.js index 9ee4596..6ca9682 100644 --- a/src/loader.js +++ b/src/loader.js @@ -1,8 +1,9 @@ import {getOptions} from 'loader-utils'; import validateOptions from 'schema-utils'; - import fest from 'fest'; +import {getDependencies} from './get-dependencies'; + const schema = { type: 'object', properties: { @@ -55,14 +56,21 @@ export default function festLoader(source) { baseDataPath: 'options' }); - const promise = new Promise(compile(source, Object.assign({}, options, { + const promise = new Promise(compile(source, { + ...options, resourcePath: this.resourcePath - }))); + })); const callback = this.async(); - promise.then(function(compiled) { + Promise.all([ + // Tracking dependencies is optional feature, that could fail + getDependencies(this.resourcePath, source).catch(() => []), + promise + ]).then(([deps, compiled]) => { + deps.forEach((dep) => this.addDependency(dep)); + callback(null, compiled); - }, function(exception) { + }).catch(function(exception) { callback(exception, ''); }); } diff --git a/src/nodes.js b/src/nodes.js new file mode 100644 index 0000000..4ab3a50 --- /dev/null +++ b/src/nodes.js @@ -0,0 +1,2 @@ +export const NODE_ATTRIBUTES = '$'; +export const NODE_TEXT = '_'; diff --git a/test/fixtures/include.xml b/test/fixtures/include.xml new file mode 100644 index 0000000..78204ea --- /dev/null +++ b/test/fixtures/include.xml @@ -0,0 +1,3 @@ + + Simple include + diff --git a/test/fixtures/nested-dependency.xml b/test/fixtures/nested-dependency.xml new file mode 100644 index 0000000..7db85dc --- /dev/null +++ b/test/fixtures/nested-dependency.xml @@ -0,0 +1,3 @@ + + + diff --git a/test/fixtures/styles.css b/test/fixtures/styles.css new file mode 100644 index 0000000..faa4215 --- /dev/null +++ b/test/fixtures/styles.css @@ -0,0 +1,3 @@ +.foo { + color: red; +} diff --git a/test/fixtures/with-dependencies.xml b/test/fixtures/with-dependencies.xml new file mode 100644 index 0000000..32ba471 --- /dev/null +++ b/test/fixtures/with-dependencies.xml @@ -0,0 +1,4 @@ + + +
+
diff --git a/test/loader.test.js b/test/loader.test.js index 2140248..93e3eb2 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -1,6 +1,12 @@ +import fs from 'fs'; +import path from 'path'; +import util from 'util'; +import loader from '../src/loader'; import compiler from './compiler.js'; import {getOutput} from './get-output'; +const readFile = util.promisify(fs.readFile); + describe('Default usage', function() { let stats, output; @@ -32,3 +38,37 @@ describe("'module' option", function() { expect(output).toMatch('module.exports = function'); }); }); + +describe('dependencies parse', function() { + test('should add dependencies', async function(done) { + const fixturePath = path.resolve('test/fixtures/with-dependencies.xml'); + const source = await readFile(fixturePath, 'utf8'); + const context = { + query: '', + resourcePath: fixturePath, + async() { + return function(err) { + if (err) { + return done(err); + } + + expect(context.addDependency).toHaveBeenCalledTimes(3); + expect(context.addDependency).toHaveBeenCalledWith( + expect.stringMatching('nested-dependency.xml') + ); + expect(context.addDependency).toHaveBeenCalledWith( + expect.stringMatching('styles.css') + ); + expect(context.addDependency).toHaveBeenCalledWith( + expect.stringMatching('include.xml') + ); + + done(); + } + }, + addDependency: jest.fn() + }; + + loader.call(context, source); + }); +});