liquid icon indicating copy to clipboard operation
liquid copied to clipboard

[Proposal] Args for render block

Open bakura10 opened this issue 2 years ago • 5 comments

Hi everyone,

Michael from Maestrooo here. I decided to close the original ticket #1530 and open a new one, as the other one was different ideas that came through my mind but it became a bit confusing and, as I did various experiments with components in Liquid, more use cases arises, so I wanted to formalize it.

Having such a feature would open a lot of flexibility to Liquid and allow better composition through better code re-use.

This proposal is based on @dylanahsmith suggestion, which actually makes a lot of sense, and add new concepts without complexifying the syntax of Liquid.

Block support for render

As of today, render tag cannot be used as a form block. This new syntax would introduce a new "args":

{% render 'button', args: %}
  {{ block.settings.text | capitalize }}
{% endrender %}

In the snippet, the content could be accessed through the "args" argument, and through the default block:

button.liquid

<button>{{ args.block | escape }}</button>

Support for named arguments

In order to have better composability, render should support named arguments:

{% render 'dropdown', args: %}
  {% args 'toggle' %}
    {% render 'button' %}Toggle{% endrender %}
  {% endarg %}

  {% args 'values' %}
      ...
  {% endarg %}
{% endrender %}

dropdown.liquid

<div>
  {{ args.toggle }}

  <div>
    {{ args.values }}
  </div>
</div>

Support for props

Using named arguments should NOT remove the ability to pass custom properties, so we should still be able to do that:

{% render 'button', size: 'sm', args: %}
  <svg class="icon">...</svg> {{ block.settings.text | capitalize }}
{% endrender %}

Maybe the "args" syntax is a bit unneeded here, and could be implicit as soon as we are using the render as a block form.

Non-leak of arguments

An absolute requirement would be to make sure that any args define inside the block form is not leak outside to prevent any accidental override of variable:

{% assign header = 'bar' %}

{% render 'modal', args: %}
  {% arg 'header' %}
    Foo
  {% endarg %}
{% endrender %}

{{ header }} <<---- Must render 'bar'

Optional

One "nice to have" would be able to pass extra parameters to args:

{% render 'modal', args: %}
  {% arg 'header', bordered: true %}
     Header
  {% endarg %}
{% endrender %}

modal.liquid:

<div class="{% if arg.header.bordered %}bordered{% endif %}">
  {{ args.header }}
</div>

Support for nested folders

At least in the snippet folder, Shopify should add support for folders to make it more maintainable:

{% render 'component/button' %} // Would look for "snippets/component/button.liquid"

After lot of trying, I feel this proposal would add something very useful without increasing the API too much, in a completely backward compatible way, and would allow most of the use cases that are required for a truly component based approach in Liquid.

Condensed syntax

If we want to make Liquid more component friendly, we should be able to allow us to easily compose components. While the render syntax with block solves the issue, it makes the syntax a bit cumbersome:

{% render 'customer-address' %}
  {% render 'icon-with-text', icon: 'picto' %}Shipping{% endrender %}
  {{ order.shipping_address | format_address }}
{% endrender %}

An alternate syntax that would be a shortcut for block render could be introduced, for instance:

