Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion packages/jinja/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,88 @@ export class ObjectValue extends RuntimeValue<Map<string, AnyRuntimeValue>> {
["items", new FunctionValue(() => this.items())],
["keys", new FunctionValue(() => this.keys())],
["values", new FunctionValue(() => this.values())],
[
"dictsort",
new FunctionValue((args) => {
// https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.dictsort
// Sort a dictionary and yield (key, value) pairs.
// Parameters:
// - case_sensitive: Sort in a case-sensitive manner (default: false)
// - by: Sort by 'key' or 'value' (default: 'key')
// - reverse: Reverse the sort order (default: false)

// Extract keyword arguments if present
let kwargs = new Map<string, AnyRuntimeValue>();
const positionalArgs = args.filter((arg) => {
if (arg instanceof KeywordArgumentsValue) {
kwargs = arg.value;
return false;
}
return true;
});

const caseSensitive = positionalArgs.at(0) ?? kwargs.get("case_sensitive") ?? new BooleanValue(false);
if (!(caseSensitive instanceof BooleanValue)) {
throw new Error("case_sensitive must be a boolean");
}

const by = positionalArgs.at(1) ?? kwargs.get("by") ?? new StringValue("key");
if (!(by instanceof StringValue)) {
throw new Error("by must be a string");
}
if (by.value !== "key" && by.value !== "value") {
throw new Error("by must be either 'key' or 'value'");
}

const reverse = positionalArgs.at(2) ?? kwargs.get("reverse") ?? new BooleanValue(false);
if (!(reverse instanceof BooleanValue)) {
throw new Error("reverse must be a boolean");
}

// Convert to array of [key, value] pairs
const items = Array.from(this.value.entries()).map(
([key, value]) => new ArrayValue([new StringValue(key), value])
);

// Sort the items
items.sort((a, b) => {
const index = by.value === "key" ? 0 : 1;
const aItem = a.value[index];
const bItem = b.value[index];

let aValue: unknown = aItem.value;
let bValue: unknown = bItem.value;

// Handle null/undefined values - put them at the end
if (aValue == null && bValue == null) return 0;
if (aValue == null) return reverse.value ? -1 : 1;
if (bValue == null) return reverse.value ? 1 : -1;

// For case-insensitive string comparison
if (!caseSensitive.value && typeof aValue === "string" && typeof bValue === "string") {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}

// Compare values
// Note: This assumes comparable types (string, number, boolean).
// Mixed types (e.g., string vs number) will use JavaScript's default comparison,
// which matches Jinja's behavior. Complex types (objects, arrays) are not typically
// used as dictionary values in Jinja templates and may produce undefined results.
const a1 = aValue as string | number | boolean;
const b1 = bValue as string | number | boolean;

if (a1 < b1) {
return reverse.value ? 1 : -1;
} else if (a1 > b1) {
return reverse.value ? -1 : 1;
}
return 0;
});

return new ArrayValue(items);
}),
],
]);

