diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 0b9b8a03871..cb1e0a1d2b2 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -431,6 +431,11 @@ module ApplicationHelper
&.html_safe
end
+ def theme_js_lookup
+ Theme.lookup_field(theme_ids, :extra_js, nil)
+ &.html_safe
+ end
+
def discourse_stylesheet_link_tag(name, opts = {})
if opts.key?(:theme_ids)
ids = opts[:theme_ids] unless customization_disabled?
diff --git a/app/models/javascript_cache.rb b/app/models/javascript_cache.rb
index 6efc44d6196..3f9f72dd204 100644
--- a/app/models/javascript_cache.rb
+++ b/app/models/javascript_cache.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class JavascriptCache < ActiveRecord::Base
belongs_to :theme_field
+ belongs_to :theme
validate :content_cannot_be_nil
@@ -26,14 +27,21 @@ end
# Table name: javascript_caches
#
# id :bigint not null, primary key
-# theme_field_id :bigint not null
+# theme_field_id :bigint
# digest :string
# content :text not null
# created_at :datetime not null
# updated_at :datetime not null
+# theme_id :bigint
#
# Indexes
#
# index_javascript_caches_on_digest (digest)
# index_javascript_caches_on_theme_field_id (theme_field_id)
+# index_javascript_caches_on_theme_id (theme_id)
+#
+# Foreign Keys
+#
+# fk_rails_... (theme_field_id => theme_fields.id) ON DELETE => cascade
+# fk_rails_... (theme_id => themes.id) ON DELETE => cascade
#
diff --git a/app/models/theme.rb b/app/models/theme.rb
index 9fd4b9ec726..167f01daadf 100644
--- a/app/models/theme.rb
+++ b/app/models/theme.rb
@@ -25,6 +25,7 @@ class Theme < ActiveRecord::Base
belongs_to :remote_theme, autosave: true
has_one :settings_field, -> { where(target_id: Theme.targets[:settings], name: "yaml") }, class_name: 'ThemeField'
+ has_one :javascript_cache, dependent: :destroy
has_many :locale_fields, -> { filter_locale_fields(I18n.fallbacks[I18n.locale]) }, class_name: 'ThemeField'
validate :component_validations
@@ -51,19 +52,26 @@ class Theme < ActiveRecord::Base
changed_fields.each(&:save!)
changed_fields.clear
+ if saved_change_to_name?
+ theme_fields.select(&:basic_html_field?).each(&:invalidate_baked!)
+ end
+
Theme.expire_site_cache! if saved_change_to_user_selectable? || saved_change_to_name?
notify_with_scheme = saved_change_to_color_scheme_id?
- name_changed = saved_change_to_name?
reload
settings_field&.ensure_baked! # Other fields require setting to be **baked**
theme_fields.each(&:ensure_baked!)
- if name_changed
- theme_fields.select { |f| f.basic_html_field? }.each do |f|
- f.value_baked = nil
- f.ensure_baked!
- end
+ all_extra_js = theme_fields.where(target_id: Theme.targets[:extra_js]).pluck(:value_baked).join("\n")
+ if all_extra_js.present?
+ js_compiler = ThemeJavascriptCompiler.new(id, name)
+ js_compiler.append_raw_script(all_extra_js)
+ js_compiler.prepend_settings(cached_settings) if cached_settings.present?
+ javascript_cache || build_javascript_cache
+ javascript_cache.update!(content: js_compiler.content)
+ else
+ javascript_cache&.destroy!
end
remove_from_cache!
@@ -238,7 +246,7 @@ class Theme < ActiveRecord::Base
end
def self.targets
- @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4, extra_scss: 5)
+ @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4, extra_scss: 5, extra_js: 6)
end
def self.lookup_target(target_id)
@@ -276,6 +284,11 @@ class Theme < ActiveRecord::Base
end
def self.resolve_baked_field(theme_ids, target, name)
+ if target == :extra_js
+ caches = JavascriptCache.where(theme_id: theme_ids)
+ caches = caches.sort_by { |cache| theme_ids.index(cache.theme_id) }
+ return caches.map { |c| "" }.join("\n")
+ end
list_baked_fields(theme_ids, target, name).map { |f| f.value_baked || f.value }.join("\n")
end
diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb
index f94620404fd..623df2f9113 100644
--- a/app/models/theme_field.rb
+++ b/app/models/theme_field.rb
@@ -46,7 +46,8 @@ class ThemeField < ActiveRecord::Base
theme_upload_var: 2,
theme_color_var: 3, # No longer used
theme_var: 4, # No longer used
- yaml: 5)
+ yaml: 5,
+ js: 6)
end
def self.theme_var_type_ids
@@ -122,6 +123,29 @@ class ThemeField < ActiveRecord::Base
[doc.to_s, errors&.join("\n")]
end
+ def process_extra_js(content)
+ errors = []
+
+ js_compiler = ThemeJavascriptCompiler.new(theme_id, theme.name)
+ filename, extension = name.split(".", 2)
+ begin
+ case extension
+ when "js.es6"
+ js_compiler.append_module(content, filename)
+ when "hbs"
+ js_compiler.append_ember_template(filename.sub("discourse/templates/", ""), content)
+ when "raw.hbs"
+ js_compiler.append_raw_template(filename, content)
+ else
+ raise ThemeJavascriptCompiler::CompileError.new(I18n.t("themes.compile_error.unrecognized_extension", extension: extension))
+ end
+ rescue ThemeJavascriptCompiler::CompileError => ex
+ errors << ex.message
+ end
+
+ [js_compiler.content, errors&.join("\n")]
+ end
+
def raw_translation_data(internal: false)
# Might raise ThemeTranslationParser::InvalidYaml
ThemeTranslationParser.new(self, internal: internal).load
@@ -227,6 +251,8 @@ class ThemeField < ActiveRecord::Base
types[:scss]
elsif target.to_s == "extra_scss"
types[:scss]
+ elsif target.to_s == "extra_js"
+ types[:js]
elsif target.to_s == "settings" || target.to_s == "translations"
types[:yaml]
end
@@ -249,6 +275,10 @@ class ThemeField < ActiveRecord::Base
ThemeField.html_fields.include?(self.name)
end
+ def extra_js_field?
+ Theme.targets[self.target_id] == :extra_js
+ end
+
def basic_scss_field?
ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
ThemeField.scss_fields.include?(self.name)
@@ -278,6 +308,10 @@ class ThemeField < ActiveRecord::Base
self.value_baked, self.error = translation_field? ? process_translation : process_html(self.value)
self.error = nil unless self.error.present?
self.compiler_version = COMPILER_VERSION
+ elsif extra_js_field?
+ self.value_baked, self.error = process_extra_js(self.value)
+ self.error = nil unless self.error.present?
+ self.compiler_version = COMPILER_VERSION
elsif basic_scss_field?
ensure_scss_compiles!
Stylesheet::Manager.clear_theme_cache!
@@ -382,6 +416,9 @@ class ThemeField < ActiveRecord::Base
ThemeFileMatcher.new(regex: /^(?:scss|stylesheets)\/(?.+)\.scss$/,
targets: :extra_scss, names: nil, types: :scss,
canonical: -> (h) { "stylesheets/#{h[:name]}.scss" }),
+ ThemeFileMatcher.new(regex: /^javascripts\/(?.+)$/,
+ targets: :extra_js, names: nil, types: :js,
+ canonical: -> (h) { "javascripts/#{h[:name]}" }),
ThemeFileMatcher.new(regex: /^settings\.ya?ml$/,
names: "yaml", types: :yaml, targets: :settings,
canonical: -> (h) { "settings.yml" }),
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 092d29116b2..51d5746f97b 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -39,6 +39,7 @@
<%- unless customization_disabled? %>
<%= raw theme_translations_lookup %>
+ <%= raw theme_js_lookup %>
<%= raw theme_lookup("head_tag") %>
<%- end %>
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 7efd5d3ee02..3730c55553d 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -76,6 +76,8 @@ en:
themes:
bad_color_scheme: "Can not update theme, invalid color palette"
other_error: "Something went wrong updating theme"
+ compile_error:
+ unrecognized_extension: "Unrecognized file extension: %{extension}"
import_error:
generic: An error occured while importing that theme
about_json: "Import Error: about.json does not exist, or is invalid"
diff --git a/db/migrate/20190513143015_add_theme_id_to_javascript_cache.rb b/db/migrate/20190513143015_add_theme_id_to_javascript_cache.rb
new file mode 100644
index 00000000000..1dc361153b9
--- /dev/null
+++ b/db/migrate/20190513143015_add_theme_id_to_javascript_cache.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class AddThemeIdToJavascriptCache < ActiveRecord::Migration[5.2]
+ def up
+ make_changes
+ execute "ALTER TABLE javascript_caches ADD CONSTRAINT enforce_theme_or_theme_field CHECK ((theme_id IS NOT NULL AND theme_field_id IS NULL) OR (theme_id IS NULL AND theme_field_id IS NOT NULL))"
+ end
+ def down
+ execute "ALTER TABLE javascript_caches DROP CONSTRAINT enforce_theme_or_theme_field"
+ revert { make_changes }
+ end
+
+ private
+
+ def make_changes
+ add_reference :javascript_caches, :theme, foreign_key: { on_delete: :cascade }
+ add_foreign_key :javascript_caches, :theme_fields, on_delete: :cascade
+
+ begin
+ Migration::SafeMigrate.disable!
+ change_column_null :javascript_caches, :theme_field_id, true
+ ensure
+ Migration::SafeMigrate.enable!
+ end
+ end
+end
diff --git a/lib/theme_javascript_compiler.rb b/lib/theme_javascript_compiler.rb
index dc1dbabd7da..22a47c351da 100644
--- a/lib/theme_javascript_compiler.rb
+++ b/lib/theme_javascript_compiler.rb
@@ -202,22 +202,36 @@ class ThemeJavascriptCompiler
@content << script + "\n"
end
+ def append_module(script, name)
+ script.prepend theme_variables
+ template = Tilt::ES6ModuleTranspilerTemplate.new {}
+ @content << template.module_transpile(script, "", name)
+ rescue MiniRacer::RuntimeError => ex
+ raise CompileError.new ex.message
+ end
+
def append_js_error(message)
@content << "console.error('Theme Transpilation Error:', #{message.inspect});"
end
private
+ def theme_variables
+ <<~JS
+ const __theme_name__ = "#{@theme_name.gsub('"', "\\\"")}";
+ const settings = Discourse.__container__
+ .lookup("service:theme-settings")
+ .getObjectForTheme(#{@theme_id});
+ const themePrefix = (key) => `theme_translations.#{@theme_id}.${key}`;
+ JS
+ end
+
def transpile(es6_source, version)
template = Tilt::ES6ModuleTranspilerTemplate.new {}
wrapped = <<~PLUGIN_API_JS
(function() {
if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') {
- const __theme_name__ = "#{@theme_name.gsub('"', "\\\"")}";
- const settings = Discourse.__container__
- .lookup("service:theme-settings")
- .getObjectForTheme(#{@theme_id});
- const themePrefix = (key) => `theme_translations.#{@theme_id}.${key}`;
+ #{theme_variables}
Discourse._registerPluginCode('#{version}', api => {
try {
#{es6_source}
diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb
index e7489a1dc26..87e182c7f62 100644
--- a/spec/models/theme_field_spec.rb
+++ b/spec/models/theme_field_spec.rb
@@ -147,6 +147,40 @@ HTML
expect(result).to include(".class5")
end
+ it "correctly handles extra JS fields" do
+ theme = Fabricate(:theme)
+ js_field = theme.set_field(target: :extra_js, name: "discourse/controllers/discovery.js.es6", value: "import 'discourse/lib/ajax'; console.log('hello');")
+ hbs_field = theme.set_field(target: :extra_js, name: "discourse/templates/discovery.hbs", value: "{{hello-world}}")
+ raw_hbs_field = theme.set_field(target: :extra_js, name: "discourse/templates/discovery.raw.hbs", value: "{{hello-world}}")
+ unknown_field = theme.set_field(target: :extra_js, name: "discourse/controllers/discovery.blah", value: "this wont work")
+ theme.save!
+
+ expected_js = <<~JS
+ define("discourse/controllers/discovery", ["discourse/lib/ajax"], function () {
+ "use strict";
+
+ var __theme_name__ = "#{theme.name}";
+ var settings = Discourse.__container__.lookup("service:theme-settings").getObjectForTheme(#{theme.id});
+ var themePrefix = function themePrefix(key) {
+ return "theme_translations.#{theme.id}." + key;
+ };
+ console.log('hello');
+ });
+ JS
+ expect(js_field.reload.value_baked).to eq(expected_js.strip)
+
+ expect(hbs_field.reload.value_baked).to include('Ember.TEMPLATES["discovery"]')
+ expect(raw_hbs_field.reload.value_baked).to include('Discourse.RAW_TEMPLATES["discourse/templates/discovery"]')
+ expect(unknown_field.reload.value_baked).to eq("")
+ expect(unknown_field.reload.error).to eq(I18n.t("themes.compile_error.unrecognized_extension", extension: "blah"))
+
+ # All together
+ expect(theme.javascript_cache.content).to include('Ember.TEMPLATES["discovery"]')
+ expect(theme.javascript_cache.content).to include('Discourse.RAW_TEMPLATES["discourse/templates/discovery"]')
+ expect(theme.javascript_cache.content).to include('define("discourse/controllers/discovery"')
+ expect(theme.javascript_cache.content).to include("var settings =")
+ end
+
def create_upload_theme_field!(name)
ThemeField.create!(
theme_id: 1,
diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb
index f1f1db49575..149badb6035 100644
--- a/spec/models/theme_spec.rb
+++ b/spec/models/theme_spec.rb
@@ -367,6 +367,7 @@ HTML
var themePrefix = function themePrefix(key) {
return 'theme_translations.#{theme.id}.' + key;
};
+
Discourse._registerPluginCode('1.0', function (api) {
try {
alert(settings.name);var a = function a() {};
@@ -402,6 +403,7 @@ HTML
var themePrefix = function themePrefix(key) {
return 'theme_translations.#{theme.id}.' + key;
};
+
Discourse._registerPluginCode('1.0', function (api) {
try {
alert(settings.name);var a = function a() {};