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.