Skip to content
8 changes: 5 additions & 3 deletions packages/jinja/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class Template {
this.parsed = parse(tokens);
}

render(items: Record<string, unknown>): string {
render(items?: Record<string, unknown>): string {
// Create a new environment for this template
const env = new Environment();

Expand All @@ -44,8 +44,10 @@ export class Template {
env.set("range", range);

// Add user-defined variables
for (const [key, value] of Object.entries(items)) {
env.set(key, value);
if (items) {
for (const [key, value] of Object.entries(items)) {
env.set(key, value);
}
}

const interpreter = new Interpreter(env);
Expand Down
16 changes: 8 additions & 8 deletions packages/jinja/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ export function parse(tokens: Token[]): Program {
function parseCallMemberExpression(): Statement {
// Handle member expressions recursively

const member = parseMemberExpression(); // foo.x
const member = parseMemberExpression(parsePrimaryExpression()); // foo.x

if (is(TOKEN_TYPES.OpenParen)) {
// foo.x()
Expand All @@ -352,15 +352,17 @@ export function parse(tokens: Token[]): Program {
return member;
}

function parseCallExpression(callee: Statement): CallExpression {
let callExpression = new CallExpression(callee, parseArgs());
function parseCallExpression(callee: Statement): Statement {
let expression: Statement = new CallExpression(callee, parseArgs());

expression = parseMemberExpression(expression); // foo.x().y

if (is(TOKEN_TYPES.OpenParen)) {
// foo.x()()
callExpression = parseCallExpression(callExpression);
expression = parseCallExpression(expression);
}

return callExpression;
return expression;
}

function parseArgs(): Statement[] {
Expand Down Expand Up @@ -433,9 +435,7 @@ export function parse(tokens: Token[]): Program {
return slices[0] as Statement; // normal member expression
}

function parseMemberExpression(): Statement {
let object = parsePrimaryExpression();

function parseMemberExpression(object: Statement): Statement {
while (is(TOKEN_TYPES.Dot) || is(TOKEN_TYPES.OpenSquareBracket)) {
const operator = tokens[current]; // . or [
++current;
Expand Down
63 changes: 63 additions & 0 deletions packages/jinja/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,48 @@ export class StringValue extends RuntimeValue<string> {
return new StringValue(this.value.trimStart());
}),
],
[
"split",
// follows Python's `str.split(sep=None, maxsplit=-1)` function behavior
// https://docs.python.org/3.13/library/stdtypes.html#str.split
new FunctionValue((args) => {
const sep = args[0] ?? new NullValue();
if (!(sep instanceof StringValue || sep instanceof NullValue)) {
throw new Error("sep argument must be a string or null");
}
const maxsplit = args[1] ?? new NumericValue(-1);
if (!(maxsplit instanceof NumericValue)) {
throw new Error("maxsplit argument must be a number");
}

let result = [];
if (sep instanceof NullValue) {
// If sep is not specified or is None, runs of consecutive whitespace are regarded as a single separator, and the
// result will contain no empty strings at the start or end if the string has leading or trailing whitespace.
// Trailing whitespace may be present when maxsplit is specified and there aren't sufficient matches in the string.
const text = this.value.trimStart();
for (const { 0: match, index } of text.matchAll(/\S+/g)) {
if (maxsplit.value !== -1 && result.length >= maxsplit.value && index !== undefined) {
result.push(match + text.slice(index + match.length));
break;
}
result.push(match);
}
} else {
// If sep is specified, consecutive delimiters are not grouped together and are deemed to delimit empty strings.
if (sep.value === "") {
throw new Error("empty separator");
}
result = this.value.split(sep.value);
if (maxsplit.value !== -1 && result.length > maxsplit.value) {
// Follow Python's behavior: If maxsplit is given, at most maxsplit splits are done,
// with any remaining text returned as the final element of the list.
result.push(result.splice(maxsplit.value).join(sep.value));
}
}
return new ArrayValue(result.map((part) => new StringValue(part)));
}),
],
]);
}

Expand Down Expand Up @@ -543,6 +585,8 @@ export class Interpreter {
}
})
);
case "join":
return new StringValue(operand.value.map((x) => x.value).join(""));
default:
throw new Error(`Unknown ArrayValue filter: ${filter.value}`);
}
Expand Down Expand Up @@ -570,6 +614,7 @@ export class Interpreter {
)
.join("\n")
);
case "join":
case "string":
return operand; // no-op
default:
Expand Down Expand Up @@ -610,6 +655,24 @@ export class Interpreter {
throw new Error("If set, indent must be a number");
}
return new StringValue(toJSON(operand, indent.value));
} else if (filterName === "join") {
let value;
if (operand instanceof StringValue) {
// NOTE: string.split('') breaks for unicode characters
value = Array.from(operand.value);
} else if (operand instanceof ArrayValue) {
value = operand.value.map((x) => x.value);
} else {
throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`);
}
const [args, kwargs] = this.evaluateArguments(filter.args, environment);

const separator = args.at(0) ?? kwargs.get("separator") ?? new StringValue("");
if (!(separator instanceof StringValue)) {
throw new Error("separator must be a string");
}

return new StringValue(value.join(separator.value));
}

if (operand instanceof ArrayValue) {
Expand Down
14 changes: 14 additions & 0 deletions packages/jinja/test/e2e.test.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading