mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
192
app/controllers/admin/themes_controller.rb
Normal file
192
app/controllers/admin/themes_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
28
app/controllers/themes_controller.rb
Normal file
28
app/controllers/themes_controller.rb
Normal 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
|
||||
Reference in New Issue
Block a user