mirror of
https://github.com/discourse/discourse.git
synced 2026-07-31 08:38:27 -05:00
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:
co-authored by
Jarek Radosz
Chris Manson
parent
5c1cff4184
commit
af3385baba
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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("<script>xss</script>")
|
||||
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",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user