discourse/lib/theme_javascript_compiler.rb
David Taylor 82b16f4f47
DEV: Do not manipulate theme module paths at build-time (#23148)
Manipulating theme module paths means that the paths you author are not the ones used at runtime. This can lead to some very unexpected behavior and potential module name clashes. It also meant that the refactor in 16c6ab8661 was unable to correctly match up theme connector js/templates.

While this could technically be a breaking change, I think it is reasonably safe because:

1. Themes are already forced to use relative paths when referencing their own modules (since they're namespaced based on the site-specific id). The only time this might be problematic is when theme tests reference modules in the theme's main `javascripts` directory

2. For things like components/services/controllers/etc. our custom Ember resolver works backwards from the end of the path, so adding `discourse/` in the middle will not affect resolution.
2023-08-18 18:15:23 +01:00

265 lines
8.0 KiB
Ruby

# frozen_string_literal: true
class ThemeJavascriptCompiler
COLOCATED_CONNECTOR_REGEX =
%r{\A(?<prefix>.*/?)connectors/(?<outlet>[^/]+)/(?<name>[^/\.]+)\.(?<extension>.+)\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,
drop_debugger: false,
},
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)
# Replace legacy extensions
tree.transform_keys! do |filename|
if filename.ends_with? ".js.es6"
filename.sub(/\.js\.es6\z/, ".js")
elsif filename.include? "/templates/"
filename = filename.sub(/\.raw\.hbs\z/, ".hbr") if filename.ends_with? ".raw.hbs"
if filename.ends_with? ".hbr"
filename.sub(%r{/templates/}, "/raw-templates/")
else
filename
end
else
filename
end
end
# Some themes are colocating connector JS under `/connectors`. Move template to /templates to avoid module name clash
tree.transform_keys! do |filename|
match = COLOCATED_CONNECTOR_REGEX.match(filename)
next filename if !match
is_template = match[:extension] == "hbs"
is_in_templates_directory = match[:prefix].split("/").last == "templates"
if is_template && !is_in_templates_directory
"#{match[:prefix]}templates/connectors/#{match[:outlet]}/#{match[:name]}.#{match[:extension]}"
elsif !is_template && is_in_templates_directory
"#{match[:prefix].chomp("templates/")}connectors/#{match[:outlet]}/#{match[:name]}.#{match[:extension]}"
else
filename
end
end
# Handle colocated components
tree.dup.each_pair do |filename, content|
is_component_template =
filename.end_with?(".hbs") &&
filename.start_with?("discourse/components/", "admin/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/raw-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}"
# 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)\z/, "")
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}"
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, theme_id: @theme_id).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