function resolve(path) { if (path.startsWith("settings") || path.startsWith("transformed")) { return `this.${path}`; } return path; } function sexpValue(value) { if (!value) { return; } if (value.type === "PathExpression") { return resolve(value.original); } else if (value.type === "StringLiteral") { return JSON.stringify(value.value); } else if (value.type === "SubExpression") { return sexp(value); } else { return resolve(value.value); } } function pairsToObj(pairs) { let result = []; pairs.forEach((p) => { result.push(`"${p.key}": ${sexpValue(p.value)}`); }); return `{ ${result.join(", ")} }`; } function i18n(node) { let key = sexpValue(node.params[0]); let hash = node.hash; if (hash.pairs.length) { return `I18n.t(${key}, ${pairsToObj(hash.pairs)})`; } return `I18n.t(${key})`; } function sexp(value) { if (value.path.original === "hash") { return pairsToObj(value.hash.pairs); } if (value.path.original === "concat") { let result = []; value.params.forEach((p) => { result.push(sexpValue(p)); }); return result.join(" + "); } if (value.path.original === "i18n") { return i18n(value); } } function valueOf(value) { if (value.type === "SubExpression") { return sexp(value); } else if (value.type === "PathExpression") { return value.original; } else if (value.type === "StringLiteral") { return JSON.stringify(value.value); } } function argValue(arg) { return valueOf(arg.value); } function useHelper(state, name) { let id = state.helpersUsed[name]; if (!id) { id = ++state.helperNumber; state.helpersUsed[name] = id; } return `__h${id}`; } function mustacheValue(node, state) { let path = node.path.original; switch (path) { case "attach": let widgetName = argValue( node.hash.pairs.find((p) => p.key === "widget") ); const attrs = node.hash.pairs.find((p) => p.key === "attrs"); const opts = node.hash.pairs.find((p) => p.key === "opts"); const otherOpts = node.hash.pairs.find((p) => p.key === "otherOpts"); return `this.attach(${widgetName}, ${attrs ? argValue(attrs) : attrs}, ${ opts ? argValue(opts) : opts }, ${otherOpts ? argValue(otherOpts) : otherOpts})`; case "yield": return `this.attrs.contents()`; case "i18n": return i18n(node); case "avatar": let template = argValue( node.hash.pairs.find((p) => p.key === "template") ); let username = argValue( node.hash.pairs.find((p) => p.key === "username") ); let size = argValue(node.hash.pairs.find((p) => p.key === "size")); return `${useHelper( state, "avatar" )}(${size}, { template: ${template}, username: ${username} })`; case "date": return `${useHelper(state, "dateNode")}(${valueOf(node.params[0])})`; case "d-icon": return `${useHelper(state, "iconNode")}(${valueOf(node.params[0])})`; default: // Shortcut: If our mustache has hash arguments, we can assume it's attaching. // For example `{{home-logo count=123}}` can become `this.attach('home-logo, { "count": 123 });` let hash = node.hash; if (hash.pairs.length) { let widgetString = JSON.stringify(path); // magic: support applying of attrs. This is commonly done like `{{home-logo attrs=attrs}}` let firstPair = hash.pairs[0]; if (firstPair.key === "attrs") { return `this.attach(${widgetString}, ${firstPair.value.original})`; } return `this.attach(${widgetString}, ${pairsToObj(hash.pairs)})`; } if (node.trusting) { return `new ${useHelper(state, "rawHtml")}({ html: '' + ${resolve( path )} + ''})`; } else { return `${resolve(path)}`; } } } class Compiler { constructor(ast) { this.idx = 0; this.ast = ast; this.state = { helpersUsed: {}, helperNumber: 0, }; } newAcc() { return `_a${this.idx++}`; } processNode(parentAcc, node) { let instructions = []; let innerAcc; switch (node.type) { case "Program": case "Template": node.body.forEach((bodyNode) => { instructions = instructions.concat( this.processNode(parentAcc, bodyNode) ); }); break; case "ElementNode": innerAcc = this.newAcc(); instructions.push(`var ${innerAcc} = [];`); node.children.forEach((child) => { instructions = instructions.concat(this.processNode(innerAcc, child)); }); if (node.attributes.length) { let attributes = []; let properties = []; node.attributes.forEach((a) => { const name = a.name; const value = a.value.type === "MustacheStatement" ? mustacheValue(a.value, this.state) : `"${a.value.chars}"`; if (a.name === "class") { properties.push(`"className":${value}`); } else { attributes.push(`"${name}":${value}`); } }); properties.push(`"attributes":{${attributes.join(", ")}}`); const propertiesString = `{${properties.join(", ")}}`; instructions.push( `${parentAcc}.push(virtualDom.h('${node.tag}', ${propertiesString}, ${innerAcc}));` ); } else { instructions.push( `${parentAcc}.push(virtualDom.h('${node.tag}', ${innerAcc}));` ); } break; case "TextNode": return `${parentAcc}.push(${JSON.stringify(node.chars)});`; case "MustacheStatement": const value = mustacheValue(node, this.state); if (value) { instructions.push(`${parentAcc}.push(${value});`); } break; case "BlockStatement": let negate = ""; switch (node.path.original) { case "unless": negate = "!"; // eslint-disable-next-line no-fallthrough case "if": instructions.push( `if (${negate}${resolve(node.params[0].original)}) {` ); node.program.body.forEach((child) => { instructions = instructions.concat( this.processNode(parentAcc, child) ); }); if (node.inverse) { instructions.push(`} else {`); node.inverse.body.forEach((child) => { instructions = instructions.concat( this.processNode(parentAcc, child) ); }); } instructions.push(`}`); break; case "each": const collection = resolve(node.params[0].original); instructions.push(`if (${collection} && ${collection}.length) {`); instructions.push( ` ${collection}.forEach(${node.program.blockParams[0]} => {` ); node.program.body.forEach((child) => { instructions = instructions.concat( this.processNode(parentAcc, child) ); }); instructions.push(` });`); instructions.push("}"); break; } break; default: break; } return instructions.join("\n"); } compile() { return this.processNode("_r", this.ast); } } const defaultGlimmer = require("@glimmer/syntax"); function compile(template, glimmer) { if (!glimmer) { glimmer = defaultGlimmer; } const compiled = glimmer.preprocess(template); const compiler = new Compiler(compiled); let code = compiler.compile(); let imports = ""; Object.keys(compiler.state.helpersUsed).forEach((h) => { let id = compiler.state.helpersUsed[h]; imports += `var __h${id} = __widget_helpers.${h}; `; }); return `function(attrs, state) { ${imports}var _r = [];\n${code}\nreturn _r; }`; } exports.compile = compile; function error(path, state, msg) { const filename = state.file.opts.filename; return path.replaceWithSourceString( `function() { console.error("${filename}: ${msg}"); }` ); } const WidgetHbsCompiler = function (babel) { let t = babel.types; return { visitor: { ImportDeclaration(path, state) { let node = path.node; if ( t.isLiteral(node.source, { value: "discourse/widgets/hbs-compiler" }) ) { let first = node.specifiers && node.specifiers[0]; if (!t.isImportDefaultSpecifier(first)) { let input = state.file.code; let usedImportStatement = input.slice(node.start, node.end); let msg = `Only \`import hbs from 'discourse/widgets/hbs-compiler'\` is supported. You used: \`${usedImportStatement}\``; throw path.buildCodeFrameError(msg); } state.importId = state.importId || path.scope.generateUidIdentifierBasedOnNode(path.node.id); path.scope.rename(first.local.name, state.importId.name); path.remove(); } }, TaggedTemplateExpression(path, state) { if (!state.importId) { return; } let tagPath = path.get("tag"); if (tagPath.node.name !== state.importId.name) { return; } if (path.node.quasi.expressions.length) { return error( path, state, "placeholders inside a tagged template string are not supported" ); } let template = path.node.quasi.quasis .map((quasi) => quasi.value.cooked) .join(""); try { path.replaceWithSourceString( compile(template, WidgetHbsCompiler.glimmer) ); } catch (e) { // eslint-disable-next-line no-console console.error("widget hbs error", e.toString()); return error(path, state, e.toString()); } }, }, }; }; WidgetHbsCompiler.cacheKey = () => "discourse-widget-hbs"; exports.WidgetHbsCompiler = WidgetHbsCompiler;