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 2 if markup =~ Syntax 3 @variable_name = $1 4 @collection_name = $2 5 @name = "-" 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 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.
