/// // @ts-check /** * The whitespace between two parts of a tag. Django splits a tag's contents on * whitespace, so a separator is part of the syntax and not something to skip: * "{% cycle 1as x %}" is the single token "1as", never "1" followed by "as". * Leaving it out of `extras` is what lets the grammar say so. * * A newline separates parts like any other whitespace, since Django matches a * tag with re.DOTALL and splits its contents on whitespace of any kind. The * scanner skips the same characters when it matches a name of its own. */ const SEP = /[ \t\r\n]+/; /** * Parts of a tag, each separated from the one before it by whitespace. * @param {RuleOrLiteral} first * @param {...RuleOrLiteral} rest * @returns {RuleOrLiteral} */ function joined(first, ...rest) { return rest.length === 0 ? first : seq(first, ...rest.flatMap((arg) => [SEP, arg])); } /** * One more part of a tag, taking the whitespace that separates it from the * part before it. * @param {RuleOrLiteral} first * @param {...RuleOrLiteral} rest * @returns {SeqRule} */ function part(first, ...rest) { return seq(SEP, joined(first, ...rest)); } /** * A tag, which may hug its delimiters ("{%cycle 1%}") or not. * @param {RuleOrLiteral} tag * @param {...RuleOrLiteral} args * @returns {SeqRule} */ function block(tag, ...args) { return seq( "{%", optional(SEP), field("tag", tag), ...args, optional(SEP), "%}", ); } /** * The "as name" clause that binds a tag's result in the context. Django spells * it the same way in every tag that takes one, so the grammar names the target * the same way too, matching the bindings in "for" and "with". * @param {GrammarSymbols} $ * @returns {SeqRule} */ function asVariable($) { return part("as", field("variable", $.identifier)); } /** * A tag's own keyword written where a value goes, with the tree an ordinary * lookup would have so that a tool sees no difference. Where a keyword and an * identifier are both valid, tree-sitter hands the parser the keyword, so the * reading in which the word names a variable has to be built back out of it — * and out of hidden rules, because an alias only renames the node its rule * makes and cannot nest one inside another. * * Django tells such a keyword from a variable by comparing the whole bit, so * "reversed" is its flag while "reversed.x" and "reversed|last" are lookups. * That second group is what `continued` selects. * * @param {string} word * @returns {Record>} */ function wordAsValueRules(word) { return { [`_${word}_lookup`]: ($) => alias(word, $.identifier), [`_${word}_dotted_lookup`]: ($) => seq(alias(word, $.identifier), ".", $.attribute), [`_${word}_value`]: ($) => alias($[`_${word}_lookup`], $.variable_attribute), [`_${word}_dotted_value`]: ($) => alias($[`_${word}_dotted_lookup`], $.variable_attribute), [`_${word}_bare`]: ($) => alias($[`_${word}_value`], $.value), [`_${word}_continued`]: ($) => choice( seq( alias($[`_${word}_dotted_value`], $.value), optional(seq("|", $.filter_expression)), ), seq(alias($[`_${word}_value`], $.value), "|", $.filter_expression), ), }; } /** * The rules wordAsValueRules() built, where a value goes. * @param {GrammarSymbols} $ * @param {string} word * @param {boolean} continued whether a "." or a "|" has to follow the word * @returns {RuleOrLiteral} */ function wordAsValue($, word, continued) { return alias( $[`_${word}_${continued ? "continued" : "bare"}`], $.filtered_value, ); } /** * @param {RuleOrLiteral[]} chosen * @param {RuleOrLiteral[]} rest * @param {RuleOrLiteral[]} spellings */ function walk(chosen, rest, spellings) { if (chosen.length > 0) { spellings.push(chosen.length === 1 ? chosen[0] : seq(...chosen)); } rest.forEach((item, i) => walk( [...chosen, item], rest.filter((_, j) => j !== i), spellings, ), ); } /** * Every way of writing some of `items` in any order, using each at most once, * which is how a tag's keyword arguments reach parse_bits(). There is no * interleaving operator to say that directly, so the orderings are spelled * out; a signature names a handful of arguments at most, and one that takes * more takes them as **kwargs instead. * @param {RuleOrLiteral[]} items * @returns {ChoiceRule|RuleOrLiteral} */ function arrangements(items) { if (items.length >= 5) { throw new Error(`Too many items for arrangements: ${items.length}`); } /** * @type {RuleOrLiteral[]} */ const spellings = []; walk([], items, spellings); return spellings.length === 1 ? spellings[0] : choice(...spellings); } /** * A tag registered with Django's simple_tag helper: positional filter * expressions first, then "name=value" keyword arguments, then the "as name" * clause that Library.simple_tag gives every such tag. The helper takes the * whitespace between the parts itself, so a caller only describes what the * tag's Python signature accepts. * * Django checks the rest of the signature when it parses the tag, so what is * spelled here is what parse_bits() would allow: too many arguments, an * unknown keyword or a repeated one are all syntax errors there. * * @param {GrammarSymbols} $ * @param {boolean|number} args how many positional arguments the signature * takes: true for any number (*args), a count for at most that many, or * false for none * @param {boolean|Record} kwargs true for any * "name=value" pair (**kwargs), false for none, or the accepted names mapped * to their value rules, each of which may be given at most once * @param {RuleOrLiteral} tag * @returns {SeqRule} */ function simpleTag($, tag, args = true, kwargs = true) { const parts = []; if (args === true) { parts.push(repeat(part($.filtered_value))); } else if (typeof args === "number" && args > 0) { // at most `args` of them, so each further one nests inside the last let rule = part($.filtered_value); for (let i = 1; i < args; ++i) { rule = seq(part($.filtered_value), optional(rule)); } parts.push(optional(rule)); } if (kwargs === true) { // parse_bits refuses a keyword argument the tag already holds, whatever // **kwargs it takes, so the scanner keeps the names rather than the grammar parts.unshift($._tag_open); parts.push( repeat(part(seq($._kwarg_name, $.identifier, "=", $.filtered_value))), ); } else if (kwargs) { parts.unshift($._tag_open); // a known signature: any of the names it accepts, in any order, none twice const named = Object.entries(kwargs).map(([name, value]) => part(seq($._kwarg_name, name, "=", value)), ); parts.push(repeat(choice(...named))); } return block(tag, ...parts, optional(asVariable($))); } /** * Django checks a filter's argument count against the registered function * while it parses the template, so the arity of a builtin is syntax here: * "length:2" and a bare "add" are both errors. */ const FILTERS_WITHOUT_ARGUMENT = [ "addslashes", "capfirst", "escape", "escapejs", "escapeseq", "filesizeformat", "first", "force_escape", "iriencode", "last", "length", "linebreaks", "linebreaksbr", "linenumbers", "lower", "make_list", "phone2numeric", "pprint", "random", "safe", "safeseq", "slugify", "striptags", "title", "unordered_list", "upper", "urlize", "wordcount", ]; /** Builtin filters that require an argument. */ const FILTERS_WITH_ARGUMENT = [ "add", "center", "cut", "default", "default_if_none", "dictsort", "dictsortreversed", "divisibleby", "get_digit", "join", "ljust", "rjust", "slice", "stringformat", "truncatechars", "truncatechars_html", "truncatewords", "truncatewords_html", "urlizetrunc", "wordwrap", ]; /** Builtin filters that take an argument or no argument. */ const FILTERS_WITH_OPTIONAL_ARGUMENT = [ "date", "floatformat", "json_script", "pluralize", "time", "timesince", "timeuntil", "urlencode", "yesno", ]; /** * django.templatetags.l10n. A library's filters need the same arity treatment * as a builtin's, but are kept apart from them so that the builtin parity * check has a list to compare against. queries/libraries.scm records which * library a name came from. */ const L10N_FILTERS_WITHOUT_ARGUMENT = ["localize", "unlocalize"]; /** django.templatetags.tz. */ const TZ_FILTERS_WITHOUT_ARGUMENT = ["localtime", "utc"]; const TZ_FILTERS_WITH_ARGUMENT = ["timezone"]; /** django.templatetags.i18n. */ const I18N_FILTERS_WITHOUT_ARGUMENT = [ "language_bidi", "language_name", "language_name_local", "language_name_translated", ]; /** * The words that head an end tag or a part of a tag group. Each is a token * only inside the group it belongs to, so anywhere else the lexer reads it as * an identifier and custom_tag accepts it: "{% endif %}" on its own parsed as * a tag some library registered. Reserving them where a tag is named makes * them an error again, while leaving them usable as ordinary words elsewhere * ("{{ endif }}" is a variable, and "{% mytag endif %}" an argument). */ const NAMES_INSIDE_A_TAG_GROUP = [ "elif", "else", "empty", "endautoescape", "endblock", "endblocktrans", "endblocktranslate", "endcache", "endcomment", "endfilter", "endfor", "endif", "endifchanged", "endlanguage", "endlocalize", "endlocaltime", "endpartialdef", "endspaceless", "endtimezone", "endverbatim", "endwith", "plural", ]; /** Django's string constant, which takes any escape but a line break. */ const STRING = /"(?:[^"\\]|\\[^\n])*"|'(?:[^'\\]|\\[^\n])*'/; const django = grammar({ name: "django", extras: ($) => [], word: ($) => $.identifier, // a separator can end a part or start the next one, which only the token // after it settles conflicts: ($) => [ [$.template], [$.autoescape_clause], [$.block_clause], [$.cache_clause], [$.elif_clause], [$.else_clause], [$.empty_clause], [$.filter_clause], [$.for_clause], [$.if_clause], [$.ifchanged_clause], [$.language_clause], [$.localize_clause], [$.localtime_clause], [$.partialdef_clause], [$.spaceless_clause], [$.timezone_clause], [$.with_clause], [$.predicate], [$.binaryOperator], [$.library, $.load], [$._translate_option], [$._filtered_value_spaced], [$._filter_expression_spaced], ], supertypes: ($) => [$.template_tag, $.template_block_groups], externals: ($) => [ $.matcher_error, $.pop_block, $.pop_partial, $.pop_verbatim, $.push_block, $.push_partial, $.push_verbatim, $.verbatim_content, $.comment_content, $._tag_open, $._bt_option, $._kwarg_name, ], reserved: { global: ($) => ["not", "if", "in", "is", "as", "for", "from"], tag_name: ($) => NAMES_INSIDE_A_TAG_GROUP, }, rules: { template: ($) => repeat1(choice($.template_tag, $.content)), content: ($) => /(?:[^\{]|\{[^\{#%}])+/, template_tag: ($) => choice($.template_block_groups, $.template_variable, $.template_comment), filtered_value: ($) => seq($.value, optional(seq("|", $.filter_expression))), filter_expression: ($) => seq($.filter, repeat(seq("|", $.filter))), value: ($) => choice($.literal, $.variable_attribute), literal: ($) => choice($.number, $.string, $.translated_string, $.boolean, $.none), /** * Digits, with single underscores allowed between them, as Python's int() * and float() accept ("1_000") and Django's Variable relies on. * * A trailing dot needs an exponent after it. Variable calls float() and * then rejects what it just parsed if the last character is a dot — the * comment in Django reads `# "2." is invalid` — so "1." is a two-part * lookup there rather than a number, while "1.e5" is a float. */ number: ($) => /[-+]?(?:(?:[0-9](?:_?[0-9])*(?:\.[0-9](?:_?[0-9])*)?|\.[0-9](?:_?[0-9])*)(?:[eE][0-9](?:_?[0-9])*)?|[0-9](?:_?[0-9])*\.[eE][0-9](?:_?[0-9])*)/, string: ($) => STRING, /** * A string to translate at render time. Django matches "_(" and ")" as * part of the constant itself, so nothing may come between them and the * string: "_( 'a' )" is a syntax error there, not a translated literal. */ translated_string: ($) => seq("_(", $.string, ")"), // the flags of "for" and "trans", spelled as the variable names they // also are; see wordAsValueRules() ...wordAsValueRules("reversed"), ...wordAsValueRules("noop"), boolean: ($) => choice("True", "False"), none: ($) => "None", attribute: ($) => /[a-zA-Z0-9][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9][a-zA-Z0-9_]*)*/, variable_attribute: ($) => seq($.identifier, optional(seq(".", $.attribute))), identifier: ($) => /[a-zA-Z][a-zA-Z0-9_]*/, binaryOperator: ($) => choice( "and", "or", "==", "!=", "<", ">", "<=", ">=", "in", prec.dynamic(1, joined("not", "in")), "is", prec.dynamic(1, joined("is", "not")), ), predicate: ($) => seq( repeat(seq("not", SEP)), $.filtered_value, repeat( seq( part($.binaryOperator), repeat(part("not")), part($.filtered_value), ), ), ), template_variable: ($) => seq( "{{", optional(SEP), alias($._filtered_value_spaced, $.filtered_value), optional(SEP), "}}", ), /** * Everything between "{{" and "}}" reaches Django as a single expression, * so whitespace may surround a pipe there. Inside a tag it may not: the * contents are split on whitespace before each argument is parsed, which * would leave a bare "|" as an argument of its own. */ _filtered_value_spaced: ($) => seq( $.value, optional( seq( optional(SEP), "|", optional(SEP), alias($._filter_expression_spaced, $.filter_expression), ), ), ), _filter_expression_spaced: ($) => seq($.filter, repeat(seq(optional(SEP), "|", optional(SEP), $.filter))), template_comment: ($) => seq("{#", /(?:[^#]|#[^}])*/, "#}"), template_block_groups: ($) => choice( $.autoescape_group, $.block_group, $.blocktranslate_group, $.cache_group, $.comment_group, $.csp_nonce_attr, $.csrf_token, $.custom_tag, $.cycle, $.debug, $.extends, $.filter_group, $.firstof, $.for_group, $.get_available_languages, $.get_current_language, $.get_current_language_bidi, $.get_current_timezone, $.get_language_info, $.get_language_info_list, $.get_media_prefix, $.get_static_prefix, $.if_group, $.ifchanged_group, $.include, $.language_group, $.load, $.localize_group, $.localtime_group, $.lorem, $.now, $.partial, $.partialdef_group, $.query_string, $.regroup, $.reset_cycle, $.spaceless_group, $.static, $.template_tag_block, $.timezone_group, $.translate, $.url_block, $.verbatim_group, $.width_ratio, $.with_group, ), /** * A filter is a name and, after a colon, at most one argument. The name is * a token of its own rather than part of a "name:" token so that Django's * builtins are keyword-extracted: a builtin written with the wrong arity * then cannot be read as a filter from a loaded library instead. */ filter: ($) => choice( field( "name", choice( ...FILTERS_WITHOUT_ARGUMENT, ...I18N_FILTERS_WITHOUT_ARGUMENT, ...L10N_FILTERS_WITHOUT_ARGUMENT, ...TZ_FILTERS_WITHOUT_ARGUMENT, ), ), seq( field( "name", choice(...FILTERS_WITH_ARGUMENT, ...TZ_FILTERS_WITH_ARGUMENT), ), ":", field("argument", $.value), ), seq( field("name", choice(...FILTERS_WITH_OPTIONAL_ARGUMENT)), optional(seq(":", field("argument", $.value))), ), $._custom_filter, ), /** * A filter a "load" brought in: its name is not known here, and so neither * is whether it takes an argument. Hidden, so the tree is the same as if * it were spelled inline, but named so that a grammar extending this one * can drop it from filter's alternatives the way it can drop custom_tag * from template_block_groups. */ _custom_filter: ($) => seq( field("name", $.identifier), optional(seq(":", field("argument", $.value))), ), autoescape_clause: ($) => seq(block("autoescape", part(choice("on", "off"))), optional($.template)), autoescape_group: ($) => seq($.autoescape_clause, block("endautoescape")), // the scanner takes the whitespace before a name it has to match itself block_clause: ($) => seq(block("block", field("name", $.push_block)), optional($.template)), block_group: ($) => seq($.block_clause, block("endblock", $.pop_block)), blocktranslate_group: ($) => choice( ...["blocktrans", "blocktranslate"].map((name) => choice( seq( block(name, $._tag_open, repeat($._translate_option)), optional($._translate_body), block("end" + name), ), seq( block( name, $._tag_open, repeat($._translate_option), $._translate_count, repeat($._translate_option), ), $._translate_body, block("plural"), optional($._translate_body), block("end" + name), ), ), ), ), /** * The scanner reads the name of the option before the grammar does, so it * can refuse one the tag has already been given: do_block_translate keeps * the options it has seen in a dict and raises on a repeat. */ _translate_option: ($) => seq( $._bt_option, choice( seq(part("with"), $._tag_kwargs), part("context", $.filtered_value), part("trimmed"), // asvar names where the rendered text is stored, which is read // after the block rather than inside it, so it is not a binding of // the block the way "with" and "count" are part("asvar", field("asvar", $.identifier)), ), ), _translate_count: ($) => seq( $._bt_option, part( "count", seq(field("variable", $.identifier), "=", $.filtered_value), ), ), _translate_body: ($) => repeat1(choice($.content, $.template_variable)), /** * The "with" option of blocktranslate and of include takes at least one of * these. Django lets a name repeat — token_kwargs builds a dict and the * later value simply wins — but writing one twice has no use and is taken * here to be a mistake, so the scanner guards these too. */ _tag_kwargs: ($) => repeat1( part( seq( $._kwarg_name, field("variable", $.identifier), "=", $.filtered_value, ), ), ), cache_clause: ($) => seq( block( "cache", part($.filtered_value), part(choice($.identifier, $.string)), repeat(part($.filtered_value)), optional(part(seq("using=", $.filtered_value))), ), optional($.template), ), cache_group: ($) => seq($.cache_clause, block("endcache")), comment_group: ($) => seq( block("comment", optional(part($.string))), optional($.comment_content), block("endcomment"), ), // csp_nonce_attr(context, media=None) csp_nonce_attr: ($) => simpleTag($, "csp_nonce_attr", 1, { media: $.filtered_value }), csrf_token: ($) => block("csrf_token"), /** * A tag a "load" brought in. Its arguments cannot be known here, so what * is accepted is what Library.simple_tag and Library.inclusion_tag take, * which is how a tag is registered unless it needs the parser itself. */ custom_tag: ($) => simpleTag($, reserved("tag_name", $.identifier)), cycle: ($) => block( "cycle", repeat1(part($.filtered_value)), optional( seq( asVariable($), // only the as-form takes the flag optional(part("silent")), ), ), ), debug: ($) => block("debug"), extends: ($) => block("extends", part($.filtered_value)), filter_clause: ($) => seq( // the filter tag hands Django the rest of its text as one expression // rather than as split arguments, so pipes here take whitespace just // as they do between "{{" and "}}" block( "filter", part(alias($._filter_expression_spaced, $.filter_expression)), ), optional($.template), ), filter_group: ($) => seq($.filter_clause, block("endfilter")), firstof: ($) => block( "firstof", repeat1(part($.filtered_value)), optional(asVariable($)), ), for_clause: ($) => seq( block( "for", part(field("variable", $.identifier)), repeat( seq( optional(SEP), ",", optional(SEP), field("variable", $.identifier), ), ), part("in"), // do_for reads the flag off the end and takes the sequence from the // bit before it, so the flag comes last or not at all. A bare // "reversed" in the sequence position leaves nothing to iterate, // naming a sequence only when a lookup or filter continues it or // when the flag follows it as well choice( seq(part($.filtered_value), optional(part("reversed"))), seq( part(wordAsValue($, "reversed", true)), optional(part("reversed")), ), seq(part(wordAsValue($, "reversed", false)), part("reversed")), ), ), optional($.template), ), empty_clause: ($) => seq(block("empty"), optional($.template)), for_group: ($) => seq($.for_clause, optional($.empty_clause), block("endfor")), /** * django.templatetags.i18n. Each of these requires exactly "as ", * or "for as ", and rejects anything further. */ get_available_languages: ($) => block("get_available_languages", asVariable($)), get_current_language: ($) => block("get_current_language", asVariable($)), get_current_language_bidi: ($) => block("get_current_language_bidi", asVariable($)), get_language_info: ($) => block("get_language_info", part("for", $.filtered_value), asVariable($)), get_language_info_list: ($) => block( "get_language_info_list", part("for", $.filtered_value), asVariable($), ), /** * django.templatetags.tz. get_current_timezone_tag requires exactly * "as ", so unlike get_static_prefix the clause is not optional and * nothing may follow it. */ get_current_timezone: ($) => block("get_current_timezone", asVariable($)), get_media_prefix: ($) => block("get_media_prefix", optional(asVariable($))), get_static_prefix: ($) => block("get_static_prefix", optional(asVariable($))), if_clause: ($) => seq(block("if", part($.predicate)), optional($.template)), elif_clause: ($) => seq(block("elif", part($.predicate)), optional($.template)), /** Shared by if_group and ifchanged_group, which spell it the same way. */ else_clause: ($) => seq(block("else"), optional($.template)), if_group: ($) => seq( $.if_clause, repeat($.elif_clause), optional($.else_clause), block("endif"), ), ifchanged_clause: ($) => seq( block("ifchanged", repeat(part($.filtered_value))), optional($.template), ), ifchanged_group: ($) => seq($.ifchanged_clause, optional($.else_clause), block("endifchanged")), /** * do_include takes "with" and "only" in either order and refuses one it has * already been given, and "with" needs at least one argument. It calls * token_kwargs with support_legacy=False, so "{% include "t" with a as b %}" * is an error here as it is there, unlike the same clause in "{% with %}". */ include: ($) => block( "include", $._tag_open, part($.filtered_value), optional( arrangements([seq(part("with"), $._tag_kwargs), part("only")]), ), ), /** django.templatetags.i18n. The language tag takes the one argument. */ language_clause: ($) => seq(block("language", part($.filtered_value)), optional($.template)), language_group: ($) => seq($.language_clause, block("endlanguage")), library: ($) => seq($.identifier, optional(seq(".", $.identifier))), load: ($) => block( "load", choice( seq(repeat1(part($.identifier)), part("from"), part($.library)), repeat1(part($.library)), ), ), /** * django.templatetags.l10n. The argument is optional and localize_tag * rejects anything but "on" or "off", including a second word. */ localize_clause: ($) => seq( block("localize", optional(part(choice("on", "off")))), optional($.template), ), localize_group: ($) => seq($.localize_clause, block("endlocalize")), /** django.templatetags.tz, taking "on" or "off" as localize does. */ localtime_clause: ($) => seq( block("localtime", optional(part(choice("on", "off")))), optional($.template), ), localtime_group: ($) => seq($.localtime_clause, block("endlocaltime")), lorem: ($) => block( "lorem", part($.filtered_value), part(choice("w", "p", "b")), optional(part("random")), ), now: ($) => block("now", part($.string), optional(asVariable($))), partial: ($) => block("partial", part($.identifier)), partialdef_clause: ($) => seq( block("partialdef", $.push_partial, optional(part("inline"))), optional($.template), ), partialdef_group: ($) => seq($.partialdef_clause, block("endpartialdef", $.pop_partial)), // querystring(context, *args, **kwargs) query_string: ($) => simpleTag($, "querystring"), regroup: ($) => block( "regroup", part($.filtered_value), part("by"), part($.attribute), optional(asVariable($)), ), reset_cycle: ($) => block("resetcycle", optional(part($.identifier))), spaceless_clause: ($) => seq(block("spaceless"), optional($.template)), spaceless_group: ($) => seq($.spaceless_clause, block("endspaceless")), static: ($) => block("static", part($.filtered_value), optional(asVariable($))), template_tag_block: ($) => block( "templatetag", part( choice( "openblock", "closeblock", "openvariable", "closevariable", "openbrace", "closebrace", "opencomment", "closecomment", ), ), ), /** django.templatetags.tz. timezone_tag takes the one argument. */ timezone_clause: ($) => seq(block("timezone", part($.filtered_value)), optional($.template)), timezone_group: ($) => seq($.timezone_clause, block("endtimezone")), /** * django.templatetags.i18n, registered under both spellings. do_translate * pops its options off one at a time and refuses one it has already seen, * so they may be written in any order but none of them twice. */ translate: ($) => block( choice("trans", "translate"), part($.filtered_value), optional( arrangements([ part("noop"), // do_translate refuses "as" and "noop" as the context, comparing // the whole bit, so a lookup that only begins with one is fine part( "context", choice($.filtered_value, wordAsValue($, "noop", true)), ), asVariable($), ]), ), ), url_block: ($) => block( "url", part(choice($.string, $.identifier)), optional( choice( repeat1(part($.filtered_value)), repeat1(part(seq($.identifier, "=", $.filtered_value))), ), ), optional(asVariable($)), ), verbatim_group: ($) => seq( block("verbatim", $.push_verbatim), optional($.verbatim_content), block("endverbatim", $.pop_verbatim), ), width_ratio: ($) => block( "widthratio", part($.filtered_value), part($.filtered_value), part($.filtered_value), optional(asVariable($)), ), with_clause: ($) => seq(block("with", $._tag_open, $._tag_kwargs), optional($.template)), with_group: ($) => seq($.with_clause, block("endwith")), }, }); export default django;