discourse/lib/theme_javascript_compiler.rb
Osama Sayegh cd24eff5d9
FEATURE: Introduce theme/component QUnit tests (take 2) (#12661)
This commit allows themes and theme components to have QUnit tests. To add tests to your theme/component, create a top-level directory in your theme and name it `test`, and Discourse will save all the files in that directory (and its sub-directories) as "tests files" in the database. While tests files/directories are not required to be organized in a specific way, we recommend that you follow Discourse core's tests [structure](https://github.com/discourse/discourse/tree/master/app/assets/javascripts/discourse/tests).

Writing theme tests should be identical to writing plugins or core tests; all the `import` statements and APIs that you see in core (or plugins) to define/setup tests should just work in themes.

You do need a working Discourse install to run theme tests, and you have 2 ways to run theme tests:

* In the browser at the `/qunit` route. `/qunit` will run tests of all active themes/components as well as core and plugins. The `/qunit` now accepts a `theme_name` or `theme_url` params that you can use to run tests of a specific theme/component like so: `/qunit?theme_name=<your_theme_name>`.

* In the command line using the `themes:qunit` rake task. This take is meant to run tests of a single theme/component so you need to provide it with a theme name or URL like so: `bundle exec rake themes:qunit[name=<theme_name>]` or `bundle exec rake themes:qunit[url=<theme_url>]`.

There are some refactors to how Discourse processes JavaScript that comes with themes/components, and these refactors may break your JS customizations; see https://meta.discourse.org/t/upcoming-core-changes-that-may-break-some-themes-components-april-12/186252?u=osama for details on how you can check if your themes/components are affected and what you need to do to fix them.

This commit also improves theme error handling in Discourse. We will now be able to catch errors that occur when theme initializers are run and prevent them from breaking the site and other themes/components.
2021-04-12 15:02:58 +03:00

254 lines
7.4 KiB
Ruby

# frozen_string_literal: true
class ThemeJavascriptCompiler
module PrecompilerExtension
def initialize(theme_id)
super()
@theme_id = theme_id
end
def discourse_node_manipulator
<<~JS
// Helper to replace old themeSetting syntax
function generateHelper(settingParts) {
const settingName = settingParts.join('.');
return {
"path": {
"type": "PathExpression",
"original": "theme-setting",
"this": false,
"data": false,
"parts": [
"theme-setting"
],
"depth":0
},
"params": [
{
type: "NumberLiteral",
value: #{@theme_id},
original: #{@theme_id}
},
{
"type": "StringLiteral",
"value": settingName,
"original": settingName
}
],
"hash": {
"type": "Hash",
"pairs": [
{
"type": "HashPair",
"key": "deprecated",
"value": {
"type": "BooleanLiteral",
"value": true,
"original": true
}
}
]
}
}
}
function manipulatePath(path) {
// Override old themeSetting syntax when it's a param inside another node
if(path.parts && path.parts[0] == "themeSettings"){
const settingParts = path.parts.slice(1);
path.type = "SubExpression";
Object.assign(path, generateHelper(settingParts))
}
}
function manipulateNode(node) {
// Magically add theme id as the first param for each of these helpers)
if (node.path.parts && ["theme-i18n", "theme-prefix", "theme-setting"].includes(node.path.parts[0])) {
if(node.params.length === 1){
node.params.unshift({
type: "NumberLiteral",
value: #{@theme_id},
original: #{@theme_id}
})
}
}
// Override old themeSetting syntax when it's in its own node
if (node.path.parts && node.path.parts[0] == "themeSettings") {
Object.assign(node, generateHelper(node.path.parts.slice(1)))
}
}
JS
end
def source
[super, discourse_node_manipulator, discourse_extension].join("\n")
end
end
class RawTemplatePrecompiler < Barber::Precompiler
include PrecompilerExtension
def discourse_extension
<<~JS
let _superCompile = Handlebars.Compiler.prototype.compile;
Handlebars.Compiler.prototype.compile = function(program, options) {
// `replaceGet()` in raw-handlebars.js.es6 adds a `get` in front of things
// so undo this specific case for the old themeSettings.blah syntax
let visitor = new Handlebars.Visitor();
visitor.mutating = true;
visitor.MustacheStatement = (node) => {
if(node.path.original == 'get'
&& node.params
&& node.params[0]
&& node.params[0].parts
&& node.params[0].parts[0] == 'themeSettings'){
node.path.parts = node.params[0].parts
node.params = []
}
};
visitor.accept(program);
[
["SubExpression", manipulateNode],
["MustacheStatement", manipulateNode],
["PathExpression", manipulatePath]
].forEach((pass) => {
let visitor = new Handlebars.Visitor();
visitor.mutating = true;
visitor[pass[0]] = pass[1];
visitor.accept(program);
})
return _superCompile.apply(this, arguments);
};
JS
end
end
class EmberTemplatePrecompiler < Barber::Ember::Precompiler
include PrecompilerExtension
def discourse_extension
<<~JS
Ember.HTMLBars.registerPlugin('ast', function() {
return {
name: 'theme-template-manipulator',
visitor: {
SubExpression: manipulateNode,
MustacheStatement: manipulateNode,
PathExpression: manipulatePath
}
}
});
JS
end
end
class CompileError < StandardError
end
def self.force_default_settings(content, theme)
settings_hash = {}
theme.settings.each do |setting|
settings_hash[setting.name] = setting.default
end
content.prepend <<~JS
(function() {
require("discourse/lib/theme-settings-store").registerSettings(#{theme.id}, #{settings_hash.to_json}, { force: true });
})();
JS
end
attr_accessor :content
def initialize(theme_id, theme_name)
@theme_id = theme_id
@content = +""
@theme_name = theme_name
end
def prepend_settings(settings_hash)
@content.prepend <<~JS
(function() {
if ('require' in window) {
require("discourse/lib/theme-settings-store").registerSettings(#{@theme_id}, #{settings_hash.to_json});
}
})();
JS
end
# TODO Error handling for handlebars templates
def append_ember_template(name, hbs_template)
if !name.start_with?("javascripts/")
prefix = "javascripts"
prefix += "/" if !name.start_with?("/")
name = prefix + name
end
name = name.inspect
compiled = EmberTemplatePrecompiler.new(@theme_id).compile(hbs_template)
# the `'Ember' in window` check is needed for no_ember pages
content << <<~JS
(function() {
if ('Ember' in window) {
Ember.TEMPLATES[#{name}] = Ember.HTMLBars.template(#{compiled});
}
})();
JS
rescue Barber::PrecompilerError => e
raise CompileError.new e.instance_variable_get(:@error) # e.message contains the entire template, which could be very long
end
def raw_template_name(name)
name = name.sub(/\.(raw|hbr)$/, '')
name.inspect
end
def append_raw_template(name, hbs_template)
compiled = RawTemplatePrecompiler.new(@theme_id).compile(hbs_template)
@content << <<~JS
(function() {
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 Barber::PrecompilerError => e
raise CompileError.new e.instance_variable_get(:@error) # e.message contains the entire template, which could be very long
end
def append_raw_script(script)
@content << script + "\n"
end
def append_module(script, name, include_variables: true)
name = "discourse/theme-#{@theme_id}/#{name.gsub(/^discourse\//, '')}"
script = "#{theme_settings}#{script}" if include_variables
transpiler = DiscourseJsProcessor::Transpiler.new
@content << <<~JS
if ('define' in window) {
#{transpiler.perform(script, "", name).strip}
}
JS
rescue MiniRacer::RuntimeError => ex
raise CompileError.new ex.message
end
def append_js_error(message)
@content << "console.error('Theme Transpilation Error:', #{message.inspect});"
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