|
|
@@ -0,0 +1,178 @@
|
|
|
+/* @flow */
|
|
|
+
|
|
|
+import TemplateStream from './template-stream'
|
|
|
+import { createMapper } from './create-async-file-mapper'
|
|
|
+
|
|
|
+const serialize = require('serialize-javascript')
|
|
|
+
|
|
|
+type TemplateRendererOptions = {
|
|
|
+ template: string;
|
|
|
+ manifest?: {
|
|
|
+ server: Object;
|
|
|
+ client: Object;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+export type ParsedTemplate = {
|
|
|
+ head: string;
|
|
|
+ neck: string;
|
|
|
+ waist: string;
|
|
|
+ tail: string;
|
|
|
+};
|
|
|
+
|
|
|
+export default class TemplateRenderer {
|
|
|
+ template: ParsedTemplate;
|
|
|
+ publicPath: string;
|
|
|
+ preloadLinks: ?string;
|
|
|
+ asyncFiles: ?Array<string>;
|
|
|
+ mapFiles: ?(files: Array<string>) => Array<string>;
|
|
|
+
|
|
|
+ constructor (options: TemplateRendererOptions) {
|
|
|
+ this.template = parseTemplate(options.template)
|
|
|
+
|
|
|
+ // extra functionality with manifests
|
|
|
+ if (options.manifest) {
|
|
|
+ const serverManifest = options.manifest.server
|
|
|
+ const clientManifest = options.manifest.client
|
|
|
+ if (!serverManifest || !clientManifest) {
|
|
|
+ throw new Error(
|
|
|
+ 'The manifest option must provide both server and client manifests.'
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ this.publicPath = clientManifest.publicPath.replace(/\/$/, '')
|
|
|
+
|
|
|
+ // preload/prefetch drectives
|
|
|
+ const clientInitialFiles = []
|
|
|
+ const clientAsyncFiles = []
|
|
|
+ clientManifest.chunks.forEach(chunk => {
|
|
|
+ chunk.files.forEach(file => {
|
|
|
+ if (chunk.initial) {
|
|
|
+ clientInitialFiles.push(file)
|
|
|
+ } else {
|
|
|
+ clientAsyncFiles.push(file)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ this.preloadLinks = this.renderPreloadLinks(clientInitialFiles)
|
|
|
+ this.asyncFiles = clientAsyncFiles
|
|
|
+
|
|
|
+ // initial async chunk mapping
|
|
|
+ this.mapFiles = createMapper(serverManifest, clientManifest)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // render synchronously given rendered app content and render context
|
|
|
+ renderSync (content: string, context: ?Object) {
|
|
|
+ const template = this.template
|
|
|
+ context = context || {}
|
|
|
+ return (
|
|
|
+ template.head +
|
|
|
+ (context.head || '') +
|
|
|
+ (this.preloadLinks || '') +
|
|
|
+ this.renderPrefetchLinks(context) +
|
|
|
+ (context.styles || '') +
|
|
|
+ template.neck +
|
|
|
+ content +
|
|
|
+ this.renderState(context) +
|
|
|
+ template.waist +
|
|
|
+ this.renderAsyncChunks(context) +
|
|
|
+ template.tail
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ renderPreloadLinks (files: Array<string>): string {
|
|
|
+ return files.map(file => {
|
|
|
+ return `<link rel="preload" href="${
|
|
|
+ this.publicPath}/${file
|
|
|
+ }" as="${
|
|
|
+ /\.css$/.test(file) ? 'style' : 'script'
|
|
|
+ }">`
|
|
|
+ }).join('')
|
|
|
+ }
|
|
|
+
|
|
|
+ renderPrefetchLinks (context: Object): string {
|
|
|
+ const renderedFiles = this.getRenderedFilesFromContext(context)
|
|
|
+ if (this.asyncFiles) {
|
|
|
+ return this.asyncFiles.map(file => {
|
|
|
+ if (!renderedFiles || renderedFiles.indexOf(file) < 0) {
|
|
|
+ return `<link rel="prefetch" href="${this.publicPath}/${file}" as="script">`
|
|
|
+ } else {
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+ }).join('')
|
|
|
+ } else {
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ renderState (context: Object): string {
|
|
|
+ return context.state
|
|
|
+ ? `<script>window.__INITIAL_STATE__=${
|
|
|
+ serialize(context.state, { isJSON: true })
|
|
|
+ }</script>`
|
|
|
+ : ''
|
|
|
+ }
|
|
|
+
|
|
|
+ renderAsyncChunks (context: Object): string {
|
|
|
+ const renderedFiles = this.getRenderedFilesFromContext(context)
|
|
|
+ if (renderedFiles) {
|
|
|
+ return renderedFiles.map(file => {
|
|
|
+ return `<script src="${this.publicPath}/${file}"></script>`
|
|
|
+ }).join('')
|
|
|
+ } else {
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ getRenderedFilesFromContext (context: Object) {
|
|
|
+ if (context._evaluatedFiles && this.mapFiles) {
|
|
|
+ return this.mapFiles(Object.keys(context._evaluatedFiles))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // create a transform stream
|
|
|
+ createStream (context: ?Object) {
|
|
|
+ return new TemplateStream(this, context || {})
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function parseTemplate (
|
|
|
+ template: string,
|
|
|
+ contentPlaceholder?: string = '<!--vue-ssr-outlet-->'
|
|
|
+): ParsedTemplate {
|
|
|
+ if (typeof template === 'object') {
|
|
|
+ return template
|
|
|
+ }
|
|
|
+
|
|
|
+ let i = template.indexOf('</head>')
|
|
|
+ const j = template.indexOf(contentPlaceholder)
|
|
|
+
|
|
|
+ if (j < 0) {
|
|
|
+ throw new Error(`Content placeholder not found in template.`)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (i < 0) {
|
|
|
+ i = template.indexOf('<body>')
|
|
|
+ if (i < 0) {
|
|
|
+ i = j
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let waist = ''
|
|
|
+ let tail = template.slice(j + contentPlaceholder.length)
|
|
|
+ let k = tail.indexOf('</script>')
|
|
|
+ if (k > 0) {
|
|
|
+ k += '</script>'.length
|
|
|
+ waist = tail.slice(0, k)
|
|
|
+ tail = tail.slice(k)
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ head: template.slice(0, i),
|
|
|
+ neck: template.slice(i, j),
|
|
|
+ waist,
|
|
|
+ tail
|
|
|
+ }
|
|
|
+}
|