mirror of
https://github.com/discourse/discourse.git
synced 2025-05-21 18:12:32 +08:00
FEATURE: Support for localized themes (#6848)
- Themes can supply translation files in a format like `/locales/{locale}.yml`. These files should be valid YAML, with a single top level key equal to the locale being defined. For now these can only be defined using the `discourse_theme` CLI, importing a `.tar.gz`, or from a GIT repository. - Fallback is handled on a global level (if the locale is not defined in the theme), as well as on individual keys (if some keys are missing from the selected interface language). - Administrators can override individual keys on a per-theme basis in the /admin/customize/themes user interface. - Theme developers should access defined translations using the new theme prefix variables: JavaScript: `I18n.t(themePrefix("my_translation_key"))` Handlebars: `{{theme-i18n "my_translation_key"}}` or `{{i18n (theme-prefix "my_translation_key")}}` - To design for backwards compatibility, theme developers can check for the presence of the `themePrefix` variable in JavaScript - As part of this, the old `{{themeSetting.setting_name}}` syntax is deprecated in favour of `{{theme-setting "setting_name"}}`
This commit is contained in:
223
lib/theme_javascript_compiler.rb
Normal file
223
lib/theme_javascript_compiler.rb
Normal file
@ -0,0 +1,223 @@
|
||||
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[0] == "themeSetting"){
|
||||
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 (["theme-i18n", "theme-prefix", "theme-setting"].includes(node.path.parts[0])) {
|
||||
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[0] == "themeSetting") {
|
||||
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 themeSetting.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[0] == 'themeSetting'){
|
||||
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
|
||||
|
||||
attr_accessor :content
|
||||
|
||||
def initialize(theme_id)
|
||||
@theme_id = theme_id
|
||||
@content = ""
|
||||
end
|
||||
|
||||
def prepend_settings(settings_hash)
|
||||
@content.prepend <<~JS
|
||||
(function() {
|
||||
if ('Discourse' in window && Discourse.__container__) {
|
||||
Discourse.__container__
|
||||
.lookup("service:theme-settings")
|
||||
.registerSettings(#{@theme_id}, #{settings_hash.to_json});
|
||||
}
|
||||
})();
|
||||
JS
|
||||
end
|
||||
|
||||
# TODO Error handling for handlebars templates
|
||||
def append_ember_template(name, hbs_template)
|
||||
name = name.inspect
|
||||
compiled = EmberTemplatePrecompiler.new(@theme_id).compile(hbs_template)
|
||||
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 append_raw_template(name, hbs_template)
|
||||
name = name.sub(/\.raw$/, '').inspect
|
||||
compiled = RawTemplatePrecompiler.new(@theme_id).compile(hbs_template)
|
||||
@content << <<~JS
|
||||
(function() {
|
||||
if ('Discourse' in window) {
|
||||
Discourse.RAW_TEMPLATES[#{name}] = requirejs('discourse-common/lib/raw-handlebars').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 append_plugin_script(script, api_version)
|
||||
@content << transpile(script, api_version)
|
||||
end
|
||||
|
||||
def append_raw_script(script)
|
||||
@content << script + "\n"
|
||||
end
|
||||
|
||||
def append_js_error(message)
|
||||
@content << "console.error('Theme Transpilation Error:', #{message.inspect});"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transpile(es6_source, version)
|
||||
template = Tilt::ES6ModuleTranspilerTemplate.new {}
|
||||
wrapped = <<~PLUGIN_API_JS
|
||||
if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') {
|
||||
const themeSetting = Discourse.__container__
|
||||
.lookup("service:theme-settings")
|
||||
.getObjectForTheme(#{@theme_id});
|
||||
const themePrefix = (key) => `theme_translations.#{@theme_id}.${key}`;
|
||||
Discourse._registerPluginCode('#{version}', api => {
|
||||
#{es6_source}
|
||||
});
|
||||
}
|
||||
PLUGIN_API_JS
|
||||
|
||||
template.babel_transpile(wrapped)
|
||||
rescue MiniRacer::RuntimeError => ex
|
||||
raise CompileError.new ex.message
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user