En 3sellers usamos el lenguaje de plantillas Liquid. Liquid ofrece varias ventajas sobre ERB. La sintaxis es un poco mas sencilla. El típico usuario probablemente no va a crear sus propias plantillas, pero usuarios avanzados pueden aprenderlo mas rápido. Sin embargo lo mas importante que nos ofrece Liquid es que actúa como una barrera entre nuestros modelos ActiveRecord y el usuario. Podemos definir exactamente que métodos un usuario normal puede usar y también crear nuevos métodos para el uso único en plantillas.

Liquid ya esta bastante completo y se integra muy bien en Rails, pero de vez en cuando sale algo que no es posible hacer con lo que ofrece Liquid por defecto. Un ejemplo es repartir elementos de una colección (digamos en un listado de productos) sobre varias columnas. Hace poco cambié el comportamiento del bucle "for" justamente por aquella razón y voy a explicar un poco como lo hice.

Arquitectura de Liquid

En el fondo los componentes importantes son el contexto, los tags, los drops y los filtros.

Drops y filtros

Drops son la manera con que accedemos a nuestros objetos dentro de nuestra aplicación. Los filtros tienen la misma función que los Helpers en Rails. Ambos definen el API de nuestra aplicación que se puede utilizar dentro de una plantilla. La web de Liquid explica bien como crear y usar drops y filtros. Pero para añadir funcionalidad al lenguaje de Liquid hace falta pelearse con los tags y el contexto.

Tags

Los elementos del lenguaje se llaman Tags, como por ejemplo condicionales If/Else o bucles "for". En la gema de Liquid se encuentran en la carpeta "lib/liquid/tags". Un tag normalmente hereda de la clase Tag o (si es un tag tipo If/Else/End que puede contener mas codigo) hereda de la clase Block (que es una subclase de Tag). Para crear un Tag completamente nuevo empezarías con una de estas dos clases. Como yo solo quería repartir elementos de una colección entre varias columnas, decidí añadirlo al bucle "For", ya que el bucle hace casi todo lo que quería.

Context

El objeto context contiene los valores de variables que usamos en la plantilla. Si por ejemplo usamos {% assign numero = 3 %} para asignar el valor 3 a la variable numero se guarda la variable en el context. Abajo vemos porque esto es importante para cambiar o crear Tags.

Añadir los chunks

Quería lograr que la siguiente plantilla:

{% for chunk in collection chunks:3 %}       # collection = [1, 2, 3, 4, 5, 6, 7]
  Row {{forloop.index}}:
  {% for item in chunk %}
    {{ item }}
  {% endfor %}
{% endfor %}
	

devuelve el siguiente resultado:

Row 1: 123
Row 2: 45
Row 3: 67
	

Algo parecido ya se puede hacer con un atributo del bucle que no sale en la documentación. Si usamos "{% for i in collection limit:3 offset:continue %}" mas que una vez cada nueva llamada va a continuar en la colección donde la antigua ha terminado. Pero así no podemos distribuir uniformemente los elementos entre las columnas o filas. Lo que necesitaba yo era una distribución con un máximo de diferencia de tamaño de un elemento porque visualmente parece mas bonito.

Para añadir esa funcionalidad hay que saber que un Tag tiene que implementar dos métodos, initialize y render. El primero se utiliza cuando llamamos el método parse de Template. El segundo es donde generamos el resultado final al llamar el método render del objeto Template recibido antes.

initialize

El código del tag For es el siguiente:

	    1     def initialize(tag_name, markup, tokens)
	    2       if markup =~ Syntax
	    3         @variable_name = $1
	    4         @collection_name = $2
	    5         @name = "#{$1}-#{$2}"
	    6         @reversed = $3             
	    7         @attributes = {}
	    8         markup.scan(TagAttributes) do |key, value|
	    9           @attributes[key] = value
	   10         end        
	   11       else
	   12         raise SyntaxError.new("Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]")
	   13       end
	   14 
	   15       super
	   16     end
	
	

Ni siquiera hace falta cambiar nada, porque Liquid nos devuelve todo lo que añadimos a un tag de forma <nombre>:<valor> como una cadena en la variable markup. Y como vemos en las lineas 8 a 10 se guarda esto en el Hash @attributes.

render

Entonces lo único que tenemos que hacer es cambiar el comportamiento de For cuando produce su resultado. Abajo podéis ver el código final del método. He añadido las lineas 26 a 35 al código original.

	    1     def render(context)        
	    2       context.registers[:for] ||= Hash.new(0)
	    3     
	    4       collection = context[@collection_name]
	    5       collection = collection.to_a if collection.is_a?(Range)
	    6     
	    7       return '' unless collection.respond_to?(:each) 
	    8     
	    9       from = if @attributes['offset'] == 'continue'
	   10         context.registers[:for][@name].to_i
	   11       else
	   12         context[@attributes['offset']].to_i
	   13       end
	   14         
	   15       limit = context[@attributes['limit']]
	   16       to    = limit ? limit.to_i + from : nil  
	   17           
	   18                        
	   19       segment = slice_collection_using_each(collection, from, to)      
	   20       
	   21       return '' if segment.empty?
	   22       
	   23       segment.reverse! if @reversed
	   24 
	   25       result = []
	   26       if @attributes['chunks']
	   27         chunk_count = context[@attributes['chunks']]
	   28         chunk_length = segment.length / chunk_count
	   29         padding_count = segment.length % chunk_count
	   30         chunks = []
	   31         (1..chunk_count).each do |i|
	   32           chunks << segment.slice!(0, chunk_length + (i <= padding_count ? 1 : 0))
	   33         end
	   34         segment = chunks
	   35       end
	   36 
	   37       length = segment.length            
	   38             
	   39       # Store our progress through the collection for the continue flag
	   40       context.registers[:for][@name] = from + segment.length
	   41               
	   42       context.stack do 
	   43         segment.each_with_index do |item, index|     
	   44           context[@variable_name] = item
	   45           context['forloop'] = {
	   46             'name'    => @name,
	   47             'length'  => length,
	   48             'index'   => index + 1, 
	   49             'index0'  => index, 
	   50             'rindex'  => length - index,
	   51             'rindex0' => length - index -1,
	   52             'first'   => (index == 0),
	   53             'last'    => (index == length - 1) }
	   54 
	   55           result << render_all(@nodelist, context)
	   56         end
	   57       end
	   58       result     
	   59     end          
	
	

Primero compruebo si el autor quiere obtener el contenido de la colección en varios trozos, es decir si existe el atributo "chunks". Si eso, obtengo el valor de este atributo en la siguiente linea. Aquí hay un detalle importante y es la razón porque arriba he hablado de context. En vez de usar @attributes['chunks'] directamente lo uso con context. context nos permite acceder a los variables accesibles en este momento. Así el usuario puede definir el numero de "chunks" con otro variable.

El resto del código ya no es nada especial. Para cada trozo quito el numero correcto de elementos de la colección. Al final reemplazo la misma colección con el nuevo resultado, que es una lista de listas.