diff --git a/docs/content/en/setup.md b/docs/content/en/setup.md index cda2aeae..1388b53f 100644 --- a/docs/content/en/setup.md +++ b/docs/content/en/setup.md @@ -15,14 +15,14 @@ Add `@nuxtjs/pwa` dependency to your project: ```bash - yarn add @nuxtjs/pwa + yarn add --dev @nuxtjs/pwa ``` ```bash - npm i @nuxtjs/pwa + npm i --dev @nuxtjs/pwa ``` @@ -32,12 +32,14 @@ Edit your `nuxt.config.js` file to add pwa module:: ```js{}[nuxt.config.js] { - modules: [ + buildModules: [ '@nuxtjs/pwa', ] } ``` +**NOTE:** If using `ssr: false` with production mode without `nuxt generate`, you have to use `modules` instead of `buildModules` + ### Add Icon Ensure `static` dir exists and optionally create `static/icon.png`. (Recommended to be square png and >= `512x512px`) diff --git a/lib/icon/module.js b/lib/icon/module.js index 0570596a..9e2a0817 100755 --- a/lib/icon/module.js +++ b/lib/icon/module.js @@ -7,14 +7,10 @@ const { joinUrl, getRouteParams, sizeName } = require('../utils') const { version } = require('../../package.json') module.exports = function (pwa) { - this.nuxt.hook('build:before', () => run.call(this, pwa, true)) - - if (this.options.mode === 'spa' && !this.options.dev) { - return run.call(this, pwa, false) // Fill meta - } + this.nuxt.hook('build:before', () => run.call(this, pwa)) } -async function run (pwa, _emitAssets) { +async function run (pwa) { const { publicPath } = getRouteParams(this.options) // Defaults @@ -92,9 +88,7 @@ async function run (pwa, _emitAssets) { } // Emit assets in background - if (_emitAssets) { - emitAssets.call(this, options) - } + emitAssets.call(this, options) } async function findIcon (options) { @@ -120,7 +114,7 @@ function addPlugin (options) { if (options.plugin) { this.addPlugin({ src: path.resolve(__dirname, './plugin.js'), - fileName: 'nuxt-icons.js', + fileName: 'pwa/icons.js', options: { pluginName: options.pluginName, icons diff --git a/lib/manifest/module.js b/lib/manifest/module.js index e073f35b..e6ba3611 100755 --- a/lib/manifest/module.js +++ b/lib/manifest/module.js @@ -1,17 +1,9 @@ const hash = require('hasha') -const { joinUrl, getRouteParams, find } = require('../utils') +const { joinUrl, getRouteParams } = require('../utils') module.exports = function nuxtManifest (pwa) { - const hook = () => { - addManifest.call(this, pwa) - } - - if (this.options.mode === 'spa') { - return hook() - } - - this.nuxt.hook('build:before', hook) + this.nuxt.hook('build:before', () => addManifest.call(this, pwa)) } function addManifest (pwa) { @@ -67,11 +59,9 @@ function addManifest (pwa) { }) // Add manifest meta - if (!find(this.options.head.link, 'rel', 'manifest')) { - const baseAttribute = { rel: 'manifest', href: joinUrl(options.publicPath, manifestFileName) } - const attribute = manifest.crossorigin ? Object.assign({}, baseAttribute, { crossorigin: manifest.crossorigin }) : baseAttribute - this.options.head.link.push(attribute) - } else { - console.warn('Manifest meta already provided!') // eslint-disable-line no-console + const manifestMeta = { rel: 'manifest', href: joinUrl(options.publicPath, manifestFileName), hid: 'manifest' } + if (manifest.crossorigin) { + manifestMeta.crossorigin = manifest.crossorigin } + pwa._manifestMeta = manifestMeta } diff --git a/lib/meta/meta.json b/lib/meta/meta.json new file mode 100644 index 00000000..a55b0521 --- /dev/null +++ b/lib/meta/meta.json @@ -0,0 +1 @@ +<%= JSON.stringify(options.head, null, 2) %> diff --git a/lib/meta/meta.merge.js b/lib/meta/meta.merge.js new file mode 100644 index 00000000..c00d381f --- /dev/null +++ b/lib/meta/meta.merge.js @@ -0,0 +1,36 @@ +exports.mergeMeta = function mergeMeta (to, from) { + if (typeof to === 'function') { + // eslint-disable-next-line no-console + console.warn('Cannot merge meta. Avoid using head as a function!') + return + } + + for (const key in from) { + const value = from[key] + if (Array.isArray(value)) { + to[key] = to[key] || [] + for (const item of value) { + // Avoid duplicates + if ( + (item.hid && hasMeta(to[key], 'hid', item.hid)) || + (item.name && hasMeta(to[key], 'name', item.name)) + ) { + continue + } + // Add meta + to[key].push(item) + } + } else if (typeof value === 'object') { + to[key] = to[key] || {} + for (const attr in value) { + to[key][attr] = value[attr] + } + } else if (to[key] === undefined) { + to[key] = value + } + } +} + +function hasMeta (arr, key, val) { + return arr.find(obj => val ? obj[key] === val : obj[key]) +} diff --git a/lib/meta/module.js b/lib/meta/module.js index 7f94b326..8646123f 100755 --- a/lib/meta/module.js +++ b/lib/meta/module.js @@ -1,20 +1,24 @@ -const { join } = require('path') +const { join, resolve } = require('path') const { existsSync } = require('fs') -const { find, isUrl } = require('../utils') +const { isUrl } = require('../utils') +const { mergeMeta } = require('./meta.merge') module.exports = function nuxtMeta (pwa) { - const hook = () => { - generateMeta.call(this, pwa) - } + const { nuxt } = this - if (this.options.mode === 'spa') { - return hook() - } + nuxt.hook('build:before', () => generateMeta.call(this, pwa)) - this.nuxt.hook('build:before', hook) + // SPA Support + if (nuxt.options.target === 'static') { + nuxt.hook('generate:extendRoutes', () => SPASupport.call(this, pwa)) + } else if (!nuxt.options._build) { + SPASupport.call(this, pwa) + } } function generateMeta (pwa) { + const { nuxt } = this + // Defaults const defaults = { name: process.env.npm_package_name, @@ -46,7 +50,9 @@ function generateMeta (pwa) { // Default value for viewport if (options.viewport === undefined) { - options.viewport = options.nativeUI ? 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, minimal-ui' : 'width=device-width, initial-scale=1' + options.viewport = options.nativeUI + ? 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, minimal-ui' + : 'width=device-width, initial-scale=1' } // Default value for mobileAppIOS @@ -54,29 +60,40 @@ function generateMeta (pwa) { options.mobileAppIOS = !!options.nativeUI } + const head = { + title: '', + meta: [], + link: [], + htmlAttrs: {} + } + // Charset - if (options.charset && !find(this.options.head.meta, 'charset')) { - this.options.head.meta.push({ hid: 'charset', charset: options.charset }) + if (options.charset) { + head.meta.push({ hid: 'charset', charset: options.charset }) } // Viewport - if (options.viewport && !find(this.options.head.meta, 'name', 'viewport')) { - this.options.head.meta.push({ hid: 'viewport', name: 'viewport', content: options.viewport }) + if (options.viewport) { + head.meta.push({ hid: 'viewport', name: 'viewport', content: options.viewport }) } // mobileApp - if (options.mobileApp && !find(this.options.head.meta, 'name', 'mobile-web-app-capable')) { - this.options.head.meta.push({ hid: 'mobile-web-app-capable', name: 'mobile-web-app-capable', content: 'yes' }) + if (options.mobileApp) { + head.meta.push({ hid: 'mobile-web-app-capable', name: 'mobile-web-app-capable', content: 'yes' }) } // mobileApp (IOS) - if (options.mobileAppIOS && !find(this.options.head.meta, 'name', 'apple-mobile-web-app-capable')) { - this.options.head.meta.push({ hid: 'apple-mobile-web-app-capable', name: 'apple-mobile-web-app-capable', content: 'yes' }) + if (options.mobileAppIOS) { + head.meta.push({ hid: 'apple-mobile-web-app-capable', name: 'apple-mobile-web-app-capable', content: 'yes' }) } // statusBarStyle (IOS) - if (options.mobileAppIOS && options.appleStatusBarStyle && !find(this.options.head.meta, 'name', 'apple-mobile-web-app-status-bar-style')) { - this.options.head.meta.push({ hid: 'apple-mobile-web-app-status-bar-style', name: 'apple-mobile-web-app-status-bar-style', content: options.appleStatusBarStyle }) + if (options.mobileAppIOS && options.appleStatusBarStyle) { + head.meta.push({ + hid: 'apple-mobile-web-app-status-bar-style', + name: 'apple-mobile-web-app-status-bar-style', + content: options.appleStatusBarStyle + }) } // Icons @@ -84,105 +101,106 @@ function generateMeta (pwa) { const iconSmall = options.icons[0] const iconBig = options.icons[options.icons.length - 1] - if (!find(this.options.head.link, 'rel', 'shortcut icon')) { - this.options.head.link.push({ rel: 'shortcut icon', href: iconSmall.src }) - } - - if (!find(this.options.head.link, 'rel', 'apple-touch-icon')) { - this.options.head.link.push({ rel: 'apple-touch-icon', href: iconBig.src, sizes: iconBig.sizes }) - } + // Shortcut icon + head.link.push({ rel: 'shortcut icon', href: iconSmall.src }) + head.link.push({ rel: 'apple-touch-icon', href: iconBig.src, sizes: iconBig.sizes }) // Launch Screen Image (IOS) - if (options.mobileAppIOS && pwa._iosSplash && !find(this.options.head.link, 'rel', 'apple-touch-startup-image')) { - this.options.head.link.push({ href: pwa._iosSplash.iphonese, media: '(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.iphone6, media: '(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.iphoneplus, media: '(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.iphonex, media: '(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.iphonexr, media: '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.iphonexsmax, media: '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.ipad, media: '(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.ipadpro1, media: '(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.ipadpro2, media: '(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }) - this.options.head.link.push({ href: pwa._iosSplash.ipadpro3, media: '(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }) + if (options.mobileAppIOS && pwa._iosSplash) { + const splashes = [ + ['iphonese', '(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)'], + ['iphone6', '(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)'], + ['iphoneplus', '(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)'], + ['iphonex', '(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)'], + ['iphonexr', '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)'], + ['iphonexsmax', '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)'], + ['ipad', '(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)'], + ['ipadpro1', '(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)'], + ['ipadpro2', '(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)'], + ['ipadpro3', '(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)'] + ] + + for (const [type, media] of splashes) { + head.link.push({ + href: pwa._iosSplash[type], + media, + rel: 'apple-touch-startup-image', + hid: 'apple-touch-startup-image-' + type + }) + } } } - const favicon = join(this.options.srcDir, this.options.dir.static, 'favicon.ico') - if (options.favicon && !find(this.options.head.link, 'rel', 'shortcut icon') && existsSync(favicon)) { + // Favicon.ico as fallback + const favicon = join(nuxt.options.srcDir, nuxt.options.dir.static, 'favicon.ico') + if (options.favicon && existsSync(favicon)) { // eslint-disable-next-line no-console - console.warn('You are using a low quality icon, use icon png. See https://pwa.nuxtjs.org/icon/') - - this.options.head.link.push({ rel: 'shortcut icon', href: this.options.router.base + 'favicon.ico' }) + console.warn('You are using a low quality icon, use icon png. See https://pwa.nuxtjs.org/icon') + head.link.push({ rel: 'shortcut icon', href: nuxt.options.router.base + 'favicon.ico' }) } // Title - if (options.name && !this.options.head.title && typeof this.options.head.titleTemplate !== 'function') { - this.options.head.title = options.name - } - - // IOS launch icon title - const title = options.name || this.options.head.title || false - if (title && !find(this.options.head.meta, 'name', 'apple-mobile-web-app-title')) { - this.options.head.meta.push({ hid: 'apple-mobile-web-app-title', name: 'apple-mobile-web-app-title', content: title }) + const title = options.name || options.title + if (title) { + head.title = options.name + // IOS launch icon title + head.meta.push({ hid: 'apple-mobile-web-app-title', name: 'apple-mobile-web-app-title', content: title }) } // Author - if (options.author && !find(this.options.head.meta, 'name', 'author')) { - this.options.head.meta.push({ hid: 'author', name: 'author', content: options.author }) + if (options.author) { + head.meta.push({ hid: 'author', name: 'author', content: options.author }) } // description meta - if (options.description && !find(this.options.head.meta, 'name', 'description')) { - this.options.head.meta.push({ hid: 'description', name: 'description', content: options.description }) + if (options.description) { + head.meta.push({ hid: 'description', name: 'description', content: options.description }) } // theme-color meta - if (options.theme_color && !find(this.options.head.meta, 'name', 'theme-color')) { - this.options.head.meta.push({ hid: 'theme-color', name: 'theme-color', content: options.theme_color }) + if (options.theme_color) { + head.meta.push({ hid: 'theme-color', name: 'theme-color', content: options.theme_color }) } // Add lang to html tag - if (options.lang && !(this.options.head.htmlAttrs && this.options.head.htmlAttrs.lang)) { - if (!this.options.head.htmlAttrs) { - this.options.head.htmlAttrs = {} - } - this.options.head.htmlAttrs.lang = options.lang + if (options.lang) { + head.htmlAttrs.lang = options.lang } // og:type - if (options.ogType && !find(this.options.head.meta, 'property', 'og:type') && !find(this.options.head.meta, 'name', 'og:type')) { - this.options.head.meta.push({ hid: 'og:type', name: 'og:type', property: 'og:type', content: options.ogType }) + if (options.ogType) { + head.meta.push({ hid: 'og:type', name: 'og:type', property: 'og:type', content: options.ogType }) } // og:title if (options.ogTitle === true) { options.ogTitle = options.name } - if (options.ogTitle && !find(this.options.head.meta, 'property', 'og:title') && !find(this.options.head.meta, 'name', 'og:title')) { - this.options.head.meta.push({ hid: 'og:title', name: 'og:title', property: 'og:title', content: options.ogTitle }) + if (options.ogTitle) { + head.meta.push({ hid: 'og:title', name: 'og:title', property: 'og:title', content: options.ogTitle }) } // og:site_name if (options.ogSiteName === true) { options.ogSiteName = options.name } - if (options.ogSiteName && !find(this.options.head.meta, 'property', 'og:site_name') && !find(this.options.head.meta, 'name', 'og:site_name')) { - this.options.head.meta.push({ hid: 'og:site_name', name: 'og:site_name', property: 'og:site_name', content: options.ogSiteName }) + if (options.ogSiteName) { + head.meta.push({ hid: 'og:site_name', name: 'og:site_name', property: 'og:site_name', content: options.ogSiteName }) } // og:description if (options.ogDescription === true) { options.ogDescription = options.description } - if (options.ogDescription && !find(this.options.head.meta, 'property', 'og:description') && !find(this.options.head.meta, 'name', 'og:description')) { - this.options.head.meta.push({ hid: 'og:description', name: 'og:description', property: 'og:description', content: options.ogDescription }) + if (options.ogDescription) { + head.meta.push({ hid: 'og:description', name: 'og:description', property: 'og:description', content: options.ogDescription }) } // og:url if (options.ogHost && options.ogUrl === true) { options.ogUrl = options.ogHost } - if (options.ogUrl && options.ogUrl !== true && !find(this.options.head.meta, 'property', 'og:url') && !find(this.options.head.meta, 'name', 'og:url')) { - this.options.head.meta.push({ hid: 'og:url', name: 'og:url', property: 'og:url', content: options.ogUrl }) + if (options.ogUrl && options.ogUrl !== true) { + head.meta.push({ hid: 'og:url', name: 'og:url', property: 'og:url', content: options.ogUrl }) } // og:image @@ -197,22 +215,22 @@ function generateMeta (pwa) { } else if (typeof options.ogImage === 'string') { options.ogImage = { path: options.ogImage } } - if (options.ogImage && !find(this.options.head.meta, 'property', 'og:image') && !find(this.options.head.meta, 'name', 'og:image')) { + if (options.ogImage) { if (options.ogHost || isUrl(options.ogImage.path)) { - this.options.head.meta.push({ + head.meta.push({ hid: 'og:image', name: 'og:image', property: 'og:image', content: isUrl(options.ogImage.path) ? options.ogImage.path : options.ogHost + options.ogImage.path }) if (options.ogImage.width && options.ogImage.height) { - this.options.head.meta.push({ + head.meta.push({ hid: 'og:image:width', name: 'og:image:width', property: 'og:image:width', content: options.ogImage.width }) - this.options.head.meta.push({ + head.meta.push({ hid: 'og:image:height', name: 'og:image:height', property: 'og:image:height', @@ -220,7 +238,7 @@ function generateMeta (pwa) { }) } if (options.ogImage.type) { - this.options.head.meta.push({ + head.meta.push({ hid: 'og:image:type', name: 'og:image:type', property: 'og:image:type', @@ -231,17 +249,53 @@ function generateMeta (pwa) { } // twitter:card - if (options.twitterCard && !find(this.options.head.meta, 'property', 'twitter:card') && !find(this.options.head.meta, 'name', 'twitter:card')) { - this.options.head.meta.push({ hid: 'twitter:card', name: 'twitter:card', property: 'twitter:card', content: options.twitterCard }) + if (options.twitterCard) { + head.meta.push({ hid: 'twitter:card', name: 'twitter:card', property: 'twitter:card', content: options.twitterCard }) } // twitter:site - if (options.twitterSite && !find(this.options.head.meta, 'property', 'twitter:site') && !find(this.options.head.meta, 'name', 'twitter:site')) { - this.options.head.meta.push({ hid: 'twitter:site', name: 'twitter:site', property: 'twitter:site', content: options.twitterSite }) + if (options.twitterSite) { + head.meta.push({ hid: 'twitter:site', name: 'twitter:site', property: 'twitter:site', content: options.twitterSite }) } // twitter:creator - if (options.twitterCreator && !find(this.options.head.meta, 'property', 'twitter:creator') && !find(this.options.head.meta, 'name', 'twitter:creator')) { - this.options.head.meta.push({ hid: 'twitter:creator', name: 'twitter:creator', property: 'twitter:creator', content: options.twitterCreator }) + if (options.twitterCreator) { + head.meta.push({ hid: 'twitter:creator', name: 'twitter:creator', property: 'twitter:creator', content: options.twitterCreator }) + } + + // manifest meta + if (pwa._manifestMeta) { + head.link.push(pwa._manifestMeta) + } + + this.addPlugin({ + src: resolve(__dirname, './plugin.js'), + fileName: 'pwa/meta.js', + options: {} + }) + + this.addTemplate({ + src: resolve(__dirname, 'meta.json'), + fileName: 'pwa/meta.json', + options: { head } + }) + + this.addTemplate({ + src: resolve(__dirname, 'meta.merge.js'), + fileName: 'pwa/meta.merge.js', + options: { head } + }) +} + +function SPASupport (_pwa) { + const { nuxt } = this + const metaJSON = resolve(nuxt.options.buildDir, 'pwa/meta.json') + if (existsSync(metaJSON)) { + // eslint-disable-next-line no-console + console.log('[PWA] Loading meta from ' + metaJSON) + mergeMeta(nuxt.options.head, require(metaJSON)) + } else { + // eslint-disable-next-line no-console + console.warn('[PWA] Cannot load meta from ' + metaJSON) } } diff --git a/lib/meta/plugin.js b/lib/meta/plugin.js new file mode 100644 index 00000000..1d97fe3b --- /dev/null +++ b/lib/meta/plugin.js @@ -0,0 +1,6 @@ +import meta from './meta.json' +import { mergeMeta } from './meta.merge' + +export default function ({ app }) { + mergeMeta(app.head, meta) +} diff --git a/lib/utils/index.js b/lib/utils/index.js index 093a0969..a169a8e0 100755 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -1,9 +1,5 @@ const path = require('path').posix -function find (arr, key, val) { - return arr.find(obj => val ? obj[key] === val : obj[key]) -} - function isUrl (url) { return url.indexOf('http') === 0 || url.indexOf('//') === 0 } @@ -53,7 +49,6 @@ function startCase (str) { } module.exports = { - find, isUrl, joinUrl, getRouteParams, diff --git a/test/__snapshots__/pwa.test.js.snap b/test/__snapshots__/pwa.test.js.snap index 805781fb..c249c9f4 100644 --- a/test/__snapshots__/pwa.test.js.snap +++ b/test/__snapshots__/pwa.test.js.snap @@ -35,6 +35,7 @@ Array [ "fixture/.nuxt/dist/server/pages", "fixture/.nuxt/loading.html", "fixture/.nuxt/mixins", + "fixture/.nuxt/pwa", "fixture/.nuxt/views", "fixture/.nuxt/views/app.template.html", "fixture/.nuxt/views/error.html", diff --git a/test/fixture/nuxt.config.js b/test/fixture/nuxt.config.js index e59657ed..6ba8d51b 100644 --- a/test/fixture/nuxt.config.js +++ b/test/fixture/nuxt.config.js @@ -2,7 +2,7 @@ module.exports = { dev: false, rootDir: __dirname, - modules: [ + buildModules: [ { handler: require('../../') } ],