Class: ThemeField

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
app/models/theme_field.rb

Defined Under Namespace

Classes: ThemeFileMatcher

Constant Summary collapse

FILE_MATCHERS =
ThemeFileMatcher.new(
  regex:
    %r{\A(?<target>(?:mobile|desktop|common))/(?<name>(?:head_tag|header|after_header|body_tag|footer))\.html\z},
  targets: %i[mobile desktop common],
  names: %w[head_tag header after_header body_tag footer],
  types: :html,
  canonical: ->(h) { "#{h[:target]}/#{h[:name]}.html" },
),
ThemeFileMatcher.new(
  regex: %r{\A(?<target>(?:mobile|desktop|common))/(?:\k<target>)\.scss\z},
  targets: %i[mobile desktop common],
  names: "scss",
  types: :scss,
  canonical: ->(h) { "#{h[:target]}/#{h[:target]}.scss" },
),
ThemeFileMatcher.new(
  regex: %r{\Acommon/embedded\.scss\z},
  targets: :common,
  names: "embedded_scss",
  types: :scss,
  canonical: ->(h) { "common/embedded.scss" },
),
ThemeFileMatcher.new(
  regex: %r{\Acommon/color_definitions\.scss\z},
  targets: :common,
  names: "color_definitions",
  types: :scss,
  canonical: ->(h) { "common/color_definitions.scss" },
),
ThemeFileMatcher.new(
  regex: %r{\A(?:scss|stylesheets)/(?<name>.+)\.scss\z},
  targets: :extra_scss,
  names: nil,
  types: :scss,
  canonical: ->(h) { "stylesheets/#{h[:name]}.scss" },
),
ThemeFileMatcher.new(
  regex: %r{\Ajavascripts/(?<name>.+)\z},
  targets: :extra_js,
  names: nil,
  types: :js,
  canonical: ->(h) { "javascripts/#{h[:name]}" },
),
ThemeFileMatcher.new(
  regex: %r{\Atest/(?<name>.+)\z},
  targets: :tests_js,
  names: nil,
  types: :js,
  canonical: ->(h) { "test/#{h[:name]}" },
),
ThemeFileMatcher.new(
  regex: /\Asettings\.ya?ml\z/,
  names: "yaml",
  types: :yaml,
  targets: :settings,
  canonical: ->(h) { "settings.yml" },
),
ThemeFileMatcher.new(
  regex: %r{\Alocales/(?<name>(?:#{I18n.available_locales.join("|")}))\.yml\z},
  names: I18n.available_locales.map(&:to_s),
  types: :yaml,
  targets: :translations,
  canonical: ->(h) { "locales/#{h[:name]}.yml" },
),
ThemeFileMatcher.new(
  regex: /(?!)/, # Never match uploads by filename, they must be named in about.json
  names: nil,
  types: :theme_upload_var,
  targets: :common,
  canonical: ->(h) { "assets/#{h[:name]}#{File.extname(h[:filename])}" },
)

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.basic_targetsObject



358
359
360
# File 'app/models/theme_field.rb', line 358

def self.basic_targets
  @basic_targets ||= %w[common desktop mobile]
end

.css_theme_type_idsObject



71
72
73
# File 'app/models/theme_field.rb', line 71

def self.css_theme_type_ids
  @css_theme_type_ids ||= [0, 1]
end

.force_recompilation!Object



75
76
77
78
79
80
# File 'app/models/theme_field.rb', line 75

def self.force_recompilation!
  find_each do |field|
    field.compiler_version = 0
    field.ensure_baked!
  end
end

.guess_type(name:, target:) ⇒ Object



336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'app/models/theme_field.rb', line 336

def self.guess_type(name:, target:)
  if basic_targets.include?(target.to_s) && html_fields.include?(name.to_s)
    types[:html]
  elsif basic_targets.include?(target.to_s) && scss_fields.include?(name.to_s)
    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
end

.html_fieldsObject



350
351
352
# File 'app/models/theme_field.rb', line 350

def self.html_fields
  @html_fields ||= %w[body_tag head_tag header footer after_header embedded_header]
end

.opts_from_file_path(filename) ⇒ Object



624
625
626
627
628
629
630
631
# File 'app/models/theme_field.rb', line 624

def self.opts_from_file_path(filename)
  FILE_MATCHERS.each do |matcher|
    if opts = matcher.opts_from_filename(filename)
      return opts
    end
  end
  nil
end

.scss_fieldsObject



354
355
356
# File 'app/models/theme_field.rb', line 354

def self.scss_fields
  @scss_fields ||= %w[scss embedded_scss color_definitions]
end

.theme_var_type_idsObject



67
68
69
# File 'app/models/theme_field.rb', line 67

def self.theme_var_type_ids
  @theme_var_type_ids ||= [2]
end

.typesObject



54
55
56
57
58
59
60
61
62
63
64
65
# File 'app/models/theme_field.rb', line 54

def self.types
  @types ||=
    Enum.new(
      html: 0,
      scss: 1,
      theme_upload_var: 2,
      theme_color_var: 3, # No longer used
      theme_var: 4, # No longer used
      yaml: 5,
      js: 6,
    )
end

Instance Method Details

#basic_html_field?Boolean

Returns:

  • (Boolean)


362
363
364
365
# File 'app/models/theme_field.rb', line 362

def basic_html_field?
  ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
    ThemeField.html_fields.include?(self.name)
end

#basic_scss_field?Boolean

Returns:

  • (Boolean)


375
376
377
378
# File 'app/models/theme_field.rb', line 375

def basic_scss_field?
  ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
    ThemeField.scss_fields.include?(self.name)
end

#compile_scss(prepended_scss = nil) ⇒ Object



437
438
439
440
441
442
443
444
445
446
447
448
# File 'app/models/theme_field.rb', line 437

def compile_scss(prepended_scss = nil)
  prepended_scss ||= Stylesheet::Importer.new({}).prepended_scss

  self.theme.with_scss_load_paths do |load_paths|
    Stylesheet::Compiler.compile(
      "#{prepended_scss} #{self.theme.scss_variables.to_s} #{self.value}",
      "#{Theme.targets[self.target_id]}.scss",
      theme: self.theme,
      load_paths: load_paths,
    )
  end
end

#compiled_css(prepended_scss) ⇒ Object



450
451
452
453
454
455
456
457
458
459
460
461
# File 'app/models/theme_field.rb', line 450

def compiled_css(prepended_scss)
  css, _source_map =
    begin
      compile_scss(prepended_scss)
    rescue SassC::SyntaxError => e
      # We don't want to raise a blocking error here
      # admin theme editor or discourse_theme CLI will show it nonetheless
      Rails.logger.error "SCSS compilation error: #{e.message}"
      ["", nil]
    end
  css
end

#contains_ember_css_selector?(text) ⇒ Boolean

Returns:

  • (Boolean)


489
490
491
# File 'app/models/theme_field.rb', line 489

def contains_ember_css_selector?(text)
  text.match(/#ember\d+|[.]ember-view/)
end

#contains_optimized_link?(text) ⇒ Boolean

Returns:

  • (Boolean)


485
486
487
# File 'app/models/theme_field.rb', line 485

def contains_optimized_link?(text)
  OptimizedImage::URL_REGEX.match?(text)
end

#dependent_fieldsObject



633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
# File 'app/models/theme_field.rb', line 633

def dependent_fields
  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

#ensure_baked!Object



397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'app/models/theme_field.rb', line 397

def ensure_baked!
  needs_baking = !self.value_baked || compiler_version != Theme.compiler_version
  return unless needs_baking

  if basic_html_field? || translation_field?
    self.value_baked, self.error =
      translation_field? ? process_translation : process_html(self.value)
    self.error = nil unless self.error.present?
    self.compiler_version = Theme.compiler_version
    CSP::Extension.clear_theme_extensions_cache!
  elsif extra_js_field? || js_tests_field?
    self.error = nil
    self.value_baked = "baked"
    self.compiler_version = Theme.compiler_version
  elsif basic_scss_field?
    ensure_scss_compiles!
    Stylesheet::Manager.clear_theme_cache!
  elsif settings_field?
    validate_yaml!
    CSP::Extension.clear_theme_extensions_cache!
    SvgSprite.expire_cache
    self.value_baked = "baked"
    self.compiler_version = Theme.compiler_version
  elsif svg_sprite_field?
    SvgSprite.expire_cache
    self.error = validate_svg_sprite_xml
    self.value_baked = "baked"
    self.compiler_version = Theme.compiler_version
  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

#ensure_scss_compiles!Object



463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
# File 'app/models/theme_field.rb', line 463

def ensure_scss_compiles!
  result = ["failed"]
  begin
    result = compile_scss
    if contains_optimized_link?(self.value)
      self.error = I18n.t("themes.errors.optimized_link")
    elsif contains_ember_css_selector?(self.value)
      self.error = I18n.t("themes.ember_selector_error")
    else
      self.error = nil unless error.nil?
    end
  rescue SassC::SyntaxError, SassC::NotRenderedError => e
    self.error = e.message unless self.destroyed?
  end
  self.compiler_version = Theme.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

#extra_js_field?Boolean

Returns:

  • (Boolean)


367
368
369
# File 'app/models/theme_field.rb', line 367

def extra_js_field?
  Theme.targets[self.target_id] == :extra_js
end

#extra_scss_field?Boolean

Returns:

  • (Boolean)


380
381
382
# File 'app/models/theme_field.rb', line 380

def extra_scss_field?
  Theme.targets[self.target_id] == :extra_scss
end

#file_pathObject

For now just work for standard fields



609
610
611
612
613
614
615
616
617
618
619
620
621
622
# File 'app/models/theme_field.rb', line 609

def file_path
  FILE_MATCHERS.each do |matcher|
    if filename =
         matcher.filename_from_opts(
           target: target_name.to_sym,
           name: name,
           type: ThemeField.types[type_id],
           filename: upload&.original_filename,
         )
      return filename
    end
  end
  nil # Not a file (e.g. a theme variable/color)
end

#invalidate_baked!Object



652
653
654
655
# File 'app/models/theme_field.rb', line 652

def invalidate_baked!
  update_column(:value_baked, nil)
  dependent_fields.update_all(value_baked: nil)
end

#js_tests_field?Boolean

Returns:

  • (Boolean)


371
372
373
# File 'app/models/theme_field.rb', line 371

def js_tests_field?
  Theme.targets[self.target_id] == :tests_js
end

#process_html(html) ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'app/models/theme_field.rb', line 90

def process_html(html)
  errors = []
  javascript_cache || build_javascript_cache

  errors << I18n.t("themes.errors.optimized_link") if contains_optimized_link?(html)

  js_compiler = ThemeJavascriptCompiler.new(theme_id, self.theme.name)

  doc = Nokogiri::HTML5.fragment(html)

  doc
    .css('script[type="text/x-handlebars"]')
    .each do |node|
      name = node["name"] || node["data-template-name"] || "broken"
      is_raw = name =~ /\.(raw|hbr)\z/
      hbs_template = node.inner_html

      begin
        if is_raw
          js_compiler.append_raw_template(name, hbs_template)
        else
          js_compiler.append_ember_template(
            "discourse/templates/#{name.delete_prefix("/")}",
            hbs_template,
          )
        end
      rescue ThemeJavascriptCompiler::CompileError => ex
        js_compiler.append_js_error("discourse/templates/#{name}", ex.message)
        errors << ex.message
      end

      node.remove
    end

  doc
    .css('script[type="text/discourse-plugin"]')
    .each_with_index do |node, index|
      version = node["version"]
      next if version.blank?

      initializer_name =
        "theme-field" + "-#{self.id}" + "-#{Theme.targets[self.target_id]}" +
          "-#{ThemeField.types[self.type_id]}" + "-script-#{index + 1}"
      begin
        js = <<~JS
        import { withPluginApi } from "discourse/lib/plugin-api";

        export default {
          name: #{initializer_name.inspect},
          after: "inject-objects",

          initialize() {
            withPluginApi(#{version.inspect}, (api) => {
              #{node.inner_html}
            });
          }
        };
      JS

        js_compiler.append_module(
          js,
          "discourse/initializers/#{initializer_name}",
          "js",
          include_variables: true,
        )
      rescue ThemeJavascriptCompiler::CompileError => ex
        js_compiler.append_js_error("discourse/initializers/#{initializer_name}", ex.message)
        errors << ex.message
      end

      node.remove
    end

  doc
    .css("script")
    .each_with_index do |node, index|
      next unless inline_javascript?(node)
      js_compiler.append_raw_script(
        "_html/#{Theme.targets[self.target_id]}/#{name}_#{index + 1}.js",
        node.inner_html,
      )
      node.remove
    end

  settings_hash = theme.build_settings_hash
  if js_compiler.has_content? && settings_hash.present?
    js_compiler.prepend_settings(settings_hash)
  end
  javascript_cache.content = js_compiler.content
  javascript_cache.source_map = js_compiler.source_map
  javascript_cache.save!

  doc.add_child(<<~HTML.html_safe) if javascript_cache.content.present?
        <link rel="preload" href="#{javascript_cache.url}" as="script">
        <script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}'></script>
      HTML
  [doc.to_s, errors&.join("\n")]
end

#process_translationObject



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'app/models/theme_field.rb', line 252

def process_translation
  errors = []
  javascript_cache || build_javascript_cache
  js_compiler = ThemeJavascriptCompiler.new(theme_id, self.theme.name)
  begin
    data = translation_data

    js = <<~JS
      export default {
        name: "theme-#{theme_id}-translations",
        initialize() {
          /* Translation data for theme #{self.theme_id} (#{self.name})*/
          const data = #{data.to_json};

          for (let lang in data){
            let cursor = I18n.translations;
            for (let key of [lang, "js", "theme_translations"]){
              cursor = cursor[key] = cursor[key] || {};
            }
            cursor[#{self.theme_id}] = data[lang];
          }
        }
      };
    JS

    js_compiler.append_module(
      js,
      "discourse/pre-initializers/theme-#{theme_id}-translations",
      "js",
      include_variables: false,
    )
  rescue ThemeTranslationParser::InvalidYaml => e
    errors << e.message
  end

  javascript_cache.content = js_compiler.content
  javascript_cache.source_map = js_compiler.source_map
  javascript_cache.save!
  doc = ""
  doc = <<~HTML.html_safe if javascript_cache.content.present?
        <link rel="preload" href="#{javascript_cache.url}" as="script">
        <script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}'></script>
      HTML
  [doc, errors&.join("\n")]
end

#raw_translation_data(internal: false) ⇒ Object



219
220
221
222
# File 'app/models/theme_field.rb', line 219

def raw_translation_data(internal: false)
  # Might raise ThemeTranslationParser::InvalidYaml
  ThemeTranslationParser.new(self, internal: internal).load
end

#settings_field?Boolean

Returns:

  • (Boolean)


384
385
386
# File 'app/models/theme_field.rb', line 384

def settings_field?
  Theme.targets[:settings] == self.target_id
end

#svg_sprite_field?Boolean

Returns:

  • (Boolean)


392
393
394
395
# File 'app/models/theme_field.rb', line 392

def svg_sprite_field?
  ThemeField.theme_var_type_ids.include?(self.type_id) &&
    self.name == SvgSprite.theme_sprite_variable_name
end

#target_nameObject



481
482
483
# File 'app/models/theme_field.rb', line 481

def target_name
  Theme.targets[target_id].to_s
end

#translation_data(with_overrides: true, internal: false, fallback_fields: nil) ⇒ Object



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'app/models/theme_field.rb', line 224

def translation_data(with_overrides: true, internal: false, fallback_fields: nil)
  fallback_fields ||= theme.theme_fields.filter_locale_fields(I18n.fallbacks[name])

  fallback_data =
    fallback_fields.each_with_index.map do |field, index|
      begin
        field.raw_translation_data(internal: internal)
      rescue ThemeTranslationParser::InvalidYaml
        # If this is the locale with the error, raise it.
        # If not, let the other theme_field raise the error when it processes itself
        raise if field.id == id
        {}
      end
    end

  # TODO: Deduplicate the fallback data in the same way as JSLocaleHelper#load_translations_merged
  #       this would reduce the size of the payload, without affecting functionality
  data = {}
  fallback_data.each { |hash| data.merge!(hash) }

  if with_overrides
    overrides = theme.translation_override_hash.deep_symbolize_keys
    data.deep_merge!(overrides)
  end

  data
end

#translation_field?Boolean

Returns:

  • (Boolean)


388
389
390
# File 'app/models/theme_field.rb', line 388

def translation_field?
  Theme.targets[:translations] == self.target_id
end

#upsert_svg_sprite!Object



670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
# File 'app/models/theme_field.rb', line 670

def upsert_svg_sprite!
  begin
    content = upload.content
  rescue => e
    Discourse.warn_exception(e, message: "Failed to fetch svg sprite for theme field #{id}")
  else
    if content.length > SvgSprite::MAX_THEME_SPRITE_SIZE
      Rails.logger.warn(
        "can't store theme svg sprite for theme #{theme_id} and upload #{upload_id}, sprite too big",
      )
    else
      ThemeSvgSprite.upsert(
        { theme_id: theme_id, upload_id: upload_id, sprite: content },
        unique_by: :theme_id,
      )
    end
  end
end

#validate_svg_sprite_xmlObject



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'app/models/theme_field.rb', line 189

def validate_svg_sprite_xml
  upload =
    begin
      Upload.find(self.upload_id)
    rescue StandardError
      nil
    end

  if Discourse.store.external?
    external_copy = Discourse.store.download_safe(upload)
    path = external_copy&.path
  else
    path = Discourse.store.path_for(upload)
  end

  error = nil

  begin
    content = File.read(path)
    if content.to_s.bytesize > SvgSprite::MAX_THEME_SPRITE_SIZE
      error = "Error with #{self.name}: Icon sprite file is too large"
    else
      Nokogiri.XML(content) { |config| config.options = Nokogiri::XML::ParseOptions::NOBLANKS }
    end
  rescue => e
    error = "Error with #{self.name}: #{e.inspect}"
  end
  error
end

#validate_yaml!Object



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'app/models/theme_field.rb', line 298

def validate_yaml!
  return unless self.name == "yaml"

  errors = []
  begin
    ThemeSettingsParser
      .new(self)
      .load do |name, default, type, opts|
        setting = ThemeSetting.new(name: name, data_type: type, theme: theme)
        translation_key = "themes.settings_errors"

        if setting.invalid?
          setting.errors.details.each_pair do |attribute, _errors|
            _errors.each do |hash|
              errors << I18n.t("#{translation_key}.#{attribute}_#{hash[:error]}", name: name)
            end
          end
        end

        errors << I18n.t("#{translation_key}.default_value_missing", name: name) if default.nil?

        if (min = opts[:min]) && (max = opts[:max])
          unless ThemeSetting.value_in_range?(default, (min..max), type)
            errors << I18n.t("#{translation_key}.default_out_range", name: name)
          end
        end

        unless ThemeSetting.acceptable_value_for_type?(default, type)
          errors << I18n.t("#{translation_key}.default_not_match_type", name: name)
        end
      end
  rescue ThemeSettingsParser::InvalidYaml => e
    errors << e.message
  end

  self.error = errors.join("\n").presence
end