diff --git a/package.json b/package.json index bc22fd79..0ffa6d00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/app-scripts", - "version": "2.1.4-201709061759", + "version": "2.1.4", "description": "Scripts for Ionic Projects", "homepage": "https://ionicframework.com/", "author": "Ionic Team (https://ionic.io)", diff --git a/src/optimization/remove-unused-fonts.spec.ts b/src/optimization/remove-unused-fonts.spec.ts new file mode 100644 index 00000000..73e8dd5f --- /dev/null +++ b/src/optimization/remove-unused-fonts.spec.ts @@ -0,0 +1,129 @@ +import { join } from 'path'; + +import { removeUnusedFonts } from './remove-unused-fonts'; +import * as helpers from '../util/helpers'; + +describe('Remove Fonts', () => { + describe('removeUnusedFonts', () => { + it('should not purge any fonts when target is not cordova', () => { + const fakeFontDirPath = join(process.cwd(), 'www', 'assets', 'fonts'); + spyOn(helpers, helpers.getStringPropertyValue.name).and.returnValue(fakeFontDirPath); + spyOn(helpers, helpers.readDirAsync.name).and.returnValue(Promise.resolve(getMockFontDirData())); + spyOn(helpers, helpers.unlinkAsync.name).and.returnValue(Promise.resolve()); + + return removeUnusedFonts({ target: 'notCordova', platform: 'web' }).then(() => { + expect(helpers.getStringPropertyValue).toHaveBeenCalled(); + expect(helpers.readDirAsync).toHaveBeenCalledWith(fakeFontDirPath); + expect(helpers.unlinkAsync).not.toHaveBeenCalled(); + }); + }); + + it('should purge all non-woffs for ionicons and roboto, and then all of noto-sans for ios', () => { + const fakeFontDirPath = join(process.cwd(), 'www', 'assets', 'fonts'); + spyOn(helpers, helpers.getStringPropertyValue.name).and.returnValue(fakeFontDirPath); + spyOn(helpers, helpers.readDirAsync.name).and.returnValue(Promise.resolve(getMockFontDirData())); + const unlinkSpy = spyOn(helpers, helpers.unlinkAsync.name).and.returnValue(Promise.resolve()); + + return removeUnusedFonts({ target: 'cordova', platform: 'ios' }).then(() => { + expect(helpers.readDirAsync).toHaveBeenCalledWith(fakeFontDirPath); + expect(unlinkSpy.calls.all()[0].args[0]).toEqual(join(fakeFontDirPath, 'ionicons.eot')); + expect(unlinkSpy.calls.all()[1].args[0]).toEqual(join(fakeFontDirPath, 'ionicons.scss')); + expect(unlinkSpy.calls.all()[2].args[0]).toEqual(join(fakeFontDirPath, 'ionicons.svg')); + expect(unlinkSpy.calls.all()[3].args[0]).toEqual(join(fakeFontDirPath, 'ionicons.ttf')); + expect(unlinkSpy.calls.all()[4].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans-bold.ttf')); + expect(unlinkSpy.calls.all()[5].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans-bold.woff')); + expect(unlinkSpy.calls.all()[6].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans-regular.ttf')); + expect(unlinkSpy.calls.all()[7].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans-regular.woff')); + expect(unlinkSpy.calls.all()[8].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans.scss')); + + expect(unlinkSpy.calls.all()[9].args[0]).toEqual(join(fakeFontDirPath, 'roboto-bold.ttf')); + expect(unlinkSpy.calls.all()[10].args[0]).toEqual(join(fakeFontDirPath, 'roboto-light.ttf')); + expect(unlinkSpy.calls.all()[11].args[0]).toEqual(join(fakeFontDirPath, 'roboto-medium.ttf')); + expect(unlinkSpy.calls.all()[12].args[0]).toEqual(join(fakeFontDirPath, 'roboto-regular.ttf')); + expect(unlinkSpy.calls.all()[13].args[0]).toEqual(join(fakeFontDirPath, 'roboto.scss')); + }); + }); + + it('should purge all non-woffs for ionicons, all of roboto and noto-sans for android', () => { + const fakeFontDirPath = join(process.cwd(), 'www', 'assets', 'fonts'); + spyOn(helpers, helpers.getStringPropertyValue.name).and.returnValue(fakeFontDirPath); + spyOn(helpers, helpers.readDirAsync.name).and.returnValue(Promise.resolve(getMockFontDirData())); + const unlinkSpy = spyOn(helpers, helpers.unlinkAsync.name).and.returnValue(Promise.resolve()); + + return removeUnusedFonts({ target: 'cordova', platform: 'android' }).then(() => { + expect(helpers.readDirAsync).toHaveBeenCalledWith(fakeFontDirPath); + expect(unlinkSpy.calls.all()[0].args[0]).toEqual(join(fakeFontDirPath, 'ionicons.eot')); + expect(unlinkSpy.calls.all()[1].args[0]).toEqual(join(fakeFontDirPath, 'ionicons.scss')); + expect(unlinkSpy.calls.all()[2].args[0]).toEqual(join(fakeFontDirPath, 'ionicons.svg')); + expect(unlinkSpy.calls.all()[3].args[0]).toEqual(join(fakeFontDirPath, 'ionicons.ttf')); + expect(unlinkSpy.calls.all()[4].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans-bold.ttf')); + expect(unlinkSpy.calls.all()[5].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans-bold.woff')); + expect(unlinkSpy.calls.all()[6].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans-regular.ttf')); + expect(unlinkSpy.calls.all()[7].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans-regular.woff')); + expect(unlinkSpy.calls.all()[8].args[0]).toEqual(join(fakeFontDirPath, 'noto-sans.scss')); + + expect(unlinkSpy.calls.all()[9].args[0]).toEqual(join(fakeFontDirPath, 'roboto-bold.ttf')); + expect(unlinkSpy.calls.all()[10].args[0]).toEqual(join(fakeFontDirPath, 'roboto-bold.woff')); + expect(unlinkSpy.calls.all()[11].args[0]).toEqual(join(fakeFontDirPath, 'roboto-bold.woff2')); + expect(unlinkSpy.calls.all()[12].args[0]).toEqual(join(fakeFontDirPath, 'roboto-light.ttf')); + expect(unlinkSpy.calls.all()[13].args[0]).toEqual(join(fakeFontDirPath, 'roboto-light.woff')); + expect(unlinkSpy.calls.all()[14].args[0]).toEqual(join(fakeFontDirPath, 'roboto-light.woff2')); + expect(unlinkSpy.calls.all()[15].args[0]).toEqual(join(fakeFontDirPath, 'roboto-medium.ttf')); + expect(unlinkSpy.calls.all()[16].args[0]).toEqual(join(fakeFontDirPath, 'roboto-medium.woff')); + expect(unlinkSpy.calls.all()[17].args[0]).toEqual(join(fakeFontDirPath, 'roboto-medium.woff2')); + expect(unlinkSpy.calls.all()[18].args[0]).toEqual(join(fakeFontDirPath, 'roboto-regular.ttf')); + expect(unlinkSpy.calls.all()[19].args[0]).toEqual(join(fakeFontDirPath, 'roboto-regular.woff')); + expect(unlinkSpy.calls.all()[20].args[0]).toEqual(join(fakeFontDirPath, 'roboto-regular.woff2')); + expect(unlinkSpy.calls.all()[21].args[0]).toEqual(join(fakeFontDirPath, 'roboto.scss')); + + }); + }); + + it('should purge all non-woffs for ionicons, all of roboto and noto-sans for windows', () => { + const fakeFontDirPath = join(process.cwd(), 'www', 'assets', 'fonts'); + spyOn(helpers, helpers.getStringPropertyValue.name).and.returnValue(fakeFontDirPath); + spyOn(helpers, helpers.readDirAsync.name).and.returnValue(Promise.resolve(getMockFontDirData())); + const unlinkSpy = spyOn(helpers, helpers.unlinkAsync.name).and.returnValue(Promise.resolve()); + + return removeUnusedFonts({ target: 'cordova', platform: 'windows' }).then(() => { + expect(helpers.readDirAsync).toHaveBeenCalledWith(fakeFontDirPath); + expect(helpers.unlinkAsync).not.toHaveBeenCalled(); + }); + }); + }); +}); + +function getMockFontDirData() { + return [ + 'ionicons.eot', + 'ionicons.scss', + 'ionicons.svg', + 'ionicons.ttf', + 'ionicons.woff', + 'ionicons.woff2', + 'noto-sans-bold.ttf', + 'noto-sans-bold.woff', + 'noto-sans-regular.ttf', + 'noto-sans-regular.woff', + 'noto-sans.scss', + 'roboto-bold.ttf', + 'roboto-bold.woff', + 'roboto-bold.woff2', + 'roboto-light.ttf', + 'roboto-light.woff', + 'roboto-light.woff2', + 'roboto-medium.ttf', + 'roboto-medium.woff', + 'roboto-medium.woff2', + 'roboto-regular.ttf', + 'roboto-regular.woff', + 'roboto-regular.woff2', + 'roboto.scss', + 'my-custom-font.eot', + 'my-custom-font.scss', + 'my-custom-font.svg', + 'my-custom-font.ttf', + 'my-custom-font.woff', + 'my-custom-font.woff2' + ]; +} diff --git a/src/optimization/remove-unused-fonts.ts b/src/optimization/remove-unused-fonts.ts index 9b554f31..f07d825c 100644 --- a/src/optimization/remove-unused-fonts.ts +++ b/src/optimization/remove-unused-fonts.ts @@ -1,91 +1,68 @@ -import { BuildContext } from '../util/interfaces'; -import { join } from 'path'; -import { Logger } from '../logger/logger'; -import { unlinkAsync } from '../util/helpers'; -import * as glob from 'glob'; - - -export function removeUnusedFonts(context: BuildContext) { - // For webapps, we pretty much need all fonts to be available because - // the web server deployment never knows which browser/platform is - // opening the app. Additionally, webapps will request fonts on-demand, - // so having them all sit in the www/assets/fonts directory doesn’t - // hurt anything if it’s never being requested. - - // However, with Cordova, the entire directory gets bundled and - // shipped in the ipa/apk, but we also know exactly which platform - // is opening the webapp. For this reason we can safely delete font - // files we know would never be opened by the platform. So app-scripts - // will continue to copy all font files over, but the cordova build - // process would delete those we know are useless and just taking up - // space. End goal is that the Cordova ipa/apk filesize is smaller. +import { extname, join } from 'path'; - // Font Format Support: - // ttf: http://caniuse.com/#feat=ttf - // woff: http://caniuse.com/#feat=woff - // woff2: http://caniuse.com/#feat=woff2 - - if (context.target === 'cordova') { - const fontsRemoved: string[] = []; - // all cordova builds should remove .eot, .svg, .ttf, and .scss files - fontsRemoved.push('*.eot'); - fontsRemoved.push('*.ttf'); - fontsRemoved.push('*.svg'); - fontsRemoved.push('*.scss'); +import { Logger } from '../logger/logger'; +import * as Constants from '../util/constants'; +import { getStringPropertyValue, readDirAsync, unlinkAsync } from '../util/helpers'; +import { BuildContext } from '../util/interfaces'; - // all cordova builds should remove Noto-Sans - // Only windows would use Noto-Sans, and it already comes with - // a system font so it wouldn't need our own copy. - fontsRemoved.push('noto-sans*'); - if (context.platform === 'android') { - // Remove all Roboto fonts. Android already comes with Roboto system - // fonts so shipping our own is unnecessary. Including roboto fonts - // is only useful for PWAs and during development. - fontsRemoved.push('roboto*'); +// For webapps, we pretty much need all fonts to be available because +// the web server deployment never knows which browser/platform is +// opening the app. Additionally, webapps will request fonts on-demand, +// so having them all sit in the www/assets/fonts directory doesn’t +// hurt anything if it’s never being requested. + +// However, with Cordova, the entire directory gets bundled and +// shipped in the ipa/apk, but we also know exactly which platform +// is opening the webapp. For this reason we can safely delete font +// files we know would never be opened by the platform. So app-scripts +// will continue to copy all font files over, but the cordova build +// process would delete those we know are useless and just taking up +// space. End goal is that the Cordova ipa/apk filesize is smaller. + +// Font Format Support: +// ttf: http://caniuse.com/#feat=ttf +// woff: http://caniuse.com/#feat=woff +// woff2: http://caniuse.com/#feat=woff2 +export function removeUnusedFonts(context: BuildContext): Promise { + const fontDir = getStringPropertyValue(Constants.ENV_VAR_FONTS_DIR); + return readDirAsync(fontDir).then((fileNames: string[]) => { + fileNames = fileNames.sort(); + const toPurge = getFontFileNamesToPurge(context.target, context.platform, fileNames); + const fullPaths = toPurge.map(fileName => join(fontDir, fileName)); + const promises = fullPaths.map(fullPath => unlinkAsync(fullPath)); + return Promise.all(promises); + }); +} - } else if (context.platform === 'ios') { - // Keep Roboto for now. Apps built for iOS may still use Material Design, - // so in that case Roboto should be available. Later we can improve the - // CLI to be smarter and read the user’s ionic config. Also, the roboto - // fonts themselves are pretty small. +export function getFontFileNamesToPurge(target: string, platform: string, fileNames: string[]): string[] { + if (target !== Constants.CORDOVA) { + return []; + } + const filesToDelete = new Set(); + for (const fileName of fileNames) { + if (platform === 'android') { + // remove noto-sans, roboto, and non-woff ionicons + if (fileName.startsWith('noto-sans') || fileName.startsWith('roboto') || (isIonicons(fileName) && !isWoof(fileName))) { + filesToDelete.add(fileName); + } + } else if (platform === 'ios') { + // remove noto-sans, non-woff ionicons + if (fileName.startsWith('noto-sans') || (fileName.startsWith('roboto') && !isWoof(fileName)) || (isIonicons(fileName) && !isWoof(fileName))) { + filesToDelete.add(fileName); + } } + // for now don't bother deleting anything for windows, need to get some info first - let filesToDelete: string[] = []; - - let promises = fontsRemoved.map(pattern => { - return new Promise(resolve => { - let searchPattern = join(context.wwwDir, 'assets', 'fonts', pattern); - - glob(searchPattern, (err, files) => { - if (err) { - Logger.error(`removeUnusedFonts: ${err}`); - - } else { - files.forEach(f => { - if (filesToDelete.indexOf(f) === -1) { - filesToDelete.push(f); - } - }); - } - - resolve(); - }); - - }); - }); - - return Promise.all(promises).then(() => { - return unlinkAsync(filesToDelete).then(() => { - if (filesToDelete.length) { - Logger.info(`removed unused font files`); - return true; - } - return false; - }); - }); } + return Array.from(filesToDelete); +} + +function isIonicons(fileName: string) { + return fileName.startsWith('ionicons'); +} - // nothing to do here, carry on - return Promise.resolve(); +// woof woof +function isWoof(fileName: string) { + return extname(fileName) === '.woff' || extname(fileName) === '.woff2'; } diff --git a/src/postprocess.ts b/src/postprocess.ts index a66ea8ed..7731c60a 100644 --- a/src/postprocess.ts +++ b/src/postprocess.ts @@ -22,27 +22,32 @@ export function postprocess(context: BuildContext) { function postprocessWorker(context: BuildContext) { - return Promise.all([ - purgeSourceMapsIfNeeded(context), - removeUnusedFonts(context), - updateIndexHtml(context), - writeFilesToDisk(context) - ]); -} + const promises: Promise[] = []; + promises.push(purgeSourceMapsIfNeeded(context)); + promises.push(updateIndexHtml(context)); -export function writeFilesToDisk(context: BuildContext) { if (getBooleanPropertyValue(Constants.ENV_AOT_WRITE_TO_DISK)) { - emptyDirSync(context.tmpDir); - const files = context.fileCache.getAll(); - files.forEach(file => { - const dirName = dirname(file.path); - const relativePath = relative(process.cwd(), dirName); - const tmpPath = join(context.tmpDir, relativePath); - const fileName = basename(file.path); - const fileToWrite = join(tmpPath, fileName); - mkdirpSync(tmpPath); - writeFileSync(fileToWrite, file.content); - }); + promises.push(writeFilesToDisk(context)); + } + + if (context.optimizeJs && getBooleanPropertyValue(Constants.ENV_PURGE_UNUSED_FONTS)) { + promises.push(removeUnusedFonts(context)); } + + return Promise.all(promises); +} + +export function writeFilesToDisk(context: BuildContext) { + emptyDirSync(context.tmpDir); + const files = context.fileCache.getAll(); + files.forEach(file => { + const dirName = dirname(file.path); + const relativePath = relative(process.cwd(), dirName); + const tmpPath = join(context.tmpDir, relativePath); + const fileName = basename(file.path); + const fileToWrite = join(tmpPath, fileName); + mkdirpSync(tmpPath); + writeFileSync(fileToWrite, file.content); + }); return Promise.resolve(); } diff --git a/src/util/config.spec.ts b/src/util/config.spec.ts index fa54a8b8..ab53e2f1 100644 --- a/src/util/config.spec.ts +++ b/src/util/config.spec.ts @@ -74,6 +74,7 @@ describe('config', () => { expect(context.wwwDir).toEqual(join(process.cwd(), Constants.WWW_DIR)); expect(context.wwwIndex).toEqual('index.html'); expect(context.buildDir).toEqual(join(process.cwd(), Constants.WWW_DIR, Constants.BUILD_DIR)); + expect(fakeConfig[Constants.ENV_VAR_FONTS_DIR]).toEqual(join(context.wwwDir, 'assets', 'fonts')); expect(context.pagesDir).toEqual(join(context.srcDir, 'pages')); expect(context.componentsDir).toEqual(join(context.srcDir, 'components')); expect(context.directivesDir).toEqual(join(context.srcDir, 'directives')); diff --git a/src/util/config.ts b/src/util/config.ts index 9ba6ebda..455739a8 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -90,6 +90,10 @@ export function generateContext(context?: BuildContext): BuildContext { setProcessEnvVar(Constants.ENV_VAR_BUILD_DIR, context.buildDir); Logger.debug(`buildDir set to ${context.buildDir}`); + const fontsDir = resolve(getConfigValue(context, '--fontsDir', null, Constants.ENV_VAR_FONTS_DIR, Constants.ENV_VAR_FONTS_DIR.toLowerCase(), join(context.wwwDir, 'assets', 'fonts'))); + setProcessEnvVar(Constants.ENV_VAR_FONTS_DIR, fontsDir); + Logger.debug(`fontsDir set to ${fontsDir}`); + context.sourcemapDir = resolve(context.sourcemapDir || getConfigValue(context, '--sourcemapDir', null, Constants.ENV_VAR_SOURCEMAP_DIR, Constants.ENV_VAR_SOURCEMAP_DIR.toLowerCase(), Constants.SOURCEMAP_DIR)); setProcessEnvVar(Constants.ENV_VAR_SOURCEMAP_DIR, context.sourcemapDir); Logger.debug(`sourcemapDir set to ${context.sourcemapDir}`); @@ -276,6 +280,10 @@ export function generateContext(context?: BuildContext): BuildContext { setProcessEnvVar(Constants.ENV_POLYFILL_FILE_NAME, polyfillName); Logger.debug(`polyfillName set to ${polyfillName}`); + const purgeUnusedFonts = getConfigValue(context, '--purgeUnusedFonts', null, Constants.ENV_PURGE_UNUSED_FONTS, Constants.ENV_PURGE_UNUSED_FONTS.toLowerCase(), 'true'); + setProcessEnvVar(Constants.ENV_PURGE_UNUSED_FONTS, purgeUnusedFonts); + Logger.debug(`purgeUnusedFonts set to ${purgeUnusedFonts}`); + /* Provider Path Stuff */ setProcessEnvVar(Constants.ENV_ACTION_SHEET_CONTROLLER_CLASSNAME, 'ActionSheetController'); setProcessEnvVar(Constants.ENV_ACTION_SHEET_CONTROLLER_PATH, join(context.ionicAngularDir, 'components', 'action-sheet', 'action-sheet-controller.js')); diff --git a/src/util/constants.ts b/src/util/constants.ts index 1aad4f50..b53fee73 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -22,6 +22,7 @@ export const PROVIDER = 'provider'; export const TABS = 'tabs'; export const AT_ANGULAR = '@angular'; export const RXJS = 'rxjs'; +export const CORDOVA = 'cordova'; export const ENV_VAR_PROD = 'prod'; export const ENV_VAR_DEV = 'dev'; @@ -36,6 +37,7 @@ export const ENV_VAR_PIPES_DIR = 'IONIC_PIPES_DIR'; export const ENV_VAR_PROVIDERS_DIR = 'IONIC_PROVIDERS_DIR'; export const ENV_VAR_TMP_DIR = 'IONIC_TMP_DIR'; export const ENV_VAR_WWW_DIR = 'IONIC_WWW_DIR'; +export const ENV_VAR_FONTS_DIR = 'IONIC_FONTS_DIR'; export const ENV_VAR_SOURCEMAP_DIR = 'IONIC_SOURCEMAP_DIR'; export const ENV_VAR_HTML_TO_SERVE = 'IONIC_HTML_TO_SERVE'; export const ENV_VAR_BUILD_DIR = 'IONIC_BUILD_DIR'; @@ -83,7 +85,7 @@ export const ENV_NG_MODULE_FILE_NAME_SUFFIX = 'IONIC_NG_MODULE_FILENAME_SUFFIX'; export const ENV_POLYFILL_FILE_NAME = 'IONIC_POLYFILL_FILE_NAME'; export const ENV_PRINT_WEBPACK_DEPENDENCY_TREE = 'IONIC_PRINT_WEBPACK_DEPENDENCY_TREE'; export const ENV_PARSE_DEEPLINKS = 'IONIC_PARSE_DEEPLINKS'; - +export const ENV_PURGE_UNUSED_FONTS = 'IONIC_PURGE_UNUSED_FONTS'; /* Providers */ diff --git a/src/util/source-maps.ts b/src/util/source-maps.ts index 09b2b823..a920d933 100644 --- a/src/util/source-maps.ts +++ b/src/util/source-maps.ts @@ -12,8 +12,8 @@ function copySourcemaps(context: BuildContext, shouldPurge: boolean) { const copyBeforePurge = getBooleanPropertyValue(Constants.ENV_VAR_MOVE_SOURCE_MAPS); for (const fullPath of fullPaths) { if (copyBeforePurge) { - mkdirpSync(context.sourcemapDir) - const relativeTo = relative(fullPath, context.sourcemapDir) + mkdirpSync(context.sourcemapDir); + const relativeTo = relative(fullPath, context.sourcemapDir); const fileName = basename(fullPath); if (fileName.indexOf('vendor.js') < 0) { promises.push(copyFileAsync(fullPath, join(context.sourcemapDir, fileName))); @@ -21,7 +21,7 @@ function copySourcemaps(context: BuildContext, shouldPurge: boolean) { } if (shouldPurge) { - promises.push(unlinkAsync(fullPath)) + promises.push(unlinkAsync(fullPath)); } } return Promise.all(promises);