diff --git a/compiler/generate/index.js b/compiler/generate/index.js index f8fde71546..8cf5e9ad2f 100644 --- a/compiler/generate/index.js +++ b/compiler/generate/index.js @@ -1,5 +1,7 @@ -import deindent from 'deindent'; -import walkHtml from './walkHtml.js'; +import deindent from './utils/deindent.js'; +import walkHtml from './utils/walkHtml.js'; + +const ROOT = 'options.target'; export default function generate ( parsed ) { const counters = { @@ -20,17 +22,28 @@ export default function generate ( parsed ) { // create component parsed.html.children.forEach( child => { - let currentElement = 'options.target'; - const elementStack = [ currentElement ]; + let current = { + target: ROOT, + indentation: 0, + block: [] + }; + + const stack = [ current ]; walkHtml( child, { enter ( node ) { if ( node.type === 'Element' ) { - currentElement = `element_${counters.element++}`; + current = { + target: `element_${counters.element++}`, + indentation: current.indentation, + block: current.block + }; + + stack.push( current ); const renderBlock = deindent` - var ${currentElement} = document.createElement( '${node.name}' ); - options.target.appendChild( ${currentElement} ); + var ${current.target} = document.createElement( '${node.name}' ); + options.target.appendChild( ${current.target} ); `; renderBlocks.push( renderBlock ); @@ -40,14 +53,51 @@ export default function generate ( parsed ) { // ` ); teardownBlocks.push( deindent` - ${currentElement}.parentNode.removeChild( ${currentElement} ); + ${current.target}.parentNode.removeChild( ${current.target} ); ` ); } else if ( node.type === 'Text' ) { + if ( current.target === ROOT ) { + const identifier = `text_${counters.text++}`; + + renderBlocks.push( deindent` + var ${identifier} = document.createTextNode( ${JSON.stringify( node.data )} ); + ${current.target}.appendChild( ${identifier} ); + ` ); + + teardownBlocks.push( deindent` + ${identifier}.parentNode.removeChild( ${identifier} ); + ` ); + } + + else { + renderBlocks.push( deindent` + ${current.target}.appendChild( document.createTextNode( ${JSON.stringify( node.data )} ) ); + ` ); + } + } + + else if ( node.type === 'MustacheTag' ) { + const identifier = `text_${counters.text++}`; + const expression = node.expression.type === 'Identifier' ? node.expression.name : 'TODO'; // TODO handle block-local state + renderBlocks.push( deindent` - ${currentElement}.appendChild( document.createTextNode( ${JSON.stringify( node.data )} ) ); + var ${identifier} = document.createTextNode( '' ); + ${current.target}.appendChild( ${identifier} ); ` ); + + updateBlocks.push( deindent` + if ( state.${expression} !== oldState.${expression} ) { + ${identifier}.data = state.${expression}; + } + ` ); + + if ( current.target === ROOT ) { + teardownBlocks.push( deindent` + ${identifier}.parentNode.removeChild( ${identifier} ); + ` ); + } } else { @@ -57,8 +107,8 @@ export default function generate ( parsed ) { leave ( node ) { if ( node.type === 'Element' ) { - elementStack.pop(); - currentElement = elementStack[ elementStack.length - 1 ]; + stack.pop(); + current = stack[ stack.length - 1 ]; } } }); @@ -127,7 +177,7 @@ export default function generate ( parsed ) { return component; } - `.trim(); + `; return { code }; } diff --git a/compiler/generate/utils/__test__.js b/compiler/generate/utils/__test__.js new file mode 100644 index 0000000000..68c9cc4829 --- /dev/null +++ b/compiler/generate/utils/__test__.js @@ -0,0 +1,38 @@ +import * as assert from 'assert'; +import deindent from './deindent.js'; + +describe( 'utils', () => { + describe( 'deindent', () => { + it( 'deindents a simple string', () => { + const deindented = deindent` + deindent me please + `; + + assert.equal( deindented, `deindent me please` ); + }); + + it( 'deindents a multiline string', () => { + const deindented = deindent` + deindent me please + and me as well + `; + + assert.equal( deindented, `deindent me please\nand me as well` ); + }); + + it( 'preserves indentation of inserted values', () => { + const insert = deindent` + line one + line two + `; + + const deindented = deindent` + before + ${insert} + after + `; + + assert.equal( deindented, `before\n\tline one\n\tline two\nafter` ); + }); + }); +}); diff --git a/compiler/generate/utils/deindent.js b/compiler/generate/utils/deindent.js new file mode 100644 index 0000000000..63730254b3 --- /dev/null +++ b/compiler/generate/utils/deindent.js @@ -0,0 +1,25 @@ +const start = /\n(\t+)/; + +export default function deindent ( strings, ...values ) { + const indentation = start.exec( strings[0] )[1]; + const pattern = new RegExp( `^${indentation}`, 'gm' ); + + let result = strings[0].replace( start, '' ).replace( pattern, '' ); + + let trailingIndentation = getTrailingIndentation( result ); + + for ( let i = 1; i < strings.length; i += 1 ) { + const value = String( values[ i - 1 ] ).replace( /\n/g, `\n${trailingIndentation}` ); + result += value + strings[i].replace( pattern, '' ); + + trailingIndentation = getTrailingIndentation( result ); + } + + return result.trim(); +} + +function getTrailingIndentation ( str ) { + let i = str.length; + while ( str[ i - 1 ] === ' ' || str[ i - 1 ] === '\t' ) i -= 1; + return str.slice( i, str.length ); +} diff --git a/compiler/generate/walkHtml.js b/compiler/generate/utils/walkHtml.js similarity index 100% rename from compiler/generate/walkHtml.js rename to compiler/generate/utils/walkHtml.js diff --git a/compiler/index.js b/compiler/index.js index 4a0199203c..6ed3caaf14 100644 --- a/compiler/index.js +++ b/compiler/index.js @@ -3,6 +3,7 @@ import generate from './generate/index.js'; export function compile ( template ) { const parsed = parse( template ); + // TODO validate template const generated = generate( parsed ); return generated; diff --git a/compiler/parse/__test__.js b/compiler/parse/__test__.js index 7ba407bb49..6684e30fe6 100644 --- a/compiler/parse/__test__.js +++ b/compiler/parse/__test__.js @@ -108,4 +108,39 @@ describe( 'parse', () => { js: null }); }); + + it( 'parses an {{#if}}...{{/if}} block', () => { + const template = `{{#if foo}}bar{{/if}}`; + + assert.deepEqual( parse( template ), { + html: { + start: 0, + end: 21, + type: 'Fragment', + children: [ + { + start: 0, + end: 21, + type: 'IfBlock', + expression: { + start: 6, + end: 9, + type: 'Identifier', + name: 'foo' + }, + children: [ + { + start: 11, + end: 14, + type: 'Text', + data: 'bar' + } + ] + } + ] + }, + css: null, + js: null + }); + }); }); diff --git a/compiler/parse/index.js b/compiler/parse/index.js index 59500fe388..2c07fe96ac 100644 --- a/compiler/parse/index.js +++ b/compiler/parse/index.js @@ -1,8 +1,6 @@ -import { parseExpressionAt } from 'acorn'; import { locate } from 'locate-character'; +import fragment from './state/fragment.js'; -const validTagName = /^[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/; -const voidElementNames = /^(?:area|base|br|col|command|doctype|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i; const whitespace = /\s/; export default function parse ( template ) { @@ -20,11 +18,15 @@ export default function parse ( template ) { throw new Error( `${message} (${line}:${column})` ); }, - eat ( str ) { + eat ( str, required ) { if ( this.match( str ) ) { this.index += str.length; return true; } + + if ( required ) { + this.error( `Expected ${str}` ); + } }, match ( str ) { @@ -44,6 +46,14 @@ export default function parse ( template ) { remaining () { return this.template.slice( this.index ); + }, + + requireWhitespace () { + if ( !whitespace.test( this.template[ this.index ] ) ) { + this.error( `Expected whitespace` ); + } + + this.allowWhitespace(); } }; @@ -59,236 +69,10 @@ export default function parse ( template ) { parser.stack.push( html ); - function fragment () { - parser.allowWhitespace(); - - if ( parser.match( '<' ) ) { - return tag; - } - - if ( parser.match( '{{' ) ) { - return mustache; - } - - return text; - } - - function tag () { - const start = parser.index++; - - const isClosingTag = parser.eat( '/' ); - - // TODO handle cases like
  • one
  • two - - const name = readTagName(); - - if ( isClosingTag ) { - if ( !parser.eat( '>' ) ) parser.error( `Expected '>'` ); - - parser.current().end = parser.index; - parser.stack.pop(); - - return fragment; - } - - const attributes = []; - - let attribute; - while ( attribute = readAttribute() ) { - attributes.push( attribute ); - } - - parser.allowWhitespace(); - - const element = { - start, - end: null, // filled in later - type: 'Element', - name, - attributes, - children: [] - }; - - parser.current().children.push( element ); - - const selfClosing = parser.eat( '/' ) || voidElementNames.test( name ); - - if ( !parser.eat( '>' ) ) { - parser.error( `Expected >` ); - } - - if ( selfClosing ) { - element.end = parser.index; - } else { - // don't push self-closing elements onto the stack - parser.stack.push( element ); - } - - return fragment; - } - - function readTagName () { - const start = parser.index; - - const name = parser.readUntil( /(\s|\/|>)/ ); - if ( !validTagName.test( name ) ) { - parser.error( `Expected valid tag name`, start ); - } - - return name; - } - - function readAttribute () { - const name = parser.readUntil( /(\s|=|\/|>)/ ); - if ( !name ) return null; - - parser.allowWhitespace(); - - const value = parser.eat( '=' ) ? readAttributeValue() : true; - - return { name, value }; - } - - function readAttributeValue () { - if ( parser.eat( `'` ) ) return readQuotedAttributeValue( `'` ); - if ( parser.eat( `"` ) ) return readQuotedAttributeValue( `"` ); - - parser.error( `TODO unquoted attribute values` ); - } - - function readQuotedAttributeValue ( quoteMark ) { - let currentChunk = { - start: parser.index, - end: null, - type: 'AttributeText', - data: '' - }; - - let escaped = false; - - const chunks = []; - - while ( parser.index < parser.template.length ) { - if ( escaped ) { - currentChunk.data += parser.template[ parser.index++ ]; - } - - else { - if ( parser.match( '{{' ) ) { - const index = parser.index; - currentChunk.end = index; - - if ( currentChunk.data ) { - chunks.push( currentChunk ); - } - - const expression = readExpression(); - parser.allowWhitespace(); - if ( !parser.eat( '}}' ) ) { - parser.error( `Expected }}` ); - } - - chunks.push({ - start: index, - end: parser.index, - type: 'MustacheTag', - expression - }); - - currentChunk = { - start: parser.index, - end: null, - type: 'AttributeText', - data: '' - }; - } - - else if ( parser.match( '\\' ) ) { - escaped = true; - } - - else if ( parser.match( quoteMark ) ) { - if ( currentChunk.data ) { - chunks.push( currentChunk ); - return chunks; - } - } - } - } - - parser.error( `Unexpected end of input` ); - } - - function mustache () { - const start = parser.index; - parser.index += 2; - - parser.allowWhitespace(); - - if ( parser.match( '#if' ) ) { - return ifBlock; - } - - if ( parser.match( '#each' ) ) { - return eachBlock; - } - - const expression = readExpression( template, parser.index ); - - parser.allowWhitespace(); - if ( !parser.eat( '}}' ) ) parser.error( `Expected }}` ); - - parser.current().children.push({ - start, - end: parser.index, - type: 'MustacheTag', - expression - }); - - return fragment; - } - - function ifBlock () { - throw new Error( 'TODO' ); - } - - function eachBlock () { - throw new Error( 'TODO' ); - } - - function readExpression () { - const node = parseExpressionAt( parser.template, parser.index ); - parser.index = node.end; - - // TODO check it's a valid expression. probably shouldn't have - // [arrow] function expressions, etc - - return node; - } - - function text () { - const start = parser.index; - - let data = ''; - - while ( !parser.match( '<' ) && !parser.match( '{{' ) ) { - data += template[ parser.index++ ]; - } - - parser.current().children.push({ - start, - end: parser.index, - type: 'Text', - data - }); - - return fragment; - } - let state = fragment; while ( parser.index < parser.template.length ) { - state = state(); + state = state( parser ) || fragment; } return { html, css, js }; diff --git a/compiler/parse/read/expression.js b/compiler/parse/read/expression.js new file mode 100644 index 0000000000..b67ab16e64 --- /dev/null +++ b/compiler/parse/read/expression.js @@ -0,0 +1,11 @@ +import { parseExpressionAt } from 'acorn'; + +export default function readExpression ( parser ) { + const node = parseExpressionAt( parser.template, parser.index ); + parser.index = node.end; + + // TODO check it's a valid expression. probably shouldn't have + // [arrow] function expressions, etc + + return node; +} diff --git a/compiler/parse/state/fragment.js b/compiler/parse/state/fragment.js new file mode 100644 index 0000000000..59868a3270 --- /dev/null +++ b/compiler/parse/state/fragment.js @@ -0,0 +1,17 @@ +import tag from './tag.js'; +import mustache from './mustache.js'; +import text from './text.js'; + +export default function fragment ( parser ) { + parser.allowWhitespace(); + + if ( parser.match( '<' ) ) { + return tag; + } + + if ( parser.match( '{{' ) ) { + return mustache; + } + + return text; +} diff --git a/compiler/parse/state/mustache.js b/compiler/parse/state/mustache.js new file mode 100644 index 0000000000..b3a1860047 --- /dev/null +++ b/compiler/parse/state/mustache.js @@ -0,0 +1,78 @@ +import readExpression from '../read/expression.js'; + +export default function mustache ( parser ) { + const start = parser.index; + parser.index += 2; + + parser.allowWhitespace(); + + // {{/if}} or {{/each}} + if ( parser.eat( '/' ) ) { + const current = parser.current(); + let expected; + + if ( current.type === 'IfBlock' ) { + expected = 'if'; + } else if ( current.type === 'EachBlock' ) { + expected = 'each'; + } else { + parser.error( `Unexpected block closing tag` ); + } + + parser.eat( expected, true ); + parser.allowWhitespace(); + parser.eat( '}}', true ); + + current.end = parser.index; + parser.stack.pop(); + } + + // TODO {{else}} and {{elseif expression}} + + // {{#if foo}} or {{#each foo}} + else if ( parser.eat( '#' ) ) { + let type; + + if ( parser.eat( 'if' ) ) { + type = 'IfBlock'; + } else if ( parser.eat( 'each' ) ) { + type = 'EachBlock'; + } else { + parser.error( `Expected if or each` ); + } + + parser.requireWhitespace(); + + const expression = readExpression( parser ); + + parser.allowWhitespace(); + parser.eat( '}}', true ); + + const block = { + start, + end: null, + type, + expression, + children: [] + }; + + parser.current().children.push( block ); + parser.stack.push( block ); + } + + else { + const expression = readExpression( parser ); + + parser.allowWhitespace(); + parser.eat( '}}', true ); + + parser.current().children.push({ + start, + end: parser.index, + type: 'MustacheTag', + expression + }); + } + + return null; +} diff --git a/compiler/parse/state/tag.js b/compiler/parse/state/tag.js new file mode 100644 index 0000000000..e0ee38d7a7 --- /dev/null +++ b/compiler/parse/state/tag.js @@ -0,0 +1,150 @@ +import readExpression from '../read/expression.js'; + +const validTagName = /^[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/; +const voidElementNames = /^(?:area|base|br|col|command|doctype|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i; + +export default function tag ( parser ) { + const start = parser.index++; + + const isClosingTag = parser.eat( '/' ); + + // TODO handle cases like
  • one
  • two + + const name = readTagName( parser ); + + if ( isClosingTag ) { + if ( !parser.eat( '>' ) ) parser.error( `Expected '>'` ); + + parser.current().end = parser.index; + parser.stack.pop(); + + return null; + } + + const attributes = []; + + let attribute; + while ( attribute = readAttribute( parser ) ) { + attributes.push( attribute ); + } + + parser.allowWhitespace(); + + const element = { + start, + end: null, // filled in later + type: 'Element', + name, + attributes, + children: [] + }; + + parser.current().children.push( element ); + + const selfClosing = parser.eat( '/' ) || voidElementNames.test( name ); + + if ( !parser.eat( '>' ) ) { + parser.error( `Expected >` ); + } + + if ( selfClosing ) { + element.end = parser.index; + } else { + // don't push self-closing elements onto the stack + parser.stack.push( element ); + } + + return null; +} + +function readTagName ( parser ) { + const start = parser.index; + + const name = parser.readUntil( /(\s|\/|>)/ ); + if ( !validTagName.test( name ) ) { + parser.error( `Expected valid tag name`, start ); + } + + return name; +} + +function readAttribute ( parser ) { + const name = parser.readUntil( /(\s|=|\/|>)/ ); + if ( !name ) return null; + + parser.allowWhitespace(); + + const value = parser.eat( '=' ) ? readAttributeValue( parser ) : true; + + return { name, value }; +} + +function readAttributeValue ( parser ) { + if ( parser.eat( `'` ) ) return readQuotedAttributeValue( parser, `'` ); + if ( parser.eat( `"` ) ) return readQuotedAttributeValue( parser, `"` ); + + parser.error( `TODO unquoted attribute values` ); +} + +function readQuotedAttributeValue ( parser, quoteMark ) { + let currentChunk = { + start: parser.index, + end: null, + type: 'AttributeText', + data: '' + }; + + let escaped = false; + + const chunks = []; + + while ( parser.index < parser.template.length ) { + if ( escaped ) { + currentChunk.data += parser.template[ parser.index++ ]; + } + + else { + if ( parser.match( '{{' ) ) { + const index = parser.index; + currentChunk.end = index; + + if ( currentChunk.data ) { + chunks.push( currentChunk ); + } + + const expression = readExpression(); + parser.allowWhitespace(); + if ( !parser.eat( '}}' ) ) { + parser.error( `Expected }}` ); + } + + chunks.push({ + start: index, + end: parser.index, + type: 'MustacheTag', + expression + }); + + currentChunk = { + start: parser.index, + end: null, + type: 'AttributeText', + data: '' + }; + } + + else if ( parser.match( '\\' ) ) { + escaped = true; + } + + else if ( parser.match( quoteMark ) ) { + if ( currentChunk.data ) { + chunks.push( currentChunk ); + return chunks; + } + } + } + } + + parser.error( `Unexpected end of input` ); +} diff --git a/compiler/parse/state/text.js b/compiler/parse/state/text.js new file mode 100644 index 0000000000..51ccffa74d --- /dev/null +++ b/compiler/parse/state/text.js @@ -0,0 +1,18 @@ +export default function text ( parser ) { + const start = parser.index; + + let data = ''; + + while ( !parser.match( '<' ) && !parser.match( '{{' ) ) { + data += parser.template[ parser.index++ ]; + } + + parser.current().children.push({ + start, + end: parser.index, + type: 'Text', + data + }); + + return null; +} diff --git a/package.json b/package.json index a2d139819c..e70d65d3da 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ }, "dependencies": { "acorn": "^4.0.3", - "deindent": "^0.1.0", "locate-character": "^2.0.0" } } diff --git a/test/samples/hello-world/_config.js b/test/samples/hello-world/_config.js new file mode 100644 index 0000000000..6c5815a9fd --- /dev/null +++ b/test/samples/hello-world/_config.js @@ -0,0 +1,16 @@ +import * as assert from 'assert'; + +export default { + description: 'hello world', + data: { + name: 'world' + }, + html: '

    Hello world!

    ', + + test ( component, target ) { + component.set({ name: 'everybody' }); + assert.equal( target.innerHTML, '

    Hello everybody!

    ' ); + component.teardown(); + assert.equal( target.innerHTML, '' ); + } +}; diff --git a/test/samples/hello-world/main.svelte b/test/samples/hello-world/main.svelte new file mode 100644 index 0000000000..f674269464 --- /dev/null +++ b/test/samples/hello-world/main.svelte @@ -0,0 +1 @@ +

    Hello {{name}}!

    diff --git a/test/samples/if-block/_config.js b/test/samples/if-block/_config.js new file mode 100644 index 0000000000..8d7f589d68 --- /dev/null +++ b/test/samples/if-block/_config.js @@ -0,0 +1,7 @@ +export default { + description: '{{#if}}...{{/if}} block', + data: { + visible: true + }, + html: '

    i am visible

    ' +}; diff --git a/test/samples/if-block/main.svelte b/test/samples/if-block/main.svelte new file mode 100644 index 0000000000..5150cf0330 --- /dev/null +++ b/test/samples/if-block/main.svelte @@ -0,0 +1,3 @@ +{{#if visible}} +

    i am visible

    +{{/if}} diff --git a/test/test.js b/test/test.js index 7fe4356ccd..57dc03ba76 100644 --- a/test/test.js +++ b/test/test.js @@ -1,11 +1,14 @@ import { compile } from '../compiler/index.js'; import * as assert from 'assert'; +import * as path from 'path'; import * as fs from 'fs'; import jsdom from 'jsdom'; +const cache = {}; + require.extensions[ '.svelte' ] = function ( module, filename ) { - const source = fs.readFileSync( filename, 'utf-8' ); - const { code } = compile( source ); + const code = cache[ filename ]; + if ( !code ) throw new Error( `not compiled: ${filename}` ); return module._compile( code, filename ); }; @@ -39,22 +42,53 @@ describe( 'svelte', () => { fs.readdirSync( 'test/samples' ).forEach( dir => { if ( dir[0] === '.' ) return; - it( dir, () => { - const config = loadConfig( dir ); + const config = loadConfig( dir ); + + ( config.solo ? it.only : it )( dir, () => { + let compiled; + + try { + const source = fs.readFileSync( `test/samples/${dir}/main.svelte`, 'utf-8' ); + compiled = compile( source ); + } catch ( err ) { + if ( config.compileError ) { + config.compileError( err ); + return; + } else { + throw err; + } + } + + const { code } = compiled; + + cache[ path.resolve( `test/samples/${dir}/main.svelte` ) ] = code; const factory = require( `./samples/${dir}/main.svelte` ).default; - return env().then( window => { - const target = window.document.querySelector( 'main' ); + if ( config.show ) { + console.log( code ); // eslint-disable-line no-console + } - const component = factory({ - target, - data: config.data - }); + return env() + .then( window => { + const target = window.document.querySelector( 'main' ); - if ( config.html ) { - assert.equal( target.innerHTML, config.html ); - } - }); + const component = factory({ + target, + data: config.data + }); + + if ( config.html ) { + assert.equal( target.innerHTML, config.html ); + } + + if ( config.test ) { + config.test( component, target ); + } + }) + .catch( err => { + if ( !config.show ) console.log( code ); // eslint-disable-line no-console + throw err; + }); }); }); });