UNPKG

8.85 kBJavaScriptView Raw
1/* @flow */
2
3const path = require('path')
4const serialize = require('serialize-javascript')
5
6import { isJS, isCSS } from '../util'
7import TemplateStream from './template-stream'
8import { parseTemplate } from './parse-template'
9import { createMapper } from './create-async-file-mapper'
10import type { ParsedTemplate } from './parse-template'
11import type { AsyncFileMapper } from './create-async-file-mapper'
12
13type TemplateRendererOptions = {
14 template?: string | (content: string, context: any) => string;
15 inject?: boolean;
16 clientManifest?: ClientManifest;
17 shouldPreload?: (file: string, type: string) => boolean;
18 shouldPrefetch?: (file: string, type: string) => boolean;
19 serializer?: Function;
20};
21
22export type ClientManifest = {
23 publicPath: string;
24 all: Array<string>;
25 initial: Array<string>;
26 async: Array<string>;
27 modules: {
28 [id: string]: Array<number>;
29 },
30 hasNoCssVersion?: {
31 [file: string]: boolean;
32 }
33};
34
35type Resource = {
36 file: string;
37 extension: string;
38 fileWithoutQuery: string;
39 asType: string;
40};
41
42export default class TemplateRenderer {
43 options: TemplateRendererOptions;
44 inject: boolean;
45 parsedTemplate: ParsedTemplate | Function | null;
46 publicPath: string;
47 clientManifest: ClientManifest;
48 preloadFiles: Array<Resource>;
49 prefetchFiles: Array<Resource>;
50 mapFiles: AsyncFileMapper;
51 serialize: Function;
52
53 constructor (options: TemplateRendererOptions) {
54 this.options = options
55 this.inject = options.inject !== false
56 // if no template option is provided, the renderer is created
57 // as a utility object for rendering assets like preload links and scripts.
58
59 const { template } = options
60 this.parsedTemplate = template
61 ? typeof template === 'string'
62 ? parseTemplate(template)
63 : template
64 : null
65
66 // function used to serialize initial state JSON
67 this.serialize = options.serializer || (state => {
68 return serialize(state, { isJSON: true })
69 })
70
71 // extra functionality with client manifest
72 if (options.clientManifest) {
73 const clientManifest = this.clientManifest = options.clientManifest
74 // ensure publicPath ends with /
75 this.publicPath = clientManifest.publicPath === ''
76 ? ''
77 : clientManifest.publicPath.replace(/([^\/])$/, '$1/')
78 // preload/prefetch directives
79 this.preloadFiles = (clientManifest.initial || []).map(normalizeFile)
80 this.prefetchFiles = (clientManifest.async || []).map(normalizeFile)
81 // initial async chunk mapping
82 this.mapFiles = createMapper(clientManifest)
83 }
84 }
85
86 bindRenderFns (context: Object) {
87 const renderer: any = this
88 ;['ResourceHints', 'State', 'Scripts', 'Styles'].forEach(type => {
89 context[`render${type}`] = renderer[`render${type}`].bind(renderer, context)
90 })
91 // also expose getPreloadFiles, useful for HTTP/2 push
92 context.getPreloadFiles = renderer.getPreloadFiles.bind(renderer, context)
93 }
94
95 // render synchronously given rendered app content and render context
96 render (content: string, context: ?Object): string | Promise<string> {
97 const template = this.parsedTemplate
98 if (!template) {
99 throw new Error('render cannot be called without a template.')
100 }
101 context = context || {}
102
103 if (typeof template === 'function') {
104 return template(content, context)
105 }
106
107 if (this.inject) {
108 return (
109 template.head(context) +
110 (context.head || '') +
111 this.renderResourceHints(context) +
112 this.renderStyles(context) +
113 template.neck(context) +
114 content +
115 this.renderState(context) +
116 this.renderScripts(context) +
117 template.tail(context)
118 )
119 } else {
120 return (
121 template.head(context) +
122 template.neck(context) +
123 content +
124 template.tail(context)
125 )
126 }
127 }
128
129 renderStyles (context: Object): string {
130 const initial = this.preloadFiles || []
131 const async = this.getUsedAsyncFiles(context) || []
132 const cssFiles = initial.concat(async).filter(({ file }) => isCSS(file))
133 return (
134 // render links for css files
135 (cssFiles.length
136 ? cssFiles.map(({ file }) => `<link rel="stylesheet" href="${this.publicPath}${file}">`).join('')
137 : '') +
138 // context.styles is a getter exposed by vue-style-loader which contains
139 // the inline component styles collected during SSR
140 (context.styles || '')
141 )
142 }
143
144 renderResourceHints (context: Object): string {
145 return this.renderPreloadLinks(context) + this.renderPrefetchLinks(context)
146 }
147
148 getPreloadFiles (context: Object): Array<Resource> {
149 const usedAsyncFiles = this.getUsedAsyncFiles(context)
150 if (this.preloadFiles || usedAsyncFiles) {
151 return (this.preloadFiles || []).concat(usedAsyncFiles || [])
152 } else {
153 return []
154 }
155 }
156
157 renderPreloadLinks (context: Object): string {
158 const files = this.getPreloadFiles(context)
159 const shouldPreload = this.options.shouldPreload
160 if (files.length) {
161 return files.map(({ file, extension, fileWithoutQuery, asType }) => {
162 let extra = ''
163 // by default, we only preload scripts or css
164 if (!shouldPreload && asType !== 'script' && asType !== 'style') {
165 return ''
166 }
167 // user wants to explicitly control what to preload
168 if (shouldPreload && !shouldPreload(fileWithoutQuery, asType)) {
169 return ''
170 }
171 if (asType === 'font') {
172 extra = ` type="font/${extension}" crossorigin`
173 }
174 return `<link rel="preload" href="${
175 this.publicPath}${file
176 }"${
177 asType !== '' ? ` as="${asType}"` : ''
178 }${
179 extra
180 }>`
181 }).join('')
182 } else {
183 return ''
184 }
185 }
186
187 renderPrefetchLinks (context: Object): string {
188 const shouldPrefetch = this.options.shouldPrefetch
189 if (this.prefetchFiles) {
190 const usedAsyncFiles = this.getUsedAsyncFiles(context)
191 const alreadyRendered = file => {
192 return usedAsyncFiles && usedAsyncFiles.some(f => f.file === file)
193 }
194 return this.prefetchFiles.map(({ file, fileWithoutQuery, asType }) => {
195 if (shouldPrefetch && !shouldPrefetch(fileWithoutQuery, asType)) {
196 return ''
197 }
198 if (alreadyRendered(file)) {
199 return ''
200 }
201 return `<link rel="prefetch" href="${this.publicPath}${file}">`
202 }).join('')
203 } else {
204 return ''
205 }
206 }
207
208 renderState (context: Object, options?: Object): string {
209 const {
210 contextKey = 'state',
211 windowKey = '__INITIAL_STATE__'
212 } = options || {}
213 const state = this.serialize(context[contextKey])
214 const autoRemove = process.env.NODE_ENV === 'production'
215 ? ';(function(){var s;(s=document.currentScript||document.scripts[document.scripts.length-1]).parentNode.removeChild(s);}());'
216 : ''
217 const nonceAttr = context.nonce ? ` nonce="${context.nonce}"` : ''
218 return context[contextKey]
219 ? `<script${nonceAttr}>window.${windowKey}=${state}${autoRemove}</script>`
220 : ''
221 }
222
223 renderScripts (context: Object): string {
224 if (this.clientManifest) {
225 const initial = this.preloadFiles.filter(({ file }) => isJS(file))
226 const async = (this.getUsedAsyncFiles(context) || []).filter(({ file }) => isJS(file))
227 const needed = [initial[0]].concat(async, initial.slice(1))
228 return needed.map(({ file }) => {
229 return `<script src="${this.publicPath}${file}" defer></script>`
230 }).join('')
231 } else {
232 return ''
233 }
234 }
235
236 getUsedAsyncFiles (context: Object): ?Array<Resource> {
237 if (!context._mappedFiles && context._registeredComponents && this.mapFiles) {
238 const registered = Array.from(context._registeredComponents)
239 context._mappedFiles = this.mapFiles(registered).map(normalizeFile)
240 }
241 return context._mappedFiles
242 }
243
244 // create a transform stream
245 createStream (context: ?Object): TemplateStream {
246 if (!this.parsedTemplate) {
247 throw new Error('createStream cannot be called without a template.')
248 }
249 return new TemplateStream(this, this.parsedTemplate, context || {})
250 }
251}
252
253function normalizeFile (file: string): Resource {
254 const withoutQuery = file.replace(/\?.*/, '')
255 const extension = path.extname(withoutQuery).slice(1)
256 return {
257 file,
258 extension,
259 fileWithoutQuery: withoutQuery,
260 asType: getPreloadType(extension)
261 }
262}
263
264function getPreloadType (ext: string): string {
265 if (ext === 'js') {
266 return 'script'
267 } else if (ext === 'css') {
268 return 'style'
269 } else if (/jpe?g|png|svg|gif|webp|ico/.test(ext)) {
270 return 'image'
271 } else if (/woff2?|ttf|otf|eot/.test(ext)) {
272 return 'font'
273 } else {
274 // not exhausting all possibilities here, but above covers common cases
275 return ''
276 }
277}