items(): ArrayValue {
Expand Down Expand Up @@ -818,8 +900,17 @@ export class Interpreter {
);
case "length":
return new IntegerValue(operand.value.size);
default:
default: {
// Check if the filter exists in builtins
const builtin = operand.builtins.get(filter.value);
if (builtin) {
if (builtin instanceof FunctionValue) {
return builtin.value([], environment);
}
return builtin;
}
throw new Error(`Unknown ObjectValue filter: ${filter.value}`);
}
}
} else if (operand instanceof BooleanValue) {
switch (filter.value) {
Expand Down Expand Up @@ -996,6 +1087,18 @@ export class Interpreter {
}
}
throw new Error(`Unknown StringValue filter: ${filterName}`);
} else if (operand instanceof ObjectValue) {
// Check if the filter exists in builtins for ObjectValue
const builtin = operand.builtins.get(filterName);
if (builtin && builtin instanceof FunctionValue) {
const [args, kwargs] = this.evaluateArguments(filter.args, environment);
// Pass keyword arguments as the last argument if present
if (kwargs.size > 0) {
args.push(new KeywordArgumentsValue(kwargs));
}
return builtin.value(args, environment);
}
throw new Error(`Unknown ObjectValue filter: ${filterName}`);
} else {
throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`);
}
Expand Down
192 changes: 192 additions & 0 deletions packages/jinja/test/templates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ const TEST_STRINGS = {
FILTER_OPERATOR_15: `|{{ "abcabcabc" | replace("a", "b") }}|{{ "abcabcabc" | replace("a", "b", 1) }}|{{ "abcabcabc" | replace("a", "b", count=1) }}|`,
FILTER_OPERATOR_16: `|{{ undefined | default("hello") }}|{{ false | default("hello") }}|{{ false | default("hello", true) }}|{{ 0 | default("hello", boolean=true) }}|`,
FILTER_OPERATOR_17: `{{ [1, 2, 1, -1, 2] | unique | list | length }}`,
FILTER_OPERATOR_DICTSORT_1: `{% for key, value in mydict | dictsort %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_2: `{% for key, value in mydict | dictsort(by='value') %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_3: `{% for key, value in mydict | dictsort(reverse=true) %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_4: `{% for key, value in casedict | dictsort %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_5: `{% for key, value in casedict | dictsort(case_sensitive=true) %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_6: `{% for key, value in numdict | dictsort(by='value', reverse=true) %}{{ key }}:{{ value }},{% endfor %}`,

// Filter statements
FILTER_STATEMENTS: `{% filter upper %}text{% endfilter %}`,
Expand Down Expand Up @@ -2389,6 +2395,168 @@ const TEST_PARSED = {
{ value: "length", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
],
FILTER_OPERATOR_DICTSORT_1: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "mydict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_2: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "mydict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "(", type: "OpenParen" },
{ value: "by", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "value", type: "StringLiteral" },
{ value: ")", type: "CloseParen" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_3: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "mydict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "(", type: "OpenParen" },
{ value: "reverse", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "true", type: "Identifier" },
{ value: ")", type: "CloseParen" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_4: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "casedict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_5: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "casedict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "(", type: "OpenParen" },
{ value: "case_sensitive", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "true", type: "Identifier" },
{ value: ")", type: "CloseParen" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_6: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "numdict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "(", type: "OpenParen" },
{ value: "by", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "value", type: "StringLiteral" },
{ value: ",", type: "Comma" },
{ value: "reverse", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "true", type: "Identifier" },
{ value: ")", type: "CloseParen" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],

// Filter statements
FILTER_STATEMENTS: [
Expand Down Expand Up @@ -4020,6 +4188,24 @@ const TEST_CONTEXT = {
FILTER_OPERATOR_15: {},
FILTER_OPERATOR_16: {},
FILTER_OPERATOR_17: {},
FILTER_OPERATOR_DICTSORT_1: {
mydict: { c: 3, a: 1, b: 2 },
},
FILTER_OPERATOR_DICTSORT_2: {
mydict: { c: 3, a: 1, b: 2 },
},
FILTER_OPERATOR_DICTSORT_3: {
mydict: { c: 3, a: 1, b: 2 },
},
FILTER_OPERATOR_DICTSORT_4: {
casedict: { B: 2, a: 1, C: 3 },
},
FILTER_OPERATOR_DICTSORT_5: {
casedict: { B: 2, a: 1, C: 3 },
},
FILTER_OPERATOR_DICTSORT_6: {
numdict: { apple: 5, banana: 2, cherry: 8 },
},

// Filter statements
FILTER_STATEMENTS: {},
Expand Down Expand Up @@ -4236,6 +4422,12 @@ const EXPECTED_OUTPUTS = {
FILTER_OPERATOR_15: `|bbcbbcbbc|bbcabcabc|bbcabcabc|`,
FILTER_OPERATOR_16: `|hello|false|hello|hello|`,
FILTER_OPERATOR_17: `3`,
FILTER_OPERATOR_DICTSORT_1: `a:1,b:2,c:3,`,
FILTER_OPERATOR_DICTSORT_2: `a:1,b:2,c:3,`,
FILTER_OPERATOR_DICTSORT_3: `c:3,b:2,a:1,`,
FILTER_OPERATOR_DICTSORT_4: `a:1,B:2,C:3,`,
FILTER_OPERATOR_DICTSORT_5: `B:2,C:3,a:1,`,
FILTER_OPERATOR_DICTSORT_6: `cherry:8,apple:5,banana:2,`,

// Filter statements
FILTER_STATEMENTS: `TEXT`,
Expand Down