From 5b182c0d5fb0cda22414ece29a3ebf288a22d1d0 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Sat, 28 Nov 2020 21:26:16 +0100 Subject: [PATCH] feat: ported xml-namespace-loader --- .../loaders/xml-namespace-loader.spec.ts | 425 ++++++++++++++++++ packages/webpack5/jest.setup.ts | 3 + packages/webpack5/package.json | 3 + packages/webpack5/src/configuration/base.ts | 19 +- .../webpack5/src/configuration/javascript.ts | 23 +- packages/webpack5/src/helpers/dependencies.ts | 7 + .../src/loaders/xml-namespace-loader/index.ts | 228 ++++++++++ 7 files changed, 699 insertions(+), 9 deletions(-) create mode 100644 packages/webpack5/__tests__/loaders/xml-namespace-loader.spec.ts create mode 100644 packages/webpack5/src/loaders/xml-namespace-loader/index.ts diff --git a/packages/webpack5/__tests__/loaders/xml-namespace-loader.spec.ts b/packages/webpack5/__tests__/loaders/xml-namespace-loader.spec.ts new file mode 100644 index 000000000..59136248e --- /dev/null +++ b/packages/webpack5/__tests__/loaders/xml-namespace-loader.spec.ts @@ -0,0 +1,425 @@ +import xmlNsLoader from '../../src/loaders/xml-namespace-loader'; +import dedent from 'ts-dedent'; + +const CODE_FILE = dedent` + + + + + + + + + + +`; + +interface TestSetup { + resolveMap: { [path: string]: string }; + expectedDeps: string[]; + expectedRegs: { name: string; path: string }[]; + ignore?: RegExp; + assureNoDeps?: boolean; + expectError?: boolean; + expectWarnings?: number; +} + +function getContext( + done, + { + resolveMap, + expectedDeps, + expectedRegs, + assureNoDeps, + ignore, + expectError, + expectWarnings, + }: TestSetup +) { + const actualDeps: string[] = []; + const actualWarnings: Error[] = []; + let callbackCalled = false; + + return { + rootContext: 'app', + context: 'app/component', + async: () => (error, source: string) => { + if (callbackCalled) { + done.fail('Callback called more than once!'); + } + callbackCalled = true; + + expectedDeps.forEach((expectedDep) => { + expect(actualDeps).toContain(expectedDep); + }); + + expectedRegs.forEach(({ name, path }) => { + expect(source).toContain(dedent` + global.registerModule( + '${name}', + () => require("${path}") + ) + `); + }); + + if (assureNoDeps) { + expect(actualDeps.length).toBe(0); + expect(source).not.toContain('global.registerModule'); + } + + if (expectWarnings) { + expect(actualWarnings.length).toEqual(expectWarnings); + } + + if (error && !expectError) { + done.fail(error); + } else if (!error && expectError) { + done.fail('Error expected here'); + } else { + done(); + } + }, + resolve: ( + context: string, + request: string, + callback: (err: Error, result: string) => void + ) => { + request = request.replace(/\\/g, '/'); + if (resolveMap[request]) { + callback(undefined, resolveMap[request]); + } else { + callback(new Error(`Module ${request} not found`), undefined); + } + }, + addDependency: (dep: string) => { + actualDeps.push(dep); + }, + emitWarning: (err: Error) => { + actualWarnings.push(err); + }, + emitError: (err: Error) => { + //actualWarnings.push(err); + }, + query: { ignore }, + }; +} + +describe('xml-namespace-loader', () => { + it('with namespace pointing to files', (done) => { + const resolveMap = { + 'app/nativescript-ui-chart': 'app/nativescript-ui-chart.js', + 'app/nativescript-ui-chart.xml': 'app/nativescript-ui-chart.xml', + 'app/nativescript-ui-chart.css': 'app/nativescript-ui-chart.css', + }; + + const expectedDeps = [ + 'app/nativescript-ui-chart.js', + 'app/nativescript-ui-chart.xml', + 'app/nativescript-ui-chart.css', + ]; + + const expectedRegs = [ + { name: 'nativescript-ui-chart', path: 'app/nativescript-ui-chart.js' }, + { + name: 'nativescript-ui-chart/RadCartesianChart', + path: 'app/nativescript-ui-chart.js', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart.xml', + path: 'app/nativescript-ui-chart.xml', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart.css', + path: 'app/nativescript-ui-chart.css', + }, + ]; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + }); + + xmlNsLoader.call(loaderContext, CODE_FILE); + }); + + it('with namespace/elementName pointing to files (with package.json)', (done) => { + const resolveMap = { + 'app/nativescript-ui-chart': + 'app/nativescript-ui-chart/RadCartesianChart.js', //simulate package.json + 'app/nativescript-ui-chart/RadCartesianChart': + 'app/nativescript-ui-chart/RadCartesianChart.js', + 'app/nativescript-ui-chart/RadCartesianChart.xml': + 'app/nativescript-ui-chart/RadCartesianChart.xml', + 'app/nativescript-ui-chart/RadCartesianChart.css': + 'app/nativescript-ui-chart/RadCartesianChart.css', + }; + + const expectedDeps = [ + 'app/nativescript-ui-chart/RadCartesianChart.js', + 'app/nativescript-ui-chart/RadCartesianChart.xml', + 'app/nativescript-ui-chart/RadCartesianChart.css', + ]; + + const expectedRegs = [ + { + name: 'nativescript-ui-chart', + path: 'app/nativescript-ui-chart/RadCartesianChart.js', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart', + path: 'app/nativescript-ui-chart/RadCartesianChart.js', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart.xml', + path: 'app/nativescript-ui-chart/RadCartesianChart.xml', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart.css', + path: 'app/nativescript-ui-chart/RadCartesianChart.css', + }, + ]; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + }); + xmlNsLoader.call(loaderContext, CODE_FILE); + }); + + it('with namespace/elementName pointing to files', (done) => { + const resolveMap = { + 'app/nativescript-ui-chart/RadCartesianChart': + 'app/nativescript-ui-chart/RadCartesianChart.js', + 'app/nativescript-ui-chart/RadCartesianChart.xml': + 'app/nativescript-ui-chart/RadCartesianChart.xml', + 'app/nativescript-ui-chart/RadCartesianChart.css': + 'app/nativescript-ui-chart/RadCartesianChart.css', + }; + + const expectedDeps = [ + 'app/nativescript-ui-chart/RadCartesianChart.js', + 'app/nativescript-ui-chart/RadCartesianChart.xml', + 'app/nativescript-ui-chart/RadCartesianChart.css', + ]; + + const expectedRegs = [ + { + name: 'nativescript-ui-chart', + path: 'app/nativescript-ui-chart/RadCartesianChart.js', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart', + path: 'app/nativescript-ui-chart/RadCartesianChart.js', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart.xml', + path: 'app/nativescript-ui-chart/RadCartesianChart.xml', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart.css', + path: 'app/nativescript-ui-chart/RadCartesianChart.css', + }, + ]; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + }); + xmlNsLoader.call(loaderContext, CODE_FILE); + }); + + it('with namespace/elementName pointing to files - only XML and CSS', (done) => { + const resolveMap = { + 'app/nativescript-ui-chart/RadCartesianChart.xml': + 'app/nativescript-ui-chart/RadCartesianChart.xml', + 'app/nativescript-ui-chart/RadCartesianChart.css': + 'app/nativescript-ui-chart/RadCartesianChart.css', + }; + + const expectedDeps = [ + 'app/nativescript-ui-chart/RadCartesianChart.xml', + 'app/nativescript-ui-chart/RadCartesianChart.css', + ]; + + const expectedRegs = [ + { + name: 'nativescript-ui-chart/RadCartesianChart.xml', + path: 'app/nativescript-ui-chart/RadCartesianChart.xml', + }, + { + name: 'nativescript-ui-chart/RadCartesianChart.css', + path: 'app/nativescript-ui-chart/RadCartesianChart.css', + }, + ]; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + }); + xmlNsLoader.call(loaderContext, CODE_FILE); + }); + + it('with plugin path', (done) => { + const resolveMap = { + 'nativescript-ui-chart': 'node_modules/nativescript-ui-chart/ui-chart.js', + }; + + const expectedDeps = []; + + const expectedRegs = [ + { name: 'nativescript-ui-chart', path: 'nativescript-ui-chart' }, + { + name: 'nativescript-ui-chart/RadCartesianChart', + path: 'nativescript-ui-chart', + }, + ]; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + }); + xmlNsLoader.call(loaderContext, CODE_FILE); + }); + + it('with ignored namespace should not add deps or register calls', (done) => { + const resolveMap = { + 'app/nativescript-ui-chart': 'app/nativescript-ui-chart.js', + 'app/nativescript-ui-chart.xml': 'app/nativescript-ui-chart.xml', + 'app/nativescript-ui-chart.css': 'app/nativescript-ui-chart.css', + }; + const expectedDeps = []; + const expectedRegs = []; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + ignore: /nativescript-ui-chart/, + assureNoDeps: true, + }); + + xmlNsLoader.call(loaderContext, CODE_FILE); + }); + + it('with XML declaration and Doctype does not fail', (done) => { + const resolveMap = {}; + const expectedDeps = []; + const expectedRegs = []; + + const testXml = dedent` + + + + + `; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + assureNoDeps: true, + }); + + xmlNsLoader.call(loaderContext, testXml); + }); + + it('with invalid XML fails', (done) => { + const resolveMap = {}; + const expectedDeps = []; + const expectedRegs = []; + + const testXml = ``; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + expectError: true, + }); + + xmlNsLoader.call(loaderContext, testXml); + }); + + it("doesn't throw with ios and android platform namespaces", (done) => { + const resolveMap = {}; + const expectedDeps = []; + const expectedRegs = []; + + const testXml = dedent` + + + + + + + `; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + assureNoDeps: true, + }); + + xmlNsLoader.call(loaderContext, testXml); + }); + + it('throws with unbound namespace namespaces', (done) => { + const resolveMap = {}; + const expectedDeps = []; + const expectedRegs = []; + + const testXml = ` + + + + `; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + expectError: true, + }); + + xmlNsLoader.call(loaderContext, testXml); + }); + + it("with '&&', '||', '<=' and '>=' in binding expression, emits warnings, but does not fail", (done) => { + const resolveMap = { + 'nativescript-ui-chart': 'node_modules/nativescript-ui-chart/ui-chart.js', + }; + + const expectedDeps = []; + + const expectedRegs = [ + { name: 'nativescript-ui-chart', path: 'nativescript-ui-chart' }, + { + name: 'nativescript-ui-chart/RadCartesianChart', + path: 'nativescript-ui-chart', + }, + ]; + + const testXml = ` + + + + + + `; + + const loaderContext = getContext(done, { + resolveMap, + expectedDeps, + expectedRegs, + expectWarnings: 1, + }); + + xmlNsLoader.call(loaderContext, testXml); + }); +}); diff --git a/packages/webpack5/jest.setup.ts b/packages/webpack5/jest.setup.ts index 3221dede3..cb63213d2 100644 --- a/packages/webpack5/jest.setup.ts +++ b/packages/webpack5/jest.setup.ts @@ -17,6 +17,9 @@ jest.mock( '__jest__/package.json', () => ({ main: 'src/app.js', + devDependencies: { + typescript: '*', + }, }), { virtual: true } ); diff --git a/packages/webpack5/package.json b/packages/webpack5/package.json index 366cc7c2f..2e25ad6c3 100644 --- a/packages/webpack5/package.json +++ b/packages/webpack5/package.json @@ -18,6 +18,7 @@ "dependencies": { "@babel/core": "^7.12.3", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", + "@types/sax": "^1.2.1", "babel-loader": "^8.2.1", "chalk": "^4.1.0", "clean-webpack-plugin": "^3.0.0", @@ -32,6 +33,7 @@ "react-refresh": "^0.9.0", "sass": "^1.29.0", "sass-loader": "^10.1.0", + "sax": "^1.2.4", "source-map": "^0.7.3", "svelte-native-preprocessor": "^0.2.0", "terser-webpack-plugin": "^5.0.3", @@ -43,6 +45,7 @@ "webpack-chain": "^6.5.1", "webpack-cli": "^4.2.0", "webpack-merge": "^5.4.0", + "webpack-virtual-modules": "^0.4.1", "worker-plugin": "^5.0.0" }, "devDependencies": { diff --git a/packages/webpack5/src/configuration/base.ts b/packages/webpack5/src/configuration/base.ts index 937d39eb9..95380ec3a 100644 --- a/packages/webpack5/src/configuration/base.ts +++ b/packages/webpack5/src/configuration/base.ts @@ -16,6 +16,7 @@ import { getEntryPath, getPlatform, } from '../helpers/project'; +import { hasDependency } from '../helpers/dependencies'; export default function (config: Config, env: IWebpackEnv): Config { const entryPath = getEntryPath(); @@ -136,13 +137,17 @@ export default function (config: Config, env: IWebpackEnv): Config { }); // Use Fork TS Checker to do type checking in a separate non-blocking process - config.plugin('ForkTsCheckerWebpackPlugin').use(ForkTsCheckerWebpackPlugin, [ - { - typescript: { - memoryLimit: 4096, - }, - }, - ]); + config.when(hasDependency('typescript'), (config) => { + config + .plugin('ForkTsCheckerWebpackPlugin') + .use(ForkTsCheckerWebpackPlugin, [ + { + typescript: { + memoryLimit: 4096, + }, + }, + ]); + }); // set up js // todo: do we need babel-loader? It's useful to support it diff --git a/packages/webpack5/src/configuration/javascript.ts b/packages/webpack5/src/configuration/javascript.ts index c64c19089..affb354bb 100644 --- a/packages/webpack5/src/configuration/javascript.ts +++ b/packages/webpack5/src/configuration/javascript.ts @@ -1,18 +1,37 @@ +import VirtualModulesPlugin from 'webpack-virtual-modules'; import Config from 'webpack-chain'; +import { getEntryPath } from '../helpers/project'; import { IWebpackEnv } from '../index'; import base from './base'; +import dedent from 'ts-dedent'; // todo: add base configuration for core with javascript export default function (config: Config, env: IWebpackEnv): Config { base(config, env); + const virtualEntryPath = getEntryPath() + '.virtual.js'; + + config.entry('bundle').add(virtualEntryPath); + + // Add a virtual entry module + config.plugin('VirtualModulesPlugin').use(VirtualModulesPlugin, [ + { + [virtualEntryPath]: dedent` + require('@nativescript/core/bundle-entry-points') + + const context = require.context("./", /* deep: */ true); + global.registerWebpackModules(context); + `, + }, + ]); + // set up xml config.module .rule('xml') .test(/\.xml$/) - .use('xml-loader') - .loader('xml-loader'); + .use('xml-namespace-loader') + .loader('xml-namespace-loader'); return config; } diff --git a/packages/webpack5/src/helpers/dependencies.ts b/packages/webpack5/src/helpers/dependencies.ts index 4c9aca5d8..c3c7dd470 100644 --- a/packages/webpack5/src/helpers/dependencies.ts +++ b/packages/webpack5/src/helpers/dependencies.ts @@ -1,6 +1,7 @@ import { getPackageJson, getProjectRootPath } from './project'; import path from 'path'; +// todo: memoize export function getAllDependencies(): string[] { const packageJSON = getPackageJson(); @@ -10,6 +11,12 @@ export function getAllDependencies(): string[] { ]; } +// todo: memoize +export function hasDependency(dependencyName: string) { + return getAllDependencies().includes(dependencyName); +} + +// todo: memoize export function getDependencyPath(dependencyName: string): string | null { try { const resolvedPath = require.resolve(`${dependencyName}/package.json`, { diff --git a/packages/webpack5/src/loaders/xml-namespace-loader/index.ts b/packages/webpack5/src/loaders/xml-namespace-loader/index.ts new file mode 100644 index 000000000..2bec1bdd0 --- /dev/null +++ b/packages/webpack5/src/loaders/xml-namespace-loader/index.ts @@ -0,0 +1,228 @@ +import { parse, join } from 'path'; +import { promisify } from 'util'; +import dedent from 'ts-dedent'; +import { parser } from 'sax'; + +const noop = () => {}; + +const DEBUG = false; + +interface NamespaceEntry { + name: string; + path: string; +} + +interface ParseResult { + code: string; +} + +export default function loader(content: string, map: any) { + const callback = this.async(); + + // parse content and dependencies async + parseXML + .bind(this)(content) + .then((res) => { + DEBUG && console.log({ res }); + callback(null, res.code, map); + }) + .catch((err) => { + DEBUG && console.log({ err }); + callback(err); + }); +} + +async function parseXML(content: string): Promise { + // wrap this.resolve into a promise + const resolveAsync = promisify(this.resolve); + const promises: Promise[] = []; + const namespaces: NamespaceEntry[] = []; + const distinctNamespaces = new Map(); + const moduleRegisters: string[] = []; + const { ignore } = this.query; + const errors = []; + + const saxParser = parser(true, { xmlns: true }); + + // // Register ios and android prefixes as namespaces to avoid "unbound xml namespace" errors + (saxParser as any).ns['ios'] = 'http://schemas.nativescript.org/tns.xsd'; + (saxParser as any).ns['android'] = 'http://schemas.nativescript.org/tns.xsd'; + (saxParser as any).ns['desktop'] = 'http://schemas.nativescript.org/tns.xsd'; + (saxParser as any).ns['web'] = 'http://schemas.nativescript.org/tns.xsd'; + + const handleOpenTag = async (namespace: string, elementName: string) => { + if (!namespace) { + return; + } + + if (namespace.startsWith('http')) { + return; + } + + const moduleName = `${namespace}/${elementName}`; + + if (namespaces.some((n) => n.name === moduleName)) { + return; + } + + if (ignore && moduleName.match(ignore)) { + return; + } + + const localNamespacePath = join(this.rootContext, namespace); + const localModulePath = join(localNamespacePath, elementName); + + const resolvePaths = [ + localNamespacePath, + localModulePath, + `${localModulePath}.xml`, + moduleName, + namespace, + ]; + DEBUG && console.log({ resolvePaths }); + let resolvedPath; + + for (const p of resolvePaths) { + resolvedPath = await resolveAsync(this.context, p).catch(noop); + + // break on first match + if (resolvedPath) { + break; + } + } + + DEBUG && console.log({ resolvedPath }); + + // bail if we haven't resolved a path + if (!resolvedPath) { + return; + } + + const { dir, name } = parse(resolvedPath); + + // register resolved path + short name + namespaces.push({ name: namespace, path: resolvedPath }); + namespaces.push({ name: moduleName, path: resolvedPath }); + this.addDependency(resolvedPath); + + const noExtFilename = join(dir, name); + + DEBUG && + console.log({ + noExtFilename, + }); + + // finally try resolving an XML file + await resolveAsync(this.context, `${noExtFilename}.xml`) + .then((xml) => { + this.addDependency(xml); + namespaces.push({ name: `${moduleName}.xml`, path: xml }); + }) + .catch(() => { + // if there is no XML file, fall back to namespace as the path + // will become require() + namespaces.push({ name: namespace, path: namespace }); + namespaces.push({ name: moduleName, path: namespace }); + }); + + // look for css files with the same name + await resolveAsync(this.context, `${noExtFilename}.css`) + .then((css) => { + this.addDependency(css); + // namespaces.push({ name: `${moduleName}.css`, path: css }); + }) + .catch(noop); + }; + + saxParser.onopentag = (node) => { + if ('uri' in node) { + promises.push(handleOpenTag(node.uri, node.local)); + } + }; + saxParser.onerror = (error) => { + saxParser.error = null; + + // Do only warning about invalid character "&"" for back-compatibility + // as it is common to use it in a binding expression + if ( + error.message.includes('Invalid character') && + error.message.includes('Char: &') + ) { + return this.emitWarning(error); + } + errors.push(error); + }; + + saxParser.write(content).close(); + + await Promise.all(promises); + + DEBUG && console.log({ namespaces }); + + namespaces.forEach(({ name, path }) => { + distinctNamespaces.set(name, path.replace(/\\/g, '/')); + }); + + distinctNamespaces.forEach((path, name) => { + moduleRegisters.push(dedent` + global.registerModule( + '${name}', + () => require("${path}") + ) + `); + }); + + // escape special whitespace characters + // see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Issue_with_plain_JSON.stringify_for_use_as_JavaScript + const xml = JSON.stringify(content) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + + const code = dedent` + ${moduleRegisters.join('\n')} + /* XML-NAMESPACE-LOADER */ + const ___XML_NAMESPACE_LOADER_EXPORT___ = ${xml} + export default ___XML_NAMESPACE_LOADER_EXPORT___ + `; + + if (errors.length) { + errors.map(this.emitError); + + // finally throw the first one + throw errors[0]; + } + + return { + code, + }; +} + +// +// +// +// function parseXML(xml: string) { +// const saxParser = parser(true, { xmlns: true }); +// +// saxParser.onopentag = (node) => { +// if('ns' in node) { +// const uri = node.uri +// const tag = node.local +// +// DEBUG && console.log({ +// uri, +// tag +// }) +// } +// } +// +// saxParser.onerror = (err) => { +// DEBUG && console.log(err) +// } +// +// // Register ios and android prefixes as namespaces to avoid "unbound xml namespace" errors +// // saxParser.ns['ios'] = 'http://schemas.nativescript.org/tns.xsd'; +// // saxParser.ns['android'] = 'http://schemas.nativescript.org/tns.xsd'; +// // saxParser.ns['desktop'] = 'http://schemas.nativescript.org/tns.xsd'; +// // saxParser.ns['web'] = 'http://schemas.nativescript.org/tns.xsd'; +// saxParser.write(xml).close() +// }