@ctrl/golang-template
Version:
Basic golang template parsing in js
268 lines (267 loc) • 8.55 kB
JavaScript
//#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
export { compile, parse };