FEATURE: Multiple SCSS file support for themes (#7351)

Theme developers can include any number of scss files within the /scss/ directory of a theme. These can then be imported from the main common/desktop/mobile scss.
This commit is contained in:
David Taylor 2019-04-12 11:36:08 +01:00 committed by GitHub
parent 0e9a0a31f5
commit 268d4d4c82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 302 additions and 125 deletions

View File

@ -27,6 +27,7 @@ export default Ember.Component.extend({
@computed("currentTargetName", "fieldName") @computed("currentTargetName", "fieldName")
activeSectionMode(targetName, fieldName) { activeSectionMode(targetName, fieldName) {
if (["settings", "translations"].includes(targetName)) return "yaml"; if (["settings", "translations"].includes(targetName)) return "yaml";
if (["extra_scss"].includes(targetName)) return "scss";
return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html"; return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html";
}, },
@ -73,7 +74,7 @@ export default Ember.Component.extend({
addField(name) { addField(name) {
if (!name) return; if (!name) return;
name = name.replace(/\W/g, ""); name = name.replace(/[^a-zA-Z0-9-_/]/g, "");
this.get("theme").setField(this.get("currentTargetName"), name, ""); this.get("theme").setField(this.get("currentTargetName"), name, "");
this.setProperties({ newFieldName: "", addingField: false }); this.setProperties({ newFieldName: "", addingField: false });
this.fieldAdded(this.get("currentTargetName"), name); this.fieldAdded(this.get("currentTargetName"), name);

View File

@ -27,6 +27,13 @@ const Theme = RestModel.extend({
icon: "globe", icon: "globe",
advanced: true, advanced: true,
customNames: true customNames: true
},
{
id: 5,
name: "extra_scss",
icon: "paint-brush",
advanced: true,
customNames: true
} }
].map(target => { ].map(target => {
target["edited"] = this.hasEdited(target.name); target["edited"] = this.hasEdited(target.name);
@ -46,6 +53,14 @@ const Theme = RestModel.extend({
"footer" "footer"
]; ];
const scss_fields = (this.get("theme_fields") || [])
.filter(f => f.target === "extra_scss" && f.name !== "")
.map(f => f.name);
if (scss_fields.length < 1) {
scss_fields.push("importable_scss");
}
return { return {
common: [...common, "embedded_scss"], common: [...common, "embedded_scss"],
desktop: common, desktop: common,
@ -56,7 +71,8 @@ const Theme = RestModel.extend({
...(this.get("theme_fields") || []) ...(this.get("theme_fields") || [])
.filter(f => f.target === "translations" && f.name !== "en") .filter(f => f.target === "translations" && f.name !== "en")
.map(f => f.name) .map(f => f.name)
] ],
extra_scss: scss_fields
}; };
}, },
@ -71,7 +87,7 @@ const Theme = RestModel.extend({
error: this.hasError(target, fieldName) error: this.hasError(target, fieldName)
}; };
if (target === "translations") { if (target === "translations" || target === "extra_scss") {
field.translatedName = fieldName; field.translatedName = fieldName;
} else { } else {
field.translatedName = I18n.t( field.translatedName = I18n.t(

View File

@ -1,11 +0,0 @@
module Jobs
class RebakeAllHtmlThemeFields < Jobs::Onceoff
def execute_onceoff(args)
ThemeField.where(type_id: ThemeField.types[:html]).find_each do |theme_field|
theme_field.update(value_baked: nil)
end
Theme.clear_cache!
end
end
end

View File

@ -124,13 +124,14 @@ class RemoteTheme < ActiveRecord::Base
end end
theme_info = RemoteTheme.extract_theme_info(importer) theme_info = RemoteTheme.extract_theme_info(importer)
updated_fields = []
theme_info["assets"]&.each do |name, relative_path| theme_info["assets"]&.each do |name, relative_path|
if path = importer.real_path(relative_path) if path = importer.real_path(relative_path)
new_path = "#{File.dirname(path)}/#{SecureRandom.hex}#{File.extname(path)}" new_path = "#{File.dirname(path)}/#{SecureRandom.hex}#{File.extname(path)}"
File.rename(path, new_path) # OptimizedImage has strict file name restrictions, so rename temporarily File.rename(path, new_path) # OptimizedImage has strict file name restrictions, so rename temporarily
upload = UploadCreator.new(File.open(new_path), File.basename(relative_path), for_theme: true).create_for(theme.user_id) upload = UploadCreator.new(File.open(new_path), File.basename(relative_path), for_theme: true).create_for(theme.user_id)
theme.set_field(target: :common, name: name, type: :theme_upload_var, upload_id: upload.id) updated_fields << theme.set_field(target: :common, name: name, type: :theme_upload_var, upload_id: upload.id)
end end
end end
@ -144,9 +145,13 @@ class RemoteTheme < ActiveRecord::Base
importer.all_files.each do |filename| importer.all_files.each do |filename|
next unless opts = ThemeField.opts_from_file_path(filename) next unless opts = ThemeField.opts_from_file_path(filename)
value = importer[filename] value = importer[filename]
theme.set_field(opts.merge(value: value)) updated_fields << theme.set_field(opts.merge(value: value))
end end
# Destroy fields that no longer exist in the remote theme
field_ids_to_destroy = theme.theme_fields.pluck(:id) - updated_fields.map(&:id)
ThemeField.where(id: field_ids_to_destroy).destroy_all
if !skip_update if !skip_update
self.remote_updated_at = Time.zone.now self.remote_updated_at = Time.zone.now
self.remote_version = importer.version self.remote_version = importer.version

View File

@ -51,6 +51,10 @@ class Theme < ActiveRecord::Base
Theme.expire_site_cache! if saved_change_to_user_selectable? || saved_change_to_name? Theme.expire_site_cache! if saved_change_to_user_selectable? || saved_change_to_name?
reload
settings_field&.ensure_baked! # Other fields require setting to be **baked**
theme_fields.each(&:ensure_baked!)
remove_from_cache! remove_from_cache!
clear_cached_settings! clear_cached_settings!
ColorScheme.hex_cache.clear ColorScheme.hex_cache.clear
@ -76,6 +80,8 @@ class Theme < ActiveRecord::Base
Theme.expire_site_cache! Theme.expire_site_cache!
ColorScheme.hex_cache.clear ColorScheme.hex_cache.clear
CSP::Extension.clear_theme_extensions_cache!
SvgSprite.expire_cache
end end
after_commit ->(theme) do after_commit ->(theme) do
@ -224,7 +230,7 @@ class Theme < ActiveRecord::Base
end end
def self.targets def self.targets
@targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4) @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4, extra_scss: 5)
end end
def self.lookup_target(target_id) def self.lookup_target(target_id)
@ -267,15 +273,15 @@ class Theme < ActiveRecord::Base
def self.list_baked_fields(theme_ids, target, name) def self.list_baked_fields(theme_ids, target, name)
target = target.to_sym target = target.to_sym
name = name.to_sym name = name&.to_sym
if target == :translations if target == :translations
fields = ThemeField.find_first_locale_fields(theme_ids, I18n.fallbacks[name]) fields = ThemeField.find_first_locale_fields(theme_ids, I18n.fallbacks[name])
else else
fields = ThemeField.find_by_theme_ids(theme_ids) fields = ThemeField.find_by_theme_ids(theme_ids)
.where(target_id: [Theme.targets[target], Theme.targets[:common]]) .where(target_id: [Theme.targets[target], Theme.targets[:common]])
.where(name: name.to_s) fields = fields.where(name: name.to_s) unless name.nil?
.order(:target_id) fields = fields.order(:target_id)
end end
fields.each(&:ensure_baked!) fields.each(&:ensure_baked!)
@ -325,6 +331,7 @@ class Theme < ActiveRecord::Base
changed_fields << field changed_fields << field
end end
end end
field
else else
theme_fields.build(target_id: target_id, value: value, name: name, type_id: type_id, upload_id: upload_id) if value.present? || upload_id.present? theme_fields.build(target_id: target_id, value: value, name: name, type_id: type_id, upload_id: upload_id) if value.present? || upload_id.present?
end end

View File

@ -7,11 +7,6 @@ class ThemeField < ActiveRecord::Base
belongs_to :upload belongs_to :upload
has_one :javascript_cache, dependent: :destroy has_one :javascript_cache, dependent: :destroy
after_commit do |field|
SvgSprite.expire_cache if field.target_id == Theme.targets[:settings]
SvgSprite.expire_cache if field.name == SvgSprite.theme_sprite_variable_name
end
scope :find_by_theme_ids, ->(theme_ids) { scope :find_by_theme_ids, ->(theme_ids) {
return none unless theme_ids.present? return none unless theme_ids.present?
@ -221,18 +216,16 @@ class ThemeField < ActiveRecord::Base
end end
self.error = errors.join("\n").presence self.error = errors.join("\n").presence
if !self.error && self.target_id == Theme.targets[:settings]
# when settings YAML changes, we need to re-transpile theme JS and CSS
theme.theme_fields.where.not(id: self.id).update_all(value_baked: nil)
end
end end
def self.guess_type(name:, target:) def self.guess_type(name:, target:)
if html_fields.include?(name.to_s) if basic_targets.include?(target.to_s) && html_fields.include?(name.to_s)
types[:html] types[:html]
elsif scss_fields.include?(name.to_s) elsif basic_targets.include?(target.to_s) && scss_fields.include?(name.to_s)
types[:scss] types[:scss]
elsif name.to_s == "yaml" || target.to_s == "translations" elsif target.to_s == "extra_scss"
types[:scss]
elsif target.to_s == "settings" || target.to_s == "translations"
types[:yaml] types[:yaml]
end end
end end
@ -245,46 +238,93 @@ class ThemeField < ActiveRecord::Base
@scss_fields ||= %w(scss embedded_scss) @scss_fields ||= %w(scss embedded_scss)
end end
def self.basic_targets
@basic_targets ||= %w(common desktop mobile)
end
def basic_html_field?
ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
ThemeField.html_fields.include?(self.name)
end
def basic_scss_field?
ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
ThemeField.scss_fields.include?(self.name)
end
def extra_scss_field?
Theme.targets[self.target_id] == :extra_scss
end
def settings_field?
Theme.targets[:settings] == self.target_id
end
def translation_field?
Theme.targets[:translations] == self.target_id
end
def svg_sprite_field?
ThemeField.theme_var_type_ids.include?(self.type_id) && self.name == SvgSprite.theme_sprite_variable_name
end
def ensure_baked! def ensure_baked!
if ThemeField.html_fields.include?(self.name) || translation = Theme.targets[:translations] == self.target_id needs_baking = !self.value_baked || compiler_version != COMPILER_VERSION
if !self.value_baked || compiler_version != COMPILER_VERSION return unless needs_baking
self.value_baked, self.error = translation ? process_translation : process_html(self.value)
self.error = nil unless self.error.present?
self.compiler_version = COMPILER_VERSION
if self.will_save_change_to_value_baked? || if basic_html_field? || translation_field?
self.will_save_change_to_compiler_version? || self.value_baked, self.error = translation_field? ? process_translation : process_html(self.value)
self.will_save_change_to_error? self.error = nil unless self.error.present?
self.compiler_version = COMPILER_VERSION
self.update_columns(value_baked: value_baked, elsif basic_scss_field?
compiler_version: compiler_version, ensure_scss_compiles!
error: error) Stylesheet::Manager.clear_theme_cache!
end elsif settings_field?
end validate_yaml!
theme.clear_cached_settings!
CSP::Extension.clear_theme_extensions_cache!
SvgSprite.expire_cache
self.value_baked = "baked"
self.compiler_version = COMPILER_VERSION
elsif svg_sprite_field?
SvgSprite.expire_cache
self.error = nil
self.value_baked = "baked"
self.compiler_version = COMPILER_VERSION
end end
if self.will_save_change_to_value_baked? ||
self.will_save_change_to_compiler_version? ||
self.will_save_change_to_error?
self.update_columns(value_baked: value_baked,
compiler_version: compiler_version,
error: error)
end
end
def compile_scss
Stylesheet::Compiler.compile("@import \"common/foundation/variables\"; @import \"theme_variables\"; @import \"theme_field\";",
"theme.scss",
theme_field: self.value.dup,
theme: self.theme
)
end end
def ensure_scss_compiles! def ensure_scss_compiles!
if ThemeField.scss_fields.include?(self.name) result = ["failed"]
begin begin
Stylesheet::Compiler.compile("@import \"common/foundation/variables\"; @import \"theme_variables\"; @import \"theme_field\";", result = compile_scss
"theme.scss", self.error = nil unless error.nil?
theme_field: self.value.dup, rescue SassC::SyntaxError => e
theme: self.theme self.error = e.message unless self.destroyed?
)
self.error = nil unless error.nil?
rescue SassC::SyntaxError => e
self.error = e.message unless self.destroyed?
end
if will_save_change_to_error?
update_columns(error: self.error)
end
end end
self.compiler_version = COMPILER_VERSION
self.value_baked = Digest::SHA1.hexdigest(result.join(",")) # We don't use the compiled CSS here, we just use it to invalidate the stylesheet cache
end end
def target_name def target_name
Theme.targets.invert[target_id].to_s Theme.targets[target_id].to_s
end end
class ThemeFileMatcher class ThemeFileMatcher
@ -311,7 +351,7 @@ class ThemeField < ActiveRecord::Base
hash = {} hash = {}
OPTIONS.each do |option| OPTIONS.each do |option|
plural = :"#{option}s" plural = :"#{option}s"
hash[option] = @allowed_values[plural][0] if @allowed_values[plural].length == 1 hash[option] = @allowed_values[plural][0] if @allowed_values[plural] && @allowed_values[plural].length == 1
hash[option] = match[option] if hash[option].nil? hash[option] = match[option] if hash[option].nil?
end end
hash hash
@ -337,6 +377,9 @@ class ThemeField < ActiveRecord::Base
ThemeFileMatcher.new(regex: /^common\/embedded\.scss$/, ThemeFileMatcher.new(regex: /^common\/embedded\.scss$/,
targets: :common, names: "embedded_scss", types: :scss, targets: :common, names: "embedded_scss", types: :scss,
canonical: -> (h) { "common/embedded.scss" }), canonical: -> (h) { "common/embedded.scss" }),
ThemeFileMatcher.new(regex: /^scss\/(?<name>.+)\.scss$/,
targets: :extra_scss, names: nil, types: :scss,
canonical: -> (h) { "scss/#{h[:name]}.scss" }),
ThemeFileMatcher.new(regex: /^settings\.ya?ml$/, ThemeFileMatcher.new(regex: /^settings\.ya?ml$/,
names: "yaml", types: :yaml, targets: :settings, names: "yaml", types: :yaml, targets: :settings,
canonical: -> (h) { "settings.yml" }), canonical: -> (h) { "settings.yml" }),
@ -370,24 +413,33 @@ class ThemeField < ActiveRecord::Base
nil nil
end end
before_save do def dependent_fields
validate_yaml! if extra_scss_field?
return theme.theme_fields.where(target_id: ThemeField.basic_targets.map { |t| Theme.targets[t.to_sym] },
name: ThemeField.scss_fields)
elsif settings_field?
return theme.theme_fields.where(target_id: ThemeField.basic_targets.map { |t| Theme.targets[t.to_sym] },
name: ThemeField.scss_fields + ThemeField.html_fields)
end
ThemeField.none
end
def invalidate_baked!
update_column(:value_baked, nil)
dependent_fields.update_all(value_baked: nil)
end
before_save do
if will_save_change_to_value? && !will_save_change_to_value_baked? if will_save_change_to_value? && !will_save_change_to_value_baked?
self.value_baked = nil self.value_baked = nil
end end
end end
after_save do
dependent_fields.each(&:invalidate_baked!)
end
after_commit do after_commit do
unless destroyed?
ensure_baked!
ensure_scss_compiles!
theme.clear_cached_settings!
end
Stylesheet::Manager.clear_theme_cache! if self.name.include?("scss")
CSP::Extension.clear_theme_extensions_cache! if name == 'yaml'
# TODO message for mobile vs desktop # TODO message for mobile vs desktop
MessageBus.publish "/header-change/#{theme.id}", self.value if theme && self.name == "header" MessageBus.publish "/header-change/#{theme.id}", self.value if theme && self.name == "header"
MessageBus.publish "/footer-change/#{theme.id}", self.value if theme && self.name == "footer" MessageBus.publish "/footer-change/#{theme.id}", self.value if theme && self.name == "footer"

View File

@ -5,13 +5,12 @@ class ThemeSetting < ActiveRecord::Base
validates :data_type, numericality: { only_integer: true } validates :data_type, numericality: { only_integer: true }
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
after_save do after_save :clear_settings_cache
theme.clear_cached_settings! after_destroy :clear_settings_cache
theme.remove_from_cache!
theme.theme_fields.update_all(value_baked: nil) def clear_settings_cache
theme.theme_settings.reload # All necessary caches will be cleared on next ensure_baked!
SvgSprite.expire_cache if self.name.to_s.include?("_icon") theme.settings_field&.invalidate_baked!
CSP::Extension.clear_theme_extensions_cache! if name.to_s == CSP::Extension::THEME_SETTING
end end
def self.types def self.types

View File

@ -3403,6 +3403,7 @@ en:
mobile: "Mobile" mobile: "Mobile"
settings: "Settings" settings: "Settings"
translations: "Translations" translations: "Translations"
extra_scss: "Extra SCSS"
preview: "Preview" preview: "Preview"
show_advanced: "Show advanced fields" show_advanced: "Show advanced fields"
hide_advanced: "Hide advanced fields" hide_advanced: "Hide advanced fields"

View File

@ -9,7 +9,7 @@ module Stylesheet
def self.compile_asset(asset, options = {}) def self.compile_asset(asset, options = {})
if Importer.special_imports[asset.to_s] if Importer.special_imports[asset.to_s]
filename = "theme.scss" filename = "theme_#{options[:theme_id]}.scss"
file = "@import \"common/foundation/variables\"; @import \"theme_variables\"; @import \"#{asset}\";" file = "@import \"common/foundation/variables\"; @import \"theme_variables\"; @import \"#{asset}\";"
else else
filename = "#{asset}.scss" filename = "#{asset}.scss"

View File

@ -14,7 +14,7 @@ module Stylesheet
end end
register_import "theme_field" do register_import "theme_field" do
Import.new("theme_field.scss", source: @theme_field) Import.new("#{theme_dir}/theme_field.scss", source: @theme_field)
end end
register_import "plugins" do register_import "plugins" do
@ -119,14 +119,14 @@ module Stylesheet
fields.map do |field| fields.map do |field|
value = field.value value = field.value
if value.present? if value.present?
filename = "#{field.theme.id}/#{field.target_name}-#{field.name}-#{field.theme.name.parameterize}.scss" filename = "theme_#{field.theme.id}/#{field.target_name}-#{field.name}-#{field.theme.name.parameterize}.scss"
with_comment = <<COMMENT with_comment = <<~COMMENT
// Theme: #{field.theme.name} // Theme: #{field.theme.name}
// Target: #{field.target_name} #{field.name} // Target: #{field.target_name} #{field.name}
// Last Edited: #{field.updated_at} // Last Edited: #{field.updated_at}
#{value} #{value}
COMMENT COMMENT
Import.new(filename, source: with_comment) Import.new(filename, source: with_comment)
end end
end.compact end.compact
@ -139,6 +139,39 @@ COMMENT
@theme == :nil ? nil : @theme @theme == :nil ? nil : @theme
end end
def theme_dir
"theme_#{theme.id}"
end
def importable_theme_fields
return {} unless theme
@importable_theme_fields ||= begin
hash = {}
@theme.theme_fields.where(target_id: Theme.targets[:extra_scss]).each do |field|
hash[field.name] = field.value
end
hash
end
end
def match_theme_import(path, parent_path)
# Only allow importing theme stylesheets from within other theme stylesheets
return false unless theme && parent_path.start_with?("#{theme_dir}/")
parent_dir, _ = File.split(parent_path)
# Could be relative to the importing file, or relative to the root of the theme directory
search_paths = [parent_dir, theme_dir].uniq
search_paths.each do |search_path|
resolved = Pathname.new("#{search_path}/#{path}").cleanpath.to_s # Remove unnecessary ./ and ../
next unless resolved.start_with?("#{theme_dir}/")
resolved.sub!("#{theme_dir}/", "")
if importable_theme_fields.keys.include?(resolved)
return resolved
end
end
false
end
def category_css(category) def category_css(category)
"body.category-#{category.full_slug} { background-image: url(#{upload_cdn_path(category.uploaded_background.url)}) }\n" "body.category-#{category.full_slug} { background-image: url(#{upload_cdn_path(category.uploaded_background.url)}) }\n"
end end
@ -155,6 +188,8 @@ COMMENT
end end
elsif callback = Importer.special_imports[asset] elsif callback = Importer.special_imports[asset]
instance_eval(&callback) instance_eval(&callback)
elsif resolved = match_theme_import(asset, parent_path)
Import.new("#{theme_dir}/#{resolved}", source: importable_theme_fields[resolved])
else else
Import.new(asset + ".scss") Import.new(asset + ".scss")
end end

View File

@ -276,15 +276,15 @@ class Stylesheet::Manager
scss = "" scss = ""
if [:mobile_theme, :desktop_theme].include?(@target) if [:mobile_theme, :desktop_theme].include?(@target)
scss = theme.resolve_baked_field(:common, :scss) scss_digest = theme.resolve_baked_field(:common, :scss)
scss += theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :scss) scss_digest += theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :scss)
elsif @target == :embedded_theme elsif @target == :embedded_theme
scss = theme.resolve_baked_field(:common, :embedded_scss) scss_digest = theme.resolve_baked_field(:common, :embedded_scss)
else else
raise "attempting to look up theme digest for invalid field" raise "attempting to look up theme digest for invalid field"
end end
Digest::SHA1.hexdigest(scss.to_s + color_scheme_digest.to_s + settings_digest + plugins_digest + uploads_digest) Digest::SHA1.hexdigest(scss_digest.to_s + color_scheme_digest.to_s + settings_digest + plugins_digest + uploads_digest)
end end
# this protects us from situations where new versions of a plugin removed a file # this protects us from situations where new versions of a plugin removed a file

View File

@ -30,9 +30,9 @@ class ThemeStore::TgzExporter
# Belt and braces approach here. All the user input should already be # Belt and braces approach here. All the user input should already be
# sanitized, but check for attempts to leave the temp directory anyway # sanitized, but check for attempts to leave the temp directory anyway
pathname = Pathname.new("#{@export_name}/#{path}") pathname = Pathname.new("#{@export_name}/#{path}")
folder_path = pathname.parent.realdirpath folder_path = pathname.parent.cleanpath
raise RuntimeError.new("Theme exporter tried to leave directory") unless folder_path.to_s.starts_with?("#{@temp_folder}/#{@export_name}") raise RuntimeError.new("Theme exporter tried to leave directory") unless folder_path.to_s.starts_with?("#{@export_name}")
folder_path.mkpath pathname.parent.mkpath
path = pathname.realdirpath path = pathname.realdirpath
raise RuntimeError.new("Theme exporter tried to leave directory") unless path.to_s.starts_with?("#{@temp_folder}/#{@export_name}") raise RuntimeError.new("Theme exporter tried to leave directory") unless path.to_s.starts_with?("#{@temp_folder}/#{@export_name}")

View File

@ -59,4 +59,52 @@ describe Stylesheet::Importer do
end end
context "extra_scss" do
let(:scss) { "body { background: red}" }
let(:theme) { Fabricate(:theme).tap { |t|
t.set_field(target: :extra_scss, name: "my_files/magic", value: scss)
t.save!
}}
let(:importer) { described_class.new(theme: theme) }
it "should be able to import correctly" do
# Import from regular theme file
expect(
importer.imports(
"my_files/magic",
"theme_#{theme.id}/desktop-scss-mytheme.scss"
).source).to eq(scss)
# Import from some deep file
expect(
importer.imports(
"my_files/magic",
"theme_#{theme.id}/some/deep/folder/structure/myfile.scss"
).source).to eq(scss)
# Import from parent dir
expect(
importer.imports(
"../../my_files/magic",
"theme_#{theme.id}/my_files/folder1/myfile.scss"
).source).to eq(scss)
# Import from same dir without ./
expect(
importer.imports(
"magic",
"theme_#{theme.id}/my_files/myfile.scss"
).source).to eq(scss)
# Import from same dir with ./
expect(
importer.imports(
"./magic",
"theme_#{theme.id}/my_files/myfile.scss"
).source).to eq(scss)
end
end
end end

View File

@ -73,19 +73,23 @@ describe SvgSprite do
# Works when applying override # Works when applying override
theme.update_setting(:custom_icon, "gas-pump") theme.update_setting(:custom_icon, "gas-pump")
theme.save!
expect(SvgSprite.all_icons([theme.id])).to include("gas-pump") expect(SvgSprite.all_icons([theme.id])).to include("gas-pump")
# Works when changing override # Works when changing override
theme.update_setting(:custom_icon, "gamepad") theme.update_setting(:custom_icon, "gamepad")
theme.save!
expect(SvgSprite.all_icons([theme.id])).to include("gamepad") expect(SvgSprite.all_icons([theme.id])).to include("gamepad")
expect(SvgSprite.all_icons([theme.id])).not_to include("gas-pump") expect(SvgSprite.all_icons([theme.id])).not_to include("gas-pump")
# FA5 syntax # FA5 syntax
theme.update_setting(:custom_icon, "fab fa-bandcamp") theme.update_setting(:custom_icon, "fab fa-bandcamp")
theme.save!
expect(SvgSprite.all_icons([theme.id])).to include("fab-bandcamp") expect(SvgSprite.all_icons([theme.id])).to include("fab-bandcamp")
# Internal Discourse syntax + multiple icons # Internal Discourse syntax + multiple icons
theme.update_setting(:custom_icon, "fab-android|dragon") theme.update_setting(:custom_icon, "fab-android|dragon")
theme.save!
expect(SvgSprite.all_icons([theme.id])).to include("fab-android") expect(SvgSprite.all_icons([theme.id])).to include("fab-android")
expect(SvgSprite.all_icons([theme.id])).to include("dragon") expect(SvgSprite.all_icons([theme.id])).to include("dragon")
@ -94,6 +98,7 @@ describe SvgSprite do
# Check components are included # Check components are included
theme.update(component: true) theme.update(component: true)
theme.save!
parent_theme = Fabricate(:theme) parent_theme = Fabricate(:theme)
parent_theme.add_child_theme!(theme) parent_theme.add_child_theme!(theme)
expect(SvgSprite.all_icons([parent_theme.id])).to include("dragon") expect(SvgSprite.all_icons([parent_theme.id])).to include("dragon")

View File

@ -108,7 +108,7 @@ describe ThemeStore::TgzExporter do
# but protection is in place 'just in case' # but protection is in place 'just in case'
expect do expect do
theme.set_field(target: :translations, name: "en", value: "hacked") theme.set_field(target: :translations, name: "en", value: "hacked")
theme.theme_fields[0].stubs(:file_path).returns("../../malicious") ThemeField.any_instance.stubs(:file_path).returns("../../malicious")
theme.save! theme.save!
package package
end.to raise_error(RuntimeError) end.to raise_error(RuntimeError)

View File

@ -1,15 +0,0 @@
require 'rails_helper'
describe Jobs::RebakeAllHtmlThemeFields do
let(:theme) { Fabricate(:theme) }
let(:theme_field) { ThemeField.create!(theme: theme, target_id: 0, name: "header", value: "<script>console.log(123)</script>") }
it 'extracts inline javascripts' do
theme_field.update_attributes(value_baked: 'need to be rebaked')
described_class.new.execute_onceoff({})
theme_field.reload
expect(theme_field.value_baked).to include('theme-javascripts')
end
end

View File

@ -9,7 +9,7 @@ describe RemoteTheme do
`cd #{repo_dir} && git init . ` `cd #{repo_dir} && git init . `
`cd #{repo_dir} && git config user.email 'someone@cool.com'` `cd #{repo_dir} && git config user.email 'someone@cool.com'`
`cd #{repo_dir} && git config user.name 'The Cool One'` `cd #{repo_dir} && git config user.name 'The Cool One'`
`cd #{repo_dir} && mkdir desktop mobile common assets locales` `cd #{repo_dir} && mkdir desktop mobile common assets locales scss`
files.each do |name, data| files.each do |name, data|
File.write("#{repo_dir}/#{name}", data) File.write("#{repo_dir}/#{name}", data)
`cd #{repo_dir} && git add #{name}` `cd #{repo_dir} && git add #{name}`
@ -46,6 +46,7 @@ describe RemoteTheme do
setup_git_repo( setup_git_repo(
"about.json" => about_json, "about.json" => about_json,
"desktop/desktop.scss" => scss_data, "desktop/desktop.scss" => scss_data,
"scss/file.scss" => ".class1{color:red}",
"common/header.html" => "I AM HEADER", "common/header.html" => "I AM HEADER",
"common/random.html" => "I AM SILLY", "common/random.html" => "I AM SILLY",
"common/embedded.scss" => "EMBED", "common/embedded.scss" => "EMBED",
@ -77,7 +78,7 @@ describe RemoteTheme do
expect(remote.theme_version).to eq("1.0") expect(remote.theme_version).to eq("1.0")
expect(remote.minimum_discourse_version).to eq("1.0.0") expect(remote.minimum_discourse_version).to eq("1.0.0")
expect(@theme.theme_fields.length).to eq(6) expect(@theme.theme_fields.length).to eq(7)
mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten]
@ -91,7 +92,7 @@ describe RemoteTheme do
expect(mapped["4-en"]).to eq("sometranslations") expect(mapped["4-en"]).to eq("sometranslations")
expect(mapped.length).to eq(6) expect(mapped.length).to eq(7)
expect(@theme.settings.length).to eq(1) expect(@theme.settings.length).to eq(1)
expect(@theme.settings.first.value).to eq(true) expect(@theme.settings.first.value).to eq(true)
@ -112,6 +113,8 @@ describe RemoteTheme do
`cd #{initial_repo} && git add settings.yml` `cd #{initial_repo} && git add settings.yml`
File.delete("#{initial_repo}/settings.yaml") File.delete("#{initial_repo}/settings.yaml")
File.delete("#{initial_repo}/scss/file.scss")
`cd #{initial_repo} && git commit -am "update"` `cd #{initial_repo} && git commit -am "update"`
time = Time.new('2001') time = Time.new('2001')
@ -122,7 +125,7 @@ describe RemoteTheme do
expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip)
remote.update_from_remote remote.update_from_remote
@theme.save @theme.save!
@theme.reload @theme.reload
scheme = ColorScheme.find_by(theme_id: @theme.id) scheme = ColorScheme.find_by(theme_id: @theme.id)
@ -132,6 +135,9 @@ describe RemoteTheme do
mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten]
# Scss file was deleted
expect(mapped["5-file"]).to eq(nil)
expect(mapped["0-header"]).to eq("I AM UPDATED") expect(mapped["0-header"]).to eq("I AM UPDATED")
expect(mapped["1-scss"]).to eq(scss_data) expect(mapped["1-scss"]).to eq(scss_data)

View File

@ -29,6 +29,7 @@ describe ThemeField do
it 'does not insert a script tag when there are no inline script' do it 'does not insert a script tag when there are no inline script' do
theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "body_tag", value: '<div>new div</div>') theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "body_tag", value: '<div>new div</div>')
theme_field.ensure_baked!
expect(theme_field.value_baked).to_not include('<script') expect(theme_field.value_baked).to_not include('<script')
end end
@ -53,7 +54,7 @@ describe ThemeField do
HTML HTML
theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html) theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html)
theme_field.ensure_baked!
expect(theme_field.value_baked).to include("<script src=\"#{theme_field.javascript_cache.url}\"></script>") expect(theme_field.value_baked).to include("<script src=\"#{theme_field.javascript_cache.url}\"></script>")
expect(theme_field.value_baked).to include("external-script.js") expect(theme_field.value_baked).to include("external-script.js")
expect(theme_field.value_baked).to include('<script type="text/template"') expect(theme_field.value_baked).to include('<script type="text/template"')
@ -75,7 +76,7 @@ describe ThemeField do
JavaScript JavaScript
theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html) theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html)
theme_field.ensure_baked!
expect(theme_field.javascript_cache.content).to include(extracted) expect(theme_field.javascript_cache.content).to include(extracted)
end end
@ -87,11 +88,13 @@ describe ThemeField do
HTML HTML
field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html) field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html)
field.ensure_baked!
expect(field.error).not_to eq(nil) expect(field.error).not_to eq(nil)
expect(field.value_baked).to include("<script src=\"#{field.javascript_cache.url}\"></script>") expect(field.value_baked).to include("<script src=\"#{field.javascript_cache.url}\"></script>")
expect(field.javascript_cache.content).to include("Theme Transpilation Error:") expect(field.javascript_cache.content).to include("Theme Transpilation Error:")
field.update!(value: '') field.update!(value: '')
field.ensure_baked!
expect(field.error).to eq(nil) expect(field.error).to eq(nil)
end end
@ -102,8 +105,9 @@ HTML
</script> </script>
HTML HTML
ThemeField.create!(theme_id: 1, target_id: 3, name: "yaml", value: "string_setting: \"test text \\\" 123!\"") ThemeField.create!(theme_id: 1, target_id: 3, name: "yaml", value: "string_setting: \"test text \\\" 123!\"").ensure_baked!
theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "head_tag", value: html) theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "head_tag", value: html)
theme_field.ensure_baked!
javascript_cache = theme_field.javascript_cache javascript_cache = theme_field.javascript_cache
expect(theme_field.value_baked).to include("<script src=\"#{javascript_cache.url}\"></script>") expect(theme_field.value_baked).to include("<script src=\"#{javascript_cache.url}\"></script>")
@ -115,15 +119,33 @@ HTML
it "correctly generates errors for transpiled css" do it "correctly generates errors for transpiled css" do
css = "body {" css = "body {"
field = ThemeField.create!(theme_id: 1, target_id: 0, name: "scss", value: css) field = ThemeField.create!(theme_id: 1, target_id: 0, name: "scss", value: css)
field.reload field.ensure_baked!
expect(field.error).not_to eq(nil) expect(field.error).not_to eq(nil)
field.value = "body {color: blue};" field.value = "body {color: blue};"
field.save! field.save!
field.reload field.ensure_baked!
expect(field.error).to eq(nil) expect(field.error).to eq(nil)
end end
it "allows importing scss files" do
theme = Fabricate(:theme)
main_field = theme.set_field(target: :common, name: :scss, value: ".class1{color: red}\n@import 'rootfile1';")
theme.set_field(target: :extra_scss, name: "rootfile1", value: ".class2{color:green}\n@import 'foldername/subfile1';")
theme.set_field(target: :extra_scss, name: "rootfile2", value: ".class3{color:green} ")
theme.set_field(target: :extra_scss, name: "foldername/subfile1", value: ".class4{color:yellow}\n@import 'subfile2';")
theme.set_field(target: :extra_scss, name: "foldername/subfile2", value: ".class5{color:yellow}\n@import '../rootfile2';")
theme.save!
result = main_field.compile_scss[0]
expect(result).to include(".class1")
expect(result).to include(".class2")
expect(result).to include(".class3")
expect(result).to include(".class4")
expect(result).to include(".class5")
end
def create_upload_theme_field!(name) def create_upload_theme_field!(name)
ThemeField.create!( ThemeField.create!(
theme_id: 1, theme_id: 1,
@ -131,7 +153,7 @@ HTML
value: "", value: "",
type_id: ThemeField.types[:theme_upload_var], type_id: ThemeField.types[:theme_upload_var],
name: name, name: name,
) ).tap { |tf| tf.ensure_baked! }
end end
it "ensures we don't use invalid SCSS variable names" do it "ensures we don't use invalid SCSS variable names" do
@ -145,7 +167,7 @@ HTML
def create_yaml_field(value) def create_yaml_field(value)
field = ThemeField.create!(theme_id: 1, target_id: Theme.targets[:settings], name: "yaml", value: value) field = ThemeField.create!(theme_id: 1, target_id: Theme.targets[:settings], name: "yaml", value: value)
field.reload field.ensure_baked!
field field
end end
@ -179,7 +201,7 @@ HTML
field.value = "valid_setting: true" field.value = "valid_setting: true"
field.save! field.save!
field.reload field.ensure_baked!
expect(field.error).to eq(nil) expect(field.error).to eq(nil)
end end
@ -319,6 +341,7 @@ HTML
HTML HTML
theme_field = ThemeField.create!(theme_id: theme.id, target_id: 0, name: "head_tag", value: html) theme_field = ThemeField.create!(theme_id: theme.id, target_id: 0, name: "head_tag", value: html)
theme_field.ensure_baked!
javascript_cache = theme_field.javascript_cache javascript_cache = theme_field.javascript_cache
expect(javascript_cache.content).to include("inline discourse plugin") expect(javascript_cache.content).to include("inline discourse plugin")
expect(javascript_cache.content).to include("theme_translations.#{theme.id}.") expect(javascript_cache.content).to include("theme_translations.#{theme.id}.")

