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:
parent
e23b247690
commit
be3d6a56ce
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
#
|
#
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue