diff --git a/lib/processors/bundlers/manifestBundler.js b/lib/processors/bundlers/manifestBundler.js index 03359d53b..9453f5c10 100644 --- a/lib/processors/bundlers/manifestBundler.js +++ b/lib/processors/bundlers/manifestBundler.js @@ -1,4 +1,4 @@ -const path = require("path"); +const posixPath = require("path").posix; const yazl = require("yazl"); const resourceFactory = require("@ui5/fs").resourceFactory; const log = require("@ui5/logger").getLogger("builder:processors:bundlers:manifestBundler"); @@ -23,7 +23,7 @@ class I18nResourceList { * @param {module:@ui5/fs.Resource} resource i18n resource */ add(directory, resource) { - const normalizedDirectory = path.normalize(directory); + const normalizedDirectory = posixPath.normalize(directory); if (!this.propertyFiles.has(normalizedDirectory)) { this.propertyFiles.set(normalizedDirectory, [resource]); } else { @@ -38,7 +38,7 @@ class I18nResourceList { * @returns {Array} Array of resources files */ get(directory) { - return this.propertyFiles.get(path.normalize(directory)) || []; + return this.propertyFiles.get(posixPath.normalize(directory)) || []; } } @@ -57,25 +57,63 @@ class I18nResourceList { * @returns {Promise} Promise resolving with manifest bundle resources */ module.exports = ({resources, options: {namespace, bundleName, propertiesExtension, descriptor}}) => { - function getDescriptorI18nInfo(manifest) { + function bundleNameToUrl(bundleName, appId) { + if (!bundleName.startsWith(appId)) { + return null; + } + const relativeBundleName = bundleName.substring(appId.length + 1); + return relativeBundleName.replace(/\./g, "/") + propertiesExtension; + } + + function addDescriptorI18nInfos(descriptorI18nInfos, manifest) { + function addI18nInfo(i18nPath) { + if (i18nPath.startsWith("ui5:")) { + log.warn(`Using the ui5:// protocol for i18n bundles is currently not supported ('${i18nPath}' in ${manifest.path})`); + return; + } + descriptorI18nInfos.set( + posixPath.join(posixPath.dirname(manifest.path), posixPath.dirname(i18nPath)), + posixPath.basename(i18nPath, propertiesExtension) + ); + } + const content = JSON.parse(manifest.content); - let i18nFullPath = content["sap.app"]["i18n"]; + const appI18n = content["sap.app"]["i18n"]; + let bundleUrl; // i18n section in sap.app can be either a string or an object with bundleUrl - if (typeof i18nFullPath === "object") { - i18nFullPath = i18nFullPath.bundleUrl; + if (typeof appI18n === "object") { + if (appI18n.bundleUrl) { + bundleUrl = appI18n.bundleUrl; + } else if (appI18n.bundleName) { + bundleUrl = bundleNameToUrl(appI18n.bundleName, content["sap.app"]["id"]); + } + } else if (typeof appI18n === "string") { + bundleUrl = appI18n; + } else { + bundleUrl = "i18n/i18n.properties"; } - if (!i18nFullPath) { - i18nFullPath = "i18n/i18n.properties"; + if (bundleUrl) { + addI18nInfo(bundleUrl); + } + + if (typeof appI18n === "object" && Array.isArray(appI18n.enhanceWith)) { + appI18n.enhanceWith.forEach((enhanceWithEntry) => { + let bundleUrl; + if (enhanceWithEntry.bundleUrl) { + bundleUrl = enhanceWithEntry.bundleUrl; + } else if (enhanceWithEntry.bundleName) { + bundleUrl = bundleNameToUrl(enhanceWithEntry.bundleName, content["sap.app"]["id"]); + } + if (bundleUrl) { + addI18nInfo(bundleUrl); + } + }); } - return { - path: path.join(path.dirname(manifest.path), path.dirname(i18nFullPath)), - rootName: path.basename(i18nFullPath, propertiesExtension) - }; } return Promise.all(resources.map((resource) => resource.getBuffer().then((content) => { - const basename = path.basename(resource.getPath()); + const basename = posixPath.basename(resource.getPath()); return { name: basename, isManifest: basename === descriptor, @@ -90,19 +128,23 @@ module.exports = ({resources, options: {namespace, bundleName, propertiesExtensi resources.forEach((resource) => { if (resource.isManifest) { - const descriptorI18nInfo = getDescriptorI18nInfo(resource); - descriptorI18nInfos.set(descriptorI18nInfo.path, descriptorI18nInfo.rootName); + addDescriptorI18nInfos(descriptorI18nInfos, resource); archiveContent.set(resource.path, resource.content); } else { - const directory = path.dirname(resource.path); + const directory = posixPath.dirname(resource.path); i18nResourceList.add(directory, resource); } }); descriptorI18nInfos.forEach((rootName, directory) => { - i18nResourceList.get(directory) - .filter((resource) => resource.name.startsWith(rootName)) - .forEach((resource) => archiveContent.set(resource.path, resource.content)); + const i18nResources = i18nResourceList.get(directory) + .filter((resource) => resource.name.startsWith(rootName)); + + if (i18nResources.length) { + i18nResources.forEach((resource) => archiveContent.set(resource.path, resource.content)); + } else { + log.warn(`Could not find any resources for i18n bundle '${directory}'`); + } }); return archiveContent; diff --git a/test/lib/processors/bundlers/manifestBundler.js b/test/lib/processors/bundlers/manifestBundler.js index 1f607d476..0081803b6 100644 --- a/test/lib/processors/bundlers/manifestBundler.js +++ b/test/lib/processors/bundlers/manifestBundler.js @@ -7,14 +7,16 @@ const mock = require("mock-require"); let manifestBundler = require("../../../../lib/processors/bundlers/manifestBundler"); test.beforeEach((t) => { - // Spying logger of processors/bundlers/manifestBundler + // Stubbing logger of processors/bundlers/manifestBundler const log = require("@ui5/logger"); const loggerInstance = log.getLogger("builder:processors:bundlers:manifestBundler"); mock("@ui5/logger", { getLogger: () => loggerInstance }); mock.reRequire("@ui5/logger"); - t.context.logVerboseSpy = sinon.spy(loggerInstance, "verbose"); + t.context.logVerboseSpy = sinon.stub(loggerInstance, "verbose"); + t.context.logWarnSpy = sinon.stub(loggerInstance, "warn"); + t.context.logErrorSpy = sinon.stub(loggerInstance, "error"); // Re-require tested module manifestBundler = mock.reRequire("../../../../lib/processors/bundlers/manifestBundler"); @@ -25,19 +27,19 @@ test.beforeEach((t) => { t.context.yazlZipFile = sinon.stub(yazl, "ZipFile").returns(zip); }); -test.afterEach.always((t) => { - mock.stop("@ui5/logger"); - t.context.logVerboseSpy.restore(); - t.context.yazlZipFile.restore(); - t.context.addBufferSpy.restore(); +test.afterEach.always(() => { + mock.stopAll(); + sinon.restore(); }); test.serial("manifestBundler with empty resources", async (t) => { const resources = []; const options = {}; await manifestBundler({resources, options}); - t.deepEqual(t.context.addBufferSpy.callCount, 0, "should not be called"); - t.deepEqual(t.context.logVerboseSpy.callCount, 0, "should not be called"); + t.is(t.context.addBufferSpy.callCount, 0); + t.is(t.context.logVerboseSpy.callCount, 0); + t.is(t.context.logWarnSpy.callCount, 0); + t.is(t.context.logErrorSpy.callCount, 0); }); test.serial("manifestBundler with manifest path not starting with '/resources'", async (t) => { @@ -53,12 +55,19 @@ test.serial("manifestBundler with manifest path not starting with '/resources'", descriptor: "manifest.json", namespace: "pony" }; + await manifestBundler({resources, options}); + t.deepEqual(t.context.addBufferSpy.callCount, 0, "should not be called"); - t.deepEqual(t.context.logVerboseSpy.callCount, 1, "should be called once"); + + t.is(t.context.logVerboseSpy.callCount, 1); t.deepEqual(t.context.logVerboseSpy.getCall(0).args, ["Not bundling resource with path pony/manifest.json since it is not based on path /resources/pony/"], "should be called with correct arguments"); + t.is(t.context.logWarnSpy.callCount, 1); + t.deepEqual(t.context.logWarnSpy.getCall(0).args, + ["Could not find any resources for i18n bundle 'pony/i18n'"]); + t.is(t.context.logErrorSpy.callCount, 0); }); test.serial("manifestBundler with manifest without i18n section in sap.app", async (t) => { @@ -74,11 +83,18 @@ test.serial("manifestBundler with manifest without i18n section in sap.app", asy descriptor: "manifest.json", namespace: "pony" }; + await manifestBundler({resources, options}); - t.deepEqual(t.context.addBufferSpy.callCount, 1, "should be called once"); + + t.is(t.context.addBufferSpy.callCount, 1, "should be called once"); t.deepEqual(t.context.addBufferSpy.getCall(0).args, ["{\"sap.app\":{}}", "manifest.json"], "should be called with correct arguments"); - t.deepEqual(t.context.logVerboseSpy.callCount, 0, "should not be called"); + + t.is(t.context.logVerboseSpy.callCount, 0); + t.is(t.context.logWarnSpy.callCount, 1); + t.deepEqual(t.context.logWarnSpy.getCall(0).args, + ["Could not find any resources for i18n bundle '/resources/pony/i18n'"]); + t.is(t.context.logErrorSpy.callCount, 0); }); test.serial("manifestBundler with manifest with i18n string", async (t) => { @@ -88,23 +104,36 @@ test.serial("manifestBundler with manifest with i18n string", async (t) => { getPath: () => "/resources/pony/manifest.json", getBuffer: async () => JSON.stringify({ "sap.app": { - "i18n": "i18n/i18n.properties" + "i18n": "i18n-bundle/i18n.properties" } }) }); + resources.push({ + name: "i18n.properties", + getPath: () => "/resources/pony/i18n-bundle/i18n.properties", + getBuffer: async () => "A=B" + }); const options = { descriptor: "manifest.json", namespace: "pony" }; + await manifestBundler({resources, options}); - t.deepEqual(t.context.addBufferSpy.callCount, 1, "should be called once"); + + t.deepEqual(t.context.addBufferSpy.callCount, 2); t.deepEqual(t.context.addBufferSpy.getCall(0).args, - ["{\"sap.app\":{\"i18n\":\"i18n/i18n.properties\"}}", "manifest.json"], + ["{\"sap.app\":{\"i18n\":\"i18n-bundle/i18n.properties\"}}", "manifest.json"], + "should be called with correct arguments"); + t.deepEqual(t.context.addBufferSpy.getCall(1).args, + ["A=B", "i18n-bundle/i18n.properties"], "should be called with correct arguments"); - t.deepEqual(t.context.logVerboseSpy.callCount, 0, "should not be called"); + + t.is(t.context.logVerboseSpy.callCount, 0); + t.is(t.context.logWarnSpy.callCount, 0); + t.is(t.context.logErrorSpy.callCount, 0); }); -test.serial("manifestBundler with manifest with i18n object", async (t) => { +test.serial("manifestBundler with manifest with i18n object (bundleUrl)", async (t) => { const resources = []; const manifestString = JSON.stringify({ "sap.app": { @@ -135,13 +164,230 @@ test.serial("manifestBundler with manifest with i18n object", async (t) => { namespace: "pony", propertiesExtension: ".properties" }; + + await manifestBundler({resources, options}); + + t.is(t.context.addBufferSpy.callCount, 3, "should be called 3 times"); + t.deepEqual(t.context.addBufferSpy.getCall(0).args, [manifestString, "manifest.json"], + "should be called with correct arguments"); + t.deepEqual(t.context.addBufferSpy.getCall(1).args, ["A=B", "i18n/i18n_de.properties"], + "should be called with correct arguments"); + t.deepEqual(t.context.addBufferSpy.getCall(2).args, ["A=C", "i18n/i18n_en.properties"], + "should be called with correct arguments"); + + t.is(t.context.logVerboseSpy.callCount, 0); + t.is(t.context.logWarnSpy.callCount, 0); + t.is(t.context.logErrorSpy.callCount, 0); +}); + +test.serial("manifestBundler with manifest with i18n object (bundleName)", async (t) => { + const resources = []; + const manifestString = JSON.stringify({ + "sap.app": { + "id": "pony", + "i18n": { + "bundleName": "pony.i18n.i18n", + "supportedLocales": ["en", "de"], + "fallbackLocale": "en" + } + } + }); + resources.push({ + name: "manifest.json", + getPath: () => "/resources/pony/manifest.json", + getBuffer: async () => manifestString + }); + resources.push({ + name: "i18n_de.properties", + getPath: () => "/resources/pony/i18n/i18n_de.properties", + getBuffer: async () => "A=B" + }); + resources.push({ + name: "i18n_en.properties", + getPath: () => "/resources/pony/i18n/i18n_en.properties", + getBuffer: async () => "A=C" + }); + const options = { + descriptor: "manifest.json", + namespace: "pony", + propertiesExtension: ".properties" + }; + + await manifestBundler({resources, options}); + + t.is(t.context.addBufferSpy.callCount, 3, "should be called 3 times"); + t.deepEqual(t.context.addBufferSpy.getCall(0).args, [manifestString, "manifest.json"], + "should be called with correct arguments"); + t.deepEqual(t.context.addBufferSpy.getCall(1).args, ["A=B", "i18n/i18n_de.properties"], + "should be called with correct arguments"); + t.deepEqual(t.context.addBufferSpy.getCall(2).args, ["A=C", "i18n/i18n_en.properties"], + "should be called with correct arguments"); + + t.is(t.context.logVerboseSpy.callCount, 0); + t.is(t.context.logWarnSpy.callCount, 0); + t.is(t.context.logErrorSpy.callCount, 0); +}); + +test.serial("manifestBundler with manifest with i18n enhanceWith", async (t) => { + const resources = []; + const manifestString = JSON.stringify({ + "sap.app": { + "id": "pony", + "i18n": { + "bundleUrl": "i18n/i18n.properties", + "enhanceWith": [ + { + "bundleUrl": "enhancement1/i18n.properties" + }, + { + "bundleName": "pony.enhancement2.i18n" + } + ] + } + } + }); + resources.push({ + name: "manifest.json", + getPath: () => "/resources/pony/manifest.json", + getBuffer: async () => manifestString + }); + resources.push({ + name: "i18n_de.properties", + getPath: () => "/resources/pony/i18n/i18n_de.properties", + getBuffer: async () => "A=B" + }); + resources.push({ + name: "i18n_en.properties", + getPath: () => "/resources/pony/i18n/i18n_en.properties", + getBuffer: async () => "A=C" + }); + resources.push({ + name: "i18n.properties", + getPath: () => "/resources/pony/enhancement1/i18n.properties", + getBuffer: async () => "A=enhancement1" + }); + resources.push({ + name: "i18n.properties", + getPath: () => "/resources/pony/enhancement2/i18n.properties", + getBuffer: async () => "A=enhancement2" + }); + const options = { + descriptor: "manifest.json", + namespace: "pony", + propertiesExtension: ".properties" + }; + await manifestBundler({resources, options}); - t.deepEqual(t.context.addBufferSpy.callCount, 3, "should be called 3 times"); - t.deepEqual(t.context.logVerboseSpy.callCount, 0, "should not be called"); + + t.is(t.context.addBufferSpy.callCount, 5, "should be called 5 times"); t.deepEqual(t.context.addBufferSpy.getCall(0).args, [manifestString, "manifest.json"], "should be called with correct arguments"); t.deepEqual(t.context.addBufferSpy.getCall(1).args, ["A=B", "i18n/i18n_de.properties"], "should be called with correct arguments"); t.deepEqual(t.context.addBufferSpy.getCall(2).args, ["A=C", "i18n/i18n_en.properties"], "should be called with correct arguments"); + t.deepEqual(t.context.addBufferSpy.getCall(3).args, ["A=enhancement1", "enhancement1/i18n.properties"], + "should be called with correct arguments"); + t.deepEqual(t.context.addBufferSpy.getCall(4).args, ["A=enhancement2", "enhancement2/i18n.properties"], + "should be called with correct arguments"); + + t.is(t.context.logVerboseSpy.callCount, 0); + t.is(t.context.logWarnSpy.callCount, 0); + t.is(t.context.logErrorSpy.callCount, 0); +}); + +test.serial("manifestBundler with manifest with missing i18n files", async (t) => { + const resources = []; + const manifestString = JSON.stringify({ + "sap.app": { + "id": "pony", + "i18n": { + "bundleUrl": "i18n/i18n.properties", + "enhanceWith": [ + { + "bundleUrl": "enhancement1/i18n.properties" + }, + { + "bundleName": "pony.enhancement2.i18n" + } + ] + } + } + }); + resources.push({ + name: "manifest.json", + getPath: () => "/resources/pony/manifest.json", + getBuffer: async () => manifestString + }); + const options = { + descriptor: "manifest.json", + namespace: "pony", + propertiesExtension: ".properties" + }; + + await manifestBundler({resources, options}); + + t.is(t.context.addBufferSpy.callCount, 1, "should be called 1 time"); + t.deepEqual(t.context.addBufferSpy.getCall(0).args, [manifestString, "manifest.json"], + "should be called with correct arguments"); + + t.is(t.context.logVerboseSpy.callCount, 0); + t.is(t.context.logWarnSpy.callCount, 3); + t.deepEqual(t.context.logWarnSpy.getCall(0).args, [ + `Could not find any resources for i18n bundle '/resources/pony/i18n'` + ]); + t.deepEqual(t.context.logWarnSpy.getCall(1).args, [ + `Could not find any resources for i18n bundle '/resources/pony/enhancement1'` + ]); + t.deepEqual(t.context.logWarnSpy.getCall(2).args, [ + `Could not find any resources for i18n bundle '/resources/pony/enhancement2'` + ]); + + t.is(t.context.logErrorSpy.callCount, 0); +}); + +test.serial("manifestBundler with manifest with ui5:// url", async (t) => { + const resources = []; + const manifestString = JSON.stringify({ + "sap.app": { + "id": "pony", + "i18n": { + "bundleUrl": "ui5://pony/i18n/i18n.properties" + } + } + }); + resources.push({ + name: "manifest.json", + getPath: () => "/resources/pony/manifest.json", + getBuffer: async () => manifestString + }); + resources.push({ + name: "i18n_de.properties", + getPath: () => "/resources/pony/i18n/i18n_de.properties", + getBuffer: async () => "A=B" + }); + resources.push({ + name: "i18n_en.properties", + getPath: () => "/resources/pony/i18n/i18n_en.properties", + getBuffer: async () => "A=C" + }); + const options = { + descriptor: "manifest.json", + namespace: "pony", + propertiesExtension: ".properties" + }; + + await manifestBundler({resources, options}); + + t.is(t.context.addBufferSpy.callCount, 1, "should be called 1 time"); + t.deepEqual(t.context.addBufferSpy.getCall(0).args, [manifestString, "manifest.json"], + "should be called with correct arguments"); + + t.is(t.context.logVerboseSpy.callCount, 0); + t.is(t.context.logWarnSpy.callCount, 1); + t.deepEqual(t.context.logWarnSpy.getCall(0).args, [ + `Using the ui5:// protocol for i18n bundles is currently not supported ` + + `('ui5://pony/i18n/i18n.properties' in /resources/pony/manifest.json)` + ]); + t.is(t.context.logErrorSpy.callCount, 0); });