DEV: Introduce minification and source maps for Theme JS (#18646)

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.
This commit is contained in:
David Taylor 2022-10-18 18:20:10 +01:00 committed by GitHub
parent e23b247690
commit be3d6a56ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 338 additions and 67 deletions

View File

@ -1,4 +1,4 @@
/* global Babel:true */ /* global Babel:true Terser:true */
// This is executed in mini_racer to provide the JS logic for lib/discourse_js_processor.rb // This is executed in mini_racer to provide the JS logic for lib/discourse_js_processor.rb
@ -110,3 +110,29 @@ exports.transpile = function (
throw error; throw error;
} }
}; };
// mini_racer doesn't have native support for getting the result of an async operation.
// To work around that, we provide a getMinifyResult which can be used to fetch the result
// in a followup method call.
let lastMinifyError, lastMinifyResult;
exports.minify = async function (sources, options) {
lastMinifyError = lastMinifyResult = null;
try {
lastMinifyResult = await Terser.minify(sources, options);
} catch (e) {
lastMinifyError = e;
}
};
exports.getMinifyResult = function () {
const error = lastMinifyError;
const result = lastMinifyResult;
lastMinifyError = lastMinifyResult = null;
if (error) {
throw error;
}
return result;
};

View File

@ -9,10 +9,10 @@ class ThemeJavascriptsController < ApplicationController
:preload_json, :preload_json,
:redirect_to_login_if_required, :redirect_to_login_if_required,
:verify_authenticity_token, :verify_authenticity_token,
only: [:show, :show_tests] only: [:show, :show_map, :show_tests]
) )
before_action :is_asset_path, :no_cookies, :apply_cdn_headers, only: [:show, :show_tests] before_action :is_asset_path, :no_cookies, :apply_cdn_headers, only: [:show, :show_map, :show_tests]
def show def show
raise Discourse::NotFound unless last_modified.present? raise Discourse::NotFound unless last_modified.present?
@ -21,18 +21,29 @@ class ThemeJavascriptsController < ApplicationController
# Security: safe due to route constraint # Security: safe due to route constraint
cache_file = "#{DISK_CACHE_PATH}/#{params[:digest]}.js" cache_file = "#{DISK_CACHE_PATH}/#{params[:digest]}.js"
unless File.exist?(cache_file) write_if_not_cached(cache_file) do
content = query.pluck_first(:content) content, has_source_map = query.pluck_first(:content, "source_map IS NOT NULL")
raise Discourse::NotFound if content.nil? if has_source_map
content += "\n//# sourceMappingURL=#{params[:digest]}.map?__ws=#{Discourse.current_hostname}\n"
FileUtils.mkdir_p(DISK_CACHE_PATH) end
File.write(cache_file, content) content
end end
# this is only required for NGINX X-SendFile it seems serve_file(cache_file)
response.headers["Content-Length"] = File.size(cache_file).to_s end
set_cache_control_headers
send_file(cache_file, disposition: :inline) def show_map
raise Discourse::NotFound unless last_modified.present?
return render body: nil, status: 304 if not_modified?
# Security: safe due to route constraint
cache_file = "#{DISK_CACHE_PATH}/#{params[:digest]}.map"
write_if_not_cached(cache_file) do
query.pluck_first(:source_map)
end
serve_file(cache_file)
end end
def show_tests def show_tests
@ -48,14 +59,11 @@ class ThemeJavascriptsController < ApplicationController
@cache_file = "#{TESTS_DISK_CACHE_PATH}/#{digest}.js" @cache_file = "#{TESTS_DISK_CACHE_PATH}/#{digest}.js"
return render body: nil, status: 304 if not_modified? return render body: nil, status: 304 if not_modified?
if !File.exist?(@cache_file) write_if_not_cached(@cache_file) do
FileUtils.mkdir_p(TESTS_DISK_CACHE_PATH) content
File.write(@cache_file, content)
end end
response.headers["Content-Length"] = File.size(@cache_file).to_s serve_file @cache_file
set_cache_control_headers
send_file(@cache_file, disposition: :inline)
end end
private private
@ -94,4 +102,22 @@ class ThemeJavascriptsController < ApplicationController
immutable_for(1.year) immutable_for(1.year)
end end
end end
def write_if_not_cached(cache_file)
unless File.exist?(cache_file)
content = yield
raise Discourse::NotFound if content.nil?
FileUtils.mkdir_p(File.dirname(cache_file))
File.write(cache_file, content)
end
end
def serve_file(cache_file)
# this is only required for NGINX X-SendFile it seems
response.headers["Content-Length"] = File.size(cache_file).to_s
set_cache_control_headers
type = cache_file.end_with?(".map") ? "application/json" : "text/javascript"
send_file(cache_file, type: type, disposition: :inline)
end
end end

View File

@ -41,6 +41,7 @@ end
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# theme_id :bigint # theme_id :bigint
# source_map :text
# #
# Indexes # Indexes
# #

View File

@ -6,7 +6,7 @@ require 'json_schemer'
class Theme < ActiveRecord::Base class Theme < ActiveRecord::Base
include GlobalPath include GlobalPath
BASE_COMPILER_VERSION = 64 BASE_COMPILER_VERSION = 65
attr_accessor :child_components attr_accessor :child_components
@ -127,7 +127,7 @@ class Theme < ActiveRecord::Base
settings_hash = build_settings_hash settings_hash = build_settings_hash
js_compiler.prepend_settings(settings_hash) if settings_hash.present? js_compiler.prepend_settings(settings_hash) if settings_hash.present?
javascript_cache || build_javascript_cache javascript_cache || build_javascript_cache
javascript_cache.update!(content: js_compiler.content) javascript_cache.update!(content: js_compiler.content, source_map: js_compiler.source_map)
else else
javascript_cache&.destroy! javascript_cache&.destroy!
end end
@ -717,13 +717,17 @@ class Theme < ActiveRecord::Base
compiler = ThemeJavascriptCompiler.new(id, name) compiler = ThemeJavascriptCompiler.new(id, name)
compiler.append_tree(tests_tree, for_tests: true) compiler.append_tree(tests_tree, for_tests: true)
content = compiler.content compiler.append_raw_script "test_setup.js", <<~JS
content = <<~JS + content
(function() { (function() {
require("discourse/lib/theme-settings-store").registerSettings(#{self.id}, #{cached_default_settings.to_json}, { force: true }); require("discourse/lib/theme-settings-store").registerSettings(#{self.id}, #{cached_default_settings.to_json}, { force: true });
})(); })();
JS JS
content = compiler.content
if compiler.source_map
content += "\n//# sourceMappingURL=data:application/json;base64,#{Base64.strict_encode64(compiler.source_map)}\n"
end
[content, Digest::SHA1.hexdigest(content)] [content, Digest::SHA1.hexdigest(content)]
end end

View File

@ -95,6 +95,7 @@ class ThemeField < ActiveRecord::Base
js_compiler.append_ember_template("discourse/templates/#{name}", hbs_template) js_compiler.append_ember_template("discourse/templates/#{name}", hbs_template)
end end
rescue ThemeJavascriptCompiler::CompileError => ex rescue ThemeJavascriptCompiler::CompileError => ex
js_compiler.append_js_error("discourse/templates/#{name}", ex.message)
errors << ex.message errors << ex.message
end end
@ -128,25 +129,23 @@ class ThemeField < ActiveRecord::Base
js_compiler.append_module(js, "discourse/initializers/#{initializer_name}", include_variables: true) js_compiler.append_module(js, "discourse/initializers/#{initializer_name}", include_variables: true)
rescue ThemeJavascriptCompiler::CompileError => ex rescue ThemeJavascriptCompiler::CompileError => ex
js_compiler.append_js_error("discourse/initializers/#{initializer_name}", ex.message)
errors << ex.message errors << ex.message
end end
node.remove node.remove
end end
doc.css('script').each do |node| doc.css('script').each_with_index do |node, index|
next unless inline_javascript?(node) next unless inline_javascript?(node)
js_compiler.append_raw_script(node.inner_html) js_compiler.append_raw_script("_html/#{Theme.targets[self.target_id]}/#{name}_#{index + 1}.js", node.inner_html)
node.remove node.remove
end end
errors.each do |error|
js_compiler.append_js_error(error)
end
settings_hash = theme.build_settings_hash settings_hash = theme.build_settings_hash
js_compiler.prepend_settings(settings_hash) if js_compiler.content.present? && settings_hash.present? js_compiler.prepend_settings(settings_hash) if js_compiler.has_content? && settings_hash.present?
javascript_cache.content = js_compiler.content javascript_cache.content = js_compiler.content
javascript_cache.source_map = js_compiler.source_map
javascript_cache.save! javascript_cache.save!
doc.add_child("<script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}'></script>") if javascript_cache.content.present? doc.add_child("<script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}'></script>") if javascript_cache.content.present?
@ -239,6 +238,7 @@ class ThemeField < ActiveRecord::Base
end end
javascript_cache.content = js_compiler.content javascript_cache.content = js_compiler.content
javascript_cache.source_map = js_compiler.source_map
javascript_cache.save! javascript_cache.save!
doc = "" doc = ""
doc = "<script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}'></script>" if javascript_cache.content.present? doc = "<script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}'></script>" if javascript_cache.content.present?

View File

@ -561,6 +561,7 @@ Discourse::Application.routes.draw do
get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ } get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ }
get "color-scheme-stylesheet/:id(/:theme_id)" => "stylesheets#color_scheme", constraints: { format: :json } get "color-scheme-stylesheet/:id(/:theme_id)" => "stylesheets#color_scheme", constraints: { format: :json }
get "theme-javascripts/:digest.js" => "theme_javascripts#show", constraints: { digest: /\h{40}/ } get "theme-javascripts/:digest.js" => "theme_javascripts#show", constraints: { digest: /\h{40}/ }
get "theme-javascripts/:digest.map" => "theme_javascripts#show_map", constraints: { digest: /\h{40}/ }
get "theme-javascripts/tests/:theme_id-:digest.js" => "theme_javascripts#show_tests" get "theme-javascripts/tests/:theme_id-:digest.js" => "theme_javascripts#show_tests"
post "uploads/lookup-metadata" => "uploads#metadata" post "uploads/lookup-metadata" => "uploads#metadata"

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSourceMapToJavascriptCache < ActiveRecord::Migration[7.0]
def change
add_column :javascript_caches, :source_map, :text
end
end

View File

@ -152,6 +152,10 @@ class DiscourseJsProcessor
} }
JS JS
# Terser
load_file_in_context(ctx, "node_modules/source-map/dist/source-map.js")
load_file_in_context(ctx, "node_modules/terser/dist/bundle.min.js")
# Template Compiler # Template Compiler
load_file_in_context(ctx, "node_modules/ember-source/dist/ember-template-compiler.js") load_file_in_context(ctx, "node_modules/ember-source/dist/ember-template-compiler.js")
load_file_in_context(ctx, "node_modules/babel-plugin-ember-template-compilation/src/plugin.js", wrap_in_module: "babel-plugin-ember-template-compilation/index") load_file_in_context(ctx, "node_modules/babel-plugin-ember-template-compilation/src/plugin.js", wrap_in_module: "babel-plugin-ember-template-compilation/index")
@ -197,6 +201,8 @@ class DiscourseJsProcessor
ctx.eval <<~JS ctx.eval <<~JS
globalThis.compileRawTemplate = require('discourse-js-processor').compileRawTemplate; globalThis.compileRawTemplate = require('discourse-js-processor').compileRawTemplate;
globalThis.transpile = require('discourse-js-processor').transpile; globalThis.transpile = require('discourse-js-processor').transpile;
globalThis.minify = require('discourse-js-processor').minify;
globalThis.getMinifyResult = require('discourse-js-processor').getMinifyResult;
JS JS
ctx ctx
@ -219,9 +225,16 @@ class DiscourseJsProcessor
@ctx @ctx
end end
# Call a method in the global scope of the v8 context.
# The `fetch_result_call` kwarg provides a workaround for the lack of mini_racer async
# result support. The first call can perform some async operation, and then `fetch_result_call`
# will be called to fetch the result.
def self.v8_call(*args, **kwargs) def self.v8_call(*args, **kwargs)
fetch_result_call = kwargs.delete(:fetch_result_call)
mutex.synchronize do mutex.synchronize do
v8.call(*args, **kwargs) result = v8.call(*args, **kwargs)
result = v8.call(fetch_result_call) if fetch_result_call
result
end end
rescue MiniRacer::RuntimeError => e rescue MiniRacer::RuntimeError => e
message = e.message message = e.message
@ -276,5 +289,8 @@ class DiscourseJsProcessor
self.class.v8_call("compileRawTemplate", source, theme_id) self.class.v8_call("compileRawTemplate", source, theme_id)
end end
def terser(tree, opts)
self.class.v8_call("minify", tree, opts, fetch_result_call: "getMinifyResult")
end
end end
end end

