parse if blocks

pull/31/head
Rich Harris 8 years ago
parent 69aeba74e2
commit e620fbbd69

@ -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 };
}

@ -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` );
});
});
});

@ -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 );
}

@ -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;

@ -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
});
});
});

@ -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 <li>one<li>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 };

@ -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;
}

@ -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;
}

@ -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;
}

@ -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 <li>one<li>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` );
}

@ -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;
}

@ -32,7 +32,6 @@
},
"dependencies": {
"acorn": "^4.0.3",
"deindent": "^0.1.0",
"locate-character": "^2.0.0"
}
}

@ -0,0 +1,16 @@
import * as assert from 'assert';
export default {
description: 'hello world',
data: {
name: 'world'
},
html: '<h1>Hello world!</h1>',
test ( component, target ) {
component.set({ name: 'everybody' });
assert.equal( target.innerHTML, '<h1>Hello everybody!</h1>' );
component.teardown();
assert.equal( target.innerHTML, '' );
}
};

@ -0,0 +1 @@
<h1>Hello {{name}}!</h1>

@ -0,0 +1,7 @@
export default {
description: '{{#if}}...{{/if}} block',
data: {
visible: true
},
html: '<p>i am visible</p>'
};

@ -0,0 +1,3 @@
{{#if visible}}
<p>i am visible</p>
{{/if}}

@ -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;
});
});
});
});

Loading…
Cancel
Save