123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- /* @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 ''
- }
- }
|