View File

@ -7,16 +7,78 @@ class ThemeJavascriptCompiler
class CompileError < StandardError class CompileError < StandardError
end end
attr_accessor :content @@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) def initialize(theme_id, theme_name)
@theme_id = theme_id @theme_id = theme_id
@content = +"" @output_tree = []
@theme_name = theme_name @theme_name = theme_name
end 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) def prepend_settings(settings_hash)
@content.prepend <<~JS @output_tree.prepend ['settings.js', <<~JS]
(function() { (function() {
if ('require' in window) { if ('require' in window) {
require("discourse/lib/theme-settings-store").registerSettings(#{@theme_id}, #{settings_hash.to_json}); require("discourse/lib/theme-settings-store").registerSettings(#{@theme_id}, #{settings_hash.to_json});
@ -90,16 +152,17 @@ class ThemeJavascriptCompiler
elsif extension == "hbr" elsif extension == "hbr"
append_raw_template(module_name.sub("discourse/templates/", ""), content) append_raw_template(module_name.sub("discourse/templates/", ""), content)
else else
append_js_error("unknown file extension '#{extension}' (#{filename})") append_js_error(filename, "unknown file extension '#{extension}' (#{filename})")
end end
rescue CompileError => e rescue CompileError => e
append_js_error "#{e.message} (#{filename})" append_js_error filename, "#{e.message} (#{filename})"
end end
end end
def append_ember_template(name, hbs_template) def append_ember_template(name, hbs_template)
name = "/#{name}" if !name.start_with?("/") module_name = name
module_name = "discourse/theme-#{@theme_id}#{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 # 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") if (match = COLOCATED_CONNECTOR_REGEX.match(module_name)) && !match[:prefix].end_with?("/templates")
@ -114,7 +177,7 @@ class ThemeJavascriptCompiler
JS JS
template_module = DiscourseJsProcessor.transpile(script, "", module_name, theme_id: @theme_id) template_module = DiscourseJsProcessor.transpile(script, "", module_name, theme_id: @theme_id)
content << <<~JS @output_tree << ["#{name}.js", <<~JS]
if ('define' in window) { if ('define' in window) {
#{template_module} #{template_module}
} }
@ -130,8 +193,12 @@ class ThemeJavascriptCompiler
def append_raw_template(name, hbs_template) def append_raw_template(name, hbs_template)
compiled = DiscourseJsProcessor::Transpiler.new.compile_raw_template(hbs_template, theme_id: @theme_id) compiled = DiscourseJsProcessor::Transpiler.new.compile_raw_template(hbs_template, theme_id: @theme_id)
@content << <<~JS source_for_comment = hbs_template.gsub("*/", '*\/').indent(4, ' ')
@output_tree << ["#{name}.js", <<~JS]
(function() { (function() {
/*
#{source_for_comment}
*/
const addRawTemplate = requirejs('discourse-common/lib/raw-templates').addRawTemplate; const addRawTemplate = requirejs('discourse-common/lib/raw-templates').addRawTemplate;
const template = requirejs('discourse-common/lib/raw-handlebars').template(#{compiled}); const template = requirejs('discourse-common/lib/raw-handlebars').template(#{compiled});
addRawTemplate(#{raw_template_name(name)}, template); addRawTemplate(#{raw_template_name(name)}, template);
@ -141,11 +208,12 @@ class ThemeJavascriptCompiler
raise CompileError.new ex.message raise CompileError.new ex.message
end end
def append_raw_script(script) def append_raw_script(filename, script)
@content << script + "\n" @output_tree << [filename, script + "\n"]
end end
def append_module(script, name, include_variables: true) def append_module(script, name, include_variables: true)
original_filename = name
name = "discourse/theme-#{@theme_id}/#{name.gsub(/^discourse\//, '')}" 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 # Some themes are colocating connector JS under `/templates/connectors`. Move out of templates to avoid module name clash
@ -155,7 +223,7 @@ class ThemeJavascriptCompiler
script = "#{theme_settings}#{script}" if include_variables script = "#{theme_settings}#{script}" if include_variables
transpiler = DiscourseJsProcessor::Transpiler.new transpiler = DiscourseJsProcessor::Transpiler.new
@content << <<~JS @output_tree << ["#{original_filename}.js", <<~JS]
if ('define' in window) { if ('define' in window) {
#{transpiler.perform(script, "", name).strip} #{transpiler.perform(script, "", name).strip}
} }
@ -164,9 +232,9 @@ class ThemeJavascriptCompiler
raise CompileError.new ex.message raise CompileError.new ex.message
end end
def append_js_error(message) def append_js_error(filename, message)
message = "[THEME #{@theme_id} '#{@theme_name}'] Compile error: #{message}" message = "[THEME #{@theme_id} '#{@theme_name}'] Compile error: #{message}"
append_raw_script "console.error(#{message.to_json});" append_raw_script filename, "console.error(#{message.to_json});"
end end
private private

View File

@ -185,4 +185,30 @@ RSpec.describe DiscourseJsProcessor do
) )
end end
end end
describe "Transpiler#terser" do
it "can minify code and provide sourcemaps" do
sources = {
"multiply.js" => "let multiply = (firstValue, secondValue) => firstValue * secondValue;",
"add.js" => "let add = (firstValue, secondValue) => firstValue + secondValue;"
}
result = DiscourseJsProcessor::Transpiler.new.terser(sources, { sourceMap: { includeSources: true } })
expect(result.keys).to contain_exactly("code", "map")
begin
# Check the code still works
ctx = MiniRacer::Context.new
ctx.eval(result["code"])
expect(ctx.eval("multiply(2, 3)")).to eq(6)
expect(ctx.eval("add(2, 3)")).to eq(5)
ensure
ctx.dispose
end
map = JSON.parse(result["map"])
expect(map["sources"]).to contain_exactly(*sources.keys)
expect(map["sourcesContent"]).to contain_exactly(*sources.values)
end
end
end end

View File

@ -8,28 +8,28 @@ RSpec.describe ThemeJavascriptCompiler do
template = "<h1>hello</h1>" template = "<h1>hello</h1>"
name = "/path/to/templates1" name = "/path/to/templates1"
compiler.append_raw_template("#{name}.raw", template) compiler.append_raw_template("#{name}.raw", template)
expect(compiler.content.to_s).to include("addRawTemplate(\"#{name}\"") expect(compiler.raw_content.to_s).to include("addRawTemplate(\"#{name}\"")
name = "/path/to/templates2" name = "/path/to/templates2"
compiler.append_raw_template("#{name}.hbr", template) compiler.append_raw_template("#{name}.hbr", template)
expect(compiler.content.to_s).to include("addRawTemplate(\"#{name}\"") expect(compiler.raw_content.to_s).to include("addRawTemplate(\"#{name}\"")
name = "/path/to/templates3" name = "/path/to/templates3"
compiler.append_raw_template("#{name}.hbs", template) compiler.append_raw_template("#{name}.hbs", template)
expect(compiler.content.to_s).to include("addRawTemplate(\"#{name}.hbs\"") expect(compiler.raw_content.to_s).to include("addRawTemplate(\"#{name}.hbs\"")
end end
end end
describe "#append_ember_template" do describe "#append_ember_template" do
it 'maintains module names so that discourse-boot.js can correct them' do it 'maintains module names so that discourse-boot.js can correct them' do
compiler.append_ember_template("/connectors/blah-1", "{{var}}") compiler.append_ember_template("/connectors/blah-1", "{{var}}")
expect(compiler.content.to_s).to include("define(\"discourse/theme-1/connectors/blah-1\", [\"exports\", \"@ember/template-factory\"]") expect(compiler.raw_content.to_s).to include("define(\"discourse/theme-1/connectors/blah-1\", [\"exports\", \"@ember/template-factory\"]")
compiler.append_ember_template("connectors/blah-2", "{{var}}") compiler.append_ember_template("connectors/blah-2", "{{var}}")
expect(compiler.content.to_s).to include("define(\"discourse/theme-1/connectors/blah-2\", [\"exports\", \"@ember/template-factory\"]") expect(compiler.raw_content.to_s).to include("define(\"discourse/theme-1/connectors/blah-2\", [\"exports\", \"@ember/template-factory\"]")
compiler.append_ember_template("javascripts/connectors/blah-3", "{{var}}") compiler.append_ember_template("javascripts/connectors/blah-3", "{{var}}")
expect(compiler.content.to_s).to include("define(\"discourse/theme-1/javascripts/connectors/blah-3\", [\"exports\", \"@ember/template-factory\"]") expect(compiler.raw_content.to_s).to include("define(\"discourse/theme-1/javascripts/connectors/blah-3\", [\"exports\", \"@ember/template-factory\"]")
end end
end end
@ -39,22 +39,22 @@ RSpec.describe ThemeJavascriptCompiler do
compiler = ThemeJavascriptCompiler.new(1, 'marks') compiler = ThemeJavascriptCompiler.new(1, 'marks')
compiler.append_ember_template("connectors/outlet/blah-1", "{{var}}") compiler.append_ember_template("connectors/outlet/blah-1", "{{var}}")
compiler.append_module("console.log('test')", "connectors/outlet/blah-1") compiler.append_module("console.log('test')", "connectors/outlet/blah-1")
expect(compiler.content.to_s).to include("discourse/theme-1/connectors/outlet/blah-1") expect(compiler.raw_content.to_s).to include("discourse/theme-1/connectors/outlet/blah-1")
expect(compiler.content.to_s).to include("discourse/theme-1/templates/connectors/outlet/blah-1") expect(compiler.raw_content.to_s).to include("discourse/theme-1/templates/connectors/outlet/blah-1")
# Colocated under `/templates/connectors` # Colocated under `/templates/connectors`
compiler = ThemeJavascriptCompiler.new(1, 'marks') compiler = ThemeJavascriptCompiler.new(1, 'marks')
compiler.append_ember_template("templates/connectors/outlet/blah-1", "{{var}}") compiler.append_ember_template("templates/connectors/outlet/blah-1", "{{var}}")
compiler.append_module("console.log('test')", "templates/connectors/outlet/blah-1") compiler.append_module("console.log('test')", "templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).to include("discourse/theme-1/connectors/outlet/blah-1") expect(compiler.raw_content.to_s).to include("discourse/theme-1/connectors/outlet/blah-1")
expect(compiler.content.to_s).to include("discourse/theme-1/templates/connectors/outlet/blah-1") expect(compiler.raw_content.to_s).to include("discourse/theme-1/templates/connectors/outlet/blah-1")
# Not colocated # Not colocated
compiler = ThemeJavascriptCompiler.new(1, 'marks') compiler = ThemeJavascriptCompiler.new(1, 'marks')
compiler.append_ember_template("templates/connectors/outlet/blah-1", "{{var}}") compiler.append_ember_template("templates/connectors/outlet/blah-1", "{{var}}")
compiler.append_module("console.log('test')", "connectors/outlet/blah-1") compiler.append_module("console.log('test')", "connectors/outlet/blah-1")
expect(compiler.content.to_s).to include("discourse/theme-1/connectors/outlet/blah-1") expect(compiler.raw_content.to_s).to include("discourse/theme-1/connectors/outlet/blah-1")
expect(compiler.content.to_s).to include("discourse/theme-1/templates/connectors/outlet/blah-1") expect(compiler.raw_content.to_s).to include("discourse/theme-1/templates/connectors/outlet/blah-1")
end end
end end
@ -83,8 +83,8 @@ RSpec.describe ThemeJavascriptCompiler do
"discourse/templates/components/mycomponent.hbs" => "{{my-component-template}}" "discourse/templates/components/mycomponent.hbs" => "{{my-component-template}}"
} }
) )
expect(compiler.content).to include('define("discourse/theme-1/components/mycomponent"') expect(compiler.raw_content).to include('define("discourse/theme-1/components/mycomponent"')
expect(compiler.content).to include('define("discourse/theme-1/discourse/templates/components/mycomponent"') expect(compiler.raw_content).to include('define("discourse/theme-1/discourse/templates/components/mycomponent"')
end end
it "handles colocated components" do it "handles colocated components" do
@ -97,8 +97,8 @@ RSpec.describe ThemeJavascriptCompiler do
"discourse/components/mycomponent.hbs" => "{{my-component-template}}" "discourse/components/mycomponent.hbs" => "{{my-component-template}}"
} }
) )
expect(compiler.content).to include("__COLOCATED_TEMPLATE__ =") expect(compiler.raw_content).to include("__COLOCATED_TEMPLATE__ =")
expect(compiler.content).to include("setComponentTemplate") expect(compiler.raw_content).to include("setComponentTemplate")
end end
it "prints error when default export missing" do it "prints error when default export missing" do
@ -111,8 +111,8 @@ RSpec.describe ThemeJavascriptCompiler do
"discourse/components/mycomponent.hbs" => "{{my-component-template}}" "discourse/components/mycomponent.hbs" => "{{my-component-template}}"
} }
) )
expect(compiler.content).to include("__COLOCATED_TEMPLATE__ =") expect(compiler.raw_content).to include("__COLOCATED_TEMPLATE__ =")
expect(compiler.content).to include("throw new Error") expect(compiler.raw_content).to include("throw new Error")
end end
it "handles template-only components" do it "handles template-only components" do
@ -121,9 +121,35 @@ RSpec.describe ThemeJavascriptCompiler do
"discourse/components/mycomponent.hbs" => "{{my-component-template}}" "discourse/components/mycomponent.hbs" => "{{my-component-template}}"
} }
) )
expect(compiler.content).to include("__COLOCATED_TEMPLATE__ =") expect(compiler.raw_content).to include("__COLOCATED_TEMPLATE__ =")
expect(compiler.content).to include("setComponentTemplate") expect(compiler.raw_content).to include("setComponentTemplate")
expect(compiler.content).to include("@ember/component/template-only") expect(compiler.raw_content).to include("@ember/component/template-only")
end
end
describe "terser compilation" do
it "applies terser and provides sourcemaps" do
sources = {
"multiply.js" => "let multiply = (firstValue, secondValue) => firstValue * secondValue;",
"add.js" => "let add = (firstValue, secondValue) => firstValue + secondValue;"
}
compiler.append_tree(sources)
expect(compiler.content).to include("multiply")
expect(compiler.content).to include("add")
map = JSON.parse(compiler.source_map)
expect(map["sources"]).to contain_exactly(*sources.keys)
expect(map["sourcesContent"].to_s).to include("let multiply")
expect(map["sourcesContent"].to_s).to include("let add")
expect(map["sourceRoot"]).to eq("theme-1/")
end
it "handles invalid JS" do
compiler.append_raw_script("filename.js", "if(someCondition")
expect(compiler.content).to include('console.error("[THEME 1')
expect(compiler.content).to include('Unexpected token')
end end
end end
end end

