mustache / spec

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

{{#item.0}} checks and context #138

Closed DeeHants closed 8 months ago

DeeHants commented 1 year ago

Not sure if this counts as a bug, and seems to be by design as per the wording of the docs, and (at least) the perl and JS implementations, but a gotcha...

We make extensive use of {{#items.0}} type checks to see if an array has any items before rendering the container and the items themselves ({{#items}}{{value1}}{{value2}}{{/items}}). This gives example code like:

{{#items.0}}
  <p>found items</p>
  {{#items}}
    {{#first}}
      <li><strong>{{name}}</strong></li>
    {{/first}}
    {{#link}}
      <li><a href="{{url}}">{{name}}</a></li>
    {{/link}}
  {{/items}}
{{/items.0}}
{{^items.0}}
  <p>no items</p>
{{/items.0}}

The issue here is that the 0th item is now part of the context stack, meaning that if items 1+ do not have a specific named value, but the 0th does, that value will be rendered instead. If you put the above mustache into the demo, you get this broken output:

<p>found items</p>
<li><strong>red</strong></li>
<li><strong>green</strong></li>
<li><a href="#Green">green</a></li>
<li><strong>blue</strong></li>
<li><a href="#Blue">blue</a></li>

It now thinks every item is "first" as it pulls that value from the {{#items.0}} context.

We can work around it in some cases, by changing {{subitem.value}} (which also exists at .0) to {{#subitem}}{{value}}{{/subitem}}. This works, as the .n entries will have a subitem which becomes the new context, and then looks for value which .0 (and .n) doesn't.

bobthecow commented 1 year ago

{{items.0}} isn't a language feature of mustache, it's an implementation detail of some mustache implementations. as with many idiomatic language features, mustache itself has no stance on how this sort of thing should work.

in JS, a better check for "array isn't empty" is probably {{items.length}}, as that will push a number on the context stack rather than the first element in the array.

jgonggrijp commented 1 year ago

{{items.0}} isn't a language feature of mustache, it's an implementation detail of some mustache implementations. as with many idiomatic language features, mustache itself has no stance on how this sort of thing should work.

Slight nuance: not every programming language might indicate the first element of an array as a 0 property, but dotted names are a required part of the spec, and they have been for a long time:

https://github.com/mustache/spec/blob/5d3b58ea35ae309c40d7a8111bfedc4c5bcd43a6/specs/sections.yml#L9-L26

Anyway, it is indeed the case that items.0 gets pushed on the context stack. This is as specified, if the programming language recognizes 0 as the first element of an array. You can work around this by using items.length, as suggested by @bobthecow, or by adding "first": false to the other items, because those end up higher on the context stack.

A third alternative is to reorganize your template a bit. The following version will even properly nest the list items within a <ul> and keep the <p> outside of it. I used items.0 here, but you could also use items.length.

{{#items}}
  {{#first}}
    <p>found items</p>
    <ul>
    <li><strong>{{name}}</strong></li>
  {{/first}}
  {{#link}}
    <li><a href="{{url}}">{{name}}</a></li>
  {{/link}}
{{/items}}
{{#items.0}}
  </ul>
{{/items.0}}
{{^items.0}}
  <p>no items</p>
{{/items.0}}

Side note: mustache.js, which powers the well-known demo page, unfortunately does not adhere to the spec very well. You can tell so in this case because it strips all indentation. If I take your original template and input data through an implementation that does adhere to the spec, I get the following output:

  <p>found items</p>
      <li><strong>red</strong></li>
      <li><strong>green</strong></li>
      <li><a href="#Green">green</a></li>
      <li><strong>blue</strong></li>
      <li><a href="#Blue">blue</a></li>

Coincidentally, I am working on a new implementation for JavaScript that does adhere to the spec, and also on a playground site where you can try templates in a spec-compliant way. If you're interested, have a look at Wontache and perhaps consider donating to my Patreon.

bobthecow commented 1 year ago

Slight nuance: not every programming language might indicate the first element of an array as a 0 property, but dotted names are a required part of the spec, and they have been for a long time

Right. I wasn't trying to say that dotted names were a language-specific feature, rather grabbing the 0th element of an array at all was language-specific.

Note that items.length depends on the implementation as well. It's idiomatic javascript (and ruby, and a bunch of other things) but it's not a feature of Mustache itself. So, for example, in PHP where arrays are a primitive and not an object, there's no built-in length method or property.

DeeHants commented 1 year ago

Thanks everyone. We can preprocess the data too to add a length field.

The wording of that section spec also suggests to me the perl implementation is not handling dotted entries correctly. I'll double check once at work.

jgonggrijp commented 1 year ago

@DeeHants What did you find?

agentgt commented 1 year ago

It would be really nice if there was a spec'ed way to deal with this like 99% use case of dealing with lists.

There are three exceptionally common use cases where the only option is to re-decorate the list:

  1. Check if the list is empty with out iterating on it (use case is omitting surrounding <ul> elements)
  2. Know if we are on the first item in the list (use case is joining items with ,)
  3. Know if we are on the last item in the list

So many implementations have varying extensions for dealing with the above. Ideally you would have a lambda do it but as I mentioned here: https://github.com/mustache/spec/issues/135#issuecomment-1285576618

It is not easily possibly even with enhanced lambdas as ambiguity comes when referencing a list. For example if I'm passed the context stack can I get access to the list (e.g. contextStack[length - 2])?

It is just sad that this is such an incredibly common use case without a clear option that doesn't vary greatly from implementation to implementation other than mindlessly redecorating the model (which in some cases is not easily possibly particularly with immutable objects etc).

Maybe power lambdas can get direct access to lists. Then you just have to port a lambda to other implementations.

jgonggrijp commented 1 year ago

@agentgt Yes, I think power lambdas could and should solve this issue. The way I currently think of it, lambdas receive a second argument which somehow (i.e., in an implementation-defined way) makes the following things possible:

Whereby lambdas must not modify the pre-existing contents of the context, and implementations are welcome to actively prevent this if the programming language can enforce it. However, lambdas can (already) push a new frame on the stack, which still has the net effect of changing what's available in the context.

Given such a hypothetical second argument (named magic below), here is how I might write power lambdas in JavaScript that make these common use cases possible. empty and enumerate basically redecorate the context on the fly, so you no longer need to do it in ad hoc preparatory code.

Data with power lambdas

{
    // Pushes a new frame on the stack that shadows all keys currently visible.
    // Each key on the new frame is a lambda that lazily checks whether the
    // corresponding key on the lower frames is an empty list.
    empty: function(section, magic) {
        var allKeys = magic.somehowGetAllKeys();
        var decoratedFrame = {};
        function createChecker(key) {
            // Crucial: the following function closes over the `magic` that was passed
            // to the `empty` lambda, so it resolves against lower frames only.
            return function() {
                return magic.somehowResolve(key).length === 0;
            };
        }
        for (var l = allKeys.length, i = 0; i < l; ++i) {
            var key = allKeys[i];
            decoratedFrame[key] = createChecker(key);
        }
        return decoratedFrame;
    },

    // Pushes a shadowing frame, similar to `empty`. However, each key pushes
    // a new list on the stack that shadows the original list. Each item in the
    // new list has the same contents as the corresponding element of the
    // underlying list, but an `index` property is added with its numerical
    // position in the list.
    enumerate: function(section, magic) {
        var allKeys = magic.somehowGetAllKeys();
        var decoratedFrame = {};
        function createEnumerated(key) {
            return function() {
                var list = magic.somehowResolve(key);
                if (!(list instanceof Array)) return list;
                var decoratedList = [];
                for (var l = list.length, i = 0; i < l; ++i) {
                    decoratedList.push({...list[i], index: i});
                }
                return decoratedList;
            };
        }
        for (var l = allKeys.length, i = 0; i < l; ++i) {
            var key = allKeys[i];
            decoratedFrame[key] = createEnumerated(key);
        }
        return decoratedFrame;
    },

    // Check whether we are currently rendering the first element of a list.
    // Only works inside an `{{#enumerate}}{{/enumerate}}`.
    first: function(section, magic) {
        var index = magic.somehowResolve('index');
        return index === 0;
    },

    // Check whether we are currently rendering the last element of a list.
    // Only works inside an `{{#enumerate}}{{/enumerate}}`.
    last: function(section, magic) {
        var index = magic.somehowResolve('index');
        var frameIndex = magic.somehowGetFrameIndexOf('index');
        // Next line assumes that lower frames have lower indices.
        var list = magic.somehowGetFrame(frameIndex - 1);
        return index === list.length - 1;
    }
}

Template with example usage

{{! rendering something only if a list is not empty, but only once, regardless
    of list length }}
{{^empty.myList}}
<ul>
{{/empty.myList}}
    {{#myList}}
    <li>{{item}}
    {{/myList}}
{{^empty.myList}}
</ul>
{{/empty.myList}}

{{! rendering something between elements, but not before or after }}
I like {{#enumerate.myList
}}{{^first}}{{^last}}, {{/last}}{{#last}} and {{/last}}{{/first}}{{item}}{{/
enumerate.myList}}.