mustache / spec

The Mustache spec.
MIT License
361 stars 71 forks source link

Proposal: Trim whitespace/newlines to get improved readability? #143

Closed dragorosson closed 1 year ago

dragorosson commented 1 year ago

Suppose I have this template that must render without newlines:

{{#isFormParam}}{{^isBinary}}[FromForm{{^isModel}} (Name = "{{baseName}}"){{/isModel}}]{{/isBinary}}{{#required}}[Required()]{{/required}}{{#pattern}}[RegularExpression("{{{.}}}")]{{/pattern}}{{#minLength}}{{#maxLength}}[StringLength({{maxLength}}, MinimumLength={{minLength}})]{{/maxLength}}{{/minLength}}{{#minLength}}{{^maxLength}} [MinLength({{minLength}})]{{/maxLength}}{{/minLength}}{{^minLength}}{{#maxLength}} [MaxLength({{.}})]{{/maxLength}}{{/minLength}}{{#minimum}}{{#maximum}}[Range({{minimum}}, {{maximum}})]{{/maximum}}{{/minimum}}{{&dataType}} {{paramName}}{{/isFormParam}}

I could make this "more" readable by introducing comments across newlines:

{{#isFormParam}}{{!
  }}{{^isBinary}}[FromForm{{^isModel}} (Name = "{{baseName}}"){{/isModel}}]{{/isBinary}}{{!
  }}{{#required}}[Required()]{{/required}}{{!
  }}{{#pattern}}[RegularExpression("{{{.}}}")]{{/pattern}}{{!
  }}{{#minLength}}{{#maxLength}}[StringLength({{maxLength}}, MinimumLength={{minLength}})]{{/maxLength}}{{/minLength}}{{!
  }}{{#minLength}}{{^maxLength}} [MinLength({{minLength}})]{{/maxLength}}{{/minLength}}{{!
  }}{{^minLength}}{{#maxLength}} [MaxLength({{.}})]{{/maxLength}}{{/minLength}}{{!
  }}{{#minimum}}{{#maximum}}[Range({{minimum}}, {{maximum}})]{{/maximum}}{{/minimum}}{{!
  }}{{&dataType}} {{paramName}}{{!
}}{{/isFormParam}}

I could use partials, but then each one has to go into its own file, which to me is too granular and hurts readability.

Reading through the spec, I don't see anything about trimming whitespace/newlines.

If there were such a feature, this template could be much more readable (untested):

{{#isFormParam~}}
  {{^isBinary~}}
    [FromForm{{^isModel}} (Name = "{{baseName}}"){{/isModel}}]
  {{~/isBinary~}}
  {{#required}}[Required()]{{/required~}}
  {{#pattern}}[RegularExpression("{{{.}}}")]{{/pattern~}}
  {{#minLength}}{{#maxLength~}}
    [StringLength({{maxLength}}, MinimumLength={{minLength}})]
  {{~/maxLength}}{{/minLength~}}
  {{#minLength}}{{^maxLength~}}
    [MinLength({{minLength}})]
  {{~/maxLength}}{{/minLength~}}
  {{^minLength}}{{#maxLength~}}
    [MaxLength({{.}})]
  {{~/maxLength}}{{/minLength~}}
  {{#minimum}}{{#maximum~}}
    [Range({{minimum}}, {{maximum}})]
  {{~/maximum}}{{/minimum~}}
  {{&dataType}} {{paramName~}}
{{/isFormParam}}

Maybe a simpler alternative would be to have a separate {{~}} statement that eats whitespace around/after it.

jgonggrijp commented 1 year ago

I personally wouldn't like to complicate the internal structure of tags in order to elide linebreaks. In my opinion, part of the charm of Mustache is its syntactic simplicity as compared to other languages such as Handlebars and Jinja. I think I could live with a dedicated linebreak- or whitespace-eating tag like {{~}}, though. That would also be relatively simple to implement; it would just be a tag that is always "standalone" (even if there is other content on the same line) and that does not render any output.

However, I can think of a few more alternative options.

1. Since you mention that partials would help, except that you don't want to create additional files for them, I suspect that you might like inline subtemplates (#63). I would personally prefer inline templates over a dedicated tag just to eat whitespace, since the former is a much more powerful feature.

2. Since you don't want any linebreaks, you could just render the template with linebreaks and then remove them afterwards in one sweep with the host programming language. Admittedly, this would amount to working around a shortcoming in the templating language, but it is a more economical option than you might think. In most programming languages, removing linebreaks from a string can be done in a single line of code. If you wrap that line of code in a function that also renders the template, you have a reusable oneliner that you can call everywhere you need linebreak-free output instead of the line that does rendering only. The total amount of code might be less than when you have to append tildes to every tag after which a linebreak should be elided. This approach, where Mustache does what it is good at while the host programming language takes care of the rest, is also commonly found when massaging the input data into a shape that works well for Mustache templates. While this might not the best option overall, I feel it is better than having linebreak elision syntax inside tags.

3. You can hide linebreaks inside any tag, not just comments. All specs allow arbitrary whitespace between the sigil and the contents of the tag, as well as between the contents and the closing delimiter:

{{#
    content
}}

In a way, you could argue that this is linebreak elision syntax already. Taking this to the extreme, you could put every tag on a separate line, without introducing any whitespace in the final output and whitout needing any additional tags. Below is how I might approach your example template:

{{#isFormParam
    }}{{^isBinary
        }}[FromForm{{^isModel
            }} (Name = "{{baseName
        }}"){{/isModel
    }}]{{/isBinary
    }}{{#required
    }}[Required()]{{/required
    }}{{#pattern
        }}[RegularExpression("{{{.
    }}}")]{{/pattern
    }}{{#minLength
        }}{{#maxLength
            }}[StringLength({{maxLength
            }}, MinimumLength={{minLength
        }})]{{/maxLength
    }}{{/minLength
    }}{{#minLength
        }}{{^maxLength
            }} [MinLength({{minLength
        }})]{{/maxLength
    }}{{/minLength
    }}{{^minLength
        }}{{#maxLength
            }} [MaxLength({{.
        }})]{{/maxLength
    }}{{/minLength
    }}{{#minimum
        }}{{#maximum
            }}[Range({{minimum
            }}, {{maximum
        }})]{{/maximum
    }}{{/minimum
    }}{{&dataType
    }} {{paramName
}}{{/isFormParam}}

This is, admittedly, not as pretty as a template in which every tag starts and ends on the same line, nor is it pleasant to have a "frown" at the start of every line. However, I would argue that having dashes or tildes inside tag delimiters sprinkled across the template is not very pretty or legible, either.

jgonggrijp commented 1 year ago

By the way, all syntax in the current spec can be tried out in the Wontache playground. Copy-pasting the following code into its load/store box will reproduce all examples so far except for the one with {{~/~}} linebreak elision syntax, as well as example payload data:

{"data":{"text":"{\n    isFormParam: true,\n    dataType: 'string',\n    paramName: 'fairy',\n    baseName: 'Tinkerbell',\n    required: true,\n    minLength: 3,\n    maxLength: 8\n}"},"templates":[{"name":"all-inline","text":"{{#isFormParam}}{{^isBinary}}[FromForm{{^isModel}} (Name = \"{{baseName}}\"){{/isModel}}]{{/isBinary}}{{#required}}[Required()]{{/required}}{{#pattern}}[RegularExpression(\"{{{.}}}\")]{{/pattern}}{{#minLength}}{{#maxLength}}[StringLength({{maxLength}}, MinimumLength={{minLength}})]{{/maxLength}}{{/minLength}}{{#minLength}}{{^maxLength}} [MinLength({{minLength}})]{{/maxLength}}{{/minLength}}{{^minLength}}{{#maxLength}} [MaxLength({{.}})]{{/maxLength}}{{/minLength}}{{#minimum}}{{#maximum}}[Range({{minimum}}, {{maximum}})]{{/maximum}}{{/minimum}}{{&dataType}} {{paramName}}{{/isFormParam}}"},{"name":"comment-breaks","text":"{{#isFormParam}}{{!\n  }}{{^isBinary}}[FromForm{{^isModel}} (Name = \"{{baseName}}\"){{/isModel}}]{{/isBinary}}{{!\n  }}{{#required}}[Required()]{{/required}}{{!\n  }}{{#pattern}}[RegularExpression(\"{{{.}}}\")]{{/pattern}}{{!\n  }}{{#minLength}}{{#maxLength}}[StringLength({{maxLength}}, MinimumLength={{minLength}})]{{/maxLength}}{{/minLength}}{{!\n  }}{{#minLength}}{{^maxLength}} [MinLength({{minLength}})]{{/maxLength}}{{/minLength}}{{!\n  }}{{^minLength}}{{#maxLength}} [MaxLength({{.}})]{{/maxLength}}{{/minLength}}{{!\n  }}{{#minimum}}{{#maximum}}[Range({{minimum}}, {{maximum}})]{{/maximum}}{{/minimum}}{{!\n  }}{{&dataType}} {{paramName}}{{!\n}}{{/isFormParam}}"},{"name":"all-separate","text":"{{#isFormParam\n    }}{{^isBinary\n        }}[FromForm{{^isModel\n            }} (Name = \"{{baseName\n        }}\"){{/isModel\n    }}]{{/isBinary\n    }}{{#required\n    }}[Required()]{{/required\n    }}{{#pattern\n        }}[RegularExpression(\"{{{.\n    }}}\")]{{/pattern\n    }}{{#minLength\n        }}{{#maxLength\n            }}[StringLength({{maxLength\n            }}, MinimumLength={{minLength\n        }})]{{/maxLength\n    }}{{/minLength\n    }}{{#minLength\n        }}{{^maxLength\n            }} [MinLength({{minLength\n        }})]{{/maxLength\n    }}{{/minLength\n    }}{{^minLength\n        }}{{#maxLength\n            }} [MaxLength({{.\n        }})]{{/maxLength\n    }}{{/minLength\n    }}{{#minimum\n        }}{{#maximum\n            }}[Range({{minimum\n            }}, {{maximum\n        }})]{{/maximum\n    }}{{/minimum\n    }}{{&dataType\n    }} {{paramName\n}}{{/isFormParam}}"}]}

Pressing the "render" button under each template confirms that they all give the same output.

dragorosson commented 1 year ago

@jgonggrijp Thank you for the terrific reply! It's the kind of response I was hoping for. It's obvious that mustache is intended to be as minimal as possible, which I appreciate. Even if a specific whitespace feature never makes it into the spec, your answer may give others like me ideas on how to best achieve this with what's available.

As to your alternatives:

  1. Since you mention that partials would help, except that you don't want to create additional files for them, I suspect that you might like inline subtemplates (https://github.com/mustache/spec/issues/63).

I do like that.

I would personally prefer inline templates over a dedicated tag just to eat whitespace, since the former is a much more powerful feature.

I agree, for this use inline templates would work fine. But now I wonder, wouldn't the file with inline templates suffer from the same problem?

  1. Since you don't want any linebreaks, you could just render the template with linebreaks and then remove them afterwards in one sweep with the host programming language. [...]

Unfortunately, the snippet I posted is itself a partial, so I don't think there's any opportunity for the programming language to insert itself there. Also, this solution would be much more involved anyway because while the tool (openapi-generator) allows template customization with the existing binary, a source code modification would require recompilation into a custom binary. And adding some sort of automated build process probably...

  1. You can hide linebreaks inside any tag, not just comments. All specs allow arbitrary whitespace between the sigil and the contents of the tag, as well as between the contents and the closing delimiter [...]

This is terrific information. I did notice somewhere that {{ #something}} is not allowed by the spec, so it's good to know that there is in fact a way to do this.

I suppose a drawback of a whitespace/newline eliding feature is that the semantics vary across templating languages. Does it eat just whitespace? Does it eat newlines? What about whitespace up to the next non-whitespace and non-sigil character? Not the easiest to remember, and it probably won't work in every situation so there'd still be some awkward workarounds. With mustache the way it is, it might be weird looking but it's pretty obvious exactly what it's going to do because content between tags is either there or not, and things outside of a pair of tags are never messed with.

jgonggrijp commented 1 year ago

(...) But now I wonder, wouldn't the file with inline templates suffer from the same problem?

It would, but to a much lesser extent. Assuming the existing semantics of partials, the syntax I last proposed in #63 and the whitespace handling I proposed in #131, your template could be written as follows:

{{:maybe-baseName}}
{{^isModel}} (Name = "{{baseName}}"){{/isModel}}{{/maybe-baseName}}
{{:maybe-fromForm}}
{{^isBinary}}[FromForm{{>maybe-baseName}}]{{/isBinary}}{{/maybe-fromForm}}
{{:maybe-required}}
{{#required}}[Required()]{{/required}}{{/maybe-required}}
{{:maybe-regex}}
{{#pattern}}[RegularExpression("{{{.}}}")]{{/pattern}}{{/maybe-regex}}
{{:string-minmax}}
[StringLength({{maxLength}}, MinimumLength={{minLength}})]{{/string-minmax}}
{{:string-min-only}}
[MinLength({{minLength}})]{{/string-min-only}}
{{:string-max-only}}
[MaxLength({{.}})]{{/string-max-only}}
{{:string-min}}
{{#maxLength}}{{>string-minmax}}{{/maxLength
    }}{{^maxLength}} {{>string-min-only}}{{/maxLength}}{{/string-min}}
{{:no-string-min}}
{{#maxLength}} {{>string-max-only}}{{/maxLength}}{{/no-string-min}}
{{:maybe-string-size}}
{{#minLength}}{{>string-min}}{{/minLength
    }}{{^minLength}}{{>no-string-min}}{{/minLength}}{{/maybe-string-size}}
{{:range}}
[Range({{minimum}}, {{maximum}})]{{/range}}
{{:maybe-range}}
{{#minimum}}{{#maximum}}{{>range}}{{/maximum}}{{/minimum}}{{/maybe-range}}
{{:typed-param}}
{{&dataType}} {{paramName}}{{/typed-param}}
{{#isFormParam}}
{{>maybe-fromForm}}
{{>maybe-required}}
{{>maybe-regex}}
{{>maybe-string-size}}
{{>maybe-range}}
{{>typed-param}}
{{/isFormParam}}

Wontache does not implement #63 yet, but I made a playground savestate that at least shows the bottom part with the vertical listing of partials does not introduce any linebreaks (render the main template all the way at the bottom):

{"data":{"text":"{\n    isFormParam: true,\n    dataType: 'string',\n    paramName: 'fairy',\n    baseName: 'Tinkerbell',\n    required: true,\n    minLength: 3,\n    maxLength: 8\n}"},"templates":[{"name":"maybe-baseName","text":"{{^isModel}} (Name = \"{{baseName}}\"){{/isModel}}"},{"name":"maybe-fromForm","text":"{{^isBinary}}[FromForm{{>maybe-baseName}}]{{/isBinary}}"},{"name":"maybe-required","text":"{{#required}}[Required()]{{/required}}"},{"name":"maybe-regex","text":"{{#pattern}}[RegularExpression(\"{{{.}}}\")]{{/pattern}}"},{"name":"string-minmax","text":"[StringLength({{maxLength}}, MinimumLength={{minLength}})]"},{"name":"string-min-only","text":"[MinLength({{minLength}})]"},{"name":"string-max-only","text":"[MaxLength({{.}})]"},{"name":"string-min","text":"{{#maxLength}}{{>string-minmax}}{{/maxLength\n    }}{{^maxLength}} {{>string-min-only}}{{/maxLength}}"},{"name":"no-string-min","text":"{{#maxLength}} {{>string-max-only}}{{/maxLength}}"},{"name":"maybe-string-size","text":"{{#minLength}}{{>string-min}}{{/minLength\n    }}{{^minLength}}{{>no-string-min}}{{/minLength}}"},{"name":"range","text":"[Range({{minimum}}, {{maximum}})]"},{"name":"maybe-range","text":"{{#minimum}}{{#maximum}}{{>range}}{{/maximum}}{{/minimum}}"},{"name":"typed-param","text":"{{&dataType}} {{paramName}}"},{"name":"main","text":"{{#isFormParam}}\n{{>maybe-fromForm}}\n{{>maybe-required}}\n{{>maybe-regex}}\n{{>maybe-string-size}}\n{{>maybe-range}}\n{{>typed-param}}\n{{/isFormParam}}"}]}

As a secondary proof of concept, I also made a version with block inheritance that required only one additional template. However, to my surprise, the linebreaks were not entirely elided this time. I think I discovered a bug in Wontache where it does not properly implement #131 yet, which I documented in wontache#69. Broken savestate below.

{"data":{"text":"{\n    isFormParam: true,\n    dataType: 'string',\n    paramName: 'fairy',\n    baseName: 'Tinkerbell',\n    required: true,\n    minLength: 3,\n    maxLength: 8\n}"},"templates":[{"name":"core","text":"{{#isFormParam}}\n{{$maybe-fromForm}}{{/maybe-fromForm}}\n{{$maybe-required}}{{/maybe-required}}\n{{$maybe-regex}}{{/maybe-regex}}\n{{$maybe-string-size}}{{/maybe-string-size}}\n{{$maybe-range}}{{/maybe-range}}\n{{$typed-param}}{{/typed-param}}\n{{/isFormParam}}"},{"name":"wrapper","text":"{{<core}}\n    {{$maybe-baseName}}\n    {{^isModel}} (Name = \"{{baseName}}\"){{/isModel}}{{/maybe-baseName}}\n    {{$maybe-fromForm}}\n    {{^isBinary}}[FromForm{{$maybe-baseName}}{{/maybe-baseName}}]{{/isBinary}}{{/maybe-fromForm}}\n    {{$maybe-required}}\n    {{#required}}[Required()]{{/required}}{{/maybe-required}}\n    {{$maybe-regex}}\n    {{#pattern}}[RegularExpression(\"{{{.}}}\")]{{/pattern}}{{/maybe-regex}}\n    {{$string-minmax}}\n    [StringLength({{maxLength}}, MinimumLength={{minLength}})]{{/string-minmax}}\n    {{$string-min-only}}\n    [MinLength({{minLength}})]{{/string-min-only}}\n    {{$string-max-only}}\n    [MaxLength({{.}})]{{/string-max-only}}\n    {{$string-min}}\n    {{#maxLength}}{{$string-minmax}}{{/string-minmax}}{{/maxLength\n        }}{{^maxLength}} {{$string-min-only}}{{/string-min-only}}{{/maxLength}}{{/string-min}}\n    {{$no-string-min}}\n    {{#maxLength}} {{$string-max-only}}{{/string-max-only}}{{/maxLength}}{{/no-string-min}}\n    {{$maybe-string-size}}\n    {{#minLength}}{{$string-min}}{{/string-min}}{{/minLength\n        }}{{^minLength}}{{$no-string-min}}{{/no-string-min}}{{/minLength}}{{/maybe-string-size}}\n    {{$range}}\n    [Range({{minimum}}, {{maximum}})]{{/range}}\n    {{$maybe-range}}\n    {{#minimum}}{{#maximum}}{{$range}}{{/range}}{{/maximum}}{{/minimum}}{{/maybe-range}}\n    {{$typed-param}}\n    {{&dataType}} {{paramName}}{{/typed-param}}\n{{/core}}"}]}

Unfortunately, the snippet I posted is itself a partial, so I don't think there's any opportunity for the programming language to insert itself there. Also, this solution would be much more involved anyway because while the tool (openapi-generator) allows template customization with the existing binary, a source code modification would require recompilation into a custom binary. And adding some sort of automated build process probably...

Ah, I stand corrected.

I suppose a drawback of a whitespace/newline eliding feature is that the semantics vary across templating languages. Does it eat just whitespace? Does it eat newlines? What about whitespace up to the next non-whitespace and non-sigil character? Not the easiest to remember, and it probably won't work in every situation so there'd still be some awkward workarounds.

Yes, good points!

With mustache the way it is, it might be weird looking but it's pretty obvious exactly what it's going to do because content between tags is either there or not, and things outside of a pair of tags are never messed with.

To be completely honest, the current Mustache spec does mess a little bit with whitespace; #131 is a generalization of rules that are already in place. My first savestate in this post only relies on those pre-existing rules, i.e., that the linebreak after a standalone partial tag is part of the tag rather than of the literal content of the template. If the interpolated template does not contain any newlines, such a standalone partial tag line will never introduce a newline in the output, despite the fact that the very line itself does end with a newline.

However, it is a subtle kind of messing that silently produces exactly the output that one would intuitively expect, without the template author ever needing to think about it consiously. Or that is the intention, anyway.

jgonggrijp commented 1 year ago

Closing this as the discussion seems finished. Feel free to comment again.