FEATURE: Multi-file javascript support for themes (#7526)

FEATURE: Multi-file javascript support for themes (#7526)

You can now add javascript files under /javascripts/* in a theme, and they will be loaded as if they were included in core, or a plugin. If you give something the same name as a core/plugin file, it will be overridden. Support file extensions are .js.es6, .hbs and .raw.hbs.

diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 0b9b8a0..cb1e0a1 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 6efc44d..3f9f72d 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 9fd4b9e..167f01d 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| "<script src='#{c.url}'></script>" }.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 f946204..623df2f 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)\/(?<name>.+)\.scss$/,
                          targets: :extra_scss, names: nil, types: :scss,
                          canonical: -> (h) { "stylesheets/#{h[:name]}.scss" }),
+    ThemeFileMatcher.new(regex: /^javascripts\/(?<name>.+)$/,
+                         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 092d291..51d5746 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 %>

[... diff too long, it was truncated ...]

GitHub sha: 7500eed4

1 Like

This commit has been mentioned on Discourse Meta. There might be relevant details there: