diff --git a/src/generators/Generator.js b/src/generators/Generator.js index d6056930e2..65b391d20b 100644 --- a/src/generators/Generator.js +++ b/src/generators/Generator.js @@ -4,6 +4,8 @@ import isReference from '../utils/isReference.js'; import flattenReference from '../utils/flattenReference.js'; import globalWhitelist from '../utils/globalWhitelist.js'; import reservedNames from '../utils/reservedNames.js'; +import namespaces from '../utils/namespaces.js'; +import { removeNode, removeObjectKey } from '../utils/removeNode.js'; import getIntro from './shared/utils/getIntro.js'; import getOutro from './shared/utils/getOutro.js'; import processCss from './shared/processCss.js'; @@ -21,6 +23,7 @@ export default class Generator { this.helpers = new Set(); this.components = new Set(); this.events = new Set(); + this.importedComponents = new Map(); this.bindingGroups = []; @@ -257,67 +260,42 @@ export default class Generator { }; } - parseJs () { + parseJs ( ssr ) { const { source } = this; const { js } = this.parsed; const imports = this.imports; const computations = []; - let defaultExport = null; const templateProperties = {}; + let namespace = null; + let hasJs = !!js; + if ( js ) { this.addSourcemapLocations( js.content ); + const body = js.content.body.slice(); // slice, because we're going to be mutating the original // imports need to be hoisted out of the IIFE - for ( let i = 0; i < js.content.body.length; i += 1 ) { - const node = js.content.body[i]; + for ( let i = 0; i < body.length; i += 1 ) { + const node = body[i]; if ( node.type === 'ImportDeclaration' ) { - let a = node.start; - let b = node.end; - while ( /[ \t]/.test( source[ a - 1 ] ) ) a -= 1; - while ( source[b] === '\n' ) b += 1; - + removeNode( this.code, js.content, node ); imports.push( node ); - this.code.remove( a, b ); + node.specifiers.forEach( specifier => { this.importedNames.add( specifier.local.name ); }); } } - defaultExport = js.content.body.find( node => node.type === 'ExportDefaultDeclaration' ); + const defaultExport = body.find( node => node.type === 'ExportDefaultDeclaration' ); if ( defaultExport ) { - const finalNode = js.content.body[ js.content.body.length - 1 ]; - if ( defaultExport === finalNode ) { - // export is last property, we can just return it - this.code.overwrite( defaultExport.start, defaultExport.declaration.start, `return ` ); - } else { - const { declarations } = annotateWithScopes( js ); - let template = 'template'; - for ( let i = 1; declarations.has( template ); template = `template_${i++}` ); - - this.code.overwrite( defaultExport.start, defaultExport.declaration.start, `var ${template} = ` ); - - let i = defaultExport.start; - while ( /\s/.test( source[ i - 1 ] ) ) i--; - - const indentation = source.slice( i, defaultExport.start ); - this.code.appendLeft( finalNode.end, `\n\n${indentation}return ${template};` ); - } - defaultExport.declaration.properties.forEach( prop => { templateProperties[ prop.key.name ] = prop; }); - - this.code.prependRight( js.content.start, `var ${this.alias( 'template' )} = (function () {` ); - } else { - this.code.prependRight( js.content.start, '(function () {' ); } - this.code.appendLeft( js.content.end, '}());' ); - [ 'helpers', 'events', 'components' ].forEach( key => { if ( templateProperties[ key ] ) { templateProperties[ key ].value.properties.forEach( prop => { @@ -353,11 +331,102 @@ export default class Generator { templateProperties.computed.value.properties.forEach( prop => visit( prop.key.name ) ); } + + if ( templateProperties.namespace ) { + const ns = templateProperties.namespace.value.value; + namespace = namespaces[ ns ] || ns; + + removeObjectKey( this.code, defaultExport.declaration, 'namespace' ); + } + + if ( templateProperties.components ) { + let hasNonImportedComponent = false; + templateProperties.components.value.properties.forEach( property => { + const key = property.key.name; + const value = source.slice( property.value.start, property.value.end ); + if ( this.importedNames.has( value ) ) { + this.importedComponents.set( key, value ); + } else { + hasNonImportedComponent = true; + } + }); + if ( hasNonImportedComponent ) { + // remove the specific components that were imported, as we'll refer to them directly + Array.from( this.importedComponents.keys() ).forEach( key => { + removeObjectKey( this.code, templateProperties.components.value, key ); + }); + } else { + // remove the entire components portion of the export + removeObjectKey( this.code, defaultExport.declaration, 'components' ); + } + } + + // Remove these after version 2 + if ( templateProperties.onrender ) { + const { key } = templateProperties.onrender; + this.code.overwrite( key.start, key.end, 'oncreate', true ); + templateProperties.oncreate = templateProperties.onrender; + } + + if ( templateProperties.onteardown ) { + const { key } = templateProperties.onteardown; + this.code.overwrite( key.start, key.end, 'ondestroy', true ); + templateProperties.ondestroy = templateProperties.onteardown; + } + + // in an SSR context, we don't need to include events, methods, oncreate or ondestroy + if ( ssr ) { + if ( templateProperties.oncreate ) removeNode( this.code, defaultExport.declaration, templateProperties.oncreate ); + if ( templateProperties.ondestroy ) removeNode( this.code, defaultExport.declaration, templateProperties.ondestroy ); + if ( templateProperties.methods ) removeNode( this.code, defaultExport.declaration, templateProperties.methods ); + if ( templateProperties.events ) removeNode( this.code, defaultExport.declaration, templateProperties.events ); + } + + // now that we've analysed the default export, we can determine whether or not we need to keep it + let hasDefaultExport = !!defaultExport; + if ( defaultExport && defaultExport.declaration.properties.length === 0 ) { + hasDefaultExport = false; + removeNode( this.code, js.content, defaultExport ); + } + + // if we do need to keep it, then we need to generate a return statement + if ( hasDefaultExport ) { + const finalNode = body[ body.length - 1 ]; + if ( defaultExport === finalNode ) { + // export is last property, we can just return it + this.code.overwrite( defaultExport.start, defaultExport.declaration.start, `return ` ); + } else { + const { declarations } = annotateWithScopes( js ); + let template = 'template'; + for ( let i = 1; declarations.has( template ); template = `template_${i++}` ); + + this.code.overwrite( defaultExport.start, defaultExport.declaration.start, `var ${template} = ` ); + + let i = defaultExport.start; + while ( /\s/.test( source[ i - 1 ] ) ) i--; + + const indentation = source.slice( i, defaultExport.start ); + this.code.appendLeft( finalNode.end, `\n\n${indentation}return ${template};` ); + } + } + + // user code gets wrapped in an IIFE + if ( js.content.body.length ) { + const prefix = hasDefaultExport ? `var ${this.alias( 'template' )} = (function () {` : `(function () {`; + this.code.prependRight( js.content.start, prefix ).appendLeft( js.content.end, '}());' ); + } + + // if there's no need to include user code, remove it altogether + else { + this.code.remove( js.content.start, js.content.end ); + hasJs = false; + } } return { computations, - defaultExport, + hasJs, + namespace, templateProperties }; } diff --git a/src/generators/dom/index.js b/src/generators/dom/index.js index 5bfb472e86..4f829776fd 100644 --- a/src/generators/dom/index.js +++ b/src/generators/dom/index.js @@ -1,8 +1,6 @@ import deindent from '../../utils/deindent.js'; import getBuilders from './utils/getBuilders.js'; import CodeBuilder from '../../utils/CodeBuilder.js'; -import namespaces from '../../utils/namespaces.js'; -import removeObjectKey from '../../utils/removeObjectKey.js'; import visitors from './visitors/index.js'; import Generator from '../Generator.js'; import * as shared from '../../shared/index.js'; @@ -17,8 +15,6 @@ class DomGenerator extends Generator { this.builders = { metaBindings: new CodeBuilder() }; - - this.importedComponents = new Map(); } addElement ( name, renderStatement, needsIdentifier = false ) { @@ -155,50 +151,7 @@ export default function dom ( parsed, source, options ) { const generator = new DomGenerator( parsed, source, name, visitors, options ); - const { computations, defaultExport, templateProperties } = generator.parseJs(); - - // Remove these after version 2 - if ( templateProperties.onrender ) { - const { key } = templateProperties.onrender; - generator.code.overwrite( key.start, key.end, 'oncreate', true ); - templateProperties.oncreate = templateProperties.onrender; - } - - if ( templateProperties.onteardown ) { - const { key } = templateProperties.onteardown; - generator.code.overwrite( key.start, key.end, 'ondestroy', true ); - templateProperties.ondestroy = templateProperties.onteardown; - } - - let namespace = null; - if ( templateProperties.namespace ) { - const ns = templateProperties.namespace.value.value; - namespace = namespaces[ ns ] || ns; - - removeObjectKey( generator, defaultExport.declaration, 'namespace' ); - } - - if ( templateProperties.components ) { - let hasNonImportedComponent = false; - templateProperties.components.value.properties.forEach( property => { - const key = property.key.name; - const value = source.slice( property.value.start, property.value.end ); - if ( generator.importedNames.has( value ) ) { - generator.importedComponents.set( key, value ); - } else { - hasNonImportedComponent = true; - } - }); - if ( hasNonImportedComponent ) { - // remove the specific components that were imported, as we'll refer to them directly - Array.from( generator.importedComponents.keys() ).forEach( key => { - removeObjectKey( generator, templateProperties.components.value, key ); - }); - } else { - // remove the entire components portion of the export - removeObjectKey( generator, defaultExport.declaration, 'components' ); - } - } + const { computations, hasJs, templateProperties, namespace } = generator.parseJs(); const getUniqueName = generator.getUniqueNameMaker( [ 'root' ] ); const component = getUniqueName( 'component' ); @@ -273,7 +226,7 @@ export default function dom ( parsed, source, options ) { ${generator.helper( 'dispatchObservers' )}( this, this._observers.post, newState, oldState ); ` ); - if ( parsed.js ) { + if ( hasJs ) { builders.main.addBlock( `[✂${parsed.js.content.start}-${parsed.js.content.end}✂]` ); } diff --git a/src/generators/server-side-rendering/index.js b/src/generators/server-side-rendering/index.js index b40d29f34b..c28b4aeef9 100644 --- a/src/generators/server-side-rendering/index.js +++ b/src/generators/server-side-rendering/index.js @@ -40,7 +40,7 @@ export default function ssr ( parsed, source, options ) { const generator = new SsrGenerator( parsed, source, name, visitors, options ); - const { computations, templateProperties } = generator.parseJs(); + const { computations, hasJs, templateProperties } = generator.parseJs( true ); const builders = { main: new CodeBuilder(), @@ -117,7 +117,9 @@ export default function ssr ( parsed, source, options ) { ` ); templateProperties.components.value.properties.forEach( prop => { - builders.renderCss.addLine( `addComponent( ${generator.alias( 'template' )}.components.${prop.key.name} );` ); + const { name } = prop.key; + const expression = generator.importedComponents.get( name ) || `${generator.alias( 'template' )}.components.${name}`; + builders.renderCss.addLine( `addComponent( ${expression} );` ); }); } @@ -129,7 +131,7 @@ export default function ssr ( parsed, source, options ) { }; ` ); - if ( parsed.js ) { + if ( hasJs ) { builders.main.addBlock( `[✂${parsed.js.content.start}-${parsed.js.content.end}✂]` ); } diff --git a/src/generators/server-side-rendering/visitors/Component.js b/src/generators/server-side-rendering/visitors/Component.js index a2bb11d3bc..aef098a024 100644 --- a/src/generators/server-side-rendering/visitors/Component.js +++ b/src/generators/server-side-rendering/visitors/Component.js @@ -50,7 +50,7 @@ export default { })) .join( ', ' ); - const expression = node.name === ':Self' ? generator.name : `${generator.alias( 'template' )}.components.${node.name}`; + const expression = node.name === ':Self' ? generator.name : generator.importedComponents.get( node.name ) || `${generator.alias( 'template' )}.components.${node.name}`; bindings.forEach( binding => { generator.addBinding( binding, expression ); diff --git a/src/utils/removeNode.js b/src/utils/removeNode.js new file mode 100644 index 0000000000..aecec33455 --- /dev/null +++ b/src/utils/removeNode.js @@ -0,0 +1,55 @@ +const keys = { + ObjectExpression: 'properties', + Program: 'body' +}; + +const offsets = { + ObjectExpression: [ 1, -1 ], + Program: [ 0, 0 ] +}; + +export function removeNode ( code, parent, node ) { + const key = keys[ parent.type ]; + const offset = offsets[ parent.type ]; + if ( !key || !offset ) throw new Error( `not implemented: ${parent.type}` ); + + const list = parent[ key ]; + const i = list.indexOf( node ); + if ( i === -1 ) throw new Error( 'node not in list' ); + + let a; + let b; + + if ( list.length === 1 ) { + // remove everything, leave {} + a = parent.start + offset[0]; + b = parent.end + offset[1]; + } else if ( i === 0 ) { + // remove everything before second node, including comments + a = parent.start + offset[0]; + while ( /\s/.test( code.original[a] ) ) a += 1; + + b = list[i].end; + while ( /[\s,]/.test( code.original[b] ) ) b += 1; + } else { + // remove the end of the previous node to the end of this one + a = list[ i - 1 ].end; + b = node.end; + } + + code.remove( a, b ); + list.splice( i, 1 ); + return; +} + +export function removeObjectKey ( code, node, key ) { + if ( node.type !== 'ObjectExpression' ) return; + + let i = node.properties.length; + while ( i-- ) { + const property = node.properties[i]; + if ( property.key.type === 'Identifier' && property.key.name === key ) { + removeNode( code, node, property ); + } + } +} diff --git a/src/utils/removeObjectKey.js b/src/utils/removeObjectKey.js deleted file mode 100644 index 6611dffa40..0000000000 --- a/src/utils/removeObjectKey.js +++ /dev/null @@ -1,33 +0,0 @@ -export default function removeObjectKey ( generator, node, key ) { - if ( node.type !== 'ObjectExpression' ) return; - - let i = node.properties.length; - while ( i-- ) { - const property = node.properties[i]; - if ( property.key.type === 'Identifier' && property.key.name === key ) { - let a; - let b; - - if ( node.properties.length === 1 ) { - // remove everything, leave {} - a = node.start + 1; - b = node.end - 1; - } else if ( i === 0 ) { - // remove everything before second property, including comments - a = node.start + 1; - while ( /\s/.test( generator.code.original[a] ) ) a += 1; - - b = node.properties[i].end; - while ( /[\s,]/.test( generator.code.original[b] ) ) b += 1; - } else { - // remove the end of the previous property to the end of this one - a = node.properties[ i - 1 ].end; - b = property.end; - } - - generator.code.remove( a, b ); - node.properties.splice( i, 1 ); - return; - } - } -} diff --git a/test/server-side-rendering/index.js b/test/server-side-rendering/index.js index 042fc4d333..8fa6f74e0a 100644 --- a/test/server-side-rendering/index.js +++ b/test/server-side-rendering/index.js @@ -1,7 +1,8 @@ import assert from 'assert'; import * as fs from 'fs'; +import * as path from 'path'; -import { addLineNumbers, exists, loadConfig, setupHtmlEqual, svelte, tryToLoadJson } from '../helpers.js'; +import { addLineNumbers, loadConfig, setupHtmlEqual, svelte, tryToLoadJson } from '../helpers.js'; function tryToReadFile ( file ) { try { @@ -12,6 +13,10 @@ function tryToReadFile ( file ) { } } +function capitalize ( str ) { + return str[0].toUpperCase() + str.slice( 1 ); +} + describe( 'ssr', () => { before( () => { require( process.env.COVERAGE ? @@ -24,24 +29,51 @@ describe( 'ssr', () => { fs.readdirSync( 'test/server-side-rendering/samples' ).forEach( dir => { if ( dir[0] === '.' ) return; - const solo = exists( `test/server-side-rendering/samples/${dir}/solo` ); + // add .solo to a sample directory name to only run that test, or + // .show to always show the output. or both + const solo = /\.solo/.test( dir ); + let show = /\.show/.test( dir ); if ( solo && process.env.CI ) { throw new Error( 'Forgot to remove `solo: true` from test' ); } ( solo ? it.only : it )( dir, () => { - const component = require( `./samples/${dir}/main.html` ); + dir = path.resolve( 'test/server-side-rendering/samples', dir ); + const component = require( `${dir}/main.html` ); + + const expectedHtml = tryToReadFile( `${dir}/_expected.html` ); + const expectedCss = tryToReadFile( `${dir}/_expected.css` ) || ''; + + const data = tryToLoadJson( `${dir}/data.json` ); + let html; + let css; + let error; - const expectedHtml = tryToReadFile( `test/server-side-rendering/samples/${dir}/_expected.html` ); - const expectedCss = tryToReadFile( `test/server-side-rendering/samples/${dir}/_expected.css` ) || ''; + try { + html = component.render( data ); + css = component.renderCss().css; + } catch ( e ) { + show = true; + error = e; + } + + if ( show ) { + fs.readdirSync( dir ).forEach( file => { + if ( file[0] === '_' ) return; + const source = fs.readFileSync( `${dir}/${file}`, 'utf-8' ); + const name = capitalize( file.slice( 0, -path.extname( file ).length ) ); + const { code } = svelte.compile( source, { generate: 'ssr', name }); + console.group( file ); + console.log( addLineNumbers( code ) ); + console.groupEnd(); + }); + } - const data = tryToLoadJson( `test/server-side-rendering/samples/${dir}/data.json` ); - const html = component.render( data ); - const { css } = component.renderCss(); + if ( error ) throw error; - fs.writeFileSync( `test/server-side-rendering/samples/${dir}/_actual.html`, html ); - if ( css ) fs.writeFileSync( `test/server-side-rendering/samples/${dir}/_actual.css`, css ); + fs.writeFileSync( `${dir}/_actual.html`, html ); + if ( css ) fs.writeFileSync( `${dir}/_actual.css`, css ); assert.htmlEqual( html, expectedHtml ); assert.equal( css.replace( /^\s+/gm, '' ), expectedCss.replace( /^\s+/gm, '' ) );