FEATURE: Native theme support

This feature introduces the concept of themes. Themes are an evolution
of site customizations.

Themes introduce two very big conceptual changes:

- A theme may include other "child themes", children can include grand
children and so on.

- A theme may specify a color scheme

The change does away with the idea of "enabled" color schemes.

It also adds a bunch of big niceties like

- You can source a theme from a git repo

- History for themes is much improved

- You can only have a single enabled theme. Themes can be selected by
    users, if you opt for it.

On a technical level this change comes with a whole bunch of goodies

- All CSS is now compiled using a custom pipeline that uses libsass
    see /lib/stylesheet

- There is a single pipeline for css compilation (in the past we used
    one for customizations and another one for the rest of the app

- The stylesheet pipeline is now divorced of sprockets, there is no
   reliance on sprockets for CSS bundling

- CSS is generated with source maps everywhere (including themes) this
    makes debugging much easier

- Our "live reloader" is smarter and avoid a flash of unstyled content
   we run a file watcher in "puma" in dev so you no longer need to run
   rake autospec to watch for CSS changes
This commit is contained in:
Sam
2017-04-12 10:52:52 -04:00
parent 1a9afa976d
commit a3e8c3cd7b
163 changed files with 4415 additions and 2424 deletions

View File

@@ -3,7 +3,7 @@ class Admin::ColorSchemesController < Admin::AdminController
before_filter :fetch_color_scheme, only: [:update, :destroy]
def index
render_serialized([ColorScheme.base] + ColorScheme.current_version.order('id ASC').all.to_a, ColorSchemeSerializer)
render_serialized(ColorScheme.base_color_schemes + ColorScheme.order('id ASC').all.to_a, ColorSchemeSerializer)
end
def create
@@ -37,6 +37,6 @@ class Admin::ColorSchemesController < Admin::AdminController
end
def color_scheme_params
params.permit(color_scheme: [:enabled, :name, colors: [:name, :hex]])[:color_scheme]
params.permit(color_scheme: [:base_scheme_id, :name, colors: [:name, :hex]])[:color_scheme]
end
end

View File

@@ -1,92 +0,0 @@
class Admin::SiteCustomizationsController < Admin::AdminController
before_filter :enable_customization
skip_before_filter :check_xhr, only: [:show]
def index
@site_customizations = SiteCustomization.order(:name)
respond_to do |format|
format.json { render json: @site_customizations }
end
end
def create
@site_customization = SiteCustomization.new(site_customization_params)
@site_customization.user_id = current_user.id
respond_to do |format|
if @site_customization.save
log_site_customization_change(nil, site_customization_params)
format.json { render json: @site_customization, status: :created}
else
format.json { render json: @site_customization.errors, status: :unprocessable_entity }
end
end
end
def update
@site_customization = SiteCustomization.find(params[:id])
log_record = log_site_customization_change(@site_customization, site_customization_params)
respond_to do |format|
if @site_customization.update_attributes(site_customization_params)
format.json { render json: @site_customization, status: :created}
else
log_record.destroy if log_record
format.json { render json: @site_customization.errors, status: :unprocessable_entity }
end
end
end
def destroy
@site_customization = SiteCustomization.find(params[:id])
StaffActionLogger.new(current_user).log_site_customization_destroy(@site_customization)
@site_customization.destroy
respond_to do |format|
format.json { head :no_content }
end
end
def show
@site_customization = SiteCustomization.find(params[:id])
respond_to do |format|
format.json do
check_xhr
render json: SiteCustomizationSerializer.new(@site_customization)
end
format.any(:html, :text) do
raise RenderEmpty.new if request.xhr?
response.headers['Content-Disposition'] = "attachment; filename=#{@site_customization.name.parameterize}.dcstyle.json"
response.sending_file = true
render json: SiteCustomizationSerializer.new(@site_customization)
end
end
end
private
def site_customization_params
params.require(:site_customization)
.permit(:name, :stylesheet, :header, :top, :footer,
:mobile_stylesheet, :mobile_header, :mobile_top, :mobile_footer,
:head_tag, :body_tag,
:position, :enabled, :key,
:stylesheet_baked, :embedded_css)
end
def log_site_customization_change(old_record, new_params)
StaffActionLogger.new(current_user).log_site_customization_change(old_record, new_params)
end
def enable_customization
session[:disable_customization] = false
end
end

View File

@@ -6,4 +6,73 @@ class Admin::StaffActionLogsController < Admin::AdminController
render_serialized(staff_action_logs, UserHistorySerializer)
end
def diff
require_dependency "discourse_diff"
@history = UserHistory.find(params[:id])
prev = @history.previous_value
cur = @history.new_value
prev = JSON.parse(prev) if prev
cur = JSON.parse(cur) if cur
diff_fields = {}
output = "<h2>#{CGI.escapeHTML(cur["name"].to_s)}</h2><p></p>"
diff_fields["name"] = {
prev: prev["name"].to_s,
cur: cur["name"].to_s,
}
["default", "user_selectable"].each do |f|
diff_fields[f] = {
prev: (!!prev[f]).to_s,
cur: (!!cur[f]).to_s
}
end
diff_fields["color scheme"] = {
prev: prev["color_scheme"]&.fetch("name").to_s,
cur: cur["color_scheme"]&.fetch("name").to_s,
}
diff_fields["included themes"] = {
prev: child_themes(prev),
cur: child_themes(cur)
}
load_diff(diff_fields, :cur, cur)
load_diff(diff_fields, :prev, prev)
diff_fields.delete_if{|k,v| v[:cur] == v[:prev]}
diff_fields.each do |k,v|
output << "<h3>#{k}</h3><p></p>"
diff = DiscourseDiff.new(v[:prev] || "", v[:cur] || "")
output << diff.side_by_side_markdown
end
render json: {side_by_side: output}
end
protected
def child_themes(theme)
return "" unless children = theme["child_themes"]
children.map{|row| row["name"]}.join(" ").to_s
end
def load_diff(hash, key, val)
if f=val["theme_fields"]
f.each do |row|
entry = hash[row["target"] + " " + row["name"]] ||= {}
entry[key] = row["value"]
end
end
end
end

View File

@@ -0,0 +1,192 @@
class Admin::ThemesController < Admin::AdminController
skip_before_filter :check_xhr, only: [:show]
def import
@theme = nil
if params[:theme]
json = JSON::parse(params[:theme].read)
theme = json['theme']
@theme = Theme.new(name: theme["name"], user_id: current_user.id)
theme["theme_fields"]&.each do |field|
@theme.set_field(field["target"], field["name"], field["value"])
end
if @theme.save
log_theme_change(nil, @theme)
render json: @theme, status: :created
else
render json: @theme.errors, status: :unprocessable_entity
end
elsif params[:remote]
@theme = RemoteTheme.import_theme(params[:remote])
render json: @theme, status: :created
else
render json: @theme.errors, status: :unprocessable_entity
end
end
def index
@theme = Theme.order(:name).includes(:theme_fields, :remote_theme)
@color_schemes = ColorScheme.all.to_a
light = ColorScheme.new(name: I18n.t("color_schemes.default"))
@color_schemes.unshift(light)
payload = {
themes: ActiveModel::ArraySerializer.new(@theme, each_serializer: ThemeSerializer),
extras: {
color_schemes: ActiveModel::ArraySerializer.new(@color_schemes, each_serializer: ColorSchemeSerializer)
}
}
respond_to do |format|
format.json { render json: payload}
end
end
def create
@theme = Theme.new(name: theme_params[:name],
user_id: current_user.id,
user_selectable: theme_params[:user_selectable] || false,
color_scheme_id: theme_params[:color_scheme_id])
set_fields
respond_to do |format|
if @theme.save
update_default_theme
log_theme_change(nil, @theme)
format.json { render json: @theme, status: :created}
else
format.json { render json: @theme.errors, status: :unprocessable_entity }
end
end
end
def update
@theme = Theme.find(params[:id])
original_json = ThemeSerializer.new(@theme, root: false).to_json
[:name, :color_scheme_id, :user_selectable].each do |field|
if theme_params.key?(field)
@theme.send("#{field}=", theme_params[field])
end
end
if theme_params.key?(:child_theme_ids)
expected = theme_params[:child_theme_ids].map(&:to_i)
@theme.child_theme_relation.to_a.each do |child|
if expected.include?(child.child_theme_id)
expected.reject!{|id| id == child.child_theme_id}
else
child.destroy
end
end
Theme.where(id: expected).each do |theme|
@theme.add_child_theme!(theme)
end
end
set_fields
if params[:theme][:remote_check]
@theme.remote_theme.update_remote_version
@theme.remote_theme.save!
end
if params[:theme][:remote_update]
@theme.remote_theme.update_from_remote
@theme.remote_theme.save!
end
respond_to do |format|
if @theme.save
update_default_theme
log_theme_change(original_json, @theme)
format.json { render json: @theme, status: :created}
else
format.json { render json: @theme.errors, status: :unprocessable_entity }
end
end
end
def destroy
@theme = Theme.find(params[:id])
StaffActionLogger.new(current_user).log_theme_destroy(@theme)
@theme.destroy
respond_to do |format|
format.json { head :no_content }
end
end
def show
@theme = Theme.find(params[:id])
respond_to do |format|
format.json do
check_xhr
render json: ThemeSerializer.new(@theme)
end
format.any(:html, :text) do
raise RenderEmpty.new if request.xhr?
response.headers['Content-Disposition'] = "attachment; filename=#{@theme.name.parameterize}.dcstyle.json"
response.sending_file = true
render json: ThemeSerializer.new(@theme)
end
end
end
private
def update_default_theme
if theme_params.key?(:default)
is_default = theme_params[:default]
if @theme.key == SiteSetting.default_theme_key && !is_default
Theme.clear_default!
elsif is_default
@theme.set_default!
end
end
end
def theme_params
@theme_params ||=
begin
# deep munge is a train wreck, work around it for now
params[:theme][:child_theme_ids] ||= [] if params[:theme].key?(:child_theme_ids)
params.require(:theme)
.permit(:name,
:color_scheme_id,
:default,
:user_selectable,
theme_fields: [:name, :target, :value],
child_theme_ids: [])
end
end
def set_fields
return unless fields = theme_params[:theme_fields]
fields.each do |field|
@theme.set_field(field[:target], field[:name], field[:value])
end
end
def log_theme_change(old_record, new_record)
StaffActionLogger.new(current_user).log_theme_change(old_record, new_record)
end
end

View File

@@ -411,8 +411,8 @@ class ApplicationController < ActionController::Base
def custom_html_json
target = view_context.mobile_view? ? :mobile : :desktop
data = {
top: SiteCustomization.custom_top(session[:preview_style], target),
footer: SiteCustomization.custom_footer(session[:preview_style], target)
top: Theme.lookup_field(session[:preview_style], target, "after_header"),
footer: Theme.lookup_field(session[:preview_style], target, "footer")
}
if DiscoursePluginRegistry.custom_html

View File

@@ -1,35 +0,0 @@
class SiteCustomizationsController < ApplicationController
skip_before_filter :preload_json, :check_xhr, :redirect_to_login_if_required
def show
no_cookies
cache_time = request.env["HTTP_IF_MODIFIED_SINCE"]
cache_time = Time.rfc2822(cache_time) rescue nil if cache_time
stylesheet_time =
begin
if params[:key].to_s == SiteCustomization::ENABLED_KEY
SiteCustomization.where(enabled: true)
.order('created_at desc')
.limit(1)
.pluck(:created_at)
.first
else
SiteCustomization.where(key: params[:key].to_s).pluck(:created_at).first
end
end
if !stylesheet_time
raise Discourse::NotFound
end
if cache_time && stylesheet_time <= cache_time
return render nothing: true, status: 304
end
response.headers["Last-Modified"] = stylesheet_time.httpdate
expires_in 1.year, public: true
render text: SiteCustomization.stylesheet_contents(params[:key], params[:target]),
content_type: "text/css"
end
end

View File

@@ -1,12 +1,40 @@
class StylesheetsController < ApplicationController
skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show]
skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_source_map]
def show_source_map
show_resource(source_map: true)
end
def show
show_resource
end
protected
def show_resource(source_map: false)
extension = source_map ? ".css.map" : ".css"
params[:name]
no_cookies
target,digest = params[:name].split(/_([a-f0-9]{40})/)
if Rails.env == "development"
# TODO add theme
# calling this method ensures we have a cache for said target
# we hold of re-compilation till someone asks for asset
if target.include?("theme")
split_target,theme_id = target.split(/_(-?[0-9]+)/)
theme = Theme.find(theme_id) if theme_id
else
split_target,color_scheme_id = target.split(/_(-?[0-9]+)/)
theme = Theme.find_by(color_scheme_id: color_scheme_id)
end
Stylesheet::Manager.stylesheet_link_tag(split_target, nil, theme&.key)
end
cache_time = request.env["HTTP_IF_MODIFIED_SINCE"]
cache_time = Time.rfc2822(cache_time) rescue nil if cache_time
@@ -19,7 +47,7 @@ class StylesheetsController < ApplicationController
# Security note, safe due to route constraint
underscore_digest = digest ? "_" + digest : ""
location = "#{Rails.root}/#{DiscourseStylesheets::CACHE_PATH}/#{target}#{underscore_digest}.css"
location = "#{Rails.root}/#{Stylesheet::Manager::CACHE_PATH}/#{target}#{underscore_digest}#{extension}"
stylesheet_time = query.pluck(:created_at).first
@@ -33,24 +61,31 @@ class StylesheetsController < ApplicationController
unless File.exist?(location)
if current = query.first
File.write(location, current.content)
if current = query.limit(1).pluck(source_map ? :source_map : :content).first
File.write(location, current)
else
raise Discourse::NotFound
end
end
response.headers['Last-Modified'] = stylesheet_time.httpdate if stylesheet_time
immutable_for(1.year) unless Rails.env == "development"
if Rails.env == "development"
response.headers['Last-Modified'] = Time.zone.now.httpdate
immutable_for(1.second)
else
response.headers['Last-Modified'] = stylesheet_time.httpdate if stylesheet_time
immutable_for(1.year)
end
send_file(location, disposition: :inline)
end
protected
def handle_missing_cache(location, name, digest)
location = location.sub(".css.map", ".css")
source_map_location = location + ".map"
existing = File.read(location) rescue nil
if existing && digest
StylesheetCache.add(name, digest, existing)
source_map = File.read(source_map_location) rescue nil
StylesheetCache.add(name, digest, existing, source_map)
end
end

View File

@@ -0,0 +1,28 @@
class ThemesController < ::ApplicationController
def assets
theme_key = params[:key].to_s
if theme_key == "default"
theme_key = nil
else
raise Discourse::NotFound unless Theme.where(key: theme_key).exists?
end
object = [:mobile, :desktop, :desktop_theme, :mobile_theme].map do |target|
link = Stylesheet::Manager.stylesheet_link_tag(target, 'all', params[:key])
if link
href = link.split(/["']/)[1]
if Rails.env.development?
href << (href.include?("?") ? "&" : "?")
href << SecureRandom.hex
end
{
target: target,
url: href
}
end
end.compact
render json: object.as_json
end
end