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>
This commit is contained in:
David Taylor
2026-03-03 13:50:55 +00:00
committed by GitHub
co-authored by Jarek Radosz Chris Manson
parent 5c1cff4184
commit af3385baba
41 changed files with 1034 additions and 214 deletions
+7 -2
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
build:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: ${{ matrix.target }} ${{ matrix.build_type }}${{ (matrix.target == 'core' && matrix.build_type == 'frontend' && format(' ({0})', matrix.browser)) || '' }} # Update fetch-job-id step if changing this
name: ${{ matrix.target }} ${{ matrix.build_type }}${{ (matrix.target == 'core' && matrix.build_type == 'frontend' && format(' ({0})', matrix.browser)) || '' }}${{ matrix.plugin_compiler == 'rollup' && ' (rollup)' || '' }}
runs-on: >-
${{
github.repository_owner == 'discourse' && (
@@ -62,6 +62,7 @@ jobs:
LOAD_PLUGINS: ${{ (matrix.target == 'core-plugins' || matrix.target == 'official-plugins' || matrix.target == 'plugins' || matrix.target == 'chat') && '1' || '0' }}
ACTIONS_STEP_DEBUG: ${{ secrets.ACTIONS_STEP_DEBUG }}
QUNIT_REUSE_BUILD: 1
ROLLUP_PLUGIN_COMPILER: ${{ matrix.plugin_compiler == 'rollup' && '1' || '0' }}
strategy:
fail-fast: false
@@ -70,6 +71,7 @@ jobs:
build_type: [backend, frontend, system]
target: [core, plugins, themes]
browser: [Chrome]
plugin_compiler: ["legacy"]
exclude:
- build_type: backend
target: themes
@@ -87,6 +89,9 @@ jobs:
- build_type: system
target: chat
browser: Chrome
- build_type: frontend
target: plugins
plugin_compiler: "rollup"
# - build_type: frontend
# target: core
# browser: Firefox Evergreen
@@ -293,7 +298,7 @@ jobs:
- name: Ember Build
if: matrix.build_type == 'system' || matrix.build_type == 'frontend'
run: script/assemble_ember_build.rb
run: bin/rake assets:precompile:build_plugins && script/assemble_ember_build.rb
- name: Core QUnit
if: matrix.build_type == 'frontend' && matrix.target == 'core'
+23 -12
View File
@@ -90,6 +90,16 @@ module ApplicationHelper
request.env["HTTP_ACCEPT_ENCODING"] =~ /gzip/
end
def generate_import_map(plugin_assets)
imports =
plugin_assets
.filter { it[:importmap_name] }
.map { [it[:importmap_name], script_asset_path(it[:name])] }
.to_h
JSON.pretty_generate({ imports: }).html_safe
end
def script_asset_path(script)
path = ActionController::Base.helpers.asset_path("#{script}.js")
@@ -113,16 +123,17 @@ module ApplicationHelper
path = "#{resolved_s3_asset_cdn_url}#{path}"
end
# assets needed for theme testing are not compressed because they take a fair
# amount of time to compress (+30 seconds) during rebuilds/deploys when the
# vast majority of sites will never need them, so it makes more sense to serve
# them uncompressed instead of making everyone's rebuild/deploy take +30 more
# seconds.
if !script.start_with?("discourse/tests/")
if is_brotli_req?
path = path.gsub(/\.([^.]+)\z/, '.br.\1')
elsif is_gzip_req?
path = path.gsub(/\.([^.]+)\z/, '.gz.\1')
if is_brotli_req?
if path.include?("/assets/js/")
path = path.sub("/assets/js/", "/assets/br/")
else
path = path.sub(/\.([^.]+)\z/, '.br.\1')
end
elsif is_gzip_req?
if path.include?("/assets/js/")
path = path.sub("/assets/js/", "/assets/gz/")
else
path = path.sub(/\.([^.]+)\z/, '.gz.\1')
end
end
end
@@ -148,12 +159,12 @@ module ApplicationHelper
.html_safe
end
def preload_script_url(url, entrypoint: nil, type_module: false, attrs: {})
def preload_script_url(url, entrypoint: nil, type_module: false, attrs: nil)
entrypoint_attribute = entrypoint ? "data-discourse-entrypoint=\"#{entrypoint}\"" : ""
nonce_attribute = "nonce=\"#{csp_nonce_placeholder}\""
extra_attrs =
attrs.map { |k, v| "#{ERB::Util.html_escape(k)}=\"#{ERB::Util.html_escape(v)}\"" }.join(" ")
attrs&.map { |k, v| "#{ERB::Util.html_escape(k)}=\"#{ERB::Util.html_escape(v)}\"" }&.join(" ")
extra_attrs = " #{extra_attrs}" if extra_attrs.present?
add_resource_preload_list(url, "script")
+4 -4
View File
@@ -6,7 +6,6 @@ require "json_schemer"
class Theme < ActiveRecord::Base
include GlobalPath
BASE_COMPILER_VERSION = 101
CORE_THEMES = { "foundation" => -1, "horizon" => -2 }
EDITABLE_SYSTEM_ATTRIBUTES = %w[
child_theme_ids
@@ -32,7 +31,7 @@ class Theme < ActiveRecord::Base
attr_accessor :skip_child_components_update
def self.cache
@cache ||= DistributedCache.new("theme:compiler:#{BASE_COMPILER_VERSION}")
@cache ||= DistributedCache.new("theme:compiler:#{AssetProcessor::BASE_COMPILER_VERSION}")
end
belongs_to :user
@@ -243,13 +242,14 @@ class Theme < ActiveRecord::Base
def self.compiler_version
get_set_cache "compiler_version" do
dependencies = [
BASE_COMPILER_VERSION,
AssetProcessor.new.ember_version,
AssetProcessor::BASE_COMPILER_VERSION,
AssetProcessor.ember_version,
GlobalSetting.cdn_url,
GlobalSetting.s3_cdn_url,
GlobalSetting.s3_endpoint,
GlobalSetting.s3_bucket,
Discourse.current_hostname,
ENV["ROLLUP_PLUGIN_COMPILER"],
]
Digest::SHA1.hexdigest(dependencies.join)
end
+23 -5
View File
@@ -1,11 +1,29 @@
<%# locals: (opts:) %>
<%- Discourse.find_plugin_js_assets(opts).tap do |plugin_assets| %>
<script type="importmap" nonce="<%= csp_nonce_placeholder %>">
<%= generate_import_map(plugin_assets) %>
</script>
<%- plugin_assets.each do |asset| %>
<%= preload_script asset[:name], attrs: {
"data-discourse-plugin": asset[:plugin].metadata.name,
"data-official": asset[:plugin].metadata.official?,
"data-preinstalled": asset[:plugin].preinstalled?,
} %>
<% if asset[:type_module] %>
<link
rel="modulepreload"
href="<%= script_asset_path(asset[:name]) %>"
nonce="<%= csp_nonce_placeholder %>"
data-plugin-name="<%= asset[:plugin].directory_name %>"
<%= tag.attributes **asset[:plugin_attributes] %>
>
<% else %>
<%= preload_script asset[:name], attrs: asset[:plugin_attributes] %>
<% end %>
<%- end %>
<%- plugin_assets.flat_map { it[:imports] }.compact.uniq.each do |import| %>
<link
rel="modulepreload"
href="<%= script_asset_path(import) %>"
nonce="<%= csp_nonce_placeholder %>"
>
<%- end %>
<%- end %>
+1 -1
View File
@@ -14,7 +14,7 @@
<%= preload_script "test-support" %>
<%= preload_script "discourse" %>
<%= preload_script "test" %>
<%= render "layouts/plugin_js",
<%= render "layouts/plugin_js",
opts: {
include_disabled: true,
include_admin_asset: true,
+2 -1
View File
@@ -475,11 +475,12 @@ class QunitRunner
def run_ember_build
if @dry_run
puts "[dry-run] skipping ember build for themes"
puts "[dry-run] skipping ember build"
return
end
system("pnpm", "ember", "build", chdir: frontend_dir, exception: true)
system("bin/rake assets:precompile:build_plugins", exception: true)
end
def build_theme_test_pages(query)
+2 -5
View File
@@ -15,7 +15,7 @@ Rails.application.config.assets.paths.push(
)
Rails.application.config.assets.paths.push(
*Discourse.plugins.map { |p| "#{Rails.root}/app/assets/generated/#{p.directory_name}" },
*Discourse.plugins.map { |p| "#{Rails.root}/app/assets/generated/#{p.directory_name}/" },
)
# These paths are added automatically by propshaft, but we don't want them
@@ -25,7 +25,4 @@ Rails.application.config.assets.excluded_paths.push(
"#{Rails.root}/app/assets/stylesheets",
)
# We don't need/want most of Propshaft's preprocessing. Only keep the JS sourcemap handler
Rails.application.config.assets.compilers.filter! do |type, compiler|
type == "text/javascript" && compiler == Propshaft::Compiler::SourceMappingUrls
end
Rails.application.config.assets.compilers = []
+4
View File
@@ -122,6 +122,10 @@ before_service_worker_ready do |server, service_worker|
demon_class.start(1, logger: server.logger)
end
if Rails.env.development? && ENV["ROLLUP_PLUGIN_COMPILER"] == "1"
Demon::PluginJsWatcher.start(verbose: true)
end
Thread.new do
while true
begin
@@ -1,3 +1,4 @@
import { federatedExportNameFor } from "./federated-modules-helper";
import rollupVirtualImports from "./rollup-virtual-imports";
export default function (babel) {
@@ -15,6 +16,46 @@ export default function (babel) {
return;
}
if (
globalThis.ROLLUP_PLUGIN_COMPILER === "1" &&
moduleName.startsWith("discourse/plugins/")
) {
const parts = moduleName.split("/");
path.node.source = t.stringLiteral(`discourse/plugins/${parts[2]}`);
const getFederatedExportName = (exportedName) => {
const localModuleName = parts.slice(3).join("/");
return federatedExportNameFor(localModuleName, exportedName);
};
const newImportSpecifiers = path.node.specifiers.map((specifier) => {
if (specifier.type === "ImportDefaultSpecifier") {
const federatedExportName = getFederatedExportName("default");
return t.importSpecifier(
t.identifier(specifier.local.name),
t.identifier(federatedExportName)
);
} else if (specifier.type === "ImportNamespaceSpecifier") {
const federatedExportName = getFederatedExportName("*");
return t.importSpecifier(
t.identifier(specifier.local.name),
t.identifier(federatedExportName)
);
} else {
const federatedExportName = getFederatedExportName(
specifier.imported.name
);
return t.importSpecifier(
t.identifier(specifier.local.name),
t.identifier(federatedExportName)
);
}
});
path.node.specifiers = newImportSpecifiers;
return;
}
const namespaceImports = [];
const properties = path.node.specifiers
.map((specifier) => {
@@ -0,0 +1,8 @@
export function federatedExportNameFor(moduleName, exportedName) {
if (exportedName === "*") {
exportedName = "__module";
}
return (
moduleName.replaceAll("/", "$").replaceAll("-", "__") + "$$" + exportedName
);
}
@@ -1,7 +1,7 @@
import MagicString from "magic-string";
import { basename, dirname, join } from "path";
export default function discourseColocation({ themeBase }) {
export default function discourseColocation({ basePath }) {
return {
name: "discourse-colocation",
async resolveId(source, context) {
@@ -12,8 +12,8 @@ export default function discourseColocation({ themeBase }) {
if (
!(
resolvedSource.startsWith(`${themeBase}discourse/components/`) ||
resolvedSource.startsWith(`${themeBase}admin/components/`)
resolvedSource.startsWith(`${basePath}discourse/components/`) ||
resolvedSource.startsWith(`${basePath}admin/components/`)
)
) {
return;
@@ -53,8 +53,8 @@ export default function discourseColocation({ themeBase }) {
transform: {
async handler(input, id) {
if (
!id.startsWith(`${themeBase}discourse/components/`) &&
!id.startsWith(`${themeBase}admin/components/`)
!id.startsWith(`${basePath}discourse/components/`) &&
!id.startsWith(`${basePath}admin/components/`)
) {
return;
}
@@ -1,21 +0,0 @@
export default function discourseExtensionSearch() {
return {
name: "discourse-extension-search",
async resolveId(source, context) {
if (source.match(/\.\w+$/)) {
// Already has an extension
return null;
}
for (const ext of ["", ".js", ".gjs", ".hbs"]) {
const resolved = await this.resolve(`${source}${ext}`, context);
if (resolved) {
return resolved;
}
}
return null;
},
};
}
@@ -1,7 +1,13 @@
export default function discourseExternalLoader() {
import { dirname, relative } from "path";
export default function discourseExternalLoader({ basePath }) {
return {
name: "discourse-external-loader",
async resolveId(source) {
async resolveId(source, context) {
if (source.startsWith(basePath)) {
return this.resolve(`./${relative(dirname(context), source)}`, context);
}
if (!source.startsWith(".")) {
return { id: source, external: true };
}
@@ -0,0 +1,31 @@
export default function discourseFileSearch() {
return {
name: "discourse-file-search",
async resolveId(source, context) {
if (source.match(/\.\w+$/)) {
// Already has an extension
return null;
}
for (const ext of ["", ".js", ".gjs", ".hbs"]) {
const resolved = await this.resolve(`${source}${ext}`, context);
if (resolved) {
return resolved;
}
}
// If lookup has no extension, and is not /index already, check for an /index file
if (!source.match(/\.\w+$/) && !source.endsWith("/index")) {
const resolved = await this.resolve(`${source}/index`, context, {
skipSelf: false, // We want extensionsearch on the `/index` lookup as well
});
if (resolved) {
return resolved;
}
}
return null;
},
};
}
@@ -1,16 +0,0 @@
export default function discourseIndexSearch() {
return {
name: "discourse-index-search",
async resolveId(source, context) {
if (source.match(/\.\w+$/) || source.match(/\/index$/)) {
// Already has an extension or is an index
return null;
}
return (
(await this.resolve(source, context)) ||
(await this.resolve(`${source}/index`, context))
);
},
};
}
@@ -1,22 +1,48 @@
import rollupVirtualImports from "../rollup-virtual-imports";
export default function discourseVirtualLoader({ themeBase, modules, opts }) {
export default function discourseVirtualLoader({
basePath,
entrypoints,
opts,
isTheme,
}) {
const availableVirtualImports = isTheme
? rollupVirtualImports
: {
"virtual:entrypoint": rollupVirtualImports["virtual:entrypoint"],
};
return {
name: "discourse-virtual-loader",
resolveId(source) {
if (rollupVirtualImports[source]) {
return `${themeBase}${source}`;
if (
availableVirtualImports[source] ||
source.startsWith("virtual:entrypoint:")
) {
return `${basePath}${source}`;
}
},
load(id) {
if (!id.startsWith(themeBase)) {
if (!id.startsWith(basePath)) {
return;
}
const fromBase = id.slice(themeBase.length);
const fromBase = id.slice(basePath.length);
if (rollupVirtualImports[fromBase]) {
return rollupVirtualImports[fromBase](modules, opts);
if (fromBase.startsWith("virtual:entrypoint:")) {
const entrypointName = fromBase.replace("virtual:entrypoint:", "");
const entrypointConfig = entrypoints[entrypointName];
return availableVirtualImports["virtual:entrypoint"](
entrypointConfig.modules,
opts,
{
basePath,
context: this,
}
);
} else if (availableVirtualImports[fromBase]) {
return availableVirtualImports[fromBase](opts);
}
},
};
@@ -1,17 +1,28 @@
import { federatedExportNameFor } from "./federated-modules-helper";
const SUPPORTED_FILE_EXTENSIONS = [".js", ".js.es6", ".hbs", ".gjs"];
const IS_CONNECTOR_REGEX = /(^|\/)connectors\//;
export default {
"virtual:entrypoint:main": (tree, { themeId }) => {
let output = cleanMultiline(`
import "virtual:init-settings";
"virtual:entrypoint": async (
moduleFilenames,
{ themeId },
{ basePath, context }
) => {
let output = `const compatModules = {};`;
const compatModules = {};
`);
if (themeId) {
output += cleanMultiline(`
import "virtual:init-settings";
`);
}
const moduleFilenamesSet = new Set(moduleFilenames);
const exportedModules = new Set();
let i = 1;
for (const moduleFilename of Object.keys(tree)) {
for (const moduleFilename of moduleFilenames) {
if (
!SUPPORTED_FILE_EXTENSIONS.some((ext) => moduleFilename.endsWith(ext))
) {
@@ -45,9 +56,42 @@ export default {
const importPath = filenameWithoutExtension.match(IS_CONNECTOR_REGEX)
? moduleFilename
: filenameWithoutExtension;
if (exportedModules.has(importPath)) {
continue;
}
exportedModules.add(importPath);
output += `import * as Mod${i} from "./${importPath}";\n`;
output += `compatModules["${compatModuleName}"] = Mod${i};\n\n`;
const resolvedId = await context.resolve(
`./${importPath}`,
`${basePath}virtual:main`
);
const loadedModule = await context.load(resolvedId);
const reexportPairs = loadedModule.exports.map((exportedName) => {
return `${exportedName} as ${federatedExportNameFor(compatModuleName, exportedName)}`;
});
const isIndexModule =
compatModuleName.endsWith("/index") &&
!moduleFilenamesSet.has(moduleFilename.replace("/index", ""));
if (isIndexModule) {
loadedModule.exports.forEach((exportedName) => {
const federatedExportName = federatedExportNameFor(
compatModuleName.replace(/\/index$/, ""),
exportedName
);
reexportPairs.push(`${exportedName} as ${federatedExportName}`);
});
}
output += `export * as ${federatedExportNameFor(compatModuleName, "*")} from "./${importPath}";\n`;
output += `export {\n${reexportPairs.join(",\n")}\n} from "./${importPath}";\n`;
i += 1;
}
@@ -55,13 +99,13 @@ export default {
return output;
},
"virtual:init-settings": (_, { themeId, settings }) => {
"virtual:init-settings": ({ themeId, settings }) => {
return (
`import { registerSettings } from "discourse/lib/theme-settings-store";\n\n` +
`registerSettings(${themeId}, ${JSON.stringify(settings, null, 2)});\n`
);
},
"virtual:theme": (_, { themeId }) => {
"virtual:theme": ({ themeId }) => {
return cleanMultiline(`
import { getObjectForTheme } from "discourse/lib/theme-settings-store";
+70 -31
View File
@@ -13,40 +13,56 @@ import AddThemeGlobals from "./add-theme-globals";
import BabelReplaceImports from "./babel-replace-imports";
import { precompile } from "./node_modules/ember-source/dist/ember-template-compiler";
import discourseColocation from "./rollup-plugins/discourse-colocation";
import discourseExtensionSearch from "./rollup-plugins/discourse-extension-search";
import discourseExternalLoader from "./rollup-plugins/discourse-external-loader";
import discourseFileSearch from "./rollup-plugins/discourse-file-search";
import discourseGjs from "./rollup-plugins/discourse-gjs";
import discourseHbs from "./rollup-plugins/discourse-hbs";
import discourseIndexSearch from "./rollup-plugins/discourse-index-search";
import discourseTerser from "./rollup-plugins/discourse-terser";
import discourseVirtualLoader from "./rollup-plugins/discourse-virtual-loader";
import buildEmberTemplateManipulatorPlugin from "./theme-hbs-ast-transforms";
let lastRollupResult;
let lastRollupError;
globalThis.rollup = function (modules, opts) {
const themeBase = `theme-${opts.themeId}/`;
const { vol } = memfs(modules, themeBase);
let caches = new Map();
const resultPromise = rollup({
input: "virtual:entrypoint:main",
async function performRollup(modules, opts) {
let basePath = opts.pluginName
? `discourse/plugins/${opts.pluginName}/`
: `theme-${opts.themeId}/`;
const inputConfig = {};
for (const key of Object.keys(opts.entrypoints)) {
inputConfig[key] = `virtual:entrypoint:${key}`;
}
const { vol } = memfs(modules, basePath);
const cache = opts.pluginName ? caches.get(opts.pluginName) : false;
const result = await rollup({
input: inputConfig,
logLevel: "info",
fs: vol.promises,
cache,
onLog(level, message) {
if (String(message).startsWith("Circular dependency")) {
return;
}
// eslint-disable-next-line no-console
console.info(level, message);
},
plugins: [
discourseExtensionSearch(),
discourseIndexSearch(),
discourseFileSearch(),
discourseVirtualLoader({
themeBase,
modules,
isTheme: !!opts.themeId,
basePath,
entrypoints: opts.entrypoints,
opts,
}),
discourseExternalLoader(),
discourseColocation({ themeBase }),
discourseExternalLoader({ basePath }),
discourseColocation({ basePath }),
getBabelOutputPlugin({
plugins: [BabelReplaceImports],
compact: false,
@@ -57,8 +73,8 @@ globalThis.rollup = function (modules, opts) {
compact: false,
plugins: [
[DecoratorTransforms, { runEarly: true }],
opts.themeId ? AddThemeGlobals : null,
babelTransformModuleRenames,
AddThemeGlobals,
colocatedBabelPlugin,
[
HTMLBarsInlinePrecompile,
@@ -79,7 +95,7 @@ globalThis.rollup = function (modules, opts) {
],
},
],
],
].filter(Boolean),
presets: [
[
BabelPresetEnv,
@@ -96,22 +112,45 @@ globalThis.rollup = function (modules, opts) {
],
});
resultPromise
.then((bundle) => {
return bundle.generate({
format: "es",
sourcemap: "hidden",
});
})
.then(({ output }) => {
lastRollupResult = {
code: output[0].code,
map: JSON.stringify(output[0].map),
};
})
.catch((error) => {
lastRollupError = error;
});
const bundle = await result.generate({
format: "es",
sourcemap: "hidden",
entryFileNames: `${opts.filenamePrefix ?? ""}[name]${opts.filenameSuffix ?? ""}.js`,
chunkFileNames: `${opts.filenamePrefix ?? ""}chunk.[hash:6]${opts.filenameSuffix ?? ""}.js`,
});
if (opts.pluginName) {
caches.set(opts.pluginName, result.cache);
}
const chunks = Object.fromEntries(
bundle.output
.filter((c) => c.code)
.map((chunk) => {
return [
chunk.fileName,
{
code: chunk.code,
map: JSON.stringify(chunk.map),
name: chunk.name,
isEntry: chunk.isEntry,
imports: chunk.imports.filter((i) =>
bundle.output.find((c) => c.fileName === i)
),
},
];
})
);
return chunks;
}
globalThis.rollup = async function (modules, opts) {
try {
lastRollupResult = await performRollup(modules, opts);
} catch (error) {
lastRollupError = error;
}
};
globalThis.getRollupResult = function () {
+4
View File
@@ -259,6 +259,10 @@ module.exports = {
// Matches logic from GlobalSetting.load_plugins? in the ruby app
shouldLoadPlugins() {
if (process.env.ROLLUP_PLUGIN_COMPILER === "1") {
return false;
}
if (process.env.LOAD_PLUGINS === "1") {
return true;
} else if (process.env.LOAD_PLUGINS === "0") {
+33 -3
View File
@@ -18,8 +18,10 @@ import { registerDiscourseImplicitInjections } from "discourse/lib/implicit-inje
// Register Discourse's standard implicit injections on common framework classes.
registerDiscourseImplicitInjections();
import { DEBUG } from "@glimmer/env";
import Application from "@ember/application";
import { VERSION } from "@ember/version";
import { importSync } from "@embroider/macros";
import require from "require";
import { normalizeEmberEventHandling } from "discourse/lib/ember-events";
import { isTesting } from "discourse/lib/environment";
@@ -53,10 +55,38 @@ async function loadThemeFromModulePreload(link) {
}
}
export async function loadThemes() {
async function loadPluginFromModulePreload(link) {
const pluginName = link.dataset.pluginName;
try {
const compatModules = (await import(/* webpackIgnore: true */ link.href))
.default;
for (const [key, mod] of Object.entries(compatModules)) {
define(`discourse/plugins/${pluginName}/${key}`, () => mod);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Failed to load plugin ${link.dataset.pluginName} from ${link.href}`,
error
);
if (DEBUG) {
let { addError } = importSync("discourse/static/development-error");
addError(error, link.dataset.pluginName, link.href);
}
}
}
export async function loadThemesAndPlugins() {
const promises = [
...document.querySelectorAll("link[rel=modulepreload][data-theme-id]"),
].map(loadThemeFromModulePreload);
...[
...document.querySelectorAll("link[rel=modulepreload][data-theme-id]"),
].map(loadThemeFromModulePreload),
...[
...document.querySelectorAll("link[rel=modulepreload][data-plugin-name]"),
].map(loadPluginFromModulePreload),
];
await Promise.all(promises);
}
@@ -54,6 +54,7 @@ export default function identifySource(error) {
// Order matters: check more specific patterns (_admin) before general ones
const patterns = [];
// legacy
if (DEBUG) {
patterns.push(
/assets\/plugins\/([\w-]+)_admin\.js/, // Admin UI Development (no fingerprinting)
@@ -62,11 +63,15 @@ export default function identifySource(error) {
);
}
// legacy
patterns.push(
/assets\/plugins\/_?([\w-]+)-[0-9a-f]+_admin(?:\.(?:br|gz))?\.js/, // Admin UI Production (with fingerprints)
/assets\/plugins\/_?([\w-]+)-[0-9a-f]+(?:\.(?:br|gz))?\.js/ // Production (with fingerprints)
);
// new paths (ROLLUP_PLUGIN_COMPILER)
patterns.push(/assets\/(?:js|br|gz)\/plugins\/([\w-]+)_/);
for (const pattern of patterns) {
plugin = stack.match(pattern)?.[1];
if (plugin) {
@@ -0,0 +1,35 @@
import "./styles.css";
let dialogContent;
function setupErrorDialog() {
const dialog = document.createElement("dialog");
dialog.id = "discourse-error-dialog";
const heading = document.createElement("div");
const title = document.createElement("h1");
title.innerText = "Plugin Error";
heading.append(title);
const tomster = document.createElement("img");
tomster.src = "images/fishy-tomster.webp";
heading.append(tomster);
dialog.append(heading);
dialogContent = document.createElement("ul");
dialog.append(dialogContent);
document.body.append(dialog);
dialog.showModal();
}
export function addError(error, pluginName, path) {
if (!dialogContent) {
setupErrorDialog();
}
const errorElement = document.createElement("li");
errorElement.innerText += `❌ Failed to load plugin ${pluginName} from ${path}\n${String(error)}`;
dialogContent.append(errorElement);
}
@@ -0,0 +1,63 @@
#discourse-error-dialog {
--error-color: #e04e39;
background: light-dark(var(--error-color), #1e1e1e);
border-radius: 16px;
border: 1px solid light-dark(var(--error-color), #2a2a2a);
box-shadow: 0 8px 16px 8px light-dark(#aaa, #111);
color: light-dark(#fff, var(--error-color));
display: flex;
flex-direction: column;
font-family: monospace;
font-size: 13px;
overflow: hidden;
padding: 0;
max-height: 90vh;
&::before {
background: #111
linear-gradient(
-45deg,
transparent 6px,
var(--error-color) 6px,
var(--error-color) 12px,
transparent 12px
);
background-position: 4px;
background-repeat: repeat-x;
background-size: 18px 8px;
content: "";
display: block;
height: 8px;
width: 100%;
}
> div {
align-items: center;
display: flex;
flex-wrap: wrap;
margin: 16px 16px 0;
}
h1 {
flex-grow: 1;
font-family: system-ui, sans-serif;
font-size: 28px;
}
img {
height: 100px;
}
ul {
margin: 0;
overflow-y: scroll;
}
li {
background: #0004;
border-radius: 8px;
list-style: none;
margin: 0 16px 16px;
padding: 16px 16px 32px;
}
}
@@ -1,7 +1,7 @@
document.addEventListener("discourse-init", async (e) => {
performance.mark("discourse-init");
const config = e.detail;
const { default: klass, loadThemes, loadAdmin } = require(
const { default: klass, loadThemesAndPlugins, loadAdmin } = require(
`${config.modulePrefix}/app`
);
@@ -13,7 +13,7 @@ document.addEventListener("discourse-init", async (e) => {
await loadAdmin();
}
await loadThemes();
await loadThemesAndPlugins();
const app = klass.create(config);
app.start();
+6
View File
@@ -315,6 +315,12 @@ if (themeTestPages) {
"/assets/plugins/": {
target,
},
"/assets/js/plugins/": {
target,
},
"/assets/map/plugins/": {
target,
},
"/plugins/": {
target,
},
@@ -48,7 +48,15 @@ export function OK(resp = {}, headers = {}) {
const loggedIn = () => !!User.current();
const helpers = { response, success, parsePostData };
export let fixturesByUrl;
export let fixturesByUrl = {};
function replacesFixturesByUrl(newFixtures) {
for (const member of Object.keys(fixturesByUrl)) {
delete fixturesByUrl[member];
}
Object.assign(fixturesByUrl, newFixtures);
}
const instance = new Pretender();
@@ -71,7 +79,7 @@ export function applyDefaultHandlers(pretender) {
if (m && m[1] !== "create") {
let result = requirejs(e).default.call(pretender, helpers);
if (m[1] === "fixture") {
fixturesByUrl = result;
replacesFixturesByUrl(result);
}
}
});
+2 -2
View File
@@ -361,7 +361,7 @@ export default function setupTests(config) {
window.location.href = window.location.origin + "/theme-qunit";
}
const hasPluginJs = !!document.querySelector("script[data-discourse-plugin]");
const hasPluginJs = !!document.querySelector("script[data-plugin-name]");
const hasThemeJs = !!document.querySelector("script[data-theme-id]");
// forces 0 as duration for all jquery animations
@@ -384,7 +384,7 @@ export default function setupTests(config) {
// core tests run without loading plugins or themes
const isCoreTest = !hasPluginJs && !hasThemeJs;
const isPreinstalledPluginTest = !!document.querySelector(
`script[data-discourse-plugin="${CSS.escape(target)}"][data-preinstalled="true"]`
`script[data-plugin-name="${CSS.escape(target)}"][data-preinstalled="true"]`
);
if (
@@ -2,13 +2,13 @@ import loadEmberExam from "ember-exam/test-support/load";
import { setupEmberOnerrorValidation, start } from "ember-qunit";
import * as QUnit from "qunit";
import { setup } from "qunit-dom";
import { loadAdmin, loadThemes } from "discourse/app";
import { loadAdmin, loadThemesAndPlugins } from "discourse/app";
import setupTests from "discourse/tests/setup-tests";
import config from "../config/environment";
document.addEventListener("discourse-init", async () => {
await loadAdmin();
await loadThemes();
await loadThemesAndPlugins();
if (!window.EmberENV.TESTS_FILE_LOADED) {
throw new Error(
+11 -4
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class AssetProcessor
BASE_COMPILER_VERSION = 102
PROCESSOR_DIR = "tmp/asset-processor"
LOCK_FILE = "#{PROCESSOR_DIR}/build.lock"
@@ -18,6 +20,10 @@ class AssetProcessor
class TranspileError < StandardError
end
def self.booted?
!!@ctx
end
def self.transpile(data, root_path, logical_path, theme_id: nil, extension: nil)
processor = new(skip_module: skip_module?(data))
processor.perform(data, root_path, logical_path, theme_id: theme_id, extension: extension)
@@ -120,6 +126,7 @@ class AssetProcessor
source = load_or_build_processor_source
ctx.eval("globalThis.ROLLUP_PLUGIN_COMPILER = #{ENV["ROLLUP_PLUGIN_COMPILER"].to_json}")
ctx.eval(source, filename: "asset-processor.js")
ctx
@@ -168,6 +175,10 @@ class AssetProcessor
raise transpile_error
end
def self.ember_version
v8_call("emberVersion")
end
def initialize(skip_module: false)
@skip_module = skip_module
end
@@ -225,8 +236,4 @@ class AssetProcessor
def post_css(css:, map:, source_map_file:)
self.class.v8_call("postCss", css, map, source_map_file, fetch_result_call: "getPostCssResult")
end
def ember_version
self.class.v8_call("emberVersion")
end
end
+33
View File
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class Demon::PluginJsWatcher < ::Demon::Base
def self.prefix
"plugin_js_watcher"
end
def after_fork
log("[PluginJsWatcher] Loading PluginJsWatcher in process id #{Process.pid}")
@queue = Queue.new
trap("INT") { @queue.push(:stop) }
trap("TERM") { @queue.push(:stop) }
trap("HUP") { @queue.push(:stop) }
Plugin::JsManager.new.watch { @queue.pop }
ensure
STDERR.puts "Finished Plugin JS watcher thread"
end
def suppress_stdout
false
end
def suppress_stderr
false
end
def stop_signal
"TERM"
end
end
+47 -4
View File
@@ -460,23 +460,66 @@ module Discourse
plugins.each do |plugin|
if plugin.js_asset_exists?
assets << { name: "plugins/#{plugin.directory_name}", plugin: plugin }
if ENV["ROLLUP_PLUGIN_COMPILER"] == "1"
assets << {
name: Plugin::JsManager.digested_logical_path_for(plugin.directory_name, "main"),
imports: Plugin::JsManager.import_paths_for(plugin.directory_name, "main"),
plugin: plugin,
type_module: true,
importmap_name: "discourse/plugins/#{plugin.directory_name}",
}
else
assets << { name: "plugins/#{plugin.directory_name}", plugin: plugin }
end
end
if plugin.extra_js_asset_exists?
assets << { name: "plugins/#{plugin.directory_name}_extra", plugin: plugin }
assets << {
name: "plugins/#{plugin.directory_name}_extra",
plugin: plugin,
type_module: false,
}
end
if args[:include_admin_asset] && plugin.admin_js_asset_exists?
assets << { name: "plugins/#{plugin.directory_name}_admin", plugin: plugin }
if ENV["ROLLUP_PLUGIN_COMPILER"] == "1" &&
logical_path =
Plugin::JsManager.digested_logical_path_for(plugin.directory_name, "admin")
assets << {
name: logical_path,
imports: Plugin::JsManager.import_paths_for(plugin.directory_name, "admin"),
plugin: plugin,
type_module: true,
}
else
assets << { name: "plugins/#{plugin.directory_name}_admin", plugin: plugin }
end
end
if args[:include_test_assets_for]&.include?(plugin.directory_name) &&
plugin.test_js_asset_exists?
assets << { name: "plugins/test/#{plugin.directory_name}_tests", plugin: plugin }
if ENV["ROLLUP_PLUGIN_COMPILER"] == "1" &&
logical_path =
Plugin::JsManager.digested_logical_path_for(plugin.directory_name, "test")
assets << {
name: logical_path,
imports: Plugin::JsManager.import_paths_for(plugin.directory_name, "test"),
plugin: plugin,
type_module: true,
}
else
assets << { name: "plugins/test/#{plugin.directory_name}_tests", plugin: plugin }
end
end
end
assets.each do |asset|
asset[:plugin_attributes] = {
"data-official": !!asset[:plugin].metadata&.official?,
"data-preinstalled": asset[:plugin].preinstalled?,
}
end
assets
end
-11
View File
@@ -30,14 +30,3 @@ Propshaft::Helper.prepend(
end
end,
)
Propshaft::Compiler::SourceMappingUrls.prepend(
Module.new do
def source_mapping_url(*args)
# Propshaft insists on converting sourcemap URLs to absolute paths. We want to keep
# relative paths so that we can serve assets from different subdirectories without needing
# to recompile them
super.gsub(%r{sourceMappingURL=\S+/([^/]+\.map)}, 'sourceMappingURL=\1')
end
end,
)
+43
View File
@@ -0,0 +1,43 @@
# frozen_string_literal: true
class Plugin::JsCompiler
def initialize(
plugin_name,
minify: true,
tree: {},
entrypoints: {},
filename_prefix: nil,
filename_suffix: nil
)
@plugin_name = plugin_name
@tree = tree
@entrypoints = entrypoints
@minify = minify
@filename_prefix = filename_prefix
@filename_suffix = filename_suffix
end
def compile!
AssetProcessor.new.rollup(
@tree,
{
pluginName: @plugin_name,
minify: @minify,
entrypoints: @entrypoints,
filenamePrefix: @filename_prefix,
filenameSuffix: @filename_suffix,
},
)
rescue AssetProcessor::TranspileError => e
message = "[PLUGIN #{@plugin_name}] Compile error: #{e.message}"
{
"#{@filename_prefix}main#{@filename_suffix}.js" => {
"name" => "main",
"imports" => [],
"isEntry" => true,
"code" => "throw new Error(#{message.to_json});\n",
"map" => nil,
},
}
end
end
+190
View File
@@ -0,0 +1,190 @@
# frozen_string_literal: true
module Plugin
class JsManager
@@manifest_data = {}
def self.read_manifest(plugin_name)
manifest_path = "#{Rails.root}/app/assets/generated/#{plugin_name}/manifest.json"
if Rails.env.production?
@@manifest_data[plugin_name] ||= JSON.parse(File.read(manifest_path))
else
JSON.parse(File.read(manifest_path))
end
end
def self.digested_logical_path_for(plugin_name, entrypoint_name)
manifest_entry = read_manifest(plugin_name)[entrypoint_name]
"js/plugins/#{manifest_entry["fileName"].delete_suffix(".js")}" if manifest_entry
end
def self.import_paths_for(plugin_name, entrypoint_name)
read_manifest(plugin_name)[entrypoint_name]["imports"].map do
"js/plugins/#{it.delete_suffix(".js")}"
end
end
def compile!
log "Compiling #{Discourse.plugins.count} plugins..."
start = Time.now
if !GlobalSetting.mini_racer_single_threaded && AssetProcessor.booted?
raise "[Plugin::JSManager] Cannot fork for parallel compilation because AssetProcessor is already booted."
end
parallel_count = [Etc.nprocessors, 4].min
Parallel.each(Discourse.plugins, in_processes: parallel_count) do |plugin|
compile_js_bundle(plugin)
end
log "Finished initial compilation of plugins in #{(Time.now - start).round(2)}s"
end
def compile_js_bundle(plugin)
base_output_dir = "#{Rails.root}/app/assets/generated/#{plugin.directory_name}"
js_dir = "#{base_output_dir}/js/plugins"
map_dir = "#{base_output_dir}/map/plugins"
entrypoints = { "main" => "assets/javascripts", "admin" => "admin/assets/javascripts" }
entrypoints["test"] = "test/javascripts" if Rails.env.local?
tree = {}
entrypoints_config = {}
entrypoints.each do |name, js_path|
js_base = "#{plugin.directory}/#{js_path}"
files = Dir.glob("**/*", base: js_base)
next if files.empty?
entrypoints_config[name] = { modules: [] }
files.sort.each do |file|
full_path = File.join(js_base, file)
if File.file?(full_path)
normalized_file_path = file.sub(/\.js\.es6$/, ".js")
tree[normalized_file_path] = File.read(full_path)
if name == "test" && file.match(%r{/(acceptance|integration|unit)/})
if file.match?(/-test\.g?js$/)
entrypoints_config[name][:modules] << normalized_file_path
end
else
entrypoints_config[name][:modules] << normalized_file_path
end
end
end
end
hex_digest =
Digest::SHA1.hexdigest(
[
*tree.keys,
*tree.values,
AssetProcessor::BASE_COMPILER_VERSION,
AssetProcessor.ember_version,
minify?.to_s,
].join,
)
base36_digest = hex_digest.to_i(16).to_s(36).first(8)
filename_prefix = "#{plugin.directory_name}_"
filename_suffix = "-#{base36_digest}.digested"
expected_entrypoints =
entrypoints_config.keys.map do |name|
"#{js_dir}/#{filename_prefix}#{name}#{filename_suffix}.js"
end
files_exist = expected_entrypoints.all? { |path| File.exist?(path) }
if !cache? || !files_exist
compiler =
Plugin::JsCompiler.new(
plugin.directory_name,
minify: minify?,
tree: tree,
entrypoints: entrypoints_config,
filename_prefix:,
filename_suffix:,
)
result = compiler.compile!
FileUtils.mkdir_p(js_dir)
FileUtils.mkdir_p(map_dir)
manifest = {}
result.each do |file_name, info|
code = info["code"]
code += "\n//# sourceMappingURL=../../map/plugins/#{file_name}.map\n" if info["map"]
File.write("#{js_dir}/#{file_name}", code)
File.write("#{map_dir}/#{file_name}.map", info["map"]) if info["map"]
if info["isEntry"]
manifest[info["name"]] = { fileName: file_name, imports: info["imports"] }
end
end
File.write("#{base_output_dir}/manifest.json", JSON.pretty_generate(manifest))
end
# Delete any old versions
Dir
.glob("#{base_output_dir}/*/*/*")
.reject { |path| path.include?(filename_suffix) || path.include?("_extra") }
.each { |path| FileUtils.rm_rf(path) }
end
def watch
listener =
Listen.to(
*Discourse.plugins.map(&:directory),
{ ignore: [%r{/node_modules/}], only: /\.(gjs|js|hbs)\z/ },
) do |modified, added, removed|
changed_files = modified + added + removed
changed_plugins = Set.new
log "Changed files:"
changed_files.each do |file|
relative_path = Pathname.new(file).relative_path_from(Rails.root)
log "- #{relative_path}"
plugin = Discourse.plugins.find { |p| file.start_with?(p.directory) }
changed_plugins << plugin if plugin
end
log "Recompiling..."
start = Time.now
changed_plugins.each { |plugin| compile_js_bundle(plugin) }
log "Finished recompilation in #{(Time.now - start).round(2)}s"
MessageBus.publish("/file-change", ["refresh"])
rescue => e
log "Plugin JS watcher crashed \n#{e}"
end
begin
listener.start
compile!
yield
ensure
listener.stop
end
end
def minify?
Rails.env.production?
end
def cache?
true
end
def log(message)
STDERR.puts "[Plugin::JsManager] #{message}"
end
end
end
+13 -1
View File
@@ -23,7 +23,19 @@ task "assets:precompile:build" do
end
end
task "assets:precompile:before": %w[environment assets:precompile:build]
task "assets:precompile:build_plugins": "environment" do
if ENV["ROLLUP_PLUGIN_COMPILER"] == "1"
Plugin::JsManager.new.compile!
else
puts "Skipping plugin JS compilation, set ROLLUP_PLUGIN_COMPILER=1 to enable"
end
end
task "assets:precompile:before": %w[
environment
assets:precompile:build
assets:precompile:build_plugins
]
task "assets:precompile:css" => "environment" do
if ENV["DONT_PRECOMPILE_CSS"] == "1" || ENV["SKIP_DB_AND_REDIS"] == "1"
+4
View File
@@ -1,11 +1,15 @@
# frozen_string_literal: true
def brotli_s3_path(path)
return path.sub(%r{^assets/js/}, "assets/br/").sub(/\.br$/, "") if path.start_with?("assets/js/")
ext = File.extname(path)
"#{path[0..-ext.length]}br#{ext}"
end
def gzip_s3_path(path)
return path.sub(%r{^assets/js/}, "assets/gz/").sub(/\.gz$/, "") if path.start_with?("assets/js/")
ext = File.extname(path)
"#{path[0..-ext.length]}gz#{ext}"
end
+14 -4
View File
@@ -26,16 +26,26 @@ class ThemeJavascriptCompiler
def compile!
if !@compiled
@compiled = true
@input_tree.transform_keys! { |k| k.sub(/\.js\.es6$/, ".js") }
@input_tree.freeze
output =
AssetProcessor.new.rollup(
@input_tree.transform_keys { |k| k.sub(/\.js\.es6$/, ".js") },
{ themeId: @theme_id, settings: @settings, minify: @minify && !@@terser_disabled },
@input_tree,
{
themeId: @theme_id,
settings: @settings,
minify: @minify && !@@terser_disabled,
entrypoints: {
main: {
modules: @input_tree.keys,
},
},
},
)
@content = output["code"]
@source_map = output["map"]
@content = output["main.js"]["code"]
@source_map = output["main.js"]["map"]
end
[@content, @source_map]
rescue AssetProcessor::TranspileError => e
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+29 -18
View File
@@ -154,25 +154,36 @@ build_cmd << "-prod" if resolved_ember_env == "production"
core_build_reusable =
existing_core_build_usable? || (download_prebuild_assets! && existing_core_build_usable?)
if core_build_reusable && ENV["LOAD_PLUGINS"] == "0"
log "Reusing existing core ember build. Plugins not loaded. All done."
elsif core_build_reusable
log "Reusing existing core ember build. Only building plugins..."
build_env["SKIP_CORE_BUILD"] = "1"
build_cmd << "-o" << "dist/_plugin_only_build"
begin
if ENV["ROLLUP_PLUGIN_COMPILER"] == "1"
if core_build_reusable
log "Reusing existing core ember build. All done."
else
log "Running full core build..."
system(build_env, *build_cmd, exception: true, chdir: EMBER_APP_DIR)
FileUtils.rm_rf("#{EMBER_APP_DIR}/dist/assets/plugins")
FileUtils.mv(
"#{EMBER_APP_DIR}/dist/_plugin_only_build/assets/plugins",
"#{EMBER_APP_DIR}/dist/assets/plugins",
)
ensure
FileUtils.rm_rf("#{EMBER_APP_DIR}/dist/_plugin_only_build")
File.write(BUILD_INFO_FILE, JSON.pretty_generate(build_info))
end
log "Plugin build successfully integrated into dist"
else
log "Running full core build..."
system(build_env, *build_cmd, exception: true, chdir: EMBER_APP_DIR)
File.write(BUILD_INFO_FILE, JSON.pretty_generate(build_info))
if core_build_reusable && ENV["LOAD_PLUGINS"] == "0"
log "Reusing existing core ember build. Plugins not loaded. All done."
elsif core_build_reusable
log "Reusing existing core ember build. Only building plugins..."
build_env["SKIP_CORE_BUILD"] = "1"
build_cmd << "-o" << "dist/_plugin_only_build"
begin
system(build_env, *build_cmd, exception: true, chdir: EMBER_APP_DIR)
FileUtils.rm_rf("#{EMBER_APP_DIR}/dist/assets/plugins")
FileUtils.mv(
"#{EMBER_APP_DIR}/dist/_plugin_only_build/assets/plugins",
"#{EMBER_APP_DIR}/dist/assets/plugins",
)
ensure
FileUtils.rm_rf("#{EMBER_APP_DIR}/dist/_plugin_only_build")
end
log "Plugin build successfully integrated into dist"
else
log "Running full core build..."
system(build_env, *build_cmd, exception: true, chdir: EMBER_APP_DIR)
File.write(BUILD_INFO_FILE, JSON.pretty_generate(build_info))
end
end
+5 -18
View File
@@ -101,14 +101,6 @@ RSpec.describe ApplicationHelper do
expect(link).to include(%r{https://s3cdn.com/assets/start-discourse-\w{8}.js})
end
it "gives s3 cdn but without brotli/gzip extensions for theme tests assets" do
helper.request.env["HTTP_ACCEPT_ENCODING"] = "gzip, br"
link = helper.preload_script("discourse/tests/theme_qunit_ember_jquery")
expect(link).to include(
%r{https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery-\w{8}.js},
)
end
it "uses separate asset CDN if configured" do
global_setting :s3_asset_cdn_url, "https://s3-asset-cdn.example.com"
expect(helper.preload_script("start-discourse")).to include(
@@ -122,31 +114,26 @@ RSpec.describe ApplicationHelper do
helper.preload_script(
"plugins/my-plugin",
attrs: {
"data-discourse-plugin": "my-plugin",
"data-plugin-name": "my-plugin",
"data-preinstalled": "true",
"data-official": "true",
},
)
expect(result).to include('data-discourse-plugin="my-plugin"')
expect(result).to include('data-plugin-name="my-plugin"')
expect(result).to include('data-preinstalled="true"')
expect(result).to include('data-official="true"')
end
it "does not include extra attrs when none are provided" do
result = helper.preload_script("start-discourse")
expect(result).not_to include("data-discourse-plugin")
expect(result).not_to include("data-plugin-name")
expect(result).not_to include("data-preinstalled")
expect(result).not_to include("data-official")
end
it "escapes attr values" do
result =
helper.preload_script(
"plugins/test",
attrs: {
"data-discourse-plugin": "<script>xss</script>",
},
)
helper.preload_script("plugins/test", attrs: { "data-plugin-name": "<script>xss</script>" })
expect(result).not_to include("<script>xss</script>")
expect(result).to include("&lt;script&gt;xss&lt;/script&gt;")
end
@@ -156,7 +143,7 @@ RSpec.describe ApplicationHelper do
helper.preload_script(
"plugins/my-plugin",
attrs: {
"data-discourse-plugin": "my-plugin",
"data-plugin-name": "my-plugin",
"data-preinstalled": "false",
"data-official": "false",
},
+169 -23
View File
@@ -115,13 +115,17 @@ RSpec.describe AssetProcessor do
}
JS
result = AssetProcessor.new.rollup(sources, {})
result =
AssetProcessor.new.rollup(
sources,
{ entrypoints: { main: { modules: ["discourse/initializers/hello.gjs"] } } },
)
code = result["code"]
code = result["main.js"]["code"]
expect(code).to include('"hello world"')
expect(code).to include("dt7948") # Decorator transform
expect(result["map"]).not_to be_nil
expect(result["main.js"]["map"]).not_to be_nil
end
it "supports decorators and class properties without error" do
@@ -138,8 +142,12 @@ RSpec.describe AssetProcessor do
}
JS
result = AssetProcessor.new.rollup({ "discourse/initializers/foo.js" => script }, {})
expect(result["code"]).to include("dt7948.n")
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
@@ -154,8 +162,12 @@ RSpec.describe AssetProcessor do
}
JS
result = AssetProcessor.new.rollup({ "discourse/initializers/foo.js" => script }, {})
expect(result["code"]).to include("dt7948")
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
@@ -167,8 +179,11 @@ RSpec.describe AssetProcessor do
JS
result =
AssetProcessor.new.rollup({ "discourse/initializers/foo.gjs" => script }, { themeId: 22 })
expect(result["code"]).to include(
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
@@ -181,8 +196,11 @@ RSpec.describe AssetProcessor do
JS
result =
AssetProcessor.new.rollup({ "discourse/initializers/foo.js" => script }, { themeId: 22 })
expect(result["code"]).to include(
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
@@ -196,9 +214,16 @@ RSpec.describe AssetProcessor do
result =
AssetProcessor.new.rollup(
{ "discourse/connectors/outlet-name/foo.hbs" => template },
{ themeId: 22 },
{
themeId: 22,
entrypoints: {
main: {
modules: ["discourse/connectors/outlet-name/foo.hbs"],
},
},
},
)
expect(result["code"]).to include("createTemplateFactory")
expect(result["main.js"]["code"]).to include("createTemplateFactory")
end
it "handles colocation" do
@@ -222,11 +247,18 @@ RSpec.describe AssetProcessor do
"discourse/components/foo.hbs" => template,
"discourse/components/bar.hbs" => onlyTemplate,
},
{ themeId: 22 },
{
themeId: 22,
entrypoints: {
main: {
modules: %w[discourse/components/foo.js discourse/components/bar.hbs],
},
},
},
)
expect(result["code"]).to include("setComponentTemplate")
expect(result["code"]).to include(
expect(result["main.js"]["code"]).to include("setComponentTemplate")
expect(result["main.js"]["code"]).to include(
"bar = setComponentTemplate(__COLOCATED_TEMPLATE__, templateOnly());",
)
end
@@ -247,10 +279,17 @@ RSpec.describe AssetProcessor do
"discourse/components/my-component.js" => mod_1,
"discourse/components/other-component.js" => mod_2,
},
{ themeId: 22 },
{
themeId: 22,
entrypoints: {
main: {
modules: ["discourse/components/other-component.js"],
},
},
},
)
expect(result["code"]).not_to include("../components/my-component")
expect(result["main.js"]["code"]).not_to include("../components/my-component")
end
it "handles relative import of index file" do
@@ -269,10 +308,20 @@ RSpec.describe AssetProcessor do
"discourse/components/my-component.js" => mod_1,
"discourse/components/other-component/index.js" => mod_2,
},
{ themeId: 22 },
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/components/my-component.js
discourse/components/other-component/index.js
],
},
},
},
)
expect(result["code"]).not_to include("../components/my-component")
expect(result["main.js"]["code"]).not_to include("../components/my-component")
end
it "handles relative import of gjs index file" do
@@ -291,13 +340,110 @@ RSpec.describe AssetProcessor do
"discourse/components/my-component.gjs" => mod_1,
"discourse/components/other-component/index.gjs" => mod_2,
},
{ themeId: 22 },
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/components/my-component.gjs
discourse/components/other-component/index.gjs
],
},
},
},
)
expect(result["code"]).not_to include("../components/my-component")
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.new.ember_version).to match(/\A\d+\.\d+\.\d+\z/)
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