import { extname } from 'path' import Vue from 'vue' import VueMeta from 'vue-meta' import { createRenderer } from 'vue-server-renderer' import LRU from 'lru-cache' export default class SPAMetaRenderer { constructor(renderer) { this.renderer = renderer this.options = this.renderer.context.options this.vueRenderer = createRenderer() this.cache = new LRU() // Add VueMeta to Vue (this is only for SPA mode) // See app/index.js Vue.use(VueMeta, { keyName: 'head', attribute: 'data-n-head', ssrAttribute: 'data-n-head-ssr', tagIDKeyName: 'hid' }) } async getMeta() { const vm = new Vue({ render: h => h(), // Render empty html tag head: this.options.head || {} }) await this.vueRenderer.renderToString(vm) return vm.$meta().inject() } async render({ url = '/', req = {} }) { const cacheKey = `${req.modernMode ? 'modern:' : 'legacy:'}${url}` let meta = this.cache.get(cacheKey) if (meta) { return meta } meta = { HTML_ATTRS: '', HEAD_ATTRS: '', BODY_ATTRS: '', HEAD: '', BODY_SCRIPTS: '' } // Get vue-meta context const m = await this.getMeta() // HTML_ATTRS meta.HTML_ATTRS = m.htmlAttrs.text() // HEAD_ATTRS meta.HEAD_ATTRS = m.headAttrs.text() // BODY_ATTRS meta.BODY_ATTRS = m.bodyAttrs.text() // HEAD tags meta.HEAD = m.title.text() + m.meta.text() + m.link.text() + m.style.text() + m.script.text() + m.noscript.text() // BODY_SCRIPTS meta.BODY_SCRIPTS = m.script.text({ body: true }) + m.noscript.text({ body: true }) // Resources Hints meta.resourceHints = '' const { resources: { modernManifest, clientManifest } } = this.renderer.context const manifest = req.modernMode ? modernManifest : clientManifest const { shouldPreload, shouldPrefetch } = this.options.render.bundleRenderer if (this.options.render.resourceHints && manifest) { const publicPath = manifest.publicPath || '/_nuxt/' // Preload initial resources if (Array.isArray(manifest.initial)) { const { crossorigin } = this.options.build const cors = `${crossorigin ? ` crossorigin="${crossorigin}"` : ''}` meta.preloadFiles = manifest.initial .map(SPAMetaRenderer.normalizeFile) .filter(({ fileWithoutQuery, asType }) => shouldPreload(fileWithoutQuery, asType)) .map(file => ({ ...file, modern: req.modernMode })) meta.resourceHints += meta.preloadFiles .map(({ file, extension, fileWithoutQuery, asType, modern }) => { let extra = '' if (asType === 'font') { extra = ` type="font/${extension}"${cors ? '' : ' crossorigin'}` } return `` }) .join('') } // Prefetch async resources if (Array.isArray(manifest.async)) { meta.resourceHints += manifest.async .map(SPAMetaRenderer.normalizeFile) .filter(({ fileWithoutQuery, asType }) => shouldPrefetch(fileWithoutQuery, asType)) .map(({ file }) => ``) .join('') } // Add them to HEAD if (meta.resourceHints) { meta.HEAD += meta.resourceHints } } // Emulate getPreloadFiles from vue-server-renderer (works for JS chunks only) meta.getPreloadFiles = () => (meta.preloadFiles || []) // Set meta tags inside cache this.cache.set(cacheKey, meta) return meta } static normalizeFile(file) { const withoutQuery = file.replace(/\?.*/, '') const extension = extname(withoutQuery).slice(1) return { file, extension, fileWithoutQuery: withoutQuery, asType: SPAMetaRenderer.getPreloadType(extension) } } static getPreloadType(ext) { if (ext === 'js') { return 'script' } else if (ext === 'css') { return 'style' } else if (/jpe?g|png|svg|gif|webp|ico/.test(ext)) { return 'image' } else if (/woff2?|ttf|otf|eot/.test(ext)) { return 'font' } else { return '' } } }