diff --git a/packages/lwc-engine/jest.config.js b/packages/lwc-engine/jest.config.js index 9ec582e029..81d027c15a 100644 --- a/packages/lwc-engine/jest.config.js +++ b/packages/lwc-engine/jest.config.js @@ -1,6 +1,11 @@ +const path = require('path'); const BASE_CONFIG = require('../../scripts/jest/base.config'); module.exports = { ...BASE_CONFIG, + displayName: 'lwc-engine', + moduleNameMapper: { + 'test-utils': path.resolve(__dirname, 'scripts/jest/test-utils.js'), + }, }; diff --git a/packages/lwc-engine/package.json b/packages/lwc-engine/package.json index 7041777912..18f47bb9b1 100644 --- a/packages/lwc-engine/package.json +++ b/packages/lwc-engine/package.json @@ -15,6 +15,7 @@ }, "devDependencies": { "concurrently": "^3.5.1", + "lwc-template-compiler": "0.24.2", "rollup": "0.60.1", "rollup-plugin-inject": "^1.4.1", "rollup-plugin-node-resolve": "^3.0.2", diff --git a/packages/lwc-engine/scripts/jest/test-utils.js b/packages/lwc-engine/scripts/jest/test-utils.js new file mode 100644 index 0000000000..5369949906 --- /dev/null +++ b/packages/lwc-engine/scripts/jest/test-utils.js @@ -0,0 +1,27 @@ +const { compileToFunction } = require('lwc-template-compiler'); + +const TEMPLATE_CACHE = Object.create(null); + +/** + * Compiles a template string and returns the instantiated function. + * + * @param {string} source The template string + * @param {object=} config The template configuration + * @param {object=} config.modules The map of the modules used in the template + * @returns {function} + */ +function compileTemplate(source, config = {}) { + const { modules = {} } = config; + + // Check if the same template has already been compiled + if (!(source in TEMPLATE_CACHE)) { + TEMPLATE_CACHE[source] = compileToFunction(source); + } + + const templateFactory = TEMPLATE_CACHE[source]; + return templateFactory(modules); +} + +module.exports = { + compileTemplate, +}; diff --git a/packages/lwc-engine/src/framework/__tests__/class-list.spec.ts b/packages/lwc-engine/src/framework/__tests__/class-list.spec.ts index a1a2565125..d06b6c7e9d 100644 --- a/packages/lwc-engine/src/framework/__tests__/class-list.spec.ts +++ b/packages/lwc-engine/src/framework/__tests__/class-list.spec.ts @@ -1,3 +1,5 @@ +import { compileTemplate } from 'test-utils'; + import { createElement, LightningElement } from '../main'; import { getHostShadowRoot } from '../html-element'; @@ -5,14 +7,22 @@ describe('class-list', () => { describe('integration', () => { it('should support outer className', () => { class ChildComponent extends LightningElement {} - function html($api) { - return [$api.c('x-child', ChildComponent, { className: 'foo' })]; - } + + const html = compileTemplate( + ``, + { + modules: { 'x-child': ChildComponent } + } + ); + class MyComponent extends LightningElement { render() { return html; } } + const elm = createElement('x-foo', { is: MyComponent }); document.body.appendChild(elm); const childElm = getHostShadowRoot(elm).querySelector('x-child'); diff --git a/packages/lwc-engine/src/framework/__tests__/component.spec.ts b/packages/lwc-engine/src/framework/__tests__/component.spec.ts index 0468dc01b7..6a10e1cad4 100644 --- a/packages/lwc-engine/src/framework/__tests__/component.spec.ts +++ b/packages/lwc-engine/src/framework/__tests__/component.spec.ts @@ -1,3 +1,5 @@ +import { compileTemplate } from 'test-utils'; + import { createElement, LightningElement } from '../main'; import { getHostShadowRoot } from '../html-element'; @@ -10,15 +12,20 @@ describe('component', function() { return this.value; } } - MyComponent.publicProps = { breakfast: { config: 1 } }; - function html($api) { - return [$api.c('x-component', MyComponent, {})]; - } + + const html = compileTemplate( + ``, + { + modules: { "x-child": MyComponent } + } + ); class Parent extends LightningElement { value = 'salad'; get lunch() { @@ -29,7 +36,6 @@ describe('component', function() { return html; } } - Parent.publicProps = { lunch: { config: 1 @@ -39,7 +45,7 @@ describe('component', function() { const elm = createElement('x-foo', { is: Parent }); document.body.appendChild(elm); expect(elm.lunch).toBe('salad'); - expect(getHostShadowRoot(elm).querySelector('x-component').breakfast).toBe('pancakes'); + expect(getHostShadowRoot(elm).querySelector('x-child').breakfast).toBe('pancakes'); }); it('should allow calling public getters when element is accessed by querySelector', function() { @@ -54,9 +60,15 @@ describe('component', function() { config: 0 } }; - function html($api) { - return [$api.c('x-child', MyChild, {})]; - } + + const html = compileTemplate( + ``, + { + modules: { "x-child": MyChild } + } + ); class MyComponent extends LightningElement { callChildM() { value = this.template.querySelector('x-child').m; @@ -93,9 +105,11 @@ describe('component', function() { }); it('should be render reactive', function() { - function html($api, $cmp, $slotset, $ctx) { - return [$api.h('div', { key: 0 }, [$api.d($cmp.validity)])]; - } + const html = compileTemplate( + `` + ); class MyComponent extends LightningElement { state = { value: 0 }; @@ -172,9 +186,15 @@ describe('component', function() { config: 3 } }; - function html($api) { - return [$api.c('x-child', MyChild, {})]; - } + + const html = compileTemplate( + ``, + { + modules: { "x-child": MyChild } + } + ); class MyComponent extends LightningElement { render() { return html; @@ -185,6 +205,7 @@ describe('component', function() { } } MyComponent.publicMethods = ['run']; + const elm = createElement('x-foo', { is: MyComponent }); document.body.appendChild(elm); expect(elm.run()).toBe('eggs'); @@ -335,16 +356,12 @@ describe('component', function() { describe('styles', function() { it('should handle string styles', function() { let calledCSSText = false; - function html($api, $cmp) { - return [$api.h( - "section", - { - key: 0, - style: $cmp.state.customStyle - }, - [] - )]; - } + + const html = compileTemplate( + ``, + ); class MyComponent extends LightningElement { state = { customStyle: 'color: red' @@ -373,16 +390,12 @@ describe('component', function() { it('should handle undefined properly', function() { let calledCSSTextWithUndefined = false; - function html($api, $cmp, $slotset, $ctx) { - return [$api.h( - "section", - { - key: 0, - style: $cmp.state.customStyle - }, - [] - )]; - } + + const html = compileTemplate( + ``, + ); class MyComponent extends LightningElement { state = { customStyle: undefined @@ -412,16 +425,11 @@ describe('component', function() { }); it('should handle null properly', function() { - function html($api, $cmp) { - return [$api.h( - "section", - { - key: 0, - style: $cmp.state.customStyle - }, - [] - )]; - } + const html = compileTemplate( + ``, + ); class MyComponent extends LightningElement { state = { customStyle: null @@ -438,16 +446,11 @@ describe('component', function() { }); it('should diff between style objects and strings correctly', function() { - function html($api, $cmp, $slotset, $ctx) { - return [$api.h( - "section", - { - key: 0, - style: $cmp.customStyle - }, - [] - )]; - } + const html = compileTemplate( + ``, + ); class MyComponent extends LightningElement { customStyle: { color: 'red' @@ -559,9 +562,15 @@ describe('component', function() { } } MyChild.publicMethods = ['m']; - function html($api) { - return [$api.c('x-child', MyChild, {})]; - } + + const html = compileTemplate( + ``, + { + modules: { "x-child": MyChild } + } + ); class MyComponent extends LightningElement { callChildM() { this.template.querySelector('x-child').m(); @@ -588,9 +597,15 @@ describe('component', function() { } } MyChild.publicMethods = ['m']; - function html($api) { - return [$api.c('x-child', MyChild, {})]; - } + + const html = compileTemplate( + ``, + { + modules: { "x-child": MyChild } + } + ); class MyComponent extends LightningElement { getChildAttribute() { this.template.querySelector('x-child').getAttribute('title'); @@ -616,9 +631,15 @@ describe('component', function() { } } MyChild.publicMethods = ['m']; - function html($api) { - return [$api.c('x-child', MyChild, {})]; - } + + const html = compileTemplate( + ``, + { + modules: { "x-child": MyChild } + } + ); class MyComponent extends LightningElement { setChildAttribute() { this.template.querySelector('x-child').setAttribute('title', 'foo'); @@ -644,9 +665,15 @@ describe('component', function() { } } MyChild.publicMethods = ['m']; - function html($api) { - return [$api.c('x-child', MyChild, {})]; - } + + const html = compileTemplate( + ``, + { + modules: { "x-child": MyChild } + } + ); class MyComponent extends LightningElement { removeChildAttribute() { this.template.querySelector('x-child').removeAttribute('title'); diff --git a/packages/lwc-engine/src/framework/__tests__/html-element.spec.ts b/packages/lwc-engine/src/framework/__tests__/html-element.spec.ts index 8cf684dc7c..66307ffd51 100644 --- a/packages/lwc-engine/src/framework/__tests__/html-element.spec.ts +++ b/packages/lwc-engine/src/framework/__tests__/html-element.spec.ts @@ -1,3 +1,5 @@ +import { compileTemplate } from 'test-utils'; + import { createElement, register, unwrap } from '../main'; import { getHostShadowRoot, LightningElement } from '../html-element'; import assertLogger from '../../shared/assert'; @@ -12,11 +14,19 @@ describe('html-element', () => { } Child.publicMethods = ['setFoo']; + const html = compileTemplate(` + + `, { + modules: { + 'x-child': Child + } + }); + class Parent extends LightningElement { render() { - return ($api) => { - return [$api.c('x-child', Child, {})] - } + return html; } } const element = createElement('should-set-attribute-on-host-element-when-element-is-nested-in-template', { is: Parent }); diff --git a/packages/lwc-template-compiler/src/__tests__/index.spec.ts b/packages/lwc-template-compiler/src/__tests__/index.spec.ts index 378c20f34b..b16cbbfe4f 100644 --- a/packages/lwc-template-compiler/src/__tests__/index.spec.ts +++ b/packages/lwc-template-compiler/src/__tests__/index.spec.ts @@ -1,4 +1,21 @@ -import compiler from '../index'; +import compiler, { compileToFunction } from '../index'; + +function prettify(str) { + return str.toString() + .replace(/^\s+|\s+$/, '') + .split('\n') + .map(line => line.trim()) + .filter(line => line.length) + .join('\n'); +} + +function functionMatchCode(fn, code) { + return expect( + prettify(fn.toString()), + ).toContain( + prettify(code), + ); +} describe('option validation', () => { it('validated presence of options', () => { @@ -18,3 +35,75 @@ describe('option validation', () => { ); }); }); + +describe('compileToFunction', () => { + it('should compile correctly simple components', () => { + const renderFn = compileToFunction(` + + `); + + functionMatchCode(renderFn, ` + function tmpl($api, $cmp, $slotset, $ctx) { + const { + t: api_text, + h: api_element + } = $api; + + return [api_element("h1", { + key: 1 + }, [api_text("Hello world!")])]; + } + + return tmpl; + `); + }); + + it('should add component lookups if necessary', () => { + const renderFn = compileToFunction(` + + `); + + functionMatchCode(renderFn, ` + const _xFoo = modules["x-foo"]; + + function tmpl($api, $cmp, $slotset, $ctx) { + const { + c: api_custom_element + } = $api; + + return [api_custom_element("x-foo", _xFoo, { + key: 1 + }, [])]; + } + + return tmpl; + `); + }); + + it('should add template metadata if necessary', () => { + const renderFn = compileToFunction(` + + `); + + functionMatchCode(renderFn, ` + function tmpl($api, $cmp, $slotset, $ctx) { + const { + s: api_slot + } = $api; + + return [api_slot("", { + key: 1 + }, [], $slotset)]; + } + tmpl.slots = [""]; + + return tmpl; + `); + }); +}); diff --git a/packages/lwc-template-compiler/src/codegen/formatters/function.ts b/packages/lwc-template-compiler/src/codegen/formatters/function.ts new file mode 100644 index 0000000000..126ca5156d --- /dev/null +++ b/packages/lwc-template-compiler/src/codegen/formatters/function.ts @@ -0,0 +1,43 @@ +import * as t from 'babel-types'; + +import State from '../../state'; +import { + identifierFromComponentName, + generateTemplateMetadata, +} from '../helpers'; +import { + TEMPLATE_FUNCTION_NAME, + TEMPLATE_MODULES_PARAMETER, +} from '../../shared/constants'; + +function moduleNameToLookup(name: string): t.VariableDeclaration { + const localIdentifier = identifierFromComponentName(name); + + return t.variableDeclaration('const', [ + t.variableDeclarator( + localIdentifier, + t.memberExpression( + t.identifier(TEMPLATE_MODULES_PARAMETER), + t.stringLiteral(name), + true, + ), + ), + ]); +} + +export function format( + templateFn: t.FunctionDeclaration, + state: State, +): t.Program { + const lookups = state.dependencies.map(cmpClassName => + moduleNameToLookup(cmpClassName), + ); + const metadata = generateTemplateMetadata(state); + + return t.program([ + ...lookups, + templateFn, + ...metadata, + t.returnStatement(t.identifier(TEMPLATE_FUNCTION_NAME)), + ]); +} diff --git a/packages/lwc-template-compiler/src/codegen/formatters/module.ts b/packages/lwc-template-compiler/src/codegen/formatters/module.ts new file mode 100644 index 0000000000..da071bb5f9 --- /dev/null +++ b/packages/lwc-template-compiler/src/codegen/formatters/module.ts @@ -0,0 +1,32 @@ +import * as t from 'babel-types'; + +import State from '../../state'; +import { + identifierFromComponentName, + generateTemplateMetadata, +} from '../helpers'; + +function moduleNameToImport(name: string): t.ImportDeclaration { + const localIdentifier = identifierFromComponentName(name); + + return t.importDeclaration( + [t.importDefaultSpecifier(localIdentifier)], + t.stringLiteral(name), + ); +} + +export function format( + templateFn: t.FunctionDeclaration, + state: State, +): t.Program { + const imports = state.dependencies.map(cmpClassName => + moduleNameToImport(cmpClassName), + ); + const metadata = generateTemplateMetadata(state); + + return t.program([ + ...imports, + t.exportDefaultDeclaration(templateFn), + ...metadata, + ]); +} diff --git a/packages/lwc-template-compiler/src/codegen/helpers.ts b/packages/lwc-template-compiler/src/codegen/helpers.ts index 3eb79e3e70..4a31f321c8 100644 --- a/packages/lwc-template-compiler/src/codegen/helpers.ts +++ b/packages/lwc-template-compiler/src/codegen/helpers.ts @@ -1,8 +1,10 @@ import * as t from 'babel-types'; import * as toCamelCase from 'camelcase'; +import State from '../state'; import { isElement } from '../shared/ir'; import { IRElement } from '../shared/types'; +import { TEMPLATE_FUNCTION_NAME } from '../shared/constants'; export function identifierFromComponentName(name: string): t.Identifier { return t.identifier(`_${toCamelCase(name)}`); @@ -23,14 +25,6 @@ export function getMemberExpressionRoot( return current as t.Identifier; } -export function importFromComponentName(name: string): t.ImportDeclaration { - const localComponentIdentifier = identifierFromComponentName(name); - return t.importDeclaration( - [t.importDefaultSpecifier(localComponentIdentifier)], - t.stringLiteral(name), - ); -} - export function objectToAST( obj: object, valueMapper: (key: string) => t.Expression, @@ -81,3 +75,27 @@ export function destructuringAssignmentFromObject( ), ]); } + +export function generateTemplateMetadata(state: State): t.ExpressionStatement[] { + const metadataExpressions: t.ExpressionStatement[] = []; + + // Generate the slots property on template function if slots are defined in the template: + // tmpl.slots = ['', 'x'] + if (state.slots.length) { + const slotsProperty = t.memberExpression( + t.identifier(TEMPLATE_FUNCTION_NAME), + t.identifier('slots'), + ); + + const slotsArray = t.arrayExpression( + state.slots.map((slot) => t.stringLiteral(slot)), + ); + + const slotsMetadata = t.assignmentExpression('=', slotsProperty, slotsArray); + metadataExpressions.push( + t.expressionStatement(slotsMetadata), + ); + } + + return metadataExpressions; +} diff --git a/packages/lwc-template-compiler/src/codegen/index.ts b/packages/lwc-template-compiler/src/codegen/index.ts index 5a46d19198..5951cbbc55 100644 --- a/packages/lwc-template-compiler/src/codegen/index.ts +++ b/packages/lwc-template-compiler/src/codegen/index.ts @@ -34,7 +34,6 @@ import Stack from '../shared/stack'; import { identifierFromComponentName, - importFromComponentName, objectToAST, getMemberExpressionRoot, isTemplate, @@ -46,6 +45,24 @@ import { import CodeGen from './codegen'; +import { format as formatModule } from './formatters/module'; +import { format as formatFunction } from './formatters/function'; + +const TEMPLATE_FUNCTION = template( + `function ${TEMPLATE_FUNCTION_NAME}( + ${TEMPLATE_PARAMS.API}, + ${TEMPLATE_PARAMS.INSTANCE}, + ${TEMPLATE_PARAMS.SLOT_SET}, + ${TEMPLATE_PARAMS.CONTEXT} + ) { + APIS; + SLOTS; + CONTEXT; + return STATEMENT; + }`, + { sourceType: 'module' }, +); + function transform( root: IRNode, codeGen: CodeGen, @@ -437,49 +454,7 @@ function transform( return (stack.peek() as t.ArrayExpression).elements[0] as t.Expression; } -/** - * Generate metadata that will be attached to the template function - */ -function generateTemplateMetadata(state: State): t.ExpressionStatement[] { - const metadataExpressions: t.ExpressionStatement[] = []; - - // Generate the slots property on template function if slots are defined in the template - // tmpl.slots = ['', 'x'] - if (state.slots.length) { - const slotsProperty = t.memberExpression( - t.identifier(TEMPLATE_FUNCTION_NAME), - t.identifier('slots'), - ); - - const slotsArray = t.arrayExpression( - state.slots.map((slot) => t.stringLiteral(slot)), - ); - - const slotsMetadata = t.assignmentExpression('=', slotsProperty, slotsArray); - metadataExpressions.push( - t.expressionStatement(slotsMetadata), - ); - } - - return metadataExpressions; -} - -const TEMPLATE_FUNCTION = template( - `export default function ${TEMPLATE_FUNCTION_NAME}( - ${TEMPLATE_PARAMS.API}, - ${TEMPLATE_PARAMS.INSTANCE}, - ${TEMPLATE_PARAMS.SLOT_SET}, - ${TEMPLATE_PARAMS.CONTEXT} - ) { - APIS; - SLOTS; - CONTEXT; - return STATEMENT; - }`, - { sourceType: 'module' }, -); - -export default function(templateRoot: IRElement, state: State): CompilationOutput { +function generateTemplateFunction(templateRoot: IRElement, state: State): t.FunctionDeclaration { const codeGen = new CodeGen(); const statement = transform(templateRoot, codeGen, state); @@ -525,24 +500,29 @@ export default function(templateRoot: IRElement, state: State): CompilationOutpu ); } - const content = TEMPLATE_FUNCTION({ + return TEMPLATE_FUNCTION({ APIS: apis, SLOTS: slots, CONTEXT: context, STATEMENT: statement, - }) as t.ExportDefaultDeclaration; + }) as t.FunctionDeclaration; +} - const intro = state.dependencies.map((cmpClassName) => ( - importFromComponentName(cmpClassName) - )); +function format({ config }: State) { + switch (config.format) { + case 'function': + return formatFunction; - const outro = generateTemplateMetadata(state); + default: + return formatModule; + } +} + +export default function(templateRoot: IRElement, state: State): CompilationOutput { + const templateFunction = generateTemplateFunction(templateRoot, state); - const program = t.program([ - ...intro, - content, - ...outro, - ]); + const formatter = format(state); + const program = formatter(templateFunction, state); const { code } = generate(program); return { diff --git a/packages/lwc-template-compiler/src/config.ts b/packages/lwc-template-compiler/src/config.ts index 7fc52c091a..bba074985d 100644 --- a/packages/lwc-template-compiler/src/config.ts +++ b/packages/lwc-template-compiler/src/config.ts @@ -1,3 +1,5 @@ +export type Format = 'module' | 'function'; + export interface Config { /** * Enable computed member expression in the template. eg: @@ -10,10 +12,20 @@ export interface Config { export interface ResolvedConfig { computedMemberExpression: boolean; + + /** + * Internal configuration for the output format of the template. Accepts: + * * "module": generates a ES module, and use import statements to reference component + * constructor. + * * "inline": generates a function, and requires component constructor to be passed + * as parameter. + */ + format: Format; } -const DEFAULT_CONFIG = { +const DEFAULT_CONFIG: ResolvedConfig = { computedMemberExpression: false, + format: 'module', }; const REQUIRED_OPTION_NAMES = new Set([]); diff --git a/packages/lwc-template-compiler/src/index.ts b/packages/lwc-template-compiler/src/index.ts index 0ff8087f3d..38bdabb701 100644 --- a/packages/lwc-template-compiler/src/index.ts +++ b/packages/lwc-template-compiler/src/index.ts @@ -4,6 +4,7 @@ import { mergeConfig, Config } from './config'; import parse from './parser'; import generate from './codegen'; +import { TEMPLATE_MODULES_PARAMETER } from './shared/constants'; import { CompilationMetadata, CompilationWarning } from './shared/types'; export default function compiler( @@ -51,3 +52,31 @@ export default function compiler( }, }; } + +export function compileToFunction(source: string): Function { + const options = mergeConfig({}); + options.format = 'function'; + + const state = new State(source, options); + + const parsingResults = parse(source, state); + + for (const { message, level } of parsingResults.warnings) { + if (level === 'error') { + throw new Error(message); + } else if (level === 'warning') { + /* tslint:disable-next-line:no-console */ + console.warn(message); + } else { + /* tslint:disable-next-line:no-console */ + console.log(message); + } + } + + if (!parsingResults.root) { + throw new Error(`Invalid template`); + } + + const { code } = generate(parsingResults.root, state); + return new Function(TEMPLATE_MODULES_PARAMETER, code); +} diff --git a/packages/lwc-template-compiler/src/shared/constants.ts b/packages/lwc-template-compiler/src/shared/constants.ts index 2929ab2b1a..a4a5ff8ab3 100644 --- a/packages/lwc-template-compiler/src/shared/constants.ts +++ b/packages/lwc-template-compiler/src/shared/constants.ts @@ -1,3 +1,5 @@ +export const TEMPLATE_MODULES_PARAMETER: string = 'modules'; + export const TEMPLATE_FUNCTION_NAME: string = 'tmpl'; export const TEMPLATE_PARAMS: { [label: string]: string } = {