2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-08-18 18:18:09 +08:00
discourse/spec/lib/theme_javascript_compiler_spec.rb
David Taylor aa2fb29fa6
DEV: Use rollup for theme JS compilation (#33103)
This commit is a complete reimplementation of our theme JS compilation
system.

Previously, we compiled theme JS into AMD `define` statements on a
per-source-file basis, and then concatenated them together for the
client. These AMD modules would integrate with those in Discourse core,
allowing two way access between core/theme modules. Going forward, we'll
be moving away from AMD, and towards native ES modules in core. Before
we can do that, we need to stop relying on AMD as the 'glue' between
core and themes/plugins.

This change introduces Rollup (running in mini-racer) as a compiler for
theme JS. This is configured to generate a single ES Module which
exports a list of 'compat modules'. Core `import()`s the modules for
each active theme, and adds them all to AMD. In future, this consumption
can be updated to avoid AMD entirely.

All module resolution within a theme is handled by Rollup, and does not
use AMD.

Import of core/plugin modules from themes are automatically transformed
into calls to a new `window.moduleBroker` interface. For now, this is a
direct interface to AMD. In future, this can be updated to point to real
ES Modules in core.

Despite the complete overhaul of the internals, this is not a breaking
change, and should have no impact on existing themes. If any
incompatibilities are found, please report them on
https://meta.discourse.org.

---------

Co-authored-by: Jarek Radosz <jarek@cvx.dev>
Co-authored-by: Chris Manson <chris@manson.ie>
2025-07-25 12:02:29 +01:00

265 lines
10 KiB
Ruby

# frozen_string_literal: true
RSpec.describe ThemeJavascriptCompiler do
let(:compiler) { ThemeJavascriptCompiler.new(1, "marks", minify: false) }
describe "#append_ember_template" do
it "maintains module names so that discourse-boot.js can correct them" do
compiler.append_tree({ "connectors/blah-1.hbs" => "{{var}}" })
compiler.append_tree({ "connectors/blah-2.hbs" => "{{var}}" })
compiler.append_tree({ "javascripts/connectors/blah-3.hbs" => "{{var}}" })
expect(compiler.content.to_s).to include(
"themeCompatModules[\"templates/connectors/blah-1\"]",
)
expect(compiler.content.to_s).to include(
"themeCompatModules[\"templates/connectors/blah-2\"]",
)
expect(compiler.content.to_s).to include(
"themeCompatModules[\"javascripts/templates/connectors/blah-3\"]",
)
end
end
describe "connector module name handling" do
it "separates colocated connectors to avoid module name clash" do
# Colocated under `/connectors`
compiler = ThemeJavascriptCompiler.new(1, "marks", minify: false)
compiler.append_tree(
{
"connectors/outlet/blah-1.hbs" => "{{var}}",
"connectors/outlet/blah-1.js" => "export default {};",
},
)
expect(compiler.content.to_s).to include(
'themeCompatModules["connectors/outlet/blah-1"]',
).once
expect(compiler.content.to_s).to include("templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).not_to include("setComponentTemplate")
expect(JSON.parse(compiler.source_map)["sources"]).to include(
"theme-1/connectors/outlet/blah-1.js",
)
# Colocated under `/templates/connectors`
compiler = ThemeJavascriptCompiler.new(1, "marks", minify: false)
compiler.append_tree(
{
"templates/connectors/outlet/blah-1.hbs" => "{{var}}",
"templates/connectors/outlet/blah-1.js" => "export default {};",
},
)
expect(compiler.content.to_s).to include(
'themeCompatModules["connectors/outlet/blah-1"]',
).once
expect(compiler.content.to_s).to include("templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).not_to include("setComponentTemplate")
expect(JSON.parse(compiler.source_map)["sources"]).to include(
"theme-1/templates/connectors/outlet/blah-1.js",
)
# Not colocated
compiler = ThemeJavascriptCompiler.new(1, "marks", minify: false)
compiler.append_tree(
{
"templates/connectors/outlet/blah-1.hbs" => "{{var}}",
"connectors/outlet/blah-1.js" => "export default {};",
},
)
expect(compiler.content.to_s).to include(
'themeCompatModules["connectors/outlet/blah-1"]',
).once
expect(compiler.content.to_s).to include("templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).not_to include("setComponentTemplate")
expect(JSON.parse(compiler.source_map)["sources"]).to include(
"theme-1/connectors/outlet/blah-1.js",
)
# colocation in discourse directory
compiler = ThemeJavascriptCompiler.new(1, "marks", minify: false)
compiler.append_tree(
{
"discourse/connectors/outlet/blah-1.hbs" => "{{var}}",
"discourse/connectors/outlet/blah-1.js" => "export default {};",
},
)
expect(compiler.content.to_s).to include(
'themeCompatModules["discourse/connectors/outlet/blah-1"]',
).once
expect(compiler.content.to_s).to include("discourse/templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).not_to include("setComponentTemplate")
expect(JSON.parse(compiler.source_map)["sources"]).to include(
"theme-1/discourse/connectors/outlet/blah-1.js",
)
end
end
describe "error handling" do
it "handles syntax errors in ember templates" do
compiler.append_tree({ "sometemplate.hbs" => "{{invalidtemplate" })
expect(compiler.content).to include("Parse error on line 1")
end
end
describe "#append_tree" do
it "can handle multiple modules" do
compiler.append_tree(
{
"discourse/initializers/my-initializer.js" => <<~JS,
import MyComponent from "../components/mycomponent";
export default {
name: "my-initializer",
initialize() {
console.log("my-initializer", MyComponent);
},
};
JS
"discourse/components/mycomponent.js" => <<~JS,
import Component from "@glimmer/component";
export default class MyComponent extends Component {}
JS
"discourse/templates/components/mycomponent.hbs" => "{{my-component-template}}",
},
)
expect(compiler.content).to include('themeCompatModules["discourse/components/mycomponent"]')
expect(compiler.content).to include(
'themeCompatModules["discourse/templates/components/mycomponent"]',
)
end
it "handles colocated components" do
compiler.append_tree(
{
"discourse/components/mycomponent.js" => <<~JS,
import Component from "@glimmer/component";
export default class MyComponent extends Component {}
JS
"discourse/components/mycomponent.hbs" => "{{my-component-template}}",
},
)
expect(compiler.content).to include("__COLOCATED_TEMPLATE__ =")
expect(compiler.content).to include("setComponentTemplate")
end
it "handles colocated admin components" do
compiler.append_tree(
{
"admin/components/mycomponent.js" => <<~JS,
import Component from "@glimmer/component";
export default class MyComponent extends Component {}
JS
"admin/components/mycomponent.hbs" => "{{my-component-template}}",
},
)
expect(compiler.content).to include("__COLOCATED_TEMPLATE__ =")
expect(compiler.content).to include("setComponentTemplate")
end
it "applies theme AST transforms to colocated components" do
compiler = ThemeJavascriptCompiler.new(12_345_678_910, "my theme name", minify: false)
compiler.append_tree(
{ "discourse/components/mycomponent.hbs" => '{{theme-i18n "my_translation_key"}}' },
)
template_compiled_line = compiler.content.lines.find { |l| l.include?('"block":') }
expect(template_compiled_line).to include("12345678910")
end
it "handles template-only components" do
compiler.append_tree(
{ "discourse/components/mycomponent.hbs" => "{{my-component-template}}" },
)
expect(compiler.content).to include("__COLOCATED_TEMPLATE__ =")
expect(compiler.content).to include("setComponentTemplate")
expect(compiler.content).to include("@ember/component/template-only")
end
end
describe "terser compilation" do
let(:compiler) { ThemeJavascriptCompiler.new(1, "marks", {}, minify: true) }
it "applies terser and provides sourcemaps" do
sources = {
"multiply.js" =>
"export const multiply = (firstValue, secondValue) => firstValue * secondValue;",
"add.js" => "export const add = (firstValue, secondValue) => firstValue + secondValue;",
}
compiler.append_tree(sources)
expect(compiler.content).to include("multiply")
expect(compiler.content).to include("add")
expect(compiler.content).not_to include("firstValue")
expect(compiler.content).not_to include("secondValue")
map = JSON.parse(compiler.source_map)
expect(map["sources"]).to include("theme-1/multiply.js", "theme-1/add.js")
expect(map["sourcesContent"].to_s).to include("const multiply")
expect(map["sourcesContent"].to_s).to include("const add")
expect(map["sourcesContent"].to_s).to include("firstValue")
expect(map["sourcesContent"].to_s).to include("secondValue")
end
it "handles invalid JS" do
compiler.append_tree({ "filename.js" => "if(someCondition" })
expect(compiler.content).to include('throw new Error("[THEME 1')
expect(compiler.content).to include("Unexpected token")
end
end
describe "ember-this-fallback" do
it "applies its transforms" do
compiler.append_tree(
{
"discourse/components/my-component.js" => <<~JS,
import Component from "@glimmer/component";
export default class MyComponent extends Component {
value = "foo";
}
JS
"discourse/components/my-component.hbs" => "{{value}}",
},
)
expect(compiler.content).to include("ember-this-fallback")
expect(compiler.content).to include(
"The `value` property path was used in the `theme-1/discourse/components/my-component.hbs` template without using `this`. This fallback behavior has been deprecated, all properties must be looked up on `this` when used in the template: {{this.value}}",
)
end
end
describe "ember-template-imports" do
it "applies its transforms" do
compiler.append_tree({ "discourse/components/my-component.gjs" => <<~JS })
import Component from "@glimmer/component";
export default class MyComponent extends Component {
<template>
{{this.value}}
</template>
value = "foo";
}
JS
expect(compiler.content).to include(
"themeCompatModules[\"discourse/components/my-component\"]",
)
expect(compiler.content).to include('value = "foo";')
expect(compiler.content).to include("setComponentTemplate")
expect(compiler.content).to include("createTemplateFactory")
end
end
describe "safari <16 class field bugfix" do
it "is applied" do
compiler.append_tree({ "discourse/components/my-component.js" => <<~JS })
export default class MyComponent extends Component {
value = "foo";
complexValue = this.value + "bar";
}
JS
expect(compiler.content).to include('value = "foo";')
expect(compiler.content).to include('complexValue = (() => this.value + "bar")();')
end
end
end