feat: ported xml-namespace-loader

This commit is contained in:
Igor Randjelovic
2020-11-28 21:26:16 +01:00
parent 803958266d
commit 5b182c0d5f
7 changed files with 699 additions and 9 deletions

View File

@ -0,0 +1,425 @@
import xmlNsLoader from '../../src/loaders/xml-namespace-loader';
import dedent from 'ts-dedent';
const CODE_FILE = dedent`
<Page xmlns="http://www.nativescript.org/tns.xsd">
<StackLayout>
<GridLayout xmlns:chart="nativescript-ui-chart">
<chart:RadCartesianChart></chart:RadCartesianChart>
</GridLayout>
<GridLayout xmlns:chart="nativescript-ui-chart">
<chart:RadCartesianChart></chart:RadCartesianChart>
</GridLayout>
</StackLayout>
</Page>
`;
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`
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!-- comment.xml -->
<Page xmlns="http://www.nativescript.org/tns.xsd"></Page>
`;
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 = `<Page xmlns="http://www.nativescript.org/tns.xsd"></PageOpsWrongTagHere>`;
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`
<Page xmlns="http://www.nativescript.org/tns.xsd">
<ios:GridLayout />
<ios:GridLayout></ios:GridLayout>
<android:GridLayout />
<android:GridLayout></android:GridLayout>
</Page>
`;
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 = `
<Page xmlns="http://www.nativescript.org/tns.xsd">
<custom1:CustomComponent />
<custom2:CustomComponent />
</Page>`;
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 = `
<Page xmlns="http://www.nativescript.org/tns.xsd">
<StackLayout xmlns:chart="nativescript-ui-chart">
<TextField text="{{ var1 && var2 || var1 >= var2 || var2 <= var1 }}" />
<chart:RadCartesianChart></chart:RadCartesianChart>
</StackLayout>
</Page>`;
const loaderContext = getContext(done, {
resolveMap,
expectedDeps,
expectedRegs,
expectWarnings: 1,
});
xmlNsLoader.call(loaderContext, testXml);
});
});

View File

@ -17,6 +17,9 @@ jest.mock(
'__jest__/package.json',
() => ({
main: 'src/app.js',
devDependencies: {
typescript: '*',
},
}),
{ virtual: true }
);

View File

@ -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": {

View File

@ -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

View File

@ -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;
}

View File

@ -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`, {

View File

@ -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<ParseResult> {
// wrap this.resolve into a promise
const resolveAsync = promisify(this.resolve);
const promises: Promise<any>[] = [];
const namespaces: NamespaceEntry[] = [];
const distinctNamespaces = new Map<string, string>();
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(<namespace>)
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()
// }