370 lines
17 KiB
Ruby
370 lines
17 KiB
Ruby
#
|
|
# Copyright (C) 2015 - present Instructure, Inc.
|
|
#
|
|
# This file is part of Canvas.
|
|
#
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, version 3 of the License.
|
|
#
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
require 'pathname'
|
|
require 'yaml'
|
|
require 'open3'
|
|
|
|
module BrandableCSS
|
|
APP_ROOT = defined?(Rails) && Rails.root || Pathname.pwd
|
|
CONFIG = YAML.load_file(APP_ROOT.join('config/brandable_css.yml')).freeze
|
|
BRANDABLE_VARIABLES = JSON.parse(File.read(APP_ROOT.join(CONFIG['paths']['brandable_variables_json']))).freeze
|
|
MIGRATION_NAME = 'RegenerateBrandFilesBasedOnNewDefaults'.freeze
|
|
|
|
use_compressed = (defined?(Rails) && Rails.env.production?) || (ENV['RAILS_ENV'] == 'production')
|
|
SASS_STYLE = ENV['SASS_STYLE'] || ((use_compressed ? 'compressed' : 'nested')).freeze
|
|
|
|
VARIABLE_HUMAN_NAMES = {
|
|
"ic-brand-primary" => lambda { I18n.t("Primary Brand Color") },
|
|
"ic-brand-font-color-dark" => lambda { I18n.t("Main Text Color") },
|
|
"ic-link-color" => lambda { I18n.t("Link Color") },
|
|
"ic-brand-button--primary-bgd" => lambda { I18n.t("Primary Button") },
|
|
"ic-brand-button--primary-text" => lambda { I18n.t("Primary Button Text") },
|
|
"ic-brand-button--secondary-bgd" => lambda { I18n.t("Secondary Button") },
|
|
"ic-brand-button--secondary-text" => lambda { I18n.t("Secondary Button Text") },
|
|
"ic-brand-global-nav-bgd" => lambda { I18n.t("Nav Background") },
|
|
"ic-brand-global-nav-ic-icon-svg-fill" => lambda { I18n.t("Nav Icon") },
|
|
"ic-brand-global-nav-ic-icon-svg-fill--active" => lambda { I18n.t("Nav Icon Active") },
|
|
"ic-brand-global-nav-menu-item__text-color" => lambda { I18n.t("Nav Text") },
|
|
"ic-brand-global-nav-menu-item__text-color--active" => lambda { I18n.t("Nav Text Active") },
|
|
"ic-brand-global-nav-avatar-border" => lambda { I18n.t("Nav Avatar Border") },
|
|
"ic-brand-global-nav-menu-item__badge-bgd" => lambda { I18n.t("Nav Badge") },
|
|
"ic-brand-global-nav-menu-item__badge-text" => lambda { I18n.t("Nav Badge Text") },
|
|
"ic-brand-global-nav-logo-bgd" => lambda { I18n.t("Nav Logo Background") },
|
|
"ic-brand-header-image" => lambda { I18n.t("Nav Logo") },
|
|
"ic-brand-mobile-global-nav-logo" => lambda { I18n.t("Responsive Global Nav Logo") },
|
|
"ic-brand-watermark" => lambda { I18n.t("Watermark") },
|
|
"ic-brand-watermark-opacity" => lambda { I18n.t("Watermark Opacity") },
|
|
"ic-brand-favicon" => lambda { I18n.t("Favicon") },
|
|
"ic-brand-apple-touch-icon" => lambda { I18n.t("Mobile Homescreen Icon") },
|
|
"ic-brand-msapplication-tile-color" => lambda { I18n.t("Windows Tile Color") },
|
|
"ic-brand-msapplication-tile-square" => lambda { I18n.t("Windows Tile: Square") },
|
|
"ic-brand-msapplication-tile-wide" => lambda { I18n.t("Windows Tile: Wide") },
|
|
"ic-brand-right-sidebar-logo" => lambda { I18n.t("Right Sidebar Logo") },
|
|
"ic-brand-Login-body-bgd-color" => lambda { I18n.t("Background Color") },
|
|
"ic-brand-Login-body-bgd-image" => lambda { I18n.t("Background Image") },
|
|
"ic-brand-Login-body-bgd-shadow-color" => lambda { I18n.t("Body Shadow") },
|
|
"ic-brand-Login-logo" => lambda { I18n.t("Login Logo") },
|
|
"ic-brand-Login-Content-bgd-color" => lambda { I18n.t("Top Box Background") },
|
|
"ic-brand-Login-Content-border-color" => lambda { I18n.t("Top Box Border") },
|
|
"ic-brand-Login-Content-inner-bgd" => lambda { I18n.t("Inner Box Background") },
|
|
"ic-brand-Login-Content-inner-border" => lambda { I18n.t("Inner Box Border") },
|
|
"ic-brand-Login-Content-inner-body-bgd" => lambda { I18n.t("Form Background") },
|
|
"ic-brand-Login-Content-inner-body-border" => lambda { I18n.t("Form Border") },
|
|
"ic-brand-Login-Content-label-text-color" => lambda { I18n.t("Login Label") },
|
|
"ic-brand-Login-Content-password-text-color" => lambda { I18n.t("Login Link Color") },
|
|
"ic-brand-Login-footer-link-color" => lambda { I18n.t("Login Footer Link") },
|
|
"ic-brand-Login-footer-link-color-hover" => lambda { I18n.t("Login Footer Link Hover") },
|
|
"ic-brand-Login-instructure-logo" => lambda { I18n.t("Login Instructure Logo") }
|
|
}.freeze
|
|
|
|
GROUP_NAMES = {
|
|
"global_branding" => lambda { I18n.t("Global Branding") },
|
|
"global_navigation" => lambda { I18n.t("Global Navigation") },
|
|
"watermarks" => lambda { I18n.t("Watermarks & Other Images") },
|
|
"login" => lambda { I18n.t("Login Screen") }
|
|
}.freeze
|
|
|
|
HELPER_TEXTS = {
|
|
"ic-brand-header-image" => lambda { I18n.t("Accepted formats: svg, png, jpg, gif") },
|
|
"ic-brand-mobile-global-nav-logo" => lambda { I18n.t("Appears at the top of the global navigation tray that opens on mobile sized screens. display height: 48px. Accepted formats: svg, png, jpg, gif") },
|
|
"ic-brand-watermark" => lambda { I18n.t("This image appears as a background watermark to your page. Accepted formats: png, svg, gif, jpeg") },
|
|
"ic-brand-watermark-opacity" => lambda { I18n.t("Specify the transparency of the watermark background image.") },
|
|
"ic-brand-favicon" => lambda { I18n.t("You can use a single 16x16, 32x32, 48x48 ico file.") },
|
|
"ic-brand-apple-touch-icon" => lambda { I18n.t("The shortcut icon for iOS/Android devices. 180x180 png") },
|
|
"ic-brand-msapplication-tile-square" => lambda { I18n.t("558x558 png, jpg, gif (1.8x the standard tile size, so it can be scaled up or down as needed)") },
|
|
"ic-brand-msapplication-tile-wide" => lambda { I18n.t("558x270 png, jpg, gif") },
|
|
"ic-brand-right-sidebar-logo" => lambda { I18n.t("A full-size logo that appears in the right sidebar on the Canvas dashboard. Ideal size is 360 x 140 pixels. Accepted formats: svg, png, jpeg, gif") },
|
|
"ic-brand-Login-body-bgd-shadow-color" => lambda { I18n.t("accepted formats: hex, rgba, rgb, hsl") }
|
|
}.freeze
|
|
|
|
class << self
|
|
def variables_map
|
|
@variables_map ||= BRANDABLE_VARIABLES.each_with_object({}) do |variable_group, memo|
|
|
variable_group['variables'].each { |variable| memo[variable['variable_name']] = variable }
|
|
end.freeze
|
|
end
|
|
|
|
def variables_map_with_image_urls
|
|
@variables_map_with_image_urls ||= variables_map.each_with_object({}) do |(key, config), memo|
|
|
if config['type'] == 'image'
|
|
memo[key] = config.merge('default' => ActionController::Base.helpers.image_url(config['default']))
|
|
else
|
|
memo[key] = config
|
|
end
|
|
end.freeze
|
|
end
|
|
|
|
def things_that_go_into_defaults_md5
|
|
variables_map.each_with_object({}) do |(variable_name, config), memo|
|
|
default = config['default']
|
|
if config['type'] == 'image'
|
|
# to make consistent md5s whether the cdn is enabled or not, don't include hostname in defaults
|
|
default = ActionController::Base.helpers.image_path(default, host: '')
|
|
end
|
|
memo[variable_name] = default
|
|
end.freeze
|
|
end
|
|
|
|
def migration_version
|
|
# ActiveRecord usually uses integer timestamps to generate migration versions but any integer
|
|
# will work, so we just use the result of stripping out the alphabetic characters from the md5
|
|
default_variables_md5_without_migration_check.gsub(/[a-z]/, '').to_i.freeze
|
|
end
|
|
|
|
def check_if_we_need_to_create_a_db_migration
|
|
path = ActiveRecord::Migrator.migrations_paths.first
|
|
args = [path]
|
|
args << ActiveRecord::SchemaMigration unless CANVAS_RAILS5_2
|
|
migrations = ActiveRecord::MigrationContext.new(*args).migrations
|
|
['predeploy', 'postdeploy'].each do |pre_or_post|
|
|
migration = migrations.find { |m| m.name == MIGRATION_NAME + pre_or_post.camelize }
|
|
# they can't have the same id, so we just add 1 to the postdeploy one
|
|
expected_version = (pre_or_post == 'predeploy') ? migration_version : (migration_version + 1)
|
|
raise BrandConfigWithOutCompileAssets if expected_version == 85663486644871658581990
|
|
raise DefaultMD5NotUpToDateError unless migration && migration.version == expected_version
|
|
end
|
|
end
|
|
|
|
def skip_migration_check?
|
|
# our canvas_rspec build doesn't even run `yarn install` or `gulp rev` so since
|
|
# they are not expecting all the frontend assets to work, this check isn't useful
|
|
Rails.env.test? && !Rails.root.join('public', 'dist', 'rev-manifest.json').exist?
|
|
end
|
|
|
|
def default_variables_md5
|
|
@default_variables_md5 ||= begin
|
|
check_if_we_need_to_create_a_db_migration unless skip_migration_check?
|
|
default_variables_md5_without_migration_check
|
|
end
|
|
end
|
|
|
|
def default_variables_md5_without_migration_check
|
|
Digest::MD5.hexdigest(things_that_go_into_defaults_md5.to_json).freeze
|
|
end
|
|
|
|
def handle_urls(value, config, css_urls)
|
|
return value unless config['type'] == 'image' && css_urls
|
|
"url('#{value}')" if value.present?
|
|
end
|
|
|
|
# gets the *effective* value for a brandable variable
|
|
def brand_variable_value(variable_name, active_brand_config=nil, config_map=variables_map, css_urls=false)
|
|
config = config_map[variable_name]
|
|
explicit_value = active_brand_config && active_brand_config.get_value(variable_name).presence
|
|
return handle_urls(explicit_value, config, css_urls) if explicit_value
|
|
default = config['default']
|
|
if default && default.starts_with?('$')
|
|
if css_urls
|
|
return "var(--#{default[1..-1]})"
|
|
else
|
|
return brand_variable_value(default[1..-1], active_brand_config, config_map, css_urls)
|
|
end
|
|
end
|
|
|
|
# while in our sass, we want `url(/images/foo.png)`,
|
|
# the Rails Asset Helpers expect us to not have the '/images/', eg: <%= image_tag('foo.png') %>
|
|
default = default.sub(/^\/images\//, '') if config['type'] == 'image'
|
|
handle_urls(default, config, css_urls)
|
|
end
|
|
|
|
def computed_variables(active_brand_config=nil)
|
|
[
|
|
['ic-brand-primary', 'darken', 5],
|
|
['ic-brand-primary', 'darken', 10],
|
|
['ic-brand-primary', 'darken', 15],
|
|
['ic-brand-primary', 'lighten', 5],
|
|
['ic-brand-primary', 'lighten', 10],
|
|
['ic-brand-primary', 'lighten', 15],
|
|
['ic-brand-button--primary-bgd', 'darken', 5],
|
|
['ic-brand-button--primary-bgd', 'darken', 15],
|
|
['ic-brand-button--secondary-bgd', 'darken', 5],
|
|
['ic-brand-button--secondary-bgd', 'darken', 15],
|
|
['ic-brand-font-color-dark', 'lighten', 15],
|
|
['ic-brand-font-color-dark', 'lighten', 30],
|
|
['ic-link-color', 'darken', 10],
|
|
['ic-link-color', 'lighten', 10],
|
|
].each_with_object({}) do |(variable_name, darken_or_lighten, percent), memo|
|
|
color = brand_variable_value(variable_name, active_brand_config, variables_map_with_image_urls)
|
|
computed_color = CanvasColor::Color.new(color).send(darken_or_lighten, percent/100.0)
|
|
memo["#{variable_name}-#{darken_or_lighten}ed-#{percent}"] = computed_color.to_s
|
|
end
|
|
end
|
|
|
|
def all_brand_variable_values(active_brand_config=nil, css_urls=false)
|
|
variables_map.each_with_object(computed_variables(active_brand_config)) do |(key, _), memo|
|
|
memo[key] = brand_variable_value(key, active_brand_config, variables_map_with_image_urls, css_urls)
|
|
end
|
|
end
|
|
|
|
def all_brand_variable_values_as_json(active_brand_config=nil)
|
|
all_brand_variable_values(active_brand_config).to_json
|
|
end
|
|
|
|
def all_brand_variable_values_as_js(active_brand_config=nil)
|
|
"CANVAS_ACTIVE_BRAND_VARIABLES = #{all_brand_variable_values_as_json(active_brand_config)};"
|
|
end
|
|
|
|
def all_brand_variable_values_as_css(active_brand_config=nil)
|
|
":root {
|
|
#{all_brand_variable_values(active_brand_config, true).map{ |k, v| "--#{k}: #{v};"}.join("\n")}
|
|
}"
|
|
end
|
|
|
|
def public_brandable_css_folder
|
|
Pathname.new('public/dist/brandable_css')
|
|
end
|
|
|
|
def default_brand_folder
|
|
public_brandable_css_folder.join('default')
|
|
end
|
|
|
|
def default_brand_file(type, high_contrast=false)
|
|
default_brand_folder.join("variables#{high_contrast ? '-high_contrast' : ''}-#{default_variables_md5}.#{type}")
|
|
end
|
|
|
|
def high_contrast_overrides
|
|
Class.new do
|
|
def get_value(variable_name)
|
|
{"ic-brand-primary" => "#0770A3", "ic-link-color" => "#0073A7"}[variable_name]
|
|
end
|
|
end.new
|
|
end
|
|
|
|
def default(type, high_contrast=false)
|
|
bc = high_contrast ? high_contrast_overrides : nil
|
|
send("all_brand_variable_values_as_#{type}", bc)
|
|
end
|
|
|
|
def save_default!(type, high_contrast=false)
|
|
default_brand_folder.mkpath
|
|
default_brand_file(type, high_contrast).write(default(type, high_contrast))
|
|
move_default_to_s3_if_enabled!(type, high_contrast)
|
|
end
|
|
|
|
def save_default_files!
|
|
[true, false].each do |high_contrast|
|
|
['js', 'css', 'json'].each { |type| save_default!(type, high_contrast) }
|
|
end
|
|
end
|
|
|
|
def move_default_to_s3_if_enabled!(type, high_contrast=false)
|
|
return unless defined?(Canvas) && Canvas::Cdn.enabled?
|
|
s3_uploader.upload_file(public_default_path(type, high_contrast))
|
|
begin
|
|
File.delete(default_brand_file(type, high_contrast))
|
|
rescue Errno::ENOENT # continue if something else deleted it in another process
|
|
end
|
|
end
|
|
|
|
def s3_uploader
|
|
@s3_uploaderer ||= Canvas::Cdn::S3Uploader.new
|
|
end
|
|
|
|
def public_default_path(type, high_contrast=false)
|
|
"dist/brandable_css/default/variables#{high_contrast ? '-high_contrast' : ''}-#{default_variables_md5}.#{type}"
|
|
end
|
|
|
|
def variants
|
|
@variants ||= CONFIG['variants'].keys.freeze
|
|
end
|
|
|
|
def brandable_variants
|
|
@brandable_variants ||= CONFIG['variants'].select{|_, v| v['brandable']}.map{ |k,_| k }.freeze
|
|
end
|
|
|
|
def combined_checksums
|
|
if defined?(ActionController) && ActionController::Base.perform_caching && defined?(@combined_checksums)
|
|
return @combined_checksums
|
|
end
|
|
file = APP_ROOT.join(CONFIG['paths']['bundles_with_deps'] + SASS_STYLE)
|
|
if file.exist?
|
|
@combined_checksums = JSON.parse(file.read).each_with_object({}) do |(k, v), memo|
|
|
memo[k] = v.symbolize_keys.slice(:combinedChecksum, :includesNoVariables)
|
|
end.freeze
|
|
elsif defined?(Rails) && Rails.env.production?
|
|
raise "#{file.expand_path} does not exist. You need to run brandable_css before you can serve css."
|
|
else
|
|
# for dev/test there might be cases where you don't want it to raise an exception
|
|
# if you haven't ran `brandable_css` and the manifest file doesn't exist yet.
|
|
# eg: you want to test a controller action and you don't care that it links
|
|
# to a css file that hasn't been created yet.
|
|
default_value = {:combinedChecksum => "Error: unknown css checksum. you need to run brandable_css"}.freeze
|
|
@combined_checksums = Hash.new(default_value).freeze
|
|
end
|
|
end
|
|
|
|
# bundle path should be something like "bundles/speedgrader" or "plugins/analytics/something"
|
|
def cache_for(bundle_path, variant)
|
|
key = ["#{bundle_path}.scss", variant].join(CONFIG['manifest_key_seperator'])
|
|
fingerprint = combined_checksums[key]
|
|
raise "Fingerprint not found. #{bundle_path} #{variant}" unless fingerprint
|
|
fingerprint
|
|
end
|
|
|
|
def all_fingerprints_for(bundle_path)
|
|
variants.each_with_object({}) do |variant, object|
|
|
object[variant] = cache_for(bundle_path, variant)
|
|
end
|
|
end
|
|
end
|
|
|
|
class BrandConfigWithOutCompileAssets < RuntimeError
|
|
def initialize
|
|
super <<-END
|
|
|
|
It looks like you are running a migration before running `rake canvas:compile_assets`
|
|
compile_assets needs to complete before running db:migrate if brand_configs have not run
|
|
|
|
run `rake canvas:compile_assets` and then try migrations again.
|
|
|
|
END
|
|
end
|
|
end
|
|
|
|
class DefaultMD5NotUpToDateError < RuntimeError
|
|
def initialize
|
|
super <<-END
|
|
|
|
Something has changed about the default variables or images used in the Theme Editor.
|
|
If you are seeing this and _you_ did not make changes to either app/stylesheets/brandable_variables.json
|
|
or one of the images it references, it probably meeans your local setup is out of date.
|
|
|
|
First, make sure you run `rake db:migrate`
|
|
and then run `./script/nuke_node.sh`
|
|
|
|
If that does not resolve the issue, it probably means you _did_ update one of those json variables
|
|
in app/stylesheets/brandable_variables.json or one of the images it references so you need to rename
|
|
the db migrations that makes sure when this change is deployed or checked out by anyone else
|
|
makes a new .css file for the css variables for each brand based on these new defaults.
|
|
To do that, run this command and then restart your rails process. (for local dev, if you want the
|
|
changes to show up in the ui, make sure you also run `rake db:migrate` afterwards).
|
|
|
|
ONLY DO THIS IF YOU REALLY DID MEAN TO MAKE A CHANGE TO THE DEFAULT BRANDING STUFF:
|
|
|
|
mv db/migrate/*_#{MIGRATION_NAME.underscore}_predeploy.rb \\
|
|
db/migrate/#{BrandableCSS.migration_version}_#{MIGRATION_NAME.underscore}_predeploy.rb \\
|
|
&& \\
|
|
mv db/migrate/*_#{MIGRATION_NAME.underscore}_postdeploy.rb \\
|
|
db/migrate/#{BrandableCSS.migration_version + 1}_#{MIGRATION_NAME.underscore}_postdeploy.rb
|
|
|
|
FYI, current variables are: #{BrandableCSS.things_that_go_into_defaults_md5}
|
|
END
|
|
end
|
|
end
|
|
end
|