UNPKG

@ctrl/golang-template

Version:
270 lines (269 loc) 8.65 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); //#region src/template.ts const MAX_REPLACE_INPUT_LENGTH = 1e4; function getOwn(object, key) { if (object == null) return; if (typeof object !== "object" && typeof object !== "function") return; const descriptor = Object.getOwnPropertyDescriptor(object, key); return descriptor != null && "value" in descriptor ? descriptor.value : void 0; } function createScope(value) { const scope = Object.create(null); if (value == null || typeof value !== "object" || Array.isArray(value)) return scope; const descriptors = Object.getOwnPropertyDescriptors(value); for (const [key, descriptor] of Object.entries(descriptors)) if ("value" in descriptor) Object.defineProperty(scope, key, { configurable: true, enumerable: true, value: descriptor.value, writable: true }); return scope; } function get(object, path) { if (path.length === 0) return object; if (path.length === 1) return getOwn(object, path[0]); let current = object; for (const segment of path) { current = getOwn(current, segment); if (current === void 0) return; } return current; } function parsePath(dotPath) { if (!dotPath.startsWith(".")) throw new SyntaxError(`Invalid variable path: ${dotPath}`); return dotPath.slice(1).split(".").filter((s) => s.length > 0); } function parseQuotedArgs(s) { const result = []; let i = 0; while (i < s.length) if (s[i] === "\"") { const end = s.indexOf("\"", i + 1); if (end === -1) throw new SyntaxError("Unterminated quoted string in template tag"); result.push(s.slice(i + 1, end)); i = end + 1; } else i++; return result; } function tokenize(template) { const tokens = []; let pos = 0; while (pos < template.length) { const open = template.indexOf("{{", pos); if (open === -1) { tokens.push({ raw: template.slice(pos) }); break; } if (open > pos) tokens.push({ raw: template.slice(pos, open) }); const close = template.indexOf("}}", open + 2); if (close === -1) throw new SyntaxError(`Unclosed '{{' at position ${open}`); tokens.push({ tag: template.slice(open + 2, close).trim() }); pos = close + 2; } return tokens; } function buildAST(tokens) { const stack = [[]]; const blockTypes = []; const current = () => stack[stack.length - 1]; for (const token of tokens) { if ("raw" in token) { current().push({ type: "text", value: token.raw }); continue; } const tag = token.tag; if (tag === ".") current().push({ type: "dot" }); else if (tag === "else") { const blockType = blockTypes[blockTypes.length - 1]; if (blockType !== "if-true" && blockType !== "with-true") throw new SyntaxError("Unexpected {{ else }}"); stack.pop(); const parent = stack[stack.length - 1]; const node = parent[parent.length - 1]; stack.push(node.falseBranch); blockTypes[blockTypes.length - 1] = blockType === "if-true" ? "if-false" : "with-false"; } else if (tag === "end") { if (stack.length === 1) throw new SyntaxError("Unexpected {{ end }}"); stack.pop(); blockTypes.pop(); } else if (tag.startsWith("if ")) { const rest = tag.slice(3).trim(); let condition; if (rest.startsWith("and ")) condition = { op: "and", paths: rest.slice(4).trim().split(" ").filter((s) => s.length > 0).map(parsePath) }; else if (rest.startsWith("or ")) condition = { op: "or", paths: rest.slice(3).trim().split(" ").filter((s) => s.length > 0).map(parsePath) }; else if (rest.startsWith("not ")) condition = { op: "not", path: parsePath(rest.slice(4).trim()) }; else condition = { op: "var", path: parsePath(rest) }; const ifNode = { type: "if", condition, trueBranch: [], falseBranch: [] }; current().push(ifNode); stack.push(ifNode.trueBranch); blockTypes.push("if-true"); } else if (tag.startsWith("with ")) { const withNode = { type: "with", path: parsePath(tag.slice(5).trim()), trueBranch: [], falseBranch: [] }; current().push(withNode); stack.push(withNode.trueBranch); blockTypes.push("with-true"); } else if (tag.startsWith("range ")) { const rangeNode = { type: "range", path: parsePath(tag.slice(6).trim()), body: [] }; current().push(rangeNode); stack.push(rangeNode.body); blockTypes.push("range"); } else if (tag.startsWith("join ")) { const rest = tag.slice(5); const spaceIdx = rest.indexOf(" "); const path = parsePath(rest.slice(0, spaceIdx)); const delimiter = rest.slice(spaceIdx + 1).trim().slice(1, -1); current().push({ type: "join", path, delimiter }); } else if (tag.startsWith("index ")) { const rest = tag.slice(6); const spaceIdx = rest.indexOf(" "); const path = parsePath(rest.slice(0, spaceIdx)); const keyStr = rest.slice(spaceIdx + 1).trim(); const key = keyStr.startsWith("\"") ? keyStr.slice(1, -1) : Number.parseInt(keyStr, 10); if (typeof key === "number" && Number.isNaN(key)) throw new SyntaxError(`Invalid index key: ${keyStr}`); current().push({ type: "index", path, key }); } else if (tag.startsWith("re_replace ")) { const rest = tag.slice(11); const spaceIdx = rest.indexOf(" "); const path = parsePath(rest.slice(0, spaceIdx)); const args = parseQuotedArgs(rest.slice(spaceIdx + 1).trim()); if (args.length !== 2) throw new SyntaxError(`re_replace requires two quoted arguments (pattern and replacement), got ${args.length}`); current().push({ type: "re_replace", path, pattern: new RegExp(args[0], "g"), replacement: args[1] }); } else if (tag.startsWith(".")) current().push({ type: "var", path: parsePath(tag) }); else throw new SyntaxError(`Unknown template tag: {{ ${tag} }}`); } if (stack.length !== 1) throw new SyntaxError("Unclosed template block (missing {{ end }})"); return stack[0]; } function isTruthy(value) { if (value == null) return false; if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value === "string") return value.length > 0; if (Array.isArray(value)) return value.length > 0; return true; } function evalCondition(cond, vars) { switch (cond.op) { case "var": return isTruthy(get(vars, cond.path)); case "and": return cond.paths.every((path) => isTruthy(get(vars, path))); case "or": return cond.paths.some((path) => isTruthy(get(vars, path))); case "not": return !isTruthy(get(vars, cond.path)); } } function asArray(value) { return Array.isArray(value) ? value : []; } function asObjectScope(value) { return createScope(value); } function renderNodes(nodes, vars, context) { let out = ""; for (const node of nodes) switch (node.type) { case "text": out += node.value; break; case "dot": out += context == null ? "" : String(context); break; case "var": { const val = get(vars, node.path); out += val == null ? "" : String(val); break; } case "if": out += renderNodes(evalCondition(node.condition, vars) ? node.trueBranch : node.falseBranch, vars, context); break; case "with": { const val = get(vars, node.path); if (isTruthy(val)) { const innerVars = val != null && typeof val === "object" ? createScope(val) : vars; out += renderNodes(node.trueBranch, innerVars, val); } else out += renderNodes(node.falseBranch, vars, context); break; } case "range": { const arr = asArray(get(vars, node.path)); for (const item of arr) out += renderNodes(node.body, asObjectScope(item), item); break; } case "join": out += asArray(get(vars, node.path)).join(node.delimiter); break; case "index": { const val = getOwn(get(vars, node.path), node.key); out += val == null ? "" : String(val); break; } case "re_replace": { const val = get(vars, node.path); const str = val == null ? "" : String(val); if (str.length > MAX_REPLACE_INPUT_LENGTH) throw new RangeError(`re_replace input is too long; maximum length is ${MAX_REPLACE_INPUT_LENGTH}`); out += str.replaceAll(node.pattern, node.replacement); break; } } return out; } function render(nodes, vars, context) { return renderNodes(nodes, createScope(vars), context); } //#endregion //#region src/index.ts function compile(template) { const ast = buildAST(tokenize(template)); return { render: (vars) => render(ast, vars) }; } /** * Parse template and insert variables * @param str golang style template * @param variables object of variables to insert */ function parse(str, variables) { return compile(str).render(variables); } //#endregion exports.compile = compile; exports.parse = parse;