View File

@ -238,6 +238,7 @@ HTML
context "plugin api" do context "plugin api" do
def transpile(html) def transpile(html)
f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: 1, name: "after_header", value: html) f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: 1, name: "after_header", value: html)
f.ensure_baked!
return f.value_baked, f.javascript_cache return f.value_baked, f.javascript_cache
end end
@ -307,6 +308,7 @@ HTML
setting = theme.settings.find { |s| s.name == :font_size } setting = theme.settings.find { |s| s.name == :font_size }
setting.value = '30px' setting.value = '30px'
theme.save!
scss, _map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id) scss, _map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id)
expect(scss).to include("font-size:30px") expect(scss).to include("font-size:30px")
@ -356,6 +358,7 @@ HTML
setting = theme.settings.find { |s| s.name == :name } setting = theme.settings.find { |s| s.name == :name }
setting.value = 'bill' setting.value = 'bill'
theme.save!
transpiled = <<~HTML transpiled = <<~HTML
(function() { (function() {
@ -428,6 +431,7 @@ HTML
expect(user_themes).to eq([]) expect(user_themes).to eq([])
theme = Fabricate(:theme, name: "bob", user_selectable: true) theme = Fabricate(:theme, name: "bob", user_selectable: true)
theme.save!
json = Site.json_for(guardian) json = Site.json_for(guardian)
user_themes = JSON.parse(json)["user_themes"].map { |t| t["name"] } user_themes = JSON.parse(json)["user_themes"].map { |t| t["name"] }
@ -485,6 +489,7 @@ HTML
expect(cached_settings(theme.id)).to match(/\"boolean_setting\":true/) expect(cached_settings(theme.id)).to match(/\"boolean_setting\":true/)
theme.settings.first.value = "false" theme.settings.first.value = "false"
theme.save!
expect(cached_settings(theme.id)).to match(/\"boolean_setting\":false/) expect(cached_settings(theme.id)).to match(/\"boolean_setting\":false/)
child.set_field(target: :settings, name: "yaml", value: "integer_setting: 54") child.set_field(target: :settings, name: "yaml", value: "integer_setting: 54")