UNPKG

@marko/compiler

Version:
584 lines (505 loc) 15 kB
"use strict"; var ok = require("assert").ok; var nodePath = require("path"); var createError = require("raptor-util/createError"); var isObjectEmpty = require("raptor-util/isObjectEmpty"); var markoModules = require("@marko/compiler/modules"); var taglibConfig = require("../config"); var loaders = require("./loaders"); var propertyHandlers = require("./property-handlers"); var types = require("./types"); var hasOwnProperty = Object.prototype.hasOwnProperty; function resolveRelative(dirname, value) { return value[0] === "." ? markoModules.tryResolve(value, dirname) || value : value; } function resolveWithMarkoExt(dirname, value) { if (value[0] !== ".") return value; if ( markoModules.require.extensions && !(".marko" in markoModules.require.extensions)) { markoModules.require.extensions[".marko"] = undefined; try { return markoModules.tryResolve(value, dirname) || value; } finally { delete markoModules.require.extensions[".marko"]; } } return markoModules.tryResolve(value, dirname) || value; } function removeDashes(str) { return str.replace(/-([a-z])/g, function (match, lower) { return lower.toUpperCase(); }); } function hasAttributes(tagProps) { if (tagProps.attributes != null) { return true; } for (var name in tagProps) { if (hasOwnProperty.call(tagProps, name) && name.startsWith("@")) { return true; } } return false; } function normalizeHook(dirname, value) { if (typeof value === "string") { value = resolveRelative(dirname, value); return { path: value, get hook() { return markoModules.require(value); } }; } return { hook: value }; } /** * We load tag definition using this class. Properties in the taglib * definition (which is just a JavaScript object with properties) * are mapped to handler methods in an instance of this type. * * @param {Tag} tag The initially empty Tag instance that we populate * @param {String} dirname The full file system path associated with the tag being loaded * @param {String} path An informational path associated with this tag (used for error reporting) */ class TagLoader { constructor(tag, dependencyChain) { this.tag = tag; this.dependencyChain = dependencyChain; this.filePath = tag.filePath; this.dirname = tag.dir || tag.dirname; } load(tagProps) { if (!hasAttributes(tagProps)) { // allow any attributes if no attributes are declared tagProps.attributes = { "*": { type: "string", targetProperty: null, preserveName: false } }; } propertyHandlers(tagProps, this, this.dependencyChain.toString()); } _handleVar(value, dependencyChain) { var tag = this.tag; var nestedVariable; if (typeof value === "string") { nestedVariable = { name: value }; } else { nestedVariable = {}; propertyHandlers( value, { name: function (value) { nestedVariable.name = value; }, nameFromAttribute: function (value) { nestedVariable.nameFromAttribute = value; } }, dependencyChain.toString() ); if (!nestedVariable.name && !nestedVariable.nameFromAttribute) { throw new Error( 'The "name" or "name-from-attribute" attribute is required for a nested variable (' + dependencyChain + ")" ); } } tag.addNestedVariable(nestedVariable); } /** * This is handler is for any properties that didn't match * one of the default property handlers. This is used to * match properties in the form of "@attr_name" or * "<nested_tag_name>" */ "*"(name, value) { var tag = this.tag; var dependencyChain = this.dependencyChain; var parts = name.split(/\s+|\s+[,]\s+/); var i; var part; var hasNestedTag = false; var hasAttr = false; var nestedTagTargetProperty = null; // We do one pass to figure out if there is an // attribute or nested tag or both for (i = 0; i < parts.length; i++) { part = parts[i]; if (part.startsWith("@")) { hasAttr = true; if (i === 0) { // Use the first attribute value as the name of the target property nestedTagTargetProperty = part.substring(1); } } else if (part.startsWith("<")) { hasNestedTag = true; } else { // Unmatched property that is not an attribute or a // nested tag return false; } } var attrProps = {}; var tagProps = {}; var k; if (value != null && typeof value === "object") { for (k in value) { if (hasOwnProperty.call(value, k)) { if (k.startsWith("@") || k.startsWith("<")) { // Move over all of the attributes and nested tags // to the tag definition. tagProps[k] = value[k]; delete value[k]; } else { // The property is not a shorthand attribute or shorthand // tag so move it over to either the tag definition // or the attribute definition or both the tag definition // and attribute definition. var propNameDashes = removeDashes(k); if ( isSupportedProperty(propNameDashes) && loaders.isSupportedAttributeProperty(propNameDashes)) { // Move over all of the properties that are associated with a tag // and attribute tagProps[k] = value[k]; attrProps[k] = value[k]; delete value[k]; } else if (isSupportedProperty(propNameDashes)) { // Move over all of the properties that are associated with a tag tagProps[k] = value[k]; delete value[k]; } else if (loaders.isSupportedAttributeProperty(propNameDashes)) { // Move over all of the properties that are associated with an attr attrProps[k] = value[k]; delete value[k]; } } } } // If there are any left over properties then something is wrong // with the user's taglib. if (!isObjectEmpty(value)) { throw new Error( "Unsupported properties of [" + Object.keys(value).join(", ") + "]" ); } var type = attrProps.type; if (!type && hasAttr && hasNestedTag) { // If we have an attribute and a nested tag then default // the attribute type to "expression" attrProps.type = "expression"; } } else if (typeof value === "string") { if (hasNestedTag && hasAttr) { tagProps = attrProps = { type: value }; } else if (hasNestedTag) { tagProps = { type: value }; } else { attrProps = { type: value }; } } // Now that we have separated out attribute properties and tag properties // we need to create the actual attributes and nested tags for (i = 0; i < parts.length; i++) { part = parts[i]; if (part.startsWith("@")) { // This is a shorthand attribute var attrName = part.substring(1); var attr = loaders.loadAttributeFromProps( attrName, attrProps, dependencyChain.append(part) ); tag.addAttribute(attr); } else if (part.startsWith("<")) { // This is a shorthand nested tag let nestedTag = new types.Tag(this.filePath); loadTagFromProps(nestedTag, tagProps, dependencyChain.append(part)); // We use the '[]' suffix to indicate that a nested tag // can be repeated var isNestedTagRepeated = false; if (part.endsWith("[]")) { isNestedTagRepeated = true; part = part.slice(0, -2); } var nestedTagName = part.substring(1, part.length - 1); nestedTag.name = nestedTagName; nestedTag.isRepeated = nestedTag.isRepeated || isNestedTagRepeated; // Use the name of the attribute as the target property unless // this target property was explicitly provided nestedTag.targetProperty = attrProps.targetProperty || nestedTagTargetProperty; tag.addNestedTag(nestedTag); if (!nestedTag.isRepeated) { let attr = loaders.loadAttributeFromProps( nestedTag.targetProperty, { type: "object" }, dependencyChain.append(part) ); tag.addAttribute(attr); } } else { return false; } } } /** * The tag name * @param {String} value The tag name */ name(value) { var tag = this.tag; tag.name = value; } /** * The path to the renderer JS module to use for this tag. * * NOTE: We use the equivalent of require.resolve to resolve the JS module * and use the tag directory as the "from". * * @param {String} value The renderer path */ renderer(value) { this.tag.renderer = resolveWithMarkoExt(this.dirname, value); } /** * A tag can use a renderer or a template to do the rendering. If * a template is provided then the value should be the path to the * template to use to render the custom tag. */ template(value) { var tag = this.tag; var dirname = this.dirname; var path = nodePath.resolve(dirname, value); try { taglibConfig.fs.statSync(path); tag.template = path; } catch (_) { throw new Error('Template at path "' + path + '" does not exist.'); } } /** * This property is used by @marko/language-tools (editor tooling) * to override the Marko file used when generating the tags exposed * typescript / jsdoc types. */ types(value) { this.tag.types = value[0] === "." ? nodePath.resolve(this.dirname, value) : value; } /** * An Object where each property maps to an attribute definition. * The property key will be the attribute name and the property value * will be the attribute definition. Example: * { * "attributes": { * "foo": "string", * "bar": "expression" * } * } */ attributes(value) { var tag = this.tag; loaders.loadAttributes( value, tag, this.dependencyChain.append("attributes") ); } /** * Deprecated */ migrator(value) { this.migrate(value); } /** * A custom tag can be mapped to module that is used * migrate deprecated features to modern features. */ migrate(value) { if (Array.isArray(value)) { value.forEach(this.migrate, this); } else { this.tag.migrators.push(normalizeHook(this.dirname, value)); } } /** * Deprecated */ codeGenerator(value) { this.translate(value); } /** * A custom tag can be mapped to module that is is used * to generate compile-time code for the custom tag. A * node type is created based on the methods and methods * exported by the code codegen module. */ translate(value) { this.tag.translator = normalizeHook(this.dirname, value); } /** * Deprecated */ nodeFactory(value) { this.parse(value); } /** * A custom tag can be mapped to a compile-time Node that gets * added to the parsed Abstract Syntax Tree (AST). The Node can * then generate custom JS code at compile time. The value * should be a path to a JS module that gets resolved using the * equivalent of require.resolve(path) */ parse(value) { this.tag.parser = normalizeHook(this.dirname, value); } /** * Deprecated */ transformer(value) { this.transform(value); } /** * If a custom tag has an associated transformer then the transformer * will be called on the compile-time Node. The transformer can manipulate * the AST using the DOM-like API to change how the code gets generated. */ transform(value) { if (Array.isArray(value)) { value.forEach(this.transform, this); } else { this.tag.transformers.push(normalizeHook(this.dirname, value)); } } /** * A custom tag can be mapped to module that is is used * to analyze code and cache the result in memory. * This analysis data should be read by translate hooks. */ analyze(value) { this.tag.analyzer = normalizeHook(this.dirname, value); } /** * The tag type. */ type(value) { var tag = this.tag; tag.type = value; } isRepeated(value) { var tag = this.tag; tag.isRepeated = value; } targetProperty(value) { var tag = this.tag; tag.targetProperty = value; } /** * Declare a nested tag. * * Example: * { * ... * "nested-tags": { * "tab": { * "target-property": "tabs", * "is-repeated": true * } * } * } */ nestedTags(value) { var filePath = this.filePath; var tag = this.tag; for (const nestedTagName in value) { const nestedTagDef = value[nestedTagName]; var dependencyChain = this.dependencyChain.append( `nestedTags["${nestedTagName}"]` ); var nestedTag = new types.Tag(filePath); loadTagFromProps(nestedTag, nestedTagDef, dependencyChain); nestedTag.name = nestedTagName; tag.addNestedTag(nestedTag); tag.addAttribute( loaders.loadAttributeFromProps( nestedTag.targetProperty, { type: "expression" }, dependencyChain ) ); } } openTagOnly(value) { this.tag.openTagOnly = value; } /** * The description of the tag. Only used for documentation. */ description(value) { this.tag.description = value; } autocomplete(value) { this.tag.autocomplete = value; } parseOptions(value) { this.tag.parseOptions = value; } deprecated(value) { this.tag.deprecated = value; } attributeGroups(value) { if (!value) { return; } var attributeGroups = this.tag.attributeGroups || (this.tag.attributeGroups = []); this.tag.attributeGroups = attributeGroups.concat(value); } html(value) { this.tag.html = value === true; } htmlType(value) { this.tag.htmlType = value; } featureFlags(value) { this.tag.featureFlags = value; } } function isSupportedProperty(name) { return hasOwnProperty.call(TagLoader.prototype, name); } function loadTagFromProps(tag, tagProps, dependencyChain) { ok(typeof tagProps === "object", 'Invalid "tagProps"'); ok(dependencyChain, '"dependencyChain" is required'); var tagLoader = new TagLoader(tag, dependencyChain); try { tagLoader.load(tagProps); } catch (err) { throw createError( "Unable to load tag (" + dependencyChain + "): " + err, err ); } return tag; } module.exports = loadTagFromProps; loadTagFromProps.isSupportedProperty = isSupportedProperty;