@adobe/htlengine
Version:
Javascript Based HTL (Sightly) parser
403 lines (363 loc) • 13.4 kB
JavaScript
/*
* Copyright 2018 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// built-in modules
const path = require('path');
// declared dependencies
const fse = require('fs-extra');
const { SourceMapGenerator } = require('source-map');
// local modules
const TemplateParser = require('../parser/html/TemplateParser');
const ThrowingErrorListener = require('../parser/htl/ThrowingErrorListener');
const JSCodeGenVisitor = require('./JSCodeGenVisitor');
const ExpressionFormatter = require('./ExpressionFormatter');
const TemplateReference = require('../parser/commands/TemplateReference');
const VariableBinding = require('../parser/commands/VariableBinding');
const RuntimeCall = require('../parser/htl/nodes/RuntimeCall');
const Identifier = require('../parser/htl/nodes/Identifier');
const FunctionBlock = require('../parser/commands/FunctionBlock');
const DEFAULT_TEMPLATE = 'JSCodeTemplate.js';
const RUNTIME_TEMPLATE = 'JSRuntimeTemplate.js';
module.exports = class Compiler {
/**
* Generates the module import statement.
*
* @param {string} baseDir the base directory (usually cwd)
* @param {string} varName the variable name of the module to be defined.
* @param {string} moduleId the id of the module
* @returns {string} the import string.
*/
static defaultModuleGenerator(baseDir, varName, moduleId) {
// make path relative to output directory
if (path.isAbsolute(moduleId)) {
// eslint-disable-next-line no-param-reassign
moduleId = `.${path.sep}${path.relative(baseDir, moduleId)}`;
}
return `const ${varName} = require(${JSON.stringify(moduleId)});`;
}
constructor() {
this._dir = '.';
this._outfile = '';
this._runtimeGlobals = [];
this._runtimeGlobal = 'global';
this._includeRuntime = false;
this._modHTLEngine = '@adobe/htlengine';
this._codeTemplate = null;
this._defaultMarkupContext = undefined;
this._sourceFile = null;
this._sourceOffset = 0;
this._moduleImportGenerator = Compiler.defaultModuleGenerator;
}
/**
* @deprecated use {@link #withDirectoty()} instead.
*/
withOutputDirectory(dir) {
this._dir = dir;
return this;
}
/**
* Sets the base directory the compiler uses to resolve compile time paths.
* @param {string} dir the directory
* @returns {Compiler} this.
*/
withDirectory(dir) {
this._dir = dir;
return this;
}
withOutputFile(outFile) {
this._outfile = outFile;
return this;
}
withRuntimeGlobalName(name) {
this._runtimeGlobal = name;
return this;
}
withRuntimeVar(name) {
if (Array.isArray(name)) {
this._runtimeGlobals = this._runtimeGlobals.concat(name);
} else {
this._runtimeGlobals.push(name);
}
return this;
}
withRuntimeHTLEngine(mod) {
this._modHTLEngine = mod;
return this;
}
includeRuntime(include) {
this._includeRuntime = include;
return this;
}
withSourceMap(sourceMap) {
this._sourceMap = sourceMap;
return this;
}
withCodeTemplate(tmpl) {
this._codeTemplate = tmpl;
return this;
}
/**
* Sets the default markup context when writing properties to the response.
* @param {MarkupContext} context the default context
* @return this
*/
withDefaultMarkupContext(context) {
this._defaultMarkupContext = context;
return this;
}
/**
* Sets the name of the source file used when generating the source map.
* @param {string} value the source file name.
* @returns {Compiler} this
*/
withSourceFile(value) {
this._sourceFile = value;
return this;
}
/**
* Sets the offset of the code in the source file when generating the source map.
* @param {number} value the offset.
* @returns {Compiler} this
*/
withSourceOffset(value) {
this._sourceOffset = value;
return this;
}
/**
* Sets the function that generates the module import string.
* @param {function} fn the function taking the baseDir, variable name and file name.
*/
withModuleImportGenerator(fn) {
this._moduleImportGenerator = fn;
return this;
}
/**
* Compiles the specified source file and saves the result, overwriting the
* file name.
*
* @async
* @param {String} filename HTL template source file
* @param {String} name file name to save results
* @returns {Promise<String>} the full name of the resulting file
*/
async compileFile(filename, name) {
return this.compileToFile(await fse.readFile(filename, 'utf-8'), name || filename, path.dirname(filename));
}
/**
* Compiles the given HTL source code into JavaScript, which is returned as a string
*
* @async
* @param {String} source the HTL source code
* @param {String} baseDir the base directory to resolve file references
* @returns {Promise<String>} the resulting Javascript
*/
async compileToString(source, baseDir) {
return (await this.compile(source, baseDir)).template;
}
/**
* Compiles the given source string and saves the result, overwriting the
* file name.
*
* @async
* @param {String} source HTL template code
* @param {String} name file name to save results
* @param {String} baseDir the base directory to resolve file references
* @returns {Promise<String>} the full name of the resulting file
*/
async compileToFile(source, name, baseDir) {
const { template, sourceMap } = await this.compile(source, baseDir);
const filename = this._outfile || path.resolve(this._dir, name);
await fse.writeFile(filename, template);
if (sourceMap) {
await fse.writeFile(`${filename}.map`, JSON.stringify(sourceMap));
}
return filename;
}
/**
* Compiles the given HTL source code into a JavaScript function.
*
* @async
* @param {String} source HTL template code
* @param {String} baseDir the base directory to resolve file references
* @param {NodeRequire} localRequire Require function that will be used to load modules.
* @returns {Promise<Function>} the resulting function
*/
async compileToFunction(source, baseDir, localRequire = require) {
const js = await this.compileToString(source, baseDir);
// eslint-disable-next-line no-new-func
const template = new Function('exports', 'require', 'module', '__filename', '__dirname', js);
const mod = {
id: '.',
exports: {},
filename: 'internal',
children: [],
parent: null,
dirname: baseDir,
require: localRequire,
};
template.call(null, mod.exports, mod.require, mod, mod.filename, mod.dirname);
return mod.exports;
}
/**
* Parses the source and returns the command stream. It resolves any static linked templates
* recursively.
*
* @param {String} source the HTL template code
* @param {String} baseDir the base directory to resolve file references
* @param {object} mods object with module mappings from use classes
* @returns {object} The result object with a `commands` stream and `templates`.
*/
async _parse(source, baseDir, mods) {
const commands = new TemplateParser()
.withErrorListener(ThrowingErrorListener.INSTANCE)
.withDefaultMarkupContext(this._defaultMarkupContext)
.parse(source);
const templates = [];
// find any templates references and use classes and process them
for (let i = 0; i < commands.length; i += 1) {
const c = commands[i];
if (c instanceof TemplateReference) {
if (c.isTemplate()) {
let templatePath = path.resolve(baseDir, c.filename);
// eslint-disable-next-line no-await-in-loop
if (!(await fse.pathExists(templatePath))) {
templatePath = path.resolve(this._dir, c.filename);
}
// eslint-disable-next-line no-await-in-loop
const templateSource = await fse.readFile(templatePath, 'utf-8');
// eslint-disable-next-line no-await-in-loop
const res = await this._parse(templateSource, path.dirname(templatePath), mods);
// add recursive templates, if any.
templates.push(...res.templates);
// add this templates
const template = {
file: templatePath,
commands: [],
};
templates.push(template);
// prefix all templates with the variable name of the use class and discard commands
// outside functions.
let inside = false;
res.commands.forEach((cmd) => {
if (cmd instanceof FunctionBlock.Start) {
inside = true;
// eslint-disable-next-line no-underscore-dangle,no-param-reassign
cmd._expression = `${c.name}.${cmd._expression}`;
} else if (cmd instanceof FunctionBlock.End) {
inside = false;
} else if (!inside) {
return;
}
template.commands.push(cmd);
});
// remove the template reference from the stream
commands.splice(i, 1);
i -= 1;
} else {
let file = c.filename;
if (file.startsWith('./') || file.startsWith('../')) {
file = path.resolve(baseDir, file);
}
let name = mods[file];
if (!name) {
name = `$$use_${Object.keys(mods).length}`;
// eslint-disable-next-line no-param-reassign
mods[file] = name;
}
// replace command with runtime call
commands[i] = new VariableBinding.Global(c.name, new RuntimeCall('use', new Identifier(name), c.args));
}
}
}
return {
templates,
commands,
};
}
/**
* Compiles the given source string and returns the generated JS
* and sourceMap in an object.
*
* @async
* @param {String} source HTL template code
* @param {String} [baseDir] the base directory to resolve dependencies.
* defaults to the output directory.
* @returns {Promise<Object>} An object consisting of a generated template and a source map
*/
async compile(source, baseDir = this._dir) {
const mods = {};
const parseResult = await this._parse(source, baseDir, mods);
const global = [];
if (this._runtimeGlobal) {
global.push(` const ${this._runtimeGlobal} = runtime.globals;\n`);
}
this._runtimeGlobals.forEach((g) => {
global.push(` let ${ExpressionFormatter.escapeVariable(g)} = runtime.globals[${JSON.stringify(g)}];\n`);
});
const {
code, templateCode, mappings, templateMappings,
} = new JSCodeGenVisitor()
.withIndent(' ')
.withSourceMap(this._sourceMap)
.withSourceOffset(this._sourceOffset)
.withSourceFile(this._sourceFile)
.indent()
.process(parseResult);
// add modules
let imports = templateCode;
Object.entries(mods).forEach(([file, name], idx) => {
if (idx === 0 && imports) {
imports += '\n';
}
let exp = this._moduleImportGenerator(this._dir, name, file);
if (!exp) {
exp = Compiler.defaultModuleGenerator(this._dir, name, file);
}
imports += ` ${exp}\n`;
});
let template = this._codeTemplate;
if (!template) {
const codeTemplate = this._includeRuntime ? RUNTIME_TEMPLATE : DEFAULT_TEMPLATE;
template = await fse.readFile(path.join(__dirname, codeTemplate), 'utf-8');
}
if (this._includeRuntime) {
const engine = JSON.stringify(this._modHTLEngine).slice(1, -1);
template = template.replace(/MOD_HTLENGINE/, engine);
}
template = template.replace(/^\s*\/\/\s*RUNTIME_GLOBALS\s*$/m, `\n${global.join('')}`);
let index = template.search(/^\s*\/\/\s*TEMPLATES\s*$/m);
const templatesOffset = index !== -1 ? template.substring(0, index).match(/\n/g).length + 1 : 0;
template = template.replace(/^\s*\/\/\s*TEMPLATES\s*$/m, `\n${imports}`);
index = template.search(/^\s*\/\/\s*CODE\s*$/m);
const codeOffset = index !== -1 ? template.substring(0, index).match(/\n/g).length : 0;
template = template.replace(/^\s*\/\/\s*CODE\s*$/m, code);
let sourceMap = null;
if (mappings) {
const generator = new SourceMapGenerator({
sourceRoot: baseDir,
});
mappings.forEach((mapping) => {
// eslint-disable-next-line no-param-reassign
mapping.generated.line += codeOffset;
generator.addMapping(mapping);
});
templateMappings.forEach((mapping) => {
// eslint-disable-next-line no-param-reassign
mapping.generated.line += templatesOffset;
generator.addMapping(mapping);
});
sourceMap = generator.toJSON();
// relativize source files
sourceMap.sources = sourceMap.sources.map((file) => path.relative(baseDir, file));
}
return { template, sourceMap };
}
};