/* @flow */ const path = require('path') const serialize = require('serialize-javascript') import { isJS, isCSS } from '../util' import TemplateStream from './template-stream' import { parseTemplate } from './parse-template' import { createMapper } from './create-async-file-mapper' import type { ParsedTemplate } from './parse-template' import type { AsyncFileMapper } from './create-async-file-mapper' type TemplateRendererOptions = { template?: string | (content: string, context: any) => string; inject?: boolean; clientManifest?: ClientManifest; shouldPreload?: (file: string, type: string) => boolean; shouldPrefetch?: (file: string, type: string) => boolean; serializer?: Function; }; export type ClientManifest = { publicPath: string; all: Array<string>; initial: Array<string>; async: Array<string>; modules: { [id: string]: Array<number>; }, hasNoCssVersion?: { [file: string]: boolean; } }; type Resource = { file: string; extension: string; fileWithoutQuery: string; asType: string; }; export default class TemplateRenderer { options: TemplateRendererOptions; inject: boolean; parsedTemplate: ParsedTemplate | Function | null; publicPath: string; clientManifest: ClientManifest; preloadFiles: Array<Resource>; prefetchFiles: Array<Resource>; mapFiles: AsyncFileMapper; serialize: Function; constructor (options: TemplateRendererOptions) { this.options = options this.inject = options.inject !== false // if no template option is provided, the renderer is created // as a utility object for rendering assets like preload links and scripts. const { template } = options this.parsedTemplate = template ? typeof template === 'string' ? parseTemplate(template) : template : null // function used to serialize initial state JSON this.serialize = options.serializer || (state => { return serialize(state, { isJSON: true }) }) // extra functionality with client manifest if (options.clientManifest) { const clientManifest = this.clientManifest = options.clientManifest // ensure publicPath ends with / this.publicPath = clientManifest.publicPath === '' ? '' : clientManifest.publicPath.replace(/([^\/])$/, '$1/') // preload/prefetch directives this.preloadFiles = (clientManifest.initial || []).map(normalizeFile) this.prefetchFiles = (clientManifest.async || []).map(normalizeFile) // initial async chunk mapping this.mapFiles = createMapper(clientManifest) } } bindRenderFns (context: Object) { const renderer: any = this ;['ResourceHints', 'State', 'Scripts', 'Styles'].forEach(type => { context[`render${type}`] = renderer[`render${type}`].bind(renderer, context) }) // also expose getPreloadFiles, useful for HTTP/2 push context.getPreloadFiles = renderer.getPreloadFiles.bind(renderer, context) } // render synchronously given rendered app content and render context render (content: string, context: ?Object): string | Promise<string> { const template = this.parsedTemplate if (!template) { throw new Error('render cannot be called without a template.') } context = context || {} if (typeof template === 'function') { return template(content, context) } if (this.inject) { return ( template.head(context) + (context.head || '') + this.renderResourceHints(context) + this.renderStyles(context) + template.neck(context) + content + this.renderState(context) + this.renderScripts(context) + template.tail(context) ) } else { return ( template.head(context) + template.neck(context) + content + template.tail(context) ) } } renderStyles (context: Object): string { const initial = this.preloadFiles || [] const async = this.getUsedAsyncFiles(context) || [] const cssFiles = initial.concat(async).filter(({ file }) => isCSS(file)) return ( // render links for css files (cssFiles.length ? cssFiles.map(({ file }) => `<link rel="stylesheet" href="${this.publicPath}${file}">`).join('') : '') + // context.styles is a getter exposed by vue-style-loader which contains // the inline component styles collected during SSR (context.styles || '') ) } renderResourceHints (context: Object): string { return this.renderPreloadLinks(context) + this.renderPrefetchLinks(context) } getPreloadFiles (context: Object): Array<Resource> { const usedAsyncFiles = this.getUsedAsyncFiles(context) if (this.preloadFiles || usedAsyncFiles) { return (this.preloadFiles || []).concat(usedAsyncFiles || []) } else { return [] } } renderPreloadLinks (context: Object): string { const files = this.getPreloadFiles(context) const shouldPreload = this.options.shouldPreload if (files.length) { return files.map(({ file, extension, fileWithoutQuery, asType }) => { let extra = '' // by default, we only preload scripts or css if (!shouldPreload && asType !== 'script' && asType !== 'style') { return '' } // user wants to explicitly control what to preload if (shouldPreload && !shouldPreload(fileWithoutQuery, asType)) { return '' } if (asType === 'font') { extra = ` type="font/${extension}" crossorigin` } return `<link rel="preload" href="${ this.publicPath}${file }"${ asType !== '' ? ` as="${asType}"` : '' }${ extra }>` }).join('') } else { return '' } } renderPrefetchLinks (context: Object): string { const shouldPrefetch = this.options.shouldPrefetch if (this.prefetchFiles) { const usedAsyncFiles = this.getUsedAsyncFiles(context) const alreadyRendered = file => { return usedAsyncFiles && usedAsyncFiles.some(f => f.file === file) } return this.prefetchFiles.map(({ file, fileWithoutQuery, asType }) => { if (shouldPrefetch && !shouldPrefetch(fileWithoutQuery, asType)) { return '' } if (alreadyRendered(file)) { return '' } return `<link rel="prefetch" href="${this.publicPath}${file}">` }).join('') } else { return '' } } renderState (context: Object, options?: Object): string { const { contextKey = 'state', windowKey = '__INITIAL_STATE__' } = options || {} const state = this.serialize(context[contextKey]) const autoRemove = process.env.NODE_ENV === 'production' ? ';(function(){var s;(s=document.currentScript||document.scripts[document.scripts.length-1]).parentNode.removeChild(s);}());' : '' const nonceAttr = context.nonce ? ` nonce="${context.nonce}"` : '' return context[contextKey] ? `<script${nonceAttr}>window.${windowKey}=${state}${autoRemove}</script>` : '' } renderScripts (context: Object): string { if (this.clientManifest) { const initial = this.preloadFiles.filter(({ file }) => isJS(file)) const async = (this.getUsedAsyncFiles(context) || []).filter(({ file }) => isJS(file)) const needed = [initial[0]].concat(async, initial.slice(1)) return needed.map(({ file }) => { return `<script src="${this.publicPath}${file}" defer></script>` }).join('') } else { return '' } } getUsedAsyncFiles (context: Object): ?Array<Resource> { if (!context._mappedFiles && context._registeredComponents && this.mapFiles) { const registered = Array.from(context._registeredComponents) context._mappedFiles = this.mapFiles(registered).map(normalizeFile) } return context._mappedFiles } // create a transform stream createStream (context: ?Object): TemplateStream { if (!this.parsedTemplate) { throw new Error('createStream cannot be called without a template.') } return new TemplateStream(this, this.parsedTemplate, context || {}) } } function normalizeFile (file: string): Resource { const withoutQuery = file.replace(/\?.*/, '') const extension = path.extname(withoutQuery).slice(1) return { file, extension, fileWithoutQuery: withoutQuery, asType: getPreloadType(extension) } } function getPreloadType (ext: string): string { 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 { // not exhausting all possibilities here, but above covers common cases return '' } }