martes, 10 de julio de 2012

El asset pipeline y los diseñadores

Nuevo blog, primer post. En lugar de escribir un post para simplemente dar a conocer que me he hecho un blog, y luego olvidarlo durante meses (o incluso años), voy a escribir un post que (espero) marcará la temática de este blog (y luego lo olvidaré durante meses - o incluso años)

La problemática: normalmente trabajamos en un equipo donde un diseñador nos entrega una maqueta html + css y a partir de ahí construimos la aplicación. El gran problema viene cuando esta maqueta se va actualizando y es necesario mantener una sincronización. Especialmente dolorosa está siendo la sincronización de los ficheros css, que ya tienen sus buenas 400-500 líneas, y cada vez que hay un cambio en el css hay que buscar a qué línea de nuestro scss corresponde, y viceversa, y aplicar los cambios a mano.

El intento de solución: el diseñador trabaja con una aplicación rails (una única aplicación, muy sencilla, sin base de datos, para todos los proyectos en los que esté trabajando). Esta aplicación tiene las maquetas en html dentro de app/assets/<proyecto>/html, puede utilizar parciales y plantillas, y utiliza los mismos assets que la aplicación rails "de verdad". De hecho, app/assets/<proyecto> será realmente un clone (utilizando el SCM que elijamos) de la carpeta app/assets de la aplicación que estamos desarrollando. Teniendo la aplicación lanzada, el diseñador podrá ver cómo queda el listado de categorías del proyecto 1 en http://localhost:3000/proyecto1/categories/index, y el usuario ciscou del proyecto 2 en http://localhost:3000/proyecto2/users/ciscou

Implementación:

Vamos a comenzar creando una aplicación rails sin utilizar activerecord

$ rails new skeleton --skip-activerecord

Y vamos a hacer limpieza

$ cd skeleton
$ rm -rf app/assets/*
$ rm public/index.html

Creamos el (único) controlador de la aplicación

$ rails g controller html

# app/controllers/html_controller.rb
...
  before_filter :load_project
  before_filter :add_project_paths

  def show
    render template: params[:page]
  end

  private

  def load_project
    @project = params[:project]
  end

  def add_project_paths
    prepend_view_path Rails.root.join("app", "assets", @project, "html")
    Rails.application.config.assets.prefix = "/assets/#{@project}"
  end
...

Y vamos a configurar las rutas

# config/routes.rb
...
  match "/:project/*page" => "html#show"
...

¿Qué hemos hecho hasta ahora? Tenemos una aplicación que mapea rutas del tipo /proyecto1/users/ciscou al controlador html_controller, con los parámetros { :project => "proyecto1", :page => "users/ciscou" }. Además, añadimos el directorio en el que buscar vistas dentro de app/assets/proyecto1/html, y cambiamos el prefijo de los assets a /assets/proyecto1 (por defecto era /assets). De este modo, al utilizar javascript_link_tag, stylesheet_link_tag o image_tag, la url del recurso será, por ejemplo, /assets/proyecto1/application.css. De este modo, podremos saber qué fichero servir cuando tengamos más de un proyecto.

Ya sólo falta este último cambio. Normalmente, sprockets recibirá una petición a /assets/application.css y servirá el primer fichero application.css que encuentre dentro de su directorio de búsqueda. Nosotros necesitamos que la petición sea /assets/proyecto1/application.css, y que en ese caso sirva los ficheros del directorio app/assets/proyecto1. Así que vamos a monkeypatchear Sprockets. (el cambio son sólo 6 líneas, pero el método original ya era bastante largo)

# config/initializers/sprockets.rb
module Sprockets
  module Server
    def call(env)
      start_time = Time.now.to_f
      time_elapsed = lambda { ((Time.now.to_f - start_time) * 1000).to_i }

      msg = "Served asset #{env['PATH_INFO']} -"

      # Mark session as "skipped" so no `Set-Cookie` header is set
      env['rack.session.options'] ||= {}
      env['rack.session.options'][:defer] = true
      env['rack.session.options'][:skip] = true

      # Extract the path from everything after the leading slash
      path = unescape(env['PATH_INFO'].to_s.sub(/^\//, ''))

      # URLs containing a `".."` are rejected for security reasons.
      if forbidden_request?(path)
        return forbidden_response
      end

      # Strip fingerprint
      if fingerprint = path_fingerprint(path)
        path = path.sub("-#{fingerprint}", '')
      end

      # MONKEY PATCH BEGIN

      # path is /<project-name>/resource, let's extract <project-name>
      project_name_re = /^([a-zA-Z0-9_]+)\//
      project_name = path[project_name_re, 1]
      path.gsub! project_name_re, ""

      # add app/assets/<project_name>/stylesheets,
      #     app/assets/<project_name>/javascripts
      # and app/assets/<project_name>/images
      # to the load_path
      prepend_path File.join("app", "assets", project_name, "stylesheets")
      prepend_path File.join("app", "assets", project_name, "javascripts")
      prepend_path File.join("app", "assets", project_name, "images")

      # MONKEY PATCH END

      # Look up the asset.
      asset = find_asset(path, :bundle => !body_only?(env))

      # `find_asset` returns nil if the asset doesn't exist
      if asset.nil?
        logger.info "#{msg} 404 Not Found (#{time_elapsed.call}ms)"

        # Return a 404 Not Found
        not_found_response

      # Check request headers `HTTP_IF_NONE_MATCH` against the asset digest
      elsif etag_match?(asset, env)
        logger.info "#{msg} 304 Not Modified (#{time_elapsed.call}ms)"

        # Return a 304 Not Modified
        not_modified_response(asset, env)

      else
        logger.info "#{msg} 200 OK (#{time_elapsed.call}ms)"

        # Return a 200 with the asset contents
        ok_response(asset, env)
      end
    rescue Exception => e
      logger.error "Error compiling asset #{path}:"
      logger.error "#{e.class.name}: #{e.message}"

      case content_type_of(path)
      when "application/javascript"
        # Re-throw JavaScript asset exceptions to the browser
        logger.info "#{msg} 500 Internal Server Error\n\n"
        return javascript_exception_response(e)
      when "text/css"
        # Display CSS asset exceptions in the browser
        logger.info "#{msg} 500 Internal Server Error\n\n"
        return css_exception_response(e)
      else
        raise
      end
    end
  end
end

Sprockets es una aplicación rack, y como toda aplicación rack expone una interfaz con un método (call) que recibe un parámetro (env) y que devuelve una tupla de 3 elementos ([status, headers, body]). Lo único que hemos cambiado es quitar la parte proyecto1/ del path original, y añadir proyecto1/javascripts|stylesheets|images a los directorios de búsqueda de recursos para esta petición.