discourse/lib/theme_javascript_compiler.rb
Alan Guo Xiang Tan 22614ca85b
DEV: Compile theme migrations javascript files when running theme qunit (#25219)
Why this change?

Currently, is it hard to iteratively write a theme settings migrations
because our theme migrations system does not rollback. Therefore, we
want to allow theme developers to be able to write QUnit tests for their
theme migrations files enabling them to iteratively write their theme
migrations.

What does this change do?

1. Update `Theme#baked_js_tests_with_digest` to include all `ThemeField`
records of `ThemeField#target` equal to `migrations`. Note that we do
not include the `settings` and `themePrefix` variables for migration files.

2. Don't minify JavaScript test files becasue it makes debugging in
   development hard.
2024-01-16 09:50:44 +08:00

269 lines
8.1 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, minify: true)
@theme_id = theme_id
@output_tree = []
@theme_name = theme_name
@minify = minify
end
def compile!
if !@compiled
@compiled = true
@output_tree.freeze
output =
if !has_content?
{ "code" => "" }
elsif @@terser_disabled || !@minify
{ "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, include_variables: true)
# 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)
if extension == "js" || extension == "gjs"
append_module(content, module_name, extension, include_variables:)
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, extension, 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}.#{extension}", <<~JS]
if ('define' in window) {
#{transpiler.perform(script, "", name, theme_id: @theme_id, extension: extension).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