View File

@ -3,6 +3,8 @@
RSpec.describe ThemeField do RSpec.describe ThemeField do
fab!(:theme) { Fabricate(:theme) } fab!(:theme) { Fabricate(:theme) }
before { ThemeJavascriptCompiler.disable_terser! }
after { ThemeJavascriptCompiler.enable_terser! }
describe "scope: find_by_theme_ids" do describe "scope: find_by_theme_ids" do
it "returns result in the specified order" do it "returns result in the specified order" do
@ -194,6 +196,26 @@ HTML
expect(theme.javascript_cache.content).to include("define(\"discourse/theme-#{theme.id}/controllers/discovery-2\"") expect(theme.javascript_cache.content).to include("define(\"discourse/theme-#{theme.id}/controllers/discovery-2\"")
expect(theme.javascript_cache.content).to include("const settings =") expect(theme.javascript_cache.content).to include("const settings =")
expect(theme.javascript_cache.content).to include("[THEME #{theme.id} '#{theme.name}'] Compile error: unknown file extension 'blah' (discourse/controllers/discovery.blah)") expect(theme.javascript_cache.content).to include("[THEME #{theme.id} '#{theme.name}'] Compile error: unknown file extension 'blah' (discourse/controllers/discovery.blah)")
# Check sourcemap
expect(theme.javascript_cache.source_map).to eq(nil)
ThemeJavascriptCompiler.enable_terser!
js_field.update(compiler_version: "0")
theme.save!
expect(theme.javascript_cache.source_map).not_to eq(nil)
map = JSON.parse(theme.javascript_cache.source_map)
expect(map["sources"]).to contain_exactly(
"discourse/controllers/discovery-2.js",
"discourse/controllers/discovery.blah",
"discourse/controllers/discovery.js",
"discourse/templates/discovery.js",
"discovery.js",
"other_discovery.js"
)
expect(map["sourceRoot"]).to eq("theme-#{theme.id}/")
expect(map["sourcesContent"].length).to eq(6)
end end
def create_upload_theme_field!(name) def create_upload_theme_field!(name)

