mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 09:42:02 +08:00
be3d6a56ce
Theme javascript is now minified using Terser, just like our core/plugin JS bundles. This reduces the amount of data sent over the network. This commit also introduces sourcemaps for theme JS. Browser developer tools will now be able show each source file separately when browsing, and also in backtraces. For theme test JS, the sourcemap is inlined for simplicity. Network load is not a concern for tests.
250 lines
7.7 KiB
Ruby
250 lines
7.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class ThemeJavascriptCompiler
|
|
|
|
COLOCATED_CONNECTOR_REGEX = /\A(?<prefix>.*)\/connectors\/(?<outlet>[^\/]+)\/(?<name>[^\/\.]+)\z/
|
|
|
|
class CompileError < StandardError
|
|
end
|
|
|
|
@@terser_disabled = false
|
|
def self.disable_terser!
|
|
raise "Tests only" if !Rails.env.test?
|
|
@@terser_disabled = true
|
|
end
|
|
|
|
def self.enable_terser!
|
|
raise "Tests only" if !Rails.env.test?
|
|
@@terser_disabled = false
|
|
end
|
|
|
|
def initialize(theme_id, theme_name)
|
|
@theme_id = theme_id
|
|
@output_tree = []
|
|
@theme_name = theme_name
|
|
end
|
|
|
|
def compile!
|
|
if !@compiled
|
|
@compiled = true
|
|
@output_tree.freeze
|
|
output = if !has_content?
|
|
{ "code" => "" }
|
|
elsif @@terser_disabled
|
|
{ "code" => raw_content }
|
|
else
|
|
DiscourseJsProcessor::Transpiler.new.terser(@output_tree.to_h, terser_config)
|
|
end
|
|
@content = output["code"]
|
|
@source_map = output["map"]
|
|
end
|
|
[@content, @source_map]
|
|
rescue DiscourseJsProcessor::TranspileError => e
|
|
message = "[THEME #{@theme_id} '#{@theme_name}'] Compile error: #{e.message}"
|
|
@content = "console.error(#{message.to_json});\n"
|
|
[@content, @source_map]
|
|
end
|
|
|
|
def terser_config
|
|
# Based on https://github.com/ember-cli/ember-cli-terser/blob/28df3d90a5/index.js#L12-L26
|
|
{
|
|
sourceMap: { includeSources: true, root: "theme-#{@theme_id}/" },
|
|
compress: {
|
|
negate_iife: false,
|
|
sequences: 30,
|
|
},
|
|
output: {
|
|
semicolons: false,
|
|
},
|
|
}
|
|
end
|
|
|
|
def content
|
|
compile!
|
|
@content
|
|
end
|
|
|
|
def source_map
|
|
compile!
|
|
@source_map
|
|
end
|
|
|
|
def raw_content
|
|
@output_tree.map { |filename, source| source }.join("")
|
|
end
|
|
|
|
def has_content?
|
|
@output_tree.present?
|
|
end
|
|
|
|
def prepend_settings(settings_hash)
|
|
@output_tree.prepend ['settings.js', <<~JS]
|
|
(function() {
|
|
if ('require' in window) {
|
|
require("discourse/lib/theme-settings-store").registerSettings(#{@theme_id}, #{settings_hash.to_json});
|
|
}
|
|
})();
|
|
JS
|
|
end
|
|
|
|
def append_tree(tree, for_tests: false)
|
|
root_name = "discourse"
|
|
|
|
# Replace legacy extensions
|
|
tree.transform_keys! do |filename|
|
|
if filename.ends_with? ".js.es6"
|
|
filename.sub(/\.js\.es6\z/, ".js")
|
|
elsif filename.ends_with? ".raw.hbs"
|
|
filename.sub(/\.raw\.hbs\z/, ".hbr")
|
|
else
|
|
filename
|
|
end
|
|
end
|
|
|
|
# Handle colocated components
|
|
tree.dup.each_pair do |filename, content|
|
|
is_component_template = filename.end_with?(".hbs") && filename.start_with?("#{root_name}/components/")
|
|
next if !is_component_template
|
|
template_contents = content
|
|
|
|
hbs_invocation_options = {
|
|
moduleName: filename,
|
|
parseOptions: {
|
|
srcName: filename
|
|
}
|
|
}
|
|
hbs_invocation = "hbs(#{template_contents.to_json}, #{hbs_invocation_options.to_json})"
|
|
|
|
prefix = <<~JS
|
|
import { hbs } from 'ember-cli-htmlbars';
|
|
const __COLOCATED_TEMPLATE__ = #{hbs_invocation};
|
|
JS
|
|
|
|
js_filename = filename.sub(/\.hbs\z/, ".js")
|
|
js_contents = tree[js_filename] # May be nil for template-only component
|
|
if js_contents && !js_contents.include?("export default")
|
|
message = "#{filename} does not contain a `default export`. Did you forget to export the component class?"
|
|
js_contents += "throw new Error(#{message.to_json});"
|
|
end
|
|
|
|
if js_contents.nil?
|
|
# No backing class, use template-only
|
|
js_contents = <<~JS
|
|
import templateOnly from '@ember/component/template-only';
|
|
export default templateOnly();
|
|
JS
|
|
end
|
|
|
|
js_contents = prefix + js_contents
|
|
|
|
tree[js_filename] = js_contents
|
|
tree.delete(filename)
|
|
end
|
|
|
|
# Transpile and write to output
|
|
tree.each_pair do |filename, content|
|
|
module_name, extension = filename.split(".", 2)
|
|
module_name = "test/#{module_name}" if for_tests
|
|
if extension == "js"
|
|
append_module(content, module_name)
|
|
elsif extension == "hbs"
|
|
append_ember_template(module_name, content)
|
|
elsif extension == "hbr"
|
|
append_raw_template(module_name.sub("discourse/templates/", ""), content)
|
|
else
|
|
append_js_error(filename, "unknown file extension '#{extension}' (#{filename})")
|
|
end
|
|
rescue CompileError => e
|
|
append_js_error filename, "#{e.message} (#{filename})"
|
|
end
|
|
end
|
|
|
|
def append_ember_template(name, hbs_template)
|
|
module_name = name
|
|
module_name = "/#{module_name}" if !module_name.start_with?("/")
|
|
module_name = "discourse/theme-#{@theme_id}#{module_name}"
|
|
|
|
# Some themes are colocating connector JS under `/connectors`. Move template to /templates to avoid module name clash
|
|
if (match = COLOCATED_CONNECTOR_REGEX.match(module_name)) && !match[:prefix].end_with?("/templates")
|
|
module_name = "#{match[:prefix]}/templates/connectors/#{match[:outlet]}/#{match[:name]}"
|
|
end
|
|
|
|
# Mimics the ember-cli implementation
|
|
# https://github.com/ember-cli/ember-cli-htmlbars/blob/d5aa14b3/lib/template-compiler-plugin.js#L18-L26
|
|
script = <<~JS
|
|
import { hbs } from 'ember-cli-htmlbars';
|
|
export default hbs(#{hbs_template.to_json}, { moduleName: #{module_name.to_json} });
|
|
JS
|
|
|
|
template_module = DiscourseJsProcessor.transpile(script, "", module_name, theme_id: @theme_id)
|
|
@output_tree << ["#{name}.js", <<~JS]
|
|
if ('define' in window) {
|
|
#{template_module}
|
|
}
|
|
JS
|
|
rescue MiniRacer::RuntimeError, DiscourseJsProcessor::TranspileError => ex
|
|
raise CompileError.new ex.message
|
|
end
|
|
|
|
def raw_template_name(name)
|
|
name = name.sub(/\.(raw|hbr)$/, '')
|
|
name.inspect
|
|
end
|
|
|
|
def append_raw_template(name, hbs_template)
|
|
compiled = DiscourseJsProcessor::Transpiler.new.compile_raw_template(hbs_template, theme_id: @theme_id)
|
|
source_for_comment = hbs_template.gsub("*/", '*\/').indent(4, ' ')
|
|
@output_tree << ["#{name}.js", <<~JS]
|
|
(function() {
|
|
/*
|
|
#{source_for_comment}
|
|
*/
|
|
const addRawTemplate = requirejs('discourse-common/lib/raw-templates').addRawTemplate;
|
|
const template = requirejs('discourse-common/lib/raw-handlebars').template(#{compiled});
|
|
addRawTemplate(#{raw_template_name(name)}, template);
|
|
})();
|
|
JS
|
|
rescue MiniRacer::RuntimeError, DiscourseJsProcessor::TranspileError => ex
|
|
raise CompileError.new ex.message
|
|
end
|
|
|
|
def append_raw_script(filename, script)
|
|
@output_tree << [filename, script + "\n"]
|
|
end
|
|
|
|
def append_module(script, name, include_variables: true)
|
|
original_filename = name
|
|
name = "discourse/theme-#{@theme_id}/#{name.gsub(/^discourse\//, '')}"
|
|
|
|
# Some themes are colocating connector JS under `/templates/connectors`. Move out of templates to avoid module name clash
|
|
if (match = COLOCATED_CONNECTOR_REGEX.match(name)) && match[:prefix].end_with?("/templates")
|
|
name = "#{match[:prefix].delete_suffix("/templates")}/connectors/#{match[:outlet]}/#{match[:name]}"
|
|
end
|
|
|
|
script = "#{theme_settings}#{script}" if include_variables
|
|
transpiler = DiscourseJsProcessor::Transpiler.new
|
|
@output_tree << ["#{original_filename}.js", <<~JS]
|
|
if ('define' in window) {
|
|
#{transpiler.perform(script, "", name).strip}
|
|
}
|
|
JS
|
|
rescue MiniRacer::RuntimeError, DiscourseJsProcessor::TranspileError => ex
|
|
raise CompileError.new ex.message
|
|
end
|
|
|
|
def append_js_error(filename, message)
|
|
message = "[THEME #{@theme_id} '#{@theme_name}'] Compile error: #{message}"
|
|
append_raw_script filename, "console.error(#{message.to_json});"
|
|
end
|
|
|
|
private
|
|
|
|
def theme_settings
|
|
<<~JS
|
|
const settings = require("discourse/lib/theme-settings-store")
|
|
.getObjectForTheme(#{@theme_id});
|
|
const themePrefix = (key) => `theme_translations.#{@theme_id}.${key}`;
|
|
JS
|
|
end
|
|
end
|