{# customer-address #}
  {# 'icon-with-text', icon: 'picto' #}Shipping{# end #}
  {{ order.shipping_address | format_address }}
{# end #}

With a self-closing component (which would be the same as render, but with a nicer syntax):

{# arrow-button #}

Or even (although this does not "look" very Liquid-ish):

<Liquid.CustomerAddress>
  <Liquid.IconWithText icon="picto">Shipping</Liquid.IconWithText>
  {{ order.shipping_address | format_address }}
</Liquid.CustomerAddress>

And have the parser detect the <Liquid> to interpret this as a component. This would have the benefit of allowing the usage of filter (for instance here on the "icon"):

<Liquid.CustomerAddress>
  <Liquid.IconWithText icon="{{ section.settings.picto }}">Shipping</Liquid.IconWithText>
  {{ order.shipping_address | format_address }}
</Liquid.CustomerAddress>

One very nice benefit of having a condensed syntax would be to be able to make the Liquid much more expressive. For instance, in our templates we are constantly doing that kind of code absolutely everywhere by testing if a given setting is empty before outputting it:

image

With a condensed syntax, such code could be expressed like this, for instance:

{# text: variation: 'strong' #}{{ section.settings.subheading }}{# end #}
{# heading: style: 'h1', gradient: section.settings.gradient #}{{ section.settings.heading }}{# end #}

And simply have the component decide to render itself or not based on the content of the slot. This would also helps drastically to create more re-usable sections

Styles

It would be nice if components would allow to bundle their own style, so that everything can be in the same place. For instance:

banner.liquid:

{% stylesheet %}
.banner {
  ...
}
{% endstylesheet %}

{% javascript, module: true %}
  export class Banner extends HTMLElement {
      ...
  }
 
  window.customElements.define('x-banner', Banner);
{% endjavascript %}

<div class="banner">
  ...
</div>

As of today Shopify has a "stylesheet" tag but this can be used only on sections, and not inside snippet. However, with component being self-contained re-usable element, it would make lot of sense to allow their usage.

Potential issues

This proposal currently introduces one potential issue, is that it would cause a breaking change for merchant using a parameter called "args":

{% render 'modal', args: 'test' %}

One possible solution would be to detect if the render is used in block mode or not, and if not, re-use the value.

bakura10 avatar Apr 07 '22 05:04 bakura10

To prevent any breaking change maybe it could use a similar syntax to the limit option in the forloop: {% for product in products limit: 5 %}

So for a render it could be like that if someone already has an args variable:

{% render 'my-snippet' args: "props", args: "My args variable" %}
  <div>My render content {{ product.title }}</div>
{% endrender %}

Then in the snippet you would access props instead of args

<div>
  <div>
    render content: {{ props.block }}
  </div>
  
  <div>
      My arg variable: {{ args }}
  </div>
</div>

To make sure it doesn't cause any breaking change it would be mandatory to pass a value to the args option.

Also maybe it should be something like content instead of block? I feel like block doesn't really make sense in the context since the term block is already used for the repeatable items in the section schema.

tommypepsi avatar Apr 07 '22 14:04 tommypepsi

In your syntax @tommypepsi how would you be able to pass args that are made of other Liquid code? The whole point of this would be to create args that are made of Liquid, such as something below:

{% render 'modal', args: %}
  {% arg 'header' %}
    <div>
      <img src="{{product.featured_media | img_url}}">
      <p>{{ product.title }} </p>
    </div>
  {% endarg %}
{% endrender %}

By passing the args directly as you do you cannot achieve that result :).

bakura10 avatar Apr 07 '22 14:04 bakura10

It could stay the same for those:

{% render 'modal' args: "props" %}
  {% arg 'header' %}
    <div>
      <img src="{{product.featured_media | img_url}}">
      <p>{{ product.title }} </p>
    </div>
  {% endarg %}
{% endrender %}

The way I see it the args option on the render would say "create an arguments variable called props" and then the arg liquid tag in the render would say "Add the property header to the created arguments variable" (the arguments variable in this case would be props).

So you could do {{ props.header }} and {{ props.content }}

tommypepsi avatar Apr 07 '22 14:04 tommypepsi

Makes sense! I find it a bit confusing though to be able to change dynamically the key of what the "props" are, this means that a developer has no confidence to know how to reference the variable without looking at the caller. And if you are by mistake using args: "prop" instead of args: "props" in another call, then nothing will work as expected :D.

I feel that simply using "args" is easier.

I don't think actually this would cause a breaking change. For instance, if someone is currently using the render with a property named args:

{% render 'my-snippet', args: "Foo" %}

They will use it this way:

<p>{{ args }}</p>

However to access a specific "args" you would need to use dot notation:

{{ args.header }} // Access the header arg
{{ args.block }} // Access the default content (or {{ args.content }})

Shopify could write a compatibility layer for that so that when args is used as it, it simply outputs the value.

One another idea may be to use a new {% yield %}:

{% render 'foo' %}Example{% endrender %}

In the snippet:

{% yield %} // Without any argument it would output the block content

Or with named args:

{% render 'foo' %}
  {% arg header %}
     Content
  {% endarg %}
{% endrender %}

And yielding a specific arg:

{% yield header %}

This would solve this args compatibility issue, the only drawback is that this would remove the ability to pass parameters to args (for instance if I want to pass a "bordered" option to the header arg, I am not sure how it could be retrieved in the yield syntax).

bakura10 avatar Apr 08 '22 03:04 bakura10

Added a condensed syntax section in the initial message, with a nice use case I have found regarding a very recurring pattern in our templates.

bakura10 avatar Apr 23 '22 15:04 bakura10