View File

@ -5,6 +5,9 @@ RSpec.describe Theme do
Theme.clear_cache! Theme.clear_cache!
end end
before { ThemeJavascriptCompiler.disable_terser! }
after { ThemeJavascriptCompiler.enable_terser! }
fab! :user do fab! :user do
Fabricate(:user) Fabricate(:user)
end end

View File

@ -2,6 +2,9 @@
RSpec.describe ThemeJavascriptsController do RSpec.describe ThemeJavascriptsController do
include ActiveSupport::Testing::TimeHelpers include ActiveSupport::Testing::TimeHelpers
before { ThemeJavascriptCompiler.disable_terser! }
after { ThemeJavascriptCompiler.enable_terser! }
def clear_disk_cache def clear_disk_cache
if Dir.exist?(ThemeJavascriptsController::DISK_CACHE_PATH) if Dir.exist?(ThemeJavascriptsController::DISK_CACHE_PATH)
`rm -rf #{ThemeJavascriptsController::DISK_CACHE_PATH}` `rm -rf #{ThemeJavascriptsController::DISK_CACHE_PATH}`
@ -55,6 +58,40 @@ RSpec.describe ThemeJavascriptsController do
get "/theme-javascripts/#{javascript_cache.digest}.js" get "/theme-javascripts/#{javascript_cache.digest}.js"
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
it "adds sourceMappingUrl if there is a source map" do
digest = SecureRandom.hex(20)
javascript_cache.update(digest: digest)
get "/theme-javascripts/#{digest}.js"
expect(response.status).to eq(200)
expect(response.body).to eq('console.log("hello");')
digest = SecureRandom.hex(20)
javascript_cache.update(digest: digest, source_map: '{fakeSourceMap: true}')
get "/theme-javascripts/#{digest}.js"
expect(response.status).to eq(200)
expect(response.body).to eq <<~JS
console.log("hello");
//# sourceMappingURL=#{digest}.map?__ws=test.localhost
JS
end
end
describe "#show_map" do
it "returns a source map when present" do
get "/theme-javascripts/#{javascript_cache.digest}.map"
expect(response.status).to eq(404)
digest = SecureRandom.hex(20)
javascript_cache.update(digest: digest, source_map: '{fakeSourceMap: true}')
get "/theme-javascripts/#{digest}.map"
expect(response.status).to eq(200)
expect(response.body).to eq("{fakeSourceMap: true}")
javascript_cache.destroy
get "/theme-javascripts/#{digest}.map"
expect(response.status).to eq(404)
end
end end
describe "#show_tests" do describe "#show_tests" do
@ -133,5 +170,13 @@ RSpec.describe ThemeJavascriptsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.body).to include("assert.ok(343434);") expect(response.body).to include("assert.ok(343434);")
end end
it "includes inline sourcemap" do
ThemeJavascriptCompiler.enable_terser!
content, digest = component.baked_js_tests_with_digest
get "/theme-javascripts/tests/#{component.id}-#{digest}.js"
expect(response.status).to eq(200)
expect(response.body).to include("//# sourceMappingURL=data:application/json;base64,")
end
end end
end end