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 | Promise<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
const { template } = options
this.parsedTemplate = template
? typeof template === 'string'
? parseTemplate(template)
: template
: null
this.serialize =
options.serializer ||
(state => {
return serialize(state, { isJSON: true })
})
if (options.clientManifest) {
const clientManifest = (this.clientManifest = options.clientManifest)
this.publicPath =
clientManifest.publicPath === ''
? ''
: clientManifest.publicPath.replace(/([^\/])$/, '$1/')
this.preloadFiles = (clientManifest.initial || []).map(normalizeFile)
this.prefetchFiles = (clientManifest.async || []).map(normalizeFile)
this.mapFiles = createMapper(clientManifest)
}
}
bindRenderFns(context: Record<string, any>) {
const renderer: any = this
;['ResourceHints', 'State', 'Scripts', 'Styles'].forEach(type => {
context[`render${type}`] = renderer[`render${type}`].bind(
renderer,
context
)
})
context.getPreloadFiles = renderer.getPreloadFiles.bind(renderer, context)
}
render(
content: string,
context: Record<string, any> | null
): 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: Record<string, any>): string {
const initial = this.preloadFiles || []
const async = this.getUsedAsyncFiles(context) || []
const cssFiles = initial.concat(async).filter(({ file }) => isCSS(file))
return (
(cssFiles.length
? cssFiles
.map(
({ file }) =>
`<link rel="stylesheet" href="${this.publicPath}${file}">`
)
.join('')
: '') +
(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 = ''
if (!shouldPreload && asType !== 'script' && asType !== 'style') {
return ''
}
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: Record<string, any>,
options?: Record<string, any>
): string {
const { contextKey = 'state', windowKey = '__INITIAL_STATE__' } =
options || {}
const state = this.serialize(context[contextKey])
const autoRemove = __DEV__
? ''
: ';(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: Record<string, any>): Array<Resource> | undefined {
if (
!context._mappedFiles &&
context._registeredComponents &&
this.mapFiles
) {
const registered: any[] = Array.from(context._registeredComponents)
context._mappedFiles = this.mapFiles(registered).map(normalizeFile)
}
return context._mappedFiles
}
createStream(context: Record<string, any> | undefined): 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 {
return ''
}
}