zodern

Implementing Flare - Part 5

This is part 5 of a series of blog posts creating the Flare Meteor package to support single file Blaze templates.

In this part we will fix {#each .. in} tags and and accessing the variables created with {#let} and {#each item in array}.

Blaze has always supported using {#each array}, which sets the context inside the each block to the array item. In Meteor 1.2, Blaze started providing ways to create new variables without modifying the context: {#let} and {#each item in array}. Internally, Blaze stores these variables in what it calls the lexical scope.

If you try to access a variable in the lexical scope with flare, for example with:

{#let color=red}
Color: {color}
{/let}

you will find it doesn't work. We convert the color template tag into this helper:

Template.templateName.helpers({
flame_39428 () {
let count = Template.instance().view.lookup('count');
return count;
}
});

The problem is count is set to a function. view.lookup always returns a function, either a helper or a function to get the value. If you replaced the template tag with {color()}, it would work, but this is not expected and we should find a better way.

There are a number of options we have:

  1. Parse the {#let} and {#each} tags to identify any variables in the lexical scope. We can then change how we look them up so they are a value instead of a function.
  2. view.lookup calls Blaze._lexicalBindingLookup to find any lexical vars with the name. We can wrap it to identify when the value is for the lexical scope:
const isLexicalVar = Symbol();
const oldLexicalLookup = Blaze._lexicalBindingLookup;
Blaze._lexicalBindingLookup = function () {
const result = oldLexicalLookup.apply(this, arguments);

if (typeof result === 'function') {
result[isLexicalVar] = true;
}

return result;
};

Here we are replacing Blaze._lexicalBindingLookup to add a special property to the returned function. We use a symbol for the property name since it is always unique and prevents code without access to our symbol from accessing the property. This helps avoid any conflicts with other packages.

In the helpers we can check for this property and, if true, call the returned function to get the value.

  1. view.lookup does extra work that isn't necessary in our generated helpers since we don't use it to access the current or parent context. We could write a simplified version that returns the actual value for lexical vars, though this requires using many internal Blaze api's.

Each option has different benefits, but we will go with #2. The other options make more assumptions about Blaze or use its internal api's and are more likely to become out of date. Also, if option #2 becomes outdated after an update to Blaze it will be obvious when it breaks, while options #1 and #3 could become broken in subtle ways.

In Flare's client.js file we can add the code from above to wrap Blaze._lexicalBindingScope. We also want a function that calls view.lookup and checks the return value for lexical vars so we can return their value instead of a function.

const isLexicalVar = Symbol();
const oldLexicalLookup = Blaze._lexicalBindingLookup;
Blaze._lexicalBindingLookup = function () {
const result = oldLexicalLookup.apply(this, arguments);

if (typeof result === 'function') {
result[isLexicalVar] = true;
}

return result;
};

// Similar to view.lookup with these differences:
// - returns the value of lexical variables instead of a function.
// - can establish reactive dependencies
export function _lookup(name) {
const instance = Template.instance();
if (!instance) {
throw new Error('_lookup must be called within a template helper');
}

let value = instance.view.lookup(name);

if (value && value[isLexicalVar]) {
value = value();
}

return value;
}

Since our package is now using the Template object, we also need to update our package.js to depend on the templating package:

// ... rest of package.js file

Package.onUse(function (api) {
api.use('isobuild:compiler-plugin@1.0.0');
api.use(['ecmascript', 'reactive-dict', 'templating'], 'client');

api.mainModule('client.js', 'client');
});

Next we need to update the helpers Flare creates to use this lookup function. Go to the addHelpers method in Compiler.js. Delete the lookupFunc variable and add this:

const lookupName = '__flare__lookup';
const importStatement = `import { _lookup as ${lookupName} } from 'meteor/flare';\n`;

We will import the _lookup function as __flare__lookup. This should be unique enough that the script shouldn't already have any variables named this. If you want to make sure, it could check if the script already has a variable or function named that in scope, and if there is pick a different name.

There is a small overhead for every import statement, which becomes noticeable in old web browsers or when there are a very large number of files. The file might already be importing meteor/flare for $state or $parent. An optimization we could look into for the future is re-using any existing import statements the file has for meteor/flare.

Then, in the line we had used lookUpFunc, you can replace it with b.identifier(lookupName). Instead of creating let <name> = Template.instance().view.lookup(<name>);, this will result in let <name> = __flare__lookup(<name>);.

Finally, replace the method's return statement with return importStatement + print(completedAst).code;.

The updated method should look like:

class Compiler {
// ... rest of class

addHelpers(templateTags, scriptAst, templateName) {
const helpers = [];
const {
scope
} = periscopic.analyze(scriptAst);
const lookupName = '__flare__lookup';
const importStatement = `import { _lookup as ${lookupName} } from 'meteor/flare';\n`;

templateTags.forEach(tag => {
if (!tag.expression || !tag.helperName) {
return;
}

// The expression shouldn't have any variable declarations
// so any variables it references are considered globals by periscopic
const {
globals
} = periscopic.analyze(tag.expression);

// If the variable referenced by the expression is defined
// within the scrip tag or is a global, we don't need to do
// anything. Otherwise, we must assume it is a template helper.
const helperLookups = Array.from(globals.keys()).filter(key => {
return !scope.has(key) && !(key in browserGlobals);
}).map(name => {
// AST for let <name> = __flare__lookup(<name>);
return b.variableDeclaration("let", [
b.variableDeclarator(
b.identifier(name),
b.callExpression(
b.identifier(lookupName),
[b.literal(name)])
)
]);
});

const helperMethod = b.objectMethod(
"method",
b.literal(tag.helperName),
[],
b.blockStatement([
...helperLookups,
b.returnStatement(tag.expression)
])
);
helpers.push(helperMethod);
});

const completedAst = b.expressionStatement(
b.callExpression(
b.memberExpression(b.memberExpression(b.identifier("Template"), b.identifier(templateName)), b.identifier('helpers')),
[b.objectExpression(helpers)]
)
);

return importStatement + print(completedAst).code;
}
}

Now let's try this with {#let} in our counter.blaze file:

{#let color="pink"}
<p>Color from #let: {color}</p>
{/let}

Next, let's focus on {#each}. Spacebars allows each tags to be written in two different ways: {{#each ... in}} and {{#each}}

{{#each array}}
{{! Here the context refers to the current item in the array}}
<p>{{this.name}}</p>
{{/each}}

{{#each item in array}}
{{! The context is not modified, and instead we refer to
the current item in the array as item }}
<p>{{item.name}}<p>
{{/each}}

We will not be able to support both in Flare since we also allow the array to be a js expression. in is a js operator, and there is no reliable way to identify if in is being used as part of the spacebars syntax, or in a js expression. Take two examples. It might be clear to us what is intended, but it would not be clear to Flare.

{#each category in categories[type] ? categories[type] : defaultCategories }
{category}
{/each}

{#each type in categories ? categories[type] : defaultCategories}
{this}
{/each}

We will have to pick one, {{#each ... in}} or {{#each}}. Spacebar's docs discourage using {{#each}}, so we will go along with that and only support {{#each ... in}}. This will require modifying the onTag function in Compiler.findTemplateTags.

Currently in onTag, it uses this code to handle each tags:

// Make sure it supports js, and jump the
// parser to where the js expression would start
if (scanner.eat('#')) {
scanner.allowWhitespace();

allowJsExpression = scanner.eat('if') ||
scanner.eat('unless') ||
scanner.eat('each');
}

Since the js expression starts after in, we need to update the logic. After it eats each, the scanner is positioned at the space after it. We can't jump directly to in since those characters could be inside the item name. Instead, we will jump to the space between the item name and in. To visualize this:

Scanner is here:
{#each item in array}
^

We eat each, and move here:
{#each item in array}
^

We jump over any whitespace:
{#each item in array}
^

Then eat until the next space
{#each item in array}
^

Jump over any more whitespace if it exists.
In our example, there is none.

Next, eat `in`
{#each item in array}
^

The scanner is now at the start of the js expression

The code would look like:

// Make sure it supports js, and jump the
// parser to where the js expression would start
if (scanner.eat('#')) {
scanner.allowWhitespace();

if (scanner.eat('each')) {
allowJsExpression = true;
scanner.allowWhitespace();
scanner.eatUntil(' ');
scanner.allowWhitespace();
scanner.eat('in');
} else {
allowJsExpression = scanner.eat('if') ||
scanner.eat('unless') ||
scanner.eat('each');
}
}

Let's try this in a .blaze file:

{#each number in [1,2,3,4,5]}
<p>Number: {number}</p>
{/each}

One downside to {#each ... in} is the difficulty in getting the current item's value in an event handler. Usually this is worked around by adding a data- attribute with the value set to the current item, or moving the content of {#each ... in} into a separate template so the current item is in the context. Svelte and React have inline event handlers so they can access the lexical scope. For example, in svelte we might use this:

{#each [1,2,3,4,5] as number }
<p on:click="{() => console.log(number)}">Number: {number}</p>
{/each}

We will implement this in Flare. There are two changes we need to make: check for any lexical vars and helpers used in the event handlers, and remove the need for the extra parentheses around inline event handlers.

We already implemented checking for helpers and lexical vars in step 2 for js expressions in template tags. It gets all of the variables within scope of the script tag, and anything else it assumes to be a helper or lexical var.

First, we need to get a list of variables and functions in the script tag

const {
scope
} = periscopic.analyze(scriptAst);

When the compiler extracted the handler, it kept it as a string. We need to parse it into AST so it can be analyzed:

const handlerAst = acorn.parseExpressionAt(
tag.handler,
0,
{
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
sourceType: 'module',
ecmaVersion: 'latest',
}
);

// Get variables referenced in the expression
const {
globals
} = periscopic.analyze(handlerAst);

Then, like we do when adding helpers, we need to create lookups and add them to the body of the method it creates. In helpers, our compiler creates this:

let number = __flare__lookup('number');

and this works since Blaze sets everything up for us to access the lexical scope. Unfortunately, it is more difficult in event handlers. To look up a variable in a lexical scope, two pieces of information are needed: the current template, and the current view, which in this case would be the view created for the {#each .. in} tag. In an event handler, the current view is set to the same value as the current template.

Blaze provides two api's that will help us. Blaze.getView(element) will give us the view that contains an element. We will use this to get the view containing the element that was clicked. Blaze._withCurrentView(view, function) lets us set the current view while the function we give it is run.

Using those api's, this code would be able to lookup a lexical var within an event handler:

let __flare__view = Blaze.getView(event.target);
let number = Blaze._withCurrentView(__flare__view, () => __flare__lookup('number'));

The code to generate it would be:

const viewName = '__flare__view';
// AST for let __flare__view = Blaze.getView(event.target);
const viewAst = b.variableDeclaration("let", [
b.variableDeclarator(
b.identifier(viewName),
b.callExpression(
b.memberExpression(
b.identifier('Blaze'),
b.identifier('getView')
),
[
b.memberExpression(
b.identifier('event'),
b.identifier('target')
)
]
)
)
]);

// If the variable referenced by the expression is defined
// within the scrip tag or is a global, we don't need to do
// anything. Otherwise, we must assume it is a template helper.
const helperLookups = Array.from(globals.keys()).filter(key => {
return !scope.has(key) && !(key in browserGlobals);
}).map(name => {
// AST for let <name> = Blaze._withCurrentView(__flare__view, () => __flare__lookup(<name>));
return b.variableDeclaration("let", [
b.variableDeclarator(
b.identifier(name),
b.callExpression(
b.memberExpression(
b.identifier('Blaze'),
b.identifier('_withCurrentView')
),
[
b.identifier(viewName),
b.arrowFunctionExpression(
[],
b.callExpression(b.identifier(lookupName), [b.literal(name)])
)
],
)
)
]);
});

const eventMethod = b.objectMethod(
"method",
b.literal(`${tag.event} .${tag.className}`),
[b.identifier('event'), b.identifier('template')],
b.blockStatement([
...helperLookups,
b.expressionStatement(
b.callExpression(
b.memberExpression(b.identifier(tag.handler), b.identifier("call")),
[b.thisExpression(), b.identifier('event'), b.identifier('template')]
)
)
]),
);
events.push(eventMethod);

The completed code for Compiler.addTemplateEvents looks like this. We've added a scriptAst parameter, so the line in Compiler.processFilesForTarget that calls addTemplateEvents will also need to be updated.

class Compiler {
// ... rest of compiler

addTemplateEvents(templateTags, scriptAst, templateName) {
const events = [];

const {
scope
} = periscopic.analyze(scriptAst);
const lookupName = '__flare__lookup';


templateTags.forEach(tag => {
if (!tag.event) {
return;
}

const handlerAst = acorn.parseExpressionAt(
tag.handler,
0,
{
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
sourceType: 'module',
ecmaVersion: 'latest',
}
);
const {
globals
} = periscopic.analyze(handlerAst);

// If the variable referenced by the expression is defined
// within the scrip tag or is a global, we don't need to do
// anything. Otherwise, we must assume it is a template helper.
const helperLookups = Array.from(globals.keys()).filter(key => {
return !scope.has(key) && !(key in browserGlobals);
}).map(name => {
// AST for let <name> = Blaze._withCurrentView(__flare__view, () => __flare__lookup(<name>));
return b.variableDeclaration("let", [
b.variableDeclarator(
b.identifier(name),
b.callExpression(
b.memberExpression(
b.identifier('Blaze'),
b.identifier('_withCurrentView')
),
[
b.identifier(viewName),
b.arrowFunctionExpression(
[],
b.callExpression(b.identifier(lookupName), [b.literal(name)])
)
],
)
)
]);
});

const eventMethod = b.objectMethod(
"method",
b.literal(`${tag.event} .${tag.className}`),
[b.identifier('event'), b.identifier('template')],
b.blockStatement([
...helperLookups,
b.expressionStatement(
b.callExpression(
b.memberExpression(b.identifier(tag.handler), b.identifier("call")),
[b.thisExpression(), b.identifier('event'), b.identifier('template')]
)
)
]),
);
events.push(eventMethod);
});

const completedAst = b.expressionStatement(
b.callExpression(
b.memberExpression(b.memberExpression(b.identifier("Template"), b.identifier(templateName)), b.identifier('events')),
[b.objectExpression(events)]
)
);

return print(completedAst).code;
}
}

Let's try it in our counter.blaze file:

{#each number in [1,2,3,4,5]}
<p on:click={(() => console.log(number))}>Number: {number}</p>
{/each}

If you save this, the compiler will throw the error Event listener is invalid. The arrow function in the event handler is confusing the HTML parser we are using. We can fix this by wrapping everything in quotes:

{#each number in [1,2,3,4,5]}
<p on:click="{(() => console.log(number))}">Number: {number}</p>
{/each}

The arrow function is wrapped with 3 characters: ", {, and (. Can we improve this?

I have been unable to find an open source HTML parser that could be configured to parse this without the quotes. Svelte, Spacebars, Vue, and Angular have each implemented their own parser, but at this point I want to avoid that for Flare.

Since we require the expression to be wrapped in quotes, we could make the brackets optional. I am not going to do that here since someday we might make the quotes optional, but feel free to do it in your version.

The parenthesis are needed in the code we generate, but the compiler can add them automatically when needed. To check if the handler should be wrapped with parenthesis, we can look at the AST from parsing the event handler and check if the type is ArrowFunctionExpression or FunctionExpression.

let handler = tag.handler;

if (['FunctionExpression', 'ArrowFunctionExpression'].includes(handlerAst.type)) {
handler = `(${handler})`;
}

We also update where tag.handler is used to instead use the handler variable. The bottom of the post has the updated compiler.js contents.

We can now remove the extra parenthesis in counter.blaze:

<script>
import { ReactiveVar } from 'meteor/reactive-var';
import { $state, $parent } from 'meteor/flare';

export const name = 'counter';

Template.counter.onCreated(function helloOnCreated() {
$state.setDefault('counter', 0);
});

function increment(_event, instance) {
const current = $state.get('counter');
$state.set('counter', current + 1);
}

function counter() {
return $state.get('counter');
}

</script>

<button on:click={increment}>Click Me</button>
{#if counter()}
<p>You've pressed the button {counter()} times.</p>
{/if}

{#each number in [1,2,3,4,5]}
<p on:click="{() => console.log(number)}">Number: {number}</p>
{/each}

That simple change makes it look much better.

In the next post we will make giving the template a name optional. As part of this, we will implement a way to import templates and use them as an HTML element instead of using Spacebar inclusion tags.

The full compiler.js file should now contain:

const parse5 = require('parse5');
const acorn = require('acorn');
const { visit } = require('ast-types');
const os = require('os');
import TagScanner from './scanner';
import periscopic from 'periscopic';
import { types, print } from 'recast';
import { browser as browserGlobals } from 'globals';

const b = types.builders;

class Compiler {
constructor() {
this.babelCompiler = new BabelCompiler;
this.babelCompiler.setDiskCacheDirectory(os.tmpdir() + '/meteor-babel-' + Math.random() + Date.now());
}

findTemplateTags(ast) {
const result = [];

function walkChildren(children, handler) {
children.forEach(child => {
handler(child);
if (child.childNodes) {
walkChildren(child.childNodes, handler);
}
});
}

walkChildren(ast.childNodes, node => {
function onTag(scanner) {
const tagStart = scanner.index;
let allowJsExpression = true;
let isRawHtml = false;
scanner.allowWhitespace();

if (
scanner.eat('>') ||
scanner.eat('/')
) {
// Inclusion tag or closing tag, which we don't support js in
allowJsExpression = false;
} else if (scanner.eat('else')) {
allowJsExpression = false;
} else if (scanner.eat('#')) {
// Make sure it supports js, and jump the
// parser to where the js expression would start

scanner.allowWhitespace();

scanner.allowWhitespace();

if (scanner.eat('each')) {
allowJsExpression = true;
scanner.allowWhitespace();
scanner.eatUntil(' ');
scanner.allowWhitespace();
scanner.eat('in');
} else {
allowJsExpression = scanner.eat('if') ||
scanner.eat('unless');
}
} else if (scanner.eat('?')) {
isRawHtml = true;
}

let expression;
if (allowJsExpression) {
expression = acorn.parseExpressionAt(
scanner.content,
scanner.index,
{
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
sourceType: 'module',
ecmaVersion: 'latest',
}
);
scanner.index = expression.end;
}

scanner.eatUntil('}');

result.push({
node,
stringPosition: scanner.contentContext,
tagStart,
tagEnd: scanner.index,
expression,
isRawHtml
});
}

if (node.nodeName === '#text') {
new TagScanner(node.value, { type: 'nodeValue' }).findTags(onTag);
}
if (node.attrs) {
node.attrs.forEach(attr => {
if (attr.name.includes('{')) {
new TagScanner(attr.name, { type: 'attrName', attr }).findTags(onTag);
} else if (attr.name.startsWith('on:')) {
const event = attr.name.slice('on:'.length);
// Make sure value is in correct format
if (!attr.value.startsWith('{') || !attr.value.endsWith('}')) {
throw new Error('Event listener is invalid');
}

const handler = attr.value.slice('{'.length, attr.value.length - '}'.length);
result.push({
node,
stringPosition: {
type: 'attr',
attr
},
event,
handler
});
} else if (typeof attr.value === 'string') {
new TagScanner(attr.value, { type: 'attrValue', attr }).findTags(onTag);
}
});
}
});

return result;
}

spliceString(string, start, toRemove, replacement = '') {
const startText = string.slice(0, start);
const endText = string.slice(start + toRemove);

return startText + replacement + endText;
}

prepareTemplateTags(templateTags) {
function spliceContent(templateTag, startIndex, toRemove, replacement) {
const replace = (string) => {
const startText = string.slice(0, startIndex);
const endText = string.slice(startIndex + toRemove);

return startText + replacement + endText;
}

switch (templateTag.stringPosition.type) {
case 'nodeValue':
templateTag.node.value = replace(templateTag.node.value);
break;
case 'attrName':
templateTag.stringPosition.attr.name = replace(templateTag.stringPosition.attr.name);
break;
case 'attrValue':
templateTag.stringPosition.attr.value = replace(templateTag.stringPosition.attr.value);
}
}

templateTags.slice().reverse().forEach(tag => {
const openingBracketIndex = tag.tagStart - 1;
const closingBracketIndex = tag.tagEnd - 1;

// For raw HTML tags, we need to also remove the "?" character.
const bracketsNeeded = tag.isRawHtml ? 3 : 2;

if (!tag.event) {
spliceContent(tag, closingBracketIndex, 1, '}'.repeat(bracketsNeeded));
}

if (tag.expression) {
tag.helperName = `flame_${Math.floor(Math.random() * 100000)}`;
spliceContent(
tag,
tag.expression.start,
tag.expression.end - tag.expression.start,
tag.helperName
);
}

if (tag.event) {
const node = tag.node;

tag.className = `flame_${Math.floor(Math.random() * 1000000)}`;

let classAttr = node.attrs.find(attr => attr.name === 'class');
if (!classAttr) {
classAttr = { name: 'class', value: '' };
node.attrs.push(classAttr);
}
classAttr.value += ` ${tag.className}`;

const eventAttrIndex = node.attrs.indexOf(tag.stringPosition.attr);
node.attrs.splice(eventAttrIndex, 1);
}

if (!tag.event) {
// For raw HTML tags, we need to also remove the "?" character.
const toReplace = tag.isRawHtml ? 2 : 1;
spliceContent(tag, openingBracketIndex, toReplace, '{'.repeat(bracketsNeeded));
}
});
}

parseTags(file) {
// Parse the file's content to get the AST
const document = parse5.parseFragment(file.getContentsAsString());
let scripts = [];

// document.childNodes has all of the top-level tags in the file
document.childNodes = bodyContent = document.childNodes.filter(tag => {
if (tag.nodeName === 'script') {
scripts.push(tag);
return false;
}

return true;
});

const templateTags = this.findTemplateTags(document);
this.prepareTemplateTags(templateTags);

if (scripts.length !== 1) {
throw new Error(`File must have exactly one script tag in ${file.getPathInPackage()}`);
}

return {
// The childNode is a text node with the contents
script: scripts[0].childNodes[0].value,
template: parse5.serialize(document),
templateTags,
};
}

parseJs(js, filePath) {
const ast = acorn.parse(js, {
sourceType: 'module',
ecmaVersion: 'latest',
allowImportExportEverywhere: true,
locations: true,
ranges: true
});

let templateName = null;

visit(ast, {
visitExportNamedDeclaration(path) {
path.node.declaration.declarations.forEach(declaration => {
const {
id,
init
} = declaration;

if (id.type !== 'Identifier' || id.name !== 'name') {
return;
}

if (init.type !== 'Literal') {
throw new Error(`name export must be a literal in ${filePath}`);
}

templateName = declaration.init.value;
});

return false;
}
});

return {
templateName,
ast
};
}

addHelpers(templateTags, scriptAst, templateName) {
const helpers = [];
const {
scope
} = periscopic.analyze(scriptAst);
const lookupName = '__flare__lookup';
const importStatement = `import { _lookup as ${lookupName} } from 'meteor/flare';\n`;

templateTags.forEach(tag => {
if (!tag.expression || !tag.helperName) {
return;
}

// The expression shouldn't have any variable declarations
// so any variables it references are considered globals by periscopic
const {
globals
} = periscopic.analyze(tag.expression);

// If the variable referenced by the expression is defined
// within the scrip tag or is a global, we don't need to do
// anything. Otherwise, we must assume it is a template helper.
const helperLookups = Array.from(globals.keys()).filter(key => {
return !scope.has(key) && !(key in browserGlobals);
}).map(name => {
// AST for let <name> = __flare__lookup(<name>);
return b.variableDeclaration("let", [
b.variableDeclarator(
b.identifier(name),
b.callExpression(
b.identifier(lookupName),
[b.literal(name)])
)
]);
});

const helperMethod = b.objectMethod(
"method",
b.literal(tag.helperName),
[],
b.blockStatement([
...helperLookups,
b.returnStatement(tag.expression)
])
);
helpers.push(helperMethod);
});

const completedAst = b.expressionStatement(
b.callExpression(
b.memberExpression(b.memberExpression(b.identifier("Template"), b.identifier(templateName)), b.identifier('helpers')),
[b.objectExpression(helpers)]
)
);

return importStatement + print(completedAst).code;
}

addTemplateEvents(templateTags, scriptAst, templateName) {
const events = [];

const {
scope
} = periscopic.analyze(scriptAst);
const lookupName = '__flare__lookup';
const viewName = '__flare__view';
// AST for let __flare__view = Blaze.getView(event.target);
const viewAst = b.variableDeclaration("let", [
b.variableDeclarator(
b.identifier(viewName),
b.callExpression(
b.memberExpression(
b.identifier('Blaze'),
b.identifier('getView')
),
[
b.memberExpression(
b.identifier('event'),
b.identifier('target')
)
]
)
)
]);

templateTags.forEach(tag => {
if (!tag.event) {
return;
}

const handlerAst = acorn.parseExpressionAt(
tag.handler,
0,
{
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
sourceType: 'module',
ecmaVersion: 'latest',
}
);
const {
globals
} = periscopic.analyze(handlerAst);

// If the variable referenced by the expression is defined
// within the scrip tag or is a global, we don't need to do
// anything. Otherwise, we must assume it is a template helper.
const helperLookups = Array.from(globals.keys()).filter(key => {
return !scope.has(key) && !(key in browserGlobals);
}).map(name => {
// AST for let <name> = Blaze._withCurrentView(__flare__view, () => __flare__lookup(<name>));
return b.variableDeclaration("let", [
b.variableDeclarator(
b.identifier(name),
b.callExpression(
b.memberExpression(
b.identifier('Blaze'),
b.identifier('_withCurrentView')
),
[
b.identifier(viewName),
b.arrowFunctionExpression(
[],
b.callExpression(b.identifier(lookupName), [b.literal(name)])
)
],
)
)
]);
});

let handler = tag.handler;

if (['FunctionExpression', 'ArrowFunctionExpression'].includes(handlerAst.type)) {
handler = `(${handler})`;
}

const eventMethod = b.objectMethod(
"method",
b.literal(`${tag.event} .${tag.className}`),
[b.identifier('event'), b.identifier('template')],
b.blockStatement([
viewAst,
...helperLookups,
b.expressionStatement(
b.callExpression(
b.memberExpression(b.identifier(handler), b.identifier("call")),
[b.thisExpression(), b.identifier('event'), b.identifier('template')]
)
)
]),
);
events.push(eventMethod);
});

const completedAst = b.expressionStatement(
b.callExpression(
b.memberExpression(b.memberExpression(b.identifier("Template"), b.identifier(templateName)), b.identifier('events')),
[b.objectExpression(events)]
)
);

return print(completedAst).code;
}

processFilesForTarget(files) {
files.forEach((file) => {
// find the contents of the template and script
const tags = this.parseTags(file);
const {
templateName = `${file.getPackageName() || 'app'}/${file.getPathInPackage()}`,
ast
} = this.parseJs(tags.script, file.getPathInPackage());

const addedHelpers = this.addHelpers(tags.templateTags, ast, templateName);
const addedEvents = this.addTemplateEvents(tags.templateTags, ast, templateName);
tags.script += '\n' + addedHelpers + '\n' + addedEvents;

const output = TemplatingTools.compileTagsWithSpacebars([{
tagName: 'template',
attribs: { name: templateName },
contents: tags.template,
sourceName: file.getPathInPackage()
}]);
const scriptContent = this.babelCompiler.processOneFileForTarget(
file, tags.script
).data;

file.addJavaScript({
data: `${output.js}\n${scriptContent}`,
path: file.getPathInPackage()
});
});
}
}

Plugin.registerCompiler({
extensions: ['blaze'],
archMatching: 'web',
isTemplate: true
}, () => new Compiler());

Have a comment? Please Send me an email


Want More? Join my monthly newsletter for new blog posts and news about Meteor and my projects.

Have a Meteor app in production? I run Monti APM for production monitoring to help improve performance and reduce errors.