1 |
|
2 |
|
3 | const path = require('path')
|
4 | const serialize = require('serialize-javascript')
|
5 |
|
6 | import { isJS, isCSS } from '../util'
|
7 | import TemplateStream from './template-stream'
|
8 | import { parseTemplate } from './parse-template'
|
9 | import { createMapper } from './create-async-file-mapper'
|
10 | import type { ParsedTemplate } from './parse-template'
|
11 | import type { AsyncFileMapper } from './create-async-file-mapper'
|
12 |
|
13 | type 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 |
|
22 | export 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 |
|
35 | type Resource = {
|
36 | file: string;
|
37 | extension: string;
|
38 | fileWithoutQuery: string;
|
39 | asType: string;
|
40 | };
|
41 |
|
42 | export 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 |
|
57 |
|
58 |
|
59 | const { template } = options
|
60 | this.parsedTemplate = template
|
61 | ? typeof template === 'string'
|
62 | ? parseTemplate(template)
|
63 | : template
|
64 | : null
|
65 |
|
66 |
|
67 | this.serialize = options.serializer || (state => {
|
68 | return serialize(state, { isJSON: true })
|
69 | })
|
70 |
|
71 |
|
72 | if (options.clientManifest) {
|
73 | const clientManifest = this.clientManifest = options.clientManifest
|
74 |
|
75 | this.publicPath = clientManifest.publicPath === ''
|
76 | ? ''
|
77 | : clientManifest.publicPath.replace(/([^\/])$/, '$1/')
|
78 |
|
79 | this.preloadFiles = (clientManifest.initial || []).map(normalizeFile)
|
80 | this.prefetchFiles = (clientManifest.async || []).map(normalizeFile)
|
81 |
|
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 |
|
92 | context.getPreloadFiles = renderer.getPreloadFiles.bind(renderer, context)
|
93 | }
|
94 |
|
95 |
|
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 |
|
135 | (cssFiles.length
|
136 | ? cssFiles.map(({ file }) => `<link rel="stylesheet" href="${this.publicPath}${file}">`).join('')
|
137 | : '') +
|
138 |
|
139 |
|
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 |
|
164 | if (!shouldPreload && asType !== 'script' && asType !== 'style') {
|
165 | return ''
|
166 | }
|
167 |
|
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 |
|
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 |
|
253 | function 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 |
|
264 | function 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 |
|
275 | return ''
|
276 | }
|
277 | }
|