2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-04 01:15:08 +08:00
discourse/spec/lib/asset_processor_spec.rb
David Taylor af3385baba
DEV: Introduce new rollup-based plugin build system (#35477)
This commit introduces a new build system for plugins, which shares a
large amount of code with the recently-modernized theme compiler.
Plugins are compiled to native ES Modules, and loaded using native
`import()` in the browser, just like themes.

To achieve inter-plugin imports, each bundled plugin entrypoint
implements a custom 'module federation' interface. Each export from
internal plugin modules is made available as a specially-named export on
the entrypoint. When modules in another plugin's namespace are imported,
they are automatically rewritten to use these federated entrypoints.

This change should be almost 100% backwards-compatible. There are some
edge cases which will behave differently, since modules are now eagerly
evaluated according to the ESM spec, instead of being lazily required
via asynchronous-module-definitions (AMD).

The native ESM format should also provide a performance improvement. The
old AMD system involved lots of nested function calls when booting the
app. Now, all the source modules are bundled up into a single ESM bundle
with minimal stack depth.

The build system implements a filesystem-based cache. That means that if
the plugin javascript files are unchanged, they will not need to be
recompiled, even after restarting the Rails server. This mimics the
behaviour of the theme system. Similarly, in production builds, existing
files will be automatically reused if they exist. In future, we plan to
include pre-built copies of common plugins in our prebuild-asset
bundles.

Initially, this new compiler is disabled by default. To test it, we can
set the `ROLLUP_PLUGIN_COMPILER=1` environment variable. We'll continue
to test, improve and document the system before enabling it by default.

---------

Co-authored-by: Jarek Radosz <jradosz@gmail.com>
Co-authored-by: Chris Manson <chris@manson.ie>
2026-03-03 13:50:55 +00:00

449 lines
12 KiB
Ruby

# frozen_string_literal: true
RSpec.describe AssetProcessor do
describe "skip_module?" do
it "returns false for empty strings" do
expect(AssetProcessor.skip_module?(nil)).to eq(false)
expect(AssetProcessor.skip_module?("")).to eq(false)
end
it "returns true if the header is present" do
expect(AssetProcessor.skip_module?("// cool comment\n// discourse-skip-module")).to eq(true)
end
it "returns false if the header is not present" do
expect(AssetProcessor.skip_module?("// just some JS\nconsole.log()")).to eq(false)
end
it "works end-to-end" do
source = <<~JS.chomp
// discourse-skip-module
console.log("hello world");
JS
expect(AssetProcessor.transpile(source, "test", "test")).to eq(source)
end
end
it "passes through modern JS syntaxes which are supported in our target browsers" do
script = <<~JS.chomp
optional?.chaining;
const template = func`test`;
let numericSeparator = 100_000_000;
logicalAssignment ||= 2;
nullishCoalescing ?? 'works';
try {
"optional catch binding";
} catch {
"works";
}
async function* asyncGeneratorFunction() {
yield await Promise.resolve('a');
}
let a = {
x,
y,
...spreadRest
};
JS
result = AssetProcessor.transpile(script, "blah", "blah/mymodule")
expect(result).to eq <<~JS.strip
define("blah/mymodule", [], function () {
"use strict";
#{script.indent(2)}
});
JS
end
it "supports decorators and class properties without error" do
script = <<~JS.chomp
class MyClass {
classProperty = 1;
#privateProperty = 1;
#privateMethod() {
console.log("hello world");
}
@decorated
myMethod(){
}
}
JS
result = AssetProcessor.transpile(script, "blah", "blah/mymodule")
expect(result).to include("dt7948.n(")
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 = AssetProcessor.new.terser(sources, { sourceMap: { includeSources: true } })
expect(result.keys).to contain_exactly("code", "decoded_map", "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
describe "Transpiler#rollup" do
it "can rollup code" do
sources = { "discourse/initializers/hello.gjs" => <<~JS }
someDecorator = () => {}
export default class MyClass {
@someDecorator
myMethod() {
console.log("hello world");
}
<template>
<div>template content</div>
</template>
}
JS
result =
AssetProcessor.new.rollup(
sources,
{ entrypoints: { main: { modules: ["discourse/initializers/hello.gjs"] } } },
)
code = result["main.js"]["code"]
expect(code).to include('"hello world"')
expect(code).to include("dt7948") # Decorator transform
expect(result["main.js"]["map"]).not_to be_nil
end
it "supports decorators and class properties without error" do
script = <<~JS.chomp
export default class MyClass {
classProperty = 1;
#privateProperty = 1;
#privateMethod() {
console.log("hello world");
}
@decorated
myMethod(){
}
}
JS
result =
AssetProcessor.new.rollup(
{ "discourse/initializers/foo.js" => script },
{ entrypoints: { main: { modules: ["discourse/initializers/foo.js"] } } },
)
expect(result["main.js"]["code"]).to include("dt7948.n")
end
it "supports object literal decorators without errors" do
script = <<~JS.chomp
export default {
@decorated foo: "bar",
@decorated
myMethod() {
console.log("hello world");
}
}
JS
result =
AssetProcessor.new.rollup(
{ "discourse/initializers/foo.js" => script },
{ entrypoints: { main: { modules: ["discourse/initializers/foo.js"] } } },
)
expect(result["main.js"]["code"]).to include("dt7948")
end
it "can use themePrefix in a template" do
script = <<~JS.chomp
themePrefix();
export default class Foo {
<template>{{themePrefix "bar"}}</template>
}
JS
result =
AssetProcessor.new.rollup(
{ "discourse/initializers/foo.gjs" => script },
{ themeId: 22, entrypoints: { main: { modules: ["discourse/initializers/foo.gjs"] } } },
)
expect(result["main.js"]["code"]).to include(
'window.moduleBroker.lookup("discourse/lib/theme-settings-store")',
)
end
it "can use themePrefix not in a template" do
script = <<~JS.chomp
export default function foo() {
return themePrefix("bar");
}
JS
result =
AssetProcessor.new.rollup(
{ "discourse/initializers/foo.js" => script },
{ themeId: 22, entrypoints: { main: { modules: ["discourse/initializers/foo.js"] } } },
)
expect(result["main.js"]["code"]).to include(
'window.moduleBroker.lookup("discourse/lib/theme-settings-store")',
)
end
end
it "can compile hbs" do
template = <<~HBS.chomp
{{log "hello world"}}
HBS
result =
AssetProcessor.new.rollup(
{ "discourse/connectors/outlet-name/foo.hbs" => template },
{
themeId: 22,
entrypoints: {
main: {
modules: ["discourse/connectors/outlet-name/foo.hbs"],
},
},
},
)
expect(result["main.js"]["code"]).to include("createTemplateFactory")
end
it "handles colocation" do
js = <<~JS.chomp
import Component from "@glimmer/component";
export default class MyComponent extends Component {}
JS
template = <<~HBS.chomp
{{log "hello world"}}
HBS
onlyTemplate = <<~HBS.chomp
{{log "hello galaxy"}}
HBS
result =
AssetProcessor.new.rollup(
{
"discourse/components/foo.js" => js,
"discourse/components/foo.hbs" => template,
"discourse/components/bar.hbs" => onlyTemplate,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[discourse/components/foo.js discourse/components/bar.hbs],
},
},
},
)
expect(result["main.js"]["code"]).to include("setComponentTemplate")
expect(result["main.js"]["code"]).to include(
"bar = setComponentTemplate(__COLOCATED_TEMPLATE__, templateOnly());",
)
end
it "handles relative imports from one module to another" do
mod_1 = <<~JS.chomp
export default "test";
JS
mod_2 = <<~JS.chomp
import MyComponent from "../components/my-component";
console.log(MyComponent);
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.js" => mod_1,
"discourse/components/other-component.js" => mod_2,
},
{
themeId: 22,
entrypoints: {
main: {
modules: ["discourse/components/other-component.js"],
},
},
},
)
expect(result["main.js"]["code"]).not_to include("../components/my-component")
end
it "handles relative import of index file" do
mod_1 = <<~JS.chomp
import MyComponent from "./other-component";
console.log(MyComponent);
JS
mod_2 = <<~JS.chomp
export default "test";
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.js" => mod_1,
"discourse/components/other-component/index.js" => mod_2,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/components/my-component.js
discourse/components/other-component/index.js
],
},
},
},
)
expect(result["main.js"]["code"]).not_to include("../components/my-component")
end
it "handles relative import of gjs index file" do
mod_1 = <<~JS.chomp
import MyComponent from "./other-component";
console.log(MyComponent);
JS
mod_2 = <<~JS.chomp
export default "test";
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.gjs" => mod_1,
"discourse/components/other-component/index.gjs" => mod_2,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/components/my-component.gjs
discourse/components/other-component/index.gjs
],
},
},
},
)
expect(result["main.js"]["code"]).not_to include("../components/my-component")
end
it "prioritizes exact match over /index match" do
mod_1 = <<~JS.chomp
export default "module 1";
JS
mod_2 = <<~JS.chomp
export default "module 2";
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.gjs" => mod_1,
"discourse/components/my-component/index.gjs" => mod_2,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/components/my-component/index.gjs
discourse/components/my-component.gjs
],
},
},
},
)
expect(result["main.js"]["code"]).to include("module 1")
expect(result["main.js"]["code"]).to include("module 2")
end
it "returns the ember version" do
expect(AssetProcessor.ember_version).to match(/\A\d+\.\d+\.\d+\z/)
end
it "errors on missing relative imports" do
mod_1 = <<~JS.chomp
import SomeModule from "../some-module";
console.log(SomeModule);
JS
expect do
AssetProcessor.new.rollup(
{ "discourse/components/my-component.gjs" => mod_1 },
{ pluginName: "myplugin" },
)
end.to raise_error(AssetProcessor::TranspileError)
end
it "outputs entrypoint manifest data" do
mod = <<~JS.chomp
export default "module 1";
JS
admin_mod = <<~JS.chomp
import comp from "./my-component";
console.log(comp);
export default "module 2";
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.gjs" => mod,
"discourse/components/my-admin-component.gjs" => admin_mod,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[discourse/components/my-component.gjs],
},
admin: {
modules: %w[discourse/components/my-admin-component.gjs],
},
},
},
)
expect(result["main.js"]["imports"].length).to eq(1)
expect(result["main.js"]["imports"].first).to include("chunk")
expect(result["main.js"]["name"]).to eq("main")
expect(result["main.js"]["isEntry"]).to eq(true)
expect(result["admin.js"]["imports"].length).to eq(1)
expect(result["admin.js"]["imports"].first).to include("chunk")
expect(result["admin.js"]["name"]).to eq("admin")
expect(result["admin.js"]["isEntry"]).to eq(true)
end
end