mirror of
https://github.com/discourse/discourse.git
synced 2026-03-04 01:15:08 +08:00
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>
449 lines
12 KiB
Ruby
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
|