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(url) { 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 = '/' }) { let meta = this.cache.get(url) if (meta) { return meta } meta = { HTML_ATTRS: '', BODY_ATTRS: '', HEAD: '', BODY_SCRIPTS: '' } // Get vue-meta context const m = await this.getMeta(url) // HTML_ATTRS meta.HTML_ATTRS = m.htmlAttrs.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 clientManifest = this.renderer.context.resources.clientManifest const shouldPreload = this.options.render.bundleRenderer.shouldPreload const shouldPrefetch = this.options.render.bundleRenderer.shouldPrefetch if (this.options.render.resourceHints && clientManifest) { const publicPath = clientManifest.publicPath || '/_nuxt/' // Preload initial resources if (Array.isArray(clientManifest.initial)) { meta.resourceHints += clientManifest.initial .map(SPAMetaRenderer.normalizeFile) .filter(({ fileWithoutQuery, asType }) => shouldPreload(fileWithoutQuery, asType)) .map(({ file, extension, fileWithoutQuery, asType }) => { let extra = '' if (asType === 'font') { extra = ` type="font/${extension}" crossorigin` } return `` }) .join('') } // Prefetch async resources if (Array.isArray(clientManifest.async)) { meta.resourceHints += clientManifest.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 = () => clientManifest.initial .map(SPAMetaRenderer.normalizeFile) .filter(({ fileWithoutQuery, asType }) => shouldPreload(fileWithoutQuery, asType)) // Set meta tags inside cache this.cache.set(url, 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 '' } } }