DEV: Improve cross-plugin/theme import handling (#40939)

1. Remove 'federated exports' system, which was named entrypoint exports
for every module inside the plugin. Replace it with a single import of
the target plugin's 'compatModules', and then update call sites to do a
'just in time' lookup of the module and export. This is implemented in a
new `babel-resolve-plugin-imports` plugin

2. Update theme & plugin build systems to produce a list of external
plugins which are imported. For plugins, it's stored in the manifest.
For themes, it's stored in a new column of the javascript_caches table.

3. Refactor theme extra_js loading to use a more structured data model,
and move the HTML generation to the erb template

4. Update core importmap to identify any missing plugin dependencies and
add a fake placeholder module for them. This allows optional imports to
exist without causing a boot error. The imported values will resolve to
'null'.

5. Update core plugins to remove use of `optionalRequire`, and replace
it with regular imports, and a `with { discourseImport: "optional" }`
suffix. This is functionally equivalent to optionalRequire, but without
leaning on the legacy `loader.js` system of core

6. Add support for `with { discourseImport: "optional" }` for
plugins/themes importing core modules. This is useful when a
theme/plugin needs to target multiple versions of Discourse core.

Co-authored-by: Jarek Radosz <jarek@cvx.dev>
This commit is contained in:
David Taylor
2026-06-30 16:11:38 +01:00
committed by GitHub
co-authored by Jarek Radosz
parent 9014e684a5
commit e3054ef590
49 changed files with 892 additions and 502 deletions
+20 -5
View File
@@ -103,6 +103,24 @@ module ApplicationHelper
.map { [it[:importmap_name], script_asset_path(it[:name])] }
.to_h
available_plugins = plugin_assets.map { |a| a[:plugin].directory_name }
external_plugin_imports =
(
plugin_assets.flat_map { |a| a[:external_plugin_imports] || [] } +
theme_js_assets.flat_map { |a| a[:external_plugin_imports] }
).uniq
external_plugin_imports.each do |plugin_name|
if available_plugins.include?(plugin_name)
imports["discourse/plugins/#{plugin_name}?"] = imports["discourse/plugins/#{plugin_name}"]
else
imports["discourse/plugins/#{plugin_name}?"] = Plugin::JsManager.optional_plugin_stub
imports["discourse/plugins/#{plugin_name}"] = Plugin::JsManager.required_plugin_stub(
plugin_name,
)
end
end
JSON.pretty_generate({ imports: }).html_safe
end
@@ -804,13 +822,10 @@ module ApplicationHelper
)
end
def theme_js_lookup
Theme.lookup_field(
def theme_js_assets
Theme.js_asset_info(
theme_id,
:extra_js,
nil,
skip_transformation: request.env[:skip_theme_ids_transformation].present?,
csp_nonce: csp_nonce_placeholder,
)
end
+10 -9
View File
@@ -37,15 +37,16 @@ end
#
# Table name: javascript_caches
#
# id :bigint not null, primary key
# content :text not null
# digest :string
# name :string
# source_map :text
# created_at :datetime not null
# updated_at :datetime not null
# theme_field_id :bigint
# theme_id :bigint
# id :bigint not null, primary key
# content :text not null
# digest :string
# external_plugin_imports :string default([]), not null, is an Array
# name :string
# source_map :text
# created_at :datetime not null
# updated_at :datetime not null
# theme_field_id :bigint
# theme_id :bigint
#
# Indexes
#
+34 -25
View File
@@ -221,7 +221,11 @@ class Theme < ActiveRecord::Base
js_compiler.append_tree(all_extra_js)
javascript_cache || build_javascript_cache
javascript_cache.update!(content: js_compiler.content, source_map: js_compiler.source_map)
javascript_cache.update!(
content: js_compiler.content,
source_map: js_compiler.source_map,
external_plugin_imports: js_compiler.external_plugin_imports,
)
else
javascript_cache&.destroy!
end
@@ -462,6 +466,35 @@ class Theme < ActiveRecord::Base
resolved.html_safe
end
# An array of `{ url:, theme_id:, external_plugin_imports: }` hashes describing
# the theme's baked `extra_js` javascript caches.
def self.js_asset_info(theme_id, skip_transformation: false)
return [] if theme_id.blank?
theme_ids = !skip_transformation ? transform_ids(theme_id) : [theme_id]
get_set_cache("#{theme_ids.join(",")}:extra_js:#{Theme.compiler_version}") do
require_rebake =
ThemeField
.where(theme_id: theme_ids, target_id: targets[:extra_js])
.where.not(compiler_version: compiler_version)
ActiveRecord::Base.transaction do
require_rebake.each(&:ensure_baked!)
Theme.where(id: require_rebake.map(&:theme_id)).each(&:update_javascript_cache!)
end
JavascriptCache
.where(theme_id: theme_ids)
.index_by(&:theme_id)
.values_at(*theme_ids)
.compact
.map do |c|
{ url: c.url, theme_id: c.theme_id, external_plugin_imports: c.external_plugin_imports }
end
end
end
def self.lookup_modifier(theme_ids, modifier_name)
theme_ids = [theme_ids] unless theme_ids.is_a?(Array)
@@ -553,30 +586,6 @@ class Theme < ActiveRecord::Base
target = :desktop if target == :desktop_theme
case target
when :extra_js
get_set_cache("#{theme_ids.join(",")}:extra_js:#{Theme.compiler_version}") do
require_rebake =
ThemeField
.where(theme_id: theme_ids, target_id: targets[:extra_js])
.where.not(compiler_version: compiler_version)
ActiveRecord::Base.transaction do
require_rebake.each { |tf| tf.ensure_baked! }
Theme.where(id: require_rebake.map(&:theme_id)).each(&:update_javascript_cache!)
end
caches =
JavascriptCache
.where(theme_id: theme_ids)
.index_by(&:theme_id)
.values_at(*theme_ids)
.compact
caches.map { |c| <<~HTML.html_safe }.join("\n")
<link rel="modulepreload" href="#{c.url}" data-theme-id="#{c.theme_id}" nonce="#{ThemeField::CSP_NONCE_PLACEHOLDER}" />
HTML
end
when :translations
theme_field_values(theme_ids, :translations, I18n.fallbacks[name])
.to_a
+3 -1
View File
@@ -66,7 +66,9 @@
<%- end %>
<%- unless customization_disabled? %>
<%= theme_js_lookup %>
<%- theme_js_assets.each do |asset| %>
<link rel="modulepreload" href="<%= asset[:url] %>" data-theme-id="<%= asset[:theme_id] %>" nonce="<%= csp_nonce_placeholder %>">
<%- end %>
<%= theme_lookup("head_tag") %>
<%- end %>
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class AddExternalPluginImportsToJavascriptCaches < ActiveRecord::Migration[8.0]
def change
add_column :javascript_caches,
:external_plugin_imports,
:string,
array: true,
null: false,
default: []
end
end
+2
View File
@@ -6282,6 +6282,7 @@ CREATE TABLE public.javascript_caches (
theme_id bigint,
source_map text,
name character varying,
external_plugin_imports character varying[] DEFAULT '{}'::character varying[] NOT NULL,
CONSTRAINT enforce_theme_or_theme_field CHECK ((((theme_id IS NOT NULL) AND (theme_field_id IS NULL)) OR ((theme_id IS NULL) AND (theme_field_id IS NOT NULL))))
);
@@ -22292,6 +22293,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20260617180115'),
('20260617104005'),
('20260617053237'),
('20260616114637'),
('20260615084100'),
('20260615082047'),
('20260612092612'),
@@ -10,7 +10,8 @@ import StripTestSelectorsPlugin from "strip-test-selectors/src/strip-test-select
import { browsers } from "../discourse/config/targets";
import babelTransformModuleRenames from "../discourse/lib/babel-transform-module-renames";
import AddThemeGlobals from "./add-theme-globals";
import BabelReplaceImports from "./babel-replace-imports";
import BabelResolveCoreImports from "./babel-resolve-core-imports";
import BabelResolvePluginImports from "./babel-resolve-plugin-imports";
import discourseColocation from "./rollup-plugins/discourse-colocation";
import discourseExternalLoader from "./rollup-plugins/discourse-external-loader";
import discourseFileSearch from "./rollup-plugins/discourse-file-search";
@@ -65,13 +66,15 @@ async function performRollup(modules, opts) {
discourseExternalLoader({ basePath }),
discourseColocation({ basePath }),
getBabelOutputPlugin({
plugins: [BabelReplaceImports],
plugins: [BabelResolveCoreImports, BabelResolvePluginImports],
compact: false,
}),
babel({
extensions: [".js", ".gjs", ".hbs"],
babelHelpers: "bundled",
compact: false,
// Support `import ... with { ... }` for cross-plugin imports
parserOpts: { plugins: ["importAttributes"] },
plugins: [
[DecoratorTransforms, { runEarly: true }],
opts.themeId ? AddThemeGlobals : null,
@@ -117,6 +120,7 @@ async function performRollup(modules, opts) {
const bundle = await result.generate({
format: "es",
sourcemap: "hidden",
importAttributesKey: "with",
entryFileNames: `${opts.filenamePrefix ?? ""}[name].[hash:6]${opts.filenameSuffix ?? ""}.js`,
chunkFileNames: `${opts.filenamePrefix ?? ""}chunk.[hash:6]${opts.filenameSuffix ?? ""}.js`,
});
@@ -125,6 +129,15 @@ async function performRollup(modules, opts) {
caches.set(opts.pluginName, result.cache);
}
const externalPluginImports = [
...new Set(
bundle.output
.flatMap((c) => c.imports ?? [])
.filter((i) => i.startsWith("discourse/plugins/"))
.map((i) => i.split("/")[2])
),
];
const chunks = Object.fromEntries(
bundle.output
.filter((c) => c.code)
@@ -139,6 +152,7 @@ async function performRollup(modules, opts) {
imports: chunk.imports.filter((i) =>
bundle.output.find((c) => c.fileName === i)
),
externalPluginImports,
},
];
})
@@ -1,113 +0,0 @@
import { federatedExportNameFor } from "./federated-modules-helper";
import rollupVirtualImports from "./rollup-virtual-imports";
export default function (babel) {
const { types: t } = babel;
return {
visitor: {
ImportDeclaration(path) {
const moduleName = path.node.source.value;
if (
moduleName.startsWith(".") ||
rollupVirtualImports[moduleName] ||
moduleName.startsWith("discourse/theme-")
) {
return;
}
if (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) => {
if (specifier.type === "ImportDefaultSpecifier") {
return t.objectProperty(
t.identifier("default"),
t.identifier(specifier.local.name)
);
} else if (specifier.type === "ImportNamespaceSpecifier") {
namespaceImports.push(t.identifier(specifier.local.name));
} else {
return t.objectProperty(
t.identifier(specifier.imported.name),
t.identifier(specifier.local.name)
);
}
})
.filter(Boolean);
const replacements = [];
const moduleBrokerLookup = t.callExpression(
t.memberExpression(
t.memberExpression(
t.identifier("window"),
t.identifier("moduleBroker")
),
t.identifier("lookup")
),
[t.stringLiteral(moduleName)]
);
if (properties.length) {
replacements.push(
t.variableDeclaration("const", [
t.variableDeclarator(
t.objectPattern(properties),
moduleBrokerLookup
),
])
);
}
if (namespaceImports.length) {
for (const namespaceImport of namespaceImports) {
replacements.push(
t.variableDeclaration("const", [
t.variableDeclarator(namespaceImport, moduleBrokerLookup),
])
);
}
}
path.replaceWithMultiple(replacements);
},
},
};
}
@@ -1,44 +0,0 @@
/* eslint-disable qunit/require-expect */
import { transformSync } from "@babel/core";
import { expect, test } from "vitest";
import BabelReplaceImports from "./babel-replace-imports.js";
function compile(input) {
return transformSync(input, {
configFile: false,
plugins: [BabelReplaceImports],
}).code;
}
test("replaces imports with moduleBroker calls", () => {
expect(
compile(`
import concatClass from "discourse/helpers/concat-class";
import { default as renamedDefaultImport, namedImport, otherNamedImport as renamedImport } from "discourse/module-1";
`)
).toMatchInlineSnapshot(`
"const {
default: concatClass
} = window.moduleBroker.lookup("discourse/helpers/concat-class");
const {
default: renamedDefaultImport,
namedImport: namedImport,
otherNamedImport: renamedImport
} = window.moduleBroker.lookup("discourse/module-1");"
`);
});
test("handles namespace imports", () => {
expect(
compile(`
import * as MyModule from "discourse/module-1";
import defaultExport, * as MyModule2 from "discourse/module-2";
`)
).toMatchInlineSnapshot(`
"const MyModule = window.moduleBroker.lookup("discourse/module-1");
const {
default: defaultExport
} = window.moduleBroker.lookup("discourse/module-2");
const MyModule2 = window.moduleBroker.lookup("discourse/module-2");"
`);
});
@@ -0,0 +1,76 @@
import { readDiscourseImportMode } from "./discourse-import-attribute";
import rollupVirtualImports from "./rollup-virtual-imports";
export default function (babel) {
const { types: t } = babel;
const declare = (id, init) =>
t.variableDeclaration("const", [t.variableDeclarator(id, init)]);
return {
manipulateOptions(_opts, parserOpts) {
// Allow the `with { discourseImport: ... }` attribute to parse.
if (!parserOpts.plugins.includes("importAttributes")) {
parserOpts.plugins.push("importAttributes");
}
},
visitor: {
ImportDeclaration(path) {
const moduleName = path.node.source.value;
if (
moduleName.startsWith(".") ||
rollupVirtualImports[moduleName] ||
moduleName.startsWith("discourse/theme-") ||
moduleName.startsWith("discourse/plugins/")
) {
return;
}
// Core imports are required unless explicitly marked optional.
const optional = readDiscourseImportMode(path) === "optional";
const lookup = () =>
t.callExpression(
t.memberExpression(
t.memberExpression(
t.identifier("window"),
t.identifier("moduleBroker")
),
t.identifier("lookup")
),
optional
? [t.stringLiteral(moduleName), t.booleanLiteral(true)]
: [t.stringLiteral(moduleName)]
);
const replacements = [];
const properties = [];
for (const specifier of path.node.specifiers) {
if (specifier.type === "ImportNamespaceSpecifier") {
replacements.push(
declare(t.identifier(specifier.local.name), lookup())
);
} else {
const exportedName =
specifier.type === "ImportDefaultSpecifier"
? "default"
: specifier.imported.name;
properties.push(
t.objectProperty(
t.identifier(exportedName),
t.identifier(specifier.local.name)
)
);
}
}
if (properties.length) {
replacements.unshift(declare(t.objectPattern(properties), lookup()));
}
path.replaceWithMultiple(replacements);
},
},
};
}
@@ -0,0 +1,92 @@
/* eslint-disable qunit/require-expect */
import { transformSync } from "@babel/core";
import { expect, test } from "vitest";
import BabelResolveCoreImports from "./babel-resolve-core-imports.js";
function compile(input) {
return transformSync(input, {
configFile: false,
plugins: [BabelResolveCoreImports],
}).code;
}
test("destructures core imports from moduleBroker", () => {
expect(
compile(`
import concatClass from "discourse/helpers/concat-class";
import { default as renamedDefaultImport, namedImport, otherNamedImport as renamedImport } from "discourse/module-1";
`)
).toMatchInlineSnapshot(`
"const {
default: concatClass
} = window.moduleBroker.lookup("discourse/helpers/concat-class");
const {
default: renamedDefaultImport,
namedImport: namedImport,
otherNamedImport: renamedImport
} = window.moduleBroker.lookup("discourse/module-1");"
`);
});
test("handles core namespace imports", () => {
expect(
compile(`
import * as MyModule from "discourse/module-1";
import defaultExport, * as MyModule2 from "discourse/module-2";
`)
).toMatchInlineSnapshot(`
"const MyModule = window.moduleBroker.lookup("discourse/module-1");
const {
default: defaultExport
} = window.moduleBroker.lookup("discourse/module-2");
const MyModule2 = window.moduleBroker.lookup("discourse/module-2");"
`);
});
test("marks optional core imports with a second lookup argument", () => {
expect(
compile(`
import concatClass from "discourse/helpers/concat-class" with { discourseImport: "optional" };
import * as MyModule from "discourse/module-1" with { discourseImport: "optional" };
`)
).toMatchInlineSnapshot(`
"const {
default: concatClass
} = window.moduleBroker.lookup("discourse/helpers/concat-class", true);
const MyModule = window.moduleBroker.lookup("discourse/module-1", true);"
`);
});
test('`discourseImport: "required"` keeps the default required lookup', () => {
expect(
compile(`
import concatClass from "discourse/helpers/concat-class" with { discourseImport: "required" };
`)
).toMatchInlineSnapshot(`
"const {
default: concatClass
} = window.moduleBroker.lookup("discourse/helpers/concat-class");"
`);
});
test("throws on an unknown `discourseImport` value", () => {
expect(() =>
compile(`
import concatClass from "discourse/helpers/concat-class" with { discourseImport: "maybe" };
`)
).toThrow(/Invalid `discourseImport` import attribute "maybe"/);
});
test("leaves relative, virtual, theme and plugin imports untouched", () => {
expect(
compile(`
import sibling from "./sibling";
import { settings } from "discourse/theme-12/settings";
import Thing from "discourse/plugins/other/lib/thing";
`)
).toMatchInlineSnapshot(`
"import sibling from "./sibling";
import { settings } from "discourse/theme-12/settings";
import Thing from "discourse/plugins/other/lib/thing";"
`);
});
@@ -0,0 +1,132 @@
import { readDiscourseImportMode } from "./discourse-import-attribute";
export default function (babel) {
const { types: t } = babel;
function rewriteReferences(binding, buildValue) {
for (const reference of [...binding.referencePaths]) {
const parent = reference.parentPath;
if (parent.isExportSpecifier()) {
throw reference.buildCodeFrameError(
"Re-exporting a cross-plugin import is not supported. Import and reference it directly instead."
);
} else if (
parent.isObjectProperty({ shorthand: true, value: reference.node })
) {
parent.node.shorthand = false;
reference.replaceWith(buildValue());
} else if (parent.isCallExpression({ callee: reference.node })) {
// `(0, ...)` keeps `this === undefined` for the call.
reference.replaceWith(
t.sequenceExpression([t.numericLiteral(0), buildValue()])
);
} else {
reference.replaceWith(buildValue());
}
}
}
// Cross-plugin imports are required unless explicitly marked optional.
function isOptionalPluginImport(path) {
return readDiscourseImportMode(path) === "optional";
}
return {
manipulateOptions(_opts, parserOpts) {
// Allow the `with { discourseImport: ... }` attribute to parse.
if (!parserOpts.plugins.includes("importAttributes")) {
parserOpts.plugins.push("importAttributes");
}
},
pre() {
this.pluginImports = new Map();
},
visitor: {
Program: {
exit(path) {
const declarations = [];
for (const [importSource, localId] of this.pluginImports) {
declarations.push(
t.importDeclaration(
[t.importDefaultSpecifier(t.identifier(localId))],
t.stringLiteral(importSource)
)
);
}
path.node.body.unshift(...declarations);
},
},
ImportDeclaration(path) {
const moduleName = path.node.source.value;
if (!moduleName.startsWith("discourse/plugins/")) {
return;
}
const optional = isOptionalPluginImport(path);
const parts = moduleName.split("/");
const pluginName = parts[2];
const compatModuleName = parts.slice(3).join("/");
// Add a ? suffix for optional cross-plugin imports
const importSource = `discourse/plugins/${pluginName}${optional ? "?" : ""}`;
let localId = this.pluginImports.get(importSource);
if (!localId) {
localId = path.scope.generateUid(
`plugin_${pluginName}${optional ? "_optional" : ""}`
);
this.pluginImports.set(importSource, localId);
}
const compatModule = () => {
const primary = t.memberExpression(
t.identifier(localId),
t.stringLiteral(compatModuleName),
true
);
if (
!compatModuleName ||
compatModuleName === "index" ||
compatModuleName.endsWith("/index")
) {
return primary;
}
const indexFallback = t.memberExpression(
t.identifier(localId),
t.stringLiteral(`${compatModuleName}/index`),
true
);
return t.logicalExpression("||", primary, indexFallback);
};
for (const specifier of path.node.specifiers) {
const localName = specifier.local.name;
let exportedName;
if (specifier.type === "ImportDefaultSpecifier") {
exportedName = "default";
} else if (specifier.type === "ImportNamespaceSpecifier") {
exportedName = null;
} else {
exportedName = specifier.imported.name;
}
const buildValue = () =>
exportedName
? t.memberExpression(compatModule(), t.identifier(exportedName))
: compatModule();
rewriteReferences(path.scope.getBinding(localName), buildValue);
}
path.remove();
},
},
};
}
@@ -0,0 +1,163 @@
/* eslint-disable qunit/require-expect */
import { transformSync } from "@babel/core";
import { expect, test } from "vitest";
import BabelResolvePluginImports from "./babel-resolve-plugin-imports.js";
function compile(input) {
return transformSync(input, {
configFile: false,
plugins: [BabelResolvePluginImports],
}).code;
}
test("rewrites cross-plugin imports to the compatModules map", () => {
expect(
compile(`
import SharedThing, { somethingShared, other as renamed } from "discourse/plugins/other/lib/shared";
import * as Helpers from "discourse/plugins/other/lib/helpers";
SharedThing();
somethingShared(1);
renamed.property;
Helpers.doThing();
`)
).toMatchInlineSnapshot(`
"import _plugin_other from "discourse/plugins/other";
(0, (_plugin_other["lib/shared"] || _plugin_other["lib/shared/index"]).default)();
(0, (_plugin_other["lib/shared"] || _plugin_other["lib/shared/index"]).somethingShared)(1);
(_plugin_other["lib/shared"] || _plugin_other["lib/shared/index"]).other.property;
(_plugin_other["lib/helpers"] || _plugin_other["lib/helpers/index"]).doThing();"
`);
});
test("de-dupes the default import per plugin", () => {
expect(
compile(`
import { a } from "discourse/plugins/other/lib/one";
import { b } from "discourse/plugins/other/lib/two";
a();
b();
`)
).toMatchInlineSnapshot(`
"import _plugin_other from "discourse/plugins/other";
(0, (_plugin_other["lib/one"] || _plugin_other["lib/one/index"]).a)();
(0, (_plugin_other["lib/two"] || _plugin_other["lib/two/index"]).b)();"
`);
});
test("expands shorthand object properties inline", () => {
expect(
compile(`
import { foo, bar } from "discourse/plugins/other/lib/shared";
foo();
const obj = { bar };
`)
).toMatchInlineSnapshot(`
"import _plugin_other from "discourse/plugins/other";
(0, (_plugin_other["lib/shared"] || _plugin_other["lib/shared/index"]).foo)();
const obj = {
bar: (_plugin_other["lib/shared"] || _plugin_other["lib/shared/index"]).bar
};"
`);
});
test("throws when a cross-plugin import is re-exported", () => {
expect(() =>
compile(`
import { foo } from "discourse/plugins/other/lib/shared";
export { foo };
`)
).toThrow(/Re-exporting a cross-plugin import is not supported/);
});
test("imports are required by default, consolidating to the plain specifier", () => {
expect(
compile(`
import ChatChannel from "discourse/plugins/chat/models/chat-channel";
ChatChannel.create();
`)
).toMatchInlineSnapshot(`
"import _plugin_chat from "discourse/plugins/chat";
(_plugin_chat["models/chat-channel"] || _plugin_chat["models/chat-channel/index"]).default.create();"
`);
});
test('`discourseImport: "optional"` consolidates to the `?` specifier', () => {
expect(
compile(`
import ChatChannel from "discourse/plugins/chat/models/chat-channel" with { discourseImport: "optional" };
ChatChannel.create();
`)
).toMatchInlineSnapshot(`
"import _plugin_chat_optional from "discourse/plugins/chat?";
(_plugin_chat_optional["models/chat-channel"] || _plugin_chat_optional["models/chat-channel/index"]).default.create();"
`);
});
test("optional and required imports of the same plugin get separate specifiers", () => {
expect(
compile(`
import { a } from "discourse/plugins/chat/lib/one" with { discourseImport: "optional" };
import { b } from "discourse/plugins/chat/lib/two";
a();
b();
`)
).toMatchInlineSnapshot(`
"import _plugin_chat_optional from "discourse/plugins/chat?";
import _plugin_chat from "discourse/plugins/chat";
(0, (_plugin_chat_optional["lib/one"] || _plugin_chat_optional["lib/one/index"]).a)();
(0, (_plugin_chat["lib/two"] || _plugin_chat["lib/two/index"]).b)();"
`);
});
test('`discourseImport: "required"` consolidates to the plain specifier', () => {
expect(
compile(`
import ChatChannel from "discourse/plugins/chat/models/chat-channel" with { discourseImport: "required" };
ChatChannel.create();
`)
).toMatchInlineSnapshot(`
"import _plugin_chat from "discourse/plugins/chat";
(_plugin_chat["models/chat-channel"] || _plugin_chat["models/chat-channel/index"]).default.create();"
`);
});
test("recognises the quoted attribute key form", () => {
expect(
compile(`
import ChatChannel from "discourse/plugins/chat/models/chat-channel" with { "discourseImport": "required" };
ChatChannel.create();
`)
).toMatchInlineSnapshot(`
"import _plugin_chat from "discourse/plugins/chat";
(_plugin_chat["models/chat-channel"] || _plugin_chat["models/chat-channel/index"]).default.create();"
`);
});
test("throws on an unknown `discourseImport` value", () => {
expect(() =>
compile(`
import ChatChannel from "discourse/plugins/chat/models/chat-channel" with { discourseImport: "maybe" };
ChatChannel.create();
`)
).toThrow(/Invalid `discourseImport` import attribute "maybe"/);
});
test("leaves relative and core imports untouched", () => {
expect(
compile(`
import sibling from "./sibling";
import concatClass from "discourse/helpers/concat-class";
`)
).toMatchInlineSnapshot(`
"import sibling from "./sibling";
import concatClass from "discourse/helpers/concat-class";"
`);
});
@@ -0,0 +1,14 @@
// Reads and validates the `with { discourseImport: ... }` attribute on an
// import declaration, returning "optional", "required", or undefined.
export function readDiscourseImportMode(path) {
const attribute = (path.node.attributes ?? []).find(
(a) => (a.key.name ?? a.key.value) === "discourseImport"
);
const mode = attribute?.value.value;
if (![undefined, "optional", "required"].includes(mode)) {
throw path.buildCodeFrameError(
`Invalid \`discourseImport\` import attribute "${mode}". Allowed values are "optional" and "required".`
);
}
return mode;
}
@@ -1,8 +0,0 @@
export function federatedExportNameFor(moduleName, exportedName) {
if (exportedName === "*") {
exportedName = "__module";
}
return (
moduleName.replaceAll("/", "$").replaceAll("-", "__") + "$$" + exportedName
);
}
@@ -1,14 +1,16 @@
export default function discourseFileSearch() {
return {
name: "discourse-file-search",
async resolveId(source, context) {
async resolveId(source, context, options) {
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);
const resolved = await this.resolve(`${source}${ext}`, context, {
attributes: options.attributes,
});
if (resolved) {
return resolved;
@@ -19,6 +21,7 @@ export default function discourseFileSearch() {
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
attributes: options.attributes,
});
if (resolved) {
return resolved;
@@ -1,19 +1,14 @@
import { federatedExportNameFor } from "./federated-modules-helper";
const SUPPORTED_FILE_EXTENSIONS = [".js", ".js.es6", ".hbs", ".gjs"];
const IS_CONNECTOR_REGEX = /(^|\/)connectors\//;
export default {
"virtual:entrypoint": async (
moduleFilenames,
{ themeId, pluginName },
{ basePath, context }
) => {
"virtual:entrypoint": (moduleFilenames, { themeId, pluginName }) => {
const label = pluginName ? `PLUGIN ${pluginName}` : `THEME ${themeId}`;
let output = `const compatModules = {};`;
const imports = [];
const entries = [];
const warnings = [];
const moduleFilenamesSet = new Set(moduleFilenames);
const exportedModules = new Set();
let i = 1;
@@ -22,7 +17,9 @@ export default {
!SUPPORTED_FILE_EXTENSIONS.some((ext) => moduleFilename.endsWith(ext))
) {
// Unsupported file type. Log a warning and skip
output += `console.warn("[${label}] Unsupported file type: ${moduleFilename}");\n`;
warnings.push(
`console.warn("[${label}] Unsupported file type: ${moduleFilename}");`
);
continue;
}
@@ -60,42 +57,21 @@ export default {
}
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`;
imports.push(`import * as Mod${i} from "./${importPath}";`);
entries.push(` "${compatModuleName}": Mod${i},`);
i += 1;
}
output += "export default compatModules;\n";
return output;
return [
...imports,
...warnings,
"const compatModules = {",
...entries,
"};",
"export default compatModules;",
"",
].join("\n");
},
"virtual:theme": ({ themeId }) => {
return cleanMultiline(`
+4 -1
View File
@@ -49,7 +49,10 @@ const _pluginCallbacks = [];
let _unhandledThemeErrors = [];
window.moduleBroker = {
lookup(moduleName) {
lookup(moduleName, optional = false) {
if (optional && !require.has(moduleName)) {
return {};
}
return require(moduleName);
},
};
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class AssetProcessor
BASE_COMPILER_VERSION = 111
BASE_COMPILER_VERSION = 112
PROCESSOR_DIR = "tmp/asset-processor"
LOCK_FILE = "#{PROCESSOR_DIR}/build.lock"
+6
View File
@@ -474,6 +474,8 @@ module Discourse
plugin: plugin,
type_module: true,
importmap_name: "discourse/plugins/#{plugin.name}",
external_plugin_imports:
Plugin::JsManager.external_plugin_imports(plugin.directory_name, "main"),
}
end
end
@@ -494,6 +496,8 @@ module Discourse
imports: Plugin::JsManager.import_paths_for(plugin.directory_name, "admin"),
plugin: plugin,
type_module: true,
external_plugin_imports:
Plugin::JsManager.external_plugin_imports(plugin.directory_name, "admin"),
}
end
end
@@ -506,6 +510,8 @@ module Discourse
imports: Plugin::JsManager.import_paths_for(plugin.directory_name, "test"),
plugin: plugin,
type_module: true,
external_plugin_imports:
Plugin::JsManager.external_plugin_imports(plugin.directory_name, "test"),
}
end
end
+21 -1
View File
@@ -4,6 +4,18 @@ module Plugin
class JsManager
@cache = {}
def self.optional_plugin_stub
"data:text/javascript,/* autogenerated missing optional plugin stub */const m=new Proxy({},{get:()=>null});export default new Proxy({},{get:()=>m});"
end
def self.required_plugin_stub(plugin_name)
message =
"Plugin '#{plugin_name}' is imported by another bundle, but it is not installed on this site. " \
"If this dependency is optional, import it with `with { discourseImport: \"optional\" }`."
"data:text/javascript, /* autogenerated missing required plugin stub */ export default null; throw new Error(#{message.to_json});"
end
def self.js_asset_exists?(plugin_directory_name)
maybe_cache("js_asset_exists_#{plugin_directory_name}") do
has_source_files_in_dir(plugin_directory_name, "assets/javascripts")
@@ -43,6 +55,10 @@ module Plugin
end
end
def self.external_plugin_imports(plugin_directory_name, entrypoint_name)
read_manifest(plugin_directory_name)[entrypoint_name]["externalPluginImports"]
end
def compile!
log "Compiling #{Discourse.plugins.count} plugins..."
start = Time.now
@@ -151,7 +167,11 @@ module Plugin
File.write("#{map_dir}/#{file_name}.map", info["map"]) if info["map"]
if info["isEntry"]
manifest[info["name"]] = { fileName: file_name, imports: info["imports"] }
manifest[info["name"]] = {
fileName: file_name,
imports: info["imports"],
externalPluginImports: info["externalPluginImports"],
}
end
end
+7
View File
@@ -52,11 +52,13 @@ class ThemeJavascriptCompiler
main = output.values.find { |chunk| chunk["name"] == "main" }
@content = main["code"]
@source_map = main["map"]
@external_plugin_imports = main["externalPluginImports"] || []
end
[@content, @source_map]
rescue AssetProcessor::TranspileError => e
message = "[THEME #{@theme_id} '#{@theme_name}'] Compile error: #{e.message}"
@content = "throw new Error(#{message.to_json});\n"
@external_plugin_imports = []
[@content, @source_map]
end
@@ -70,6 +72,11 @@ class ThemeJavascriptCompiler
@source_map
end
def external_plugin_imports
compile!
@external_plugin_imports
end
def append_tree(tree)
@input_tree.merge!(tree)
end
@@ -35,7 +35,6 @@ import {
initUserStatusHtml,
renderUserStatusHtml,
} from "discourse/lib/user-status-on-autocomplete";
import { optionalRequire } from "discourse/lib/utilities";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
import { waitForClosedKeyboard } from "discourse/lib/wait-for-keyboard";
import forceScrollingElementPosition from "discourse/modifiers/force-scrolling-element-position";
@@ -53,6 +52,9 @@ import ChatReplyingIndicator from "discourse/plugins/chat/discourse/components/c
import { chatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
import TextareaInteractor from "discourse/plugins/chat/discourse/lib/textarea-interactor";
import LocalDatesCreateModal from "discourse/plugins/discourse-local-dates/discourse/components/modal/local-dates-create" with {
discourseImport: "optional",
};
const CHAT_PRESENCE_KEEP_ALIVE = 5 * 1000; // 5 seconds
@@ -217,10 +219,6 @@ export default class ChatComposer extends Component {
@action
insertDiscourseLocalDate() {
const LocalDatesCreateModal = optionalRequire(
"discourse/plugins/discourse-local-dates/discourse/components/modal/local-dates-create"
);
this.modal.show(LocalDatesCreateModal, {
model: {
insertDate: (markup) => {
@@ -5,12 +5,18 @@ import { trustHTML } from "@ember/template";
import { modifier } from "ember-modifier";
import domFromString from "discourse/lib/dom-from-string";
import applyLightbox from "discourse/lib/lightbox";
import { escapeExpression, optionalRequire } from "discourse/lib/utilities";
import { escapeExpression } from "discourse/lib/utilities";
import { and } from "discourse/truth-helpers";
import DDecoratedHtml from "discourse/ui-kit/d-decorated-html";
import { i18n } from "discourse-i18n";
import ChatUpload from "discourse/plugins/chat/discourse/components/chat-upload";
import Collapser from "discourse/plugins/chat/discourse/components/collapser";
import LazyVideo from "discourse/plugins/discourse-lazy-videos/discourse/components/lazy-video" with {
discourseImport: "optional",
};
import getVideoAttributes from "discourse/plugins/discourse-lazy-videos/lib/lazy-video-attributes" with {
discourseImport: "optional",
};
export default class ChatMessageCollapser extends Component {
@service siteSettings;
@@ -86,17 +92,7 @@ export default class ChatMessageCollapser extends Component {
return [];
}
get lazyVideoComponent() {
return optionalRequire(
"discourse/plugins/discourse-lazy-videos/discourse/components/lazy-video"
);
}
lazyVideoCooked(elements) {
const getVideoAttributes = optionalRequire(
"discourse/plugins/discourse-lazy-videos/lib/lazy-video-attributes"
);
return elements.reduce((acc, e) => {
if (this.siteSettings.lazy_videos_enabled && lazyVideoPredicate(e)) {
const videoAttributes = getVideoAttributes(e);
@@ -208,11 +204,9 @@ export default class ChatMessageCollapser extends Component {
@header={{cooked.header}}
@onToggle={{@onToggleCollapse}}
>
{{#if (and cooked.videoAttributes this.lazyVideoComponent)}}
{{#if (and cooked.videoAttributes LazyVideo)}}
<div class="chat-message-collapser-lazy-video">
<this.lazyVideoComponent
@videoAttributes={{cooked.videoAttributes}}
/>
<LazyVideo @videoAttributes={{cooked.videoAttributes}} />
</div>
{{else}}
<DDecoratedHtml
@@ -3,23 +3,21 @@ import { cached } from "@glimmer/tracking";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatComposerMessageDetails from "discourse/plugins/chat/discourse/components/chat-composer-message-details";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const StyleguideComponent = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/component"
);
const Controls = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls"
);
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import StyleguideComponent from "discourse/plugins/styleguide/discourse/components/styleguide/component" with {
discourseImport: "optional",
};
import Controls from "discourse/plugins/styleguide/discourse/components/styleguide/controls" with {
discourseImport: "optional",
};
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatComposerMessageDetails extends Component {
@service currentUser;
@@ -3,24 +3,22 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DToggleSwitch from "discourse/ui-kit/d-toggle-switch";
import Channel from "discourse/plugins/chat/discourse/components/chat/composer/channel";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel";
const StyleguideComponent = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/component"
);
const Controls = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls"
);
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import StyleguideComponent from "discourse/plugins/styleguide/discourse/components/styleguide/component" with {
discourseImport: "optional",
};
import Controls from "discourse/plugins/styleguide/discourse/components/styleguide/controls" with {
discourseImport: "optional",
};
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatComposer extends Component {
@service chatChannelComposer;
@@ -2,7 +2,6 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { optionalRequire } from "discourse/lib/utilities";
import ComboBox from "discourse/select-kit/components/combo-box";
import DToggleSwitch from "discourse/ui-kit/d-toggle-switch";
import Icon from "discourse/plugins/chat/discourse/components/chat/header/icon";
@@ -12,19 +11,18 @@ import {
HEADER_INDICATOR_PREFERENCE_NEVER,
HEADER_INDICATOR_PREFERENCE_ONLY_MENTIONS,
} from "discourse/plugins/chat/discourse/lib/chat-constants";
const StyleguideComponent = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/component"
);
const Controls = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls"
);
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import StyleguideComponent from "discourse/plugins/styleguide/discourse/components/styleguide/component" with {
discourseImport: "optional",
};
import Controls from "discourse/plugins/styleguide/discourse/components/styleguide/controls" with {
discourseImport: "optional",
};
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatHeaderIcon extends Component {
@tracked isActive = false;
@@ -3,25 +3,23 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import { not } from "discourse/truth-helpers";
import DToggleSwitch from "discourse/ui-kit/d-toggle-switch";
import ChatMessage from "discourse/plugins/chat/discourse/components/chat-message";
import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const StyleguideComponent = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/component"
);
const Controls = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls"
);
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import StyleguideComponent from "discourse/plugins/styleguide/discourse/components/styleguide/component" with {
discourseImport: "optional",
};
import Controls from "discourse/plugins/styleguide/discourse/components/styleguide/controls" with {
discourseImport: "optional",
};
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatMessage extends Component {
@service currentUser;
@@ -2,17 +2,15 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalArchiveChannel from "discourse/plugins/chat/discourse/components/chat/modal/archive-channel";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalArchiveChannel extends Component {
@service modal;
@@ -1,16 +1,14 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalCreateChannel from "discourse/plugins/chat/discourse/components/chat/modal/create-channel";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalCreateChannel extends Component {
@service modal;
@@ -2,17 +2,15 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalDeleteChannel from "discourse/plugins/chat/discourse/components/chat/modal/delete-channel";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalDeleteChannel extends Component {
@service modal;
@@ -2,17 +2,15 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalEditChannelDescription from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-description";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalEditChannelDescription extends Component {
@service modal;
@@ -2,17 +2,15 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalEditChannelName extends Component {
@service modal;
@@ -2,17 +2,15 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalMoveMessageToChannel from "discourse/plugins/chat/discourse/components/chat/modal/move-message-to-channel";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalMoveMessageToChannel extends Component {
@service modal;
@@ -1,16 +1,14 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalNewMessage extends Component {
@service modal;
@@ -2,17 +2,15 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalThreadSettings from "discourse/plugins/chat/discourse/components/chat/modal/thread-settings";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalThreadSettings extends Component {
@service modal;
@@ -2,17 +2,15 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { optionalRequire } from "discourse/lib/utilities";
import DButton from "discourse/ui-kit/d-button";
import ChatModalToggleChannelStatus from "discourse/plugins/chat/discourse/components/chat/modal/toggle-channel-status";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const Row = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/controls/row"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import Row from "discourse/plugins/styleguide/discourse/components/styleguide/controls/row" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatModalToggleChannelStatus extends Component {
@service modal;
@@ -2,16 +2,14 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/owner";
import { next } from "@ember/runloop";
import { optionalRequire } from "discourse/lib/utilities";
import Item from "discourse/plugins/chat/discourse/components/chat/thread-list/item";
import ChatFabricators from "discourse/plugins/chat/discourse/lib/fabricators";
const StyleguideComponent = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide/component"
);
const StyleguideExample = optionalRequire(
"discourse/plugins/styleguide/discourse/components/styleguide-example"
);
import StyleguideComponent from "discourse/plugins/styleguide/discourse/components/styleguide/component" with {
discourseImport: "optional",
};
import StyleguideExample from "discourse/plugins/styleguide/discourse/components/styleguide-example" with {
discourseImport: "optional",
};
export default class ChatStyleguideChatThreadListItem extends Component {
@tracked thread;
@@ -1,6 +1,8 @@
import { applyLocalDates } from "discourse/lib/local-dates";
import { withPluginApi } from "discourse/lib/plugin-api";
import { optionalRequire } from "discourse/lib/utilities";
import applySpoiler from "discourse/plugins/spoiler-alert/lib/apply-spoiler" with {
discourseImport: "optional",
};
export default {
name: "chat-plugin-decorators",
@@ -19,10 +21,6 @@ export default {
);
if (siteSettings.spoiler_enabled) {
const applySpoiler = optionalRequire(
"discourse/plugins/spoiler-alert/lib/apply-spoiler"
);
api.decorateChatMessage(
(element) => {
element.querySelectorAll(".spoiler").forEach((spoiler) => {
@@ -5,8 +5,13 @@ import { LinkTo } from "@ember/routing";
import ReviewableCreatedBy from "discourse/components/reviewable/created-by";
import ReviewableTopicLink from "discourse/components/reviewable/topic-link";
import highlightWatchedWords from "discourse/lib/highlight-watched-words";
import { optionalRequire } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title" with {
discourseImport: "optional",
};
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel" with {
discourseImport: "optional",
};
import ModelAccuracies from "../model-accuracies";
export default class ReviewableRefreshAiChatMessage extends Component {
@@ -16,10 +21,6 @@ export default class ReviewableRefreshAiChatMessage extends Component {
return;
}
const ChatChannel = optionalRequire(
"discourse/plugins/chat/discourse/models/chat-channel"
);
return ChatChannel.create(this.args.reviewable.chat_channel);
}
@@ -30,12 +31,6 @@ export default class ReviewableRefreshAiChatMessage extends Component {
);
}
get ChannelTitle() {
return optionalRequire(
"discourse/plugins/chat/discourse/components/channel-title"
);
}
<template>
<div class="review-item__meta-content">
<div class="review-item__meta-label">{{i18n
@@ -52,7 +47,7 @@ export default class ReviewableRefreshAiChatMessage extends Component {
@reviewable.target_id
}}
>
<this.ChannelTitle @channel={{this.channel}} />
<ChannelTitle @channel={{this.channel}} />
</LinkTo>
{{else}}
<ReviewableTopicLink @reviewable={{@reviewable}} />
@@ -1,27 +1,20 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { optionalRequire } from "discourse/lib/utilities";
import { and } from "discourse/truth-helpers";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title" with {
discourseImport: "optional",
};
export default class DiscoursePostEventChatChannel extends Component {
get channelTitle() {
return optionalRequire(
"discourse/plugins/chat/discourse/components/channel-title"
);
}
<template>
{{#if (and @event.channel this.channelTitle)}}
<section class="event__section event-chat-channel">
<span></span>
<LinkTo
@route="chat.channel"
@models={{@event.channel.routeModels}}
class="chat-channel-link"
>
<this.channelTitle @channel={{@event.channel}} />
</LinkTo>
</section>
{{/if}}
</template>
}
<template>
{{#if (and @event.channel ChannelTitle)}}
<section class="event__section event-chat-channel">
<span></span>
<LinkTo
@route="chat.channel"
@models={{@event.channel.routeModels}}
class="chat-channel-link"
>
<ChannelTitle @channel={{@event.channel}} />
</LinkTo>
</section>
{{/if}}
</template>
@@ -4,10 +4,12 @@ import { array } from "@ember/helper";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import { bind } from "discourse/lib/decorators";
import { optionalRequire } from "discourse/lib/utilities";
import { and } from "discourse/truth-helpers";
import DButton from "discourse/ui-kit/d-button";
import dConcatClass from "discourse/ui-kit/helpers/d-concat-class";
import ChatChannel from "discourse/plugins/chat/discourse/components/chat-channel" with {
discourseImport: "optional",
};
export default class EmbedableChatChannel extends Component {
@service chatChannelsManager;
@@ -17,13 +19,6 @@ export default class EmbedableChatChannel extends Component {
@tracked activeChannel;
// Resolved at runtime rather than statically imported: cross-plugin static
// imports aren't resolvable in the compiled plugin bundle and break the whole
// bundle load.
chatChannelComponent = optionalRequire(
"discourse/plugins/chat/discourse/components/chat-channel"
);
updateChannel = modifier(async () => {
if (this.args.chatChannelId === this.activeChannel?.id) {
return;
@@ -80,9 +75,9 @@ export default class EmbedableChatChannel extends Component {
</div>
{{/unless}}
<div class="chat-drawer">
{{#if (and this.activeChannel this.chatChannelComponent)}}
{{#if (and this.activeChannel ChatChannel)}}
{{#each (array this.activeChannel) as |channel|}}
<this.chatChannelComponent @channel={{channel}} />
<ChatChannel @channel={{channel}} />
{{/each}}
{{/if}}
</div>
@@ -2,8 +2,10 @@ import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object";
import { trackedArray } from "@ember/reactive/collections";
import { bind } from "discourse/lib/decorators";
import { optionalRequire } from "discourse/lib/utilities";
import User from "discourse/models/user";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel" with {
discourseImport: "optional",
};
import DiscoursePostEventEventStats from "./discourse-post-event-event-stats";
import DiscoursePostEventInvitee from "./discourse-post-event-invitee";
@@ -61,10 +63,6 @@ export default class DiscoursePostEventEvent {
@tracked _reminders;
constructor(args = {}) {
const ChatChannel = optionalRequire(
"discourse/plugins/chat/discourse/models/chat-channel"
);
this.id = args.id;
this.rrule = args.rrule;
this.name = args.name;
@@ -12,7 +12,6 @@ import { removeValueFromArray } from "discourse/lib/array-tools";
import { AUTO_GROUPS } from "discourse/lib/constants";
import { bind } from "discourse/lib/decorators";
import { autoTrackedArray } from "discourse/lib/tracked-tools";
import { optionalRequire } from "discourse/lib/utilities";
import ComboBox from "discourse/select-kit/components/combo-box";
import GroupChooser from "discourse/select-kit/components/group-chooser";
import { and, not } from "discourse/truth-helpers";
@@ -26,6 +25,9 @@ import dConcatClass from "discourse/ui-kit/helpers/d-concat-class";
import dIcon from "discourse/ui-kit/helpers/d-icon";
import dAutoFocus from "discourse/ui-kit/modifiers/d-auto-focus";
import { i18n } from "discourse-i18n";
import generateCurrentDateMarkup from "discourse/plugins/discourse-local-dates/lib/generate-current-date-markup" with {
discourseImport: "optional",
};
export const BAR_CHART_TYPE = "bar";
export const PIE_CHART_TYPE = "pie";
@@ -377,10 +379,6 @@ export default class PollUiBuilderModal extends Component {
(event.metaKey || event.ctrlKey) &&
this.siteSettings.discourse_local_dates_enabled
) {
const generateCurrentDateMarkup = optionalRequire(
"discourse/plugins/discourse-local-dates/lib/generate-current-date-markup"
);
if (!generateCurrentDateMarkup) {
return;
}
+26 -4
View File
@@ -214,6 +214,30 @@ RSpec.describe AssetProcessor do
expect(code).to include("keep")
end
it "preserves optionality of cross-plugin imports" do
script = <<~JS.chomp
import Example from "discourse/plugins/styleguide/discourse/components/example" with { discourseImport: "optional" };
console.log(Example);
JS
result =
AssetProcessor.new.rollup(
{ "discourse/components/foo.js" => script },
{
pluginName: "chat",
entrypoints: {
main: {
modules: ["discourse/components/foo.js"],
},
},
},
)
code = entrypoint(result, "main")["code"]
expect(code).to include('"discourse/plugins/styleguide?"')
expect(code).not_to include('"discourse/plugins/styleguide"')
end
it "can use themePrefix not in a template" do
script = <<~JS.chomp
export default function foo() {
@@ -325,11 +349,9 @@ RSpec.describe AssetProcessor do
)
expect(entrypoint(result, "main")["code"]).to include(
'compatModules["discourse/templates/connectors/foo"]',
).once
expect(entrypoint(result, "main")["code"]).to include(
'compatModules["discourse/connectors/foo"]',
'"discourse/templates/connectors/foo":',
).once
expect(entrypoint(result, "main")["code"]).to include('"discourse/connectors/foo":').once
end
it "handles relative imports from one module to another" do
+10 -16
View File
@@ -9,11 +9,9 @@ RSpec.describe ThemeJavascriptCompiler do
compiler.append_tree({ "connectors/blah-2.hbs" => "{{var}}" })
compiler.append_tree({ "javascripts/connectors/blah-3.hbs" => "{{var}}" })
expect(compiler.content.to_s).to include("compatModules[\"templates/connectors/blah-1\"]")
expect(compiler.content.to_s).to include("compatModules[\"templates/connectors/blah-2\"]")
expect(compiler.content.to_s).to include(
"compatModules[\"javascripts/templates/connectors/blah-3\"]",
)
expect(compiler.content.to_s).to include("\"templates/connectors/blah-1\":")
expect(compiler.content.to_s).to include("\"templates/connectors/blah-2\":")
expect(compiler.content.to_s).to include("\"javascripts/templates/connectors/blah-3\":")
end
end
@@ -27,7 +25,7 @@ RSpec.describe ThemeJavascriptCompiler do
"connectors/outlet/blah-1.js" => "export default class MyComponent {};",
},
)
expect(compiler.content.to_s).to include('compatModules["connectors/outlet/blah-1"]').once
expect(compiler.content.to_s).to include('"connectors/outlet/blah-1":').once
expect(compiler.content.to_s).to include("templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).not_to include("setComponentTemplate")
expect(compiler.content.to_s).to include("createTemplateFactory")
@@ -44,7 +42,7 @@ RSpec.describe ThemeJavascriptCompiler do
"templates/connectors/outlet/blah-1.js" => "export default {};",
},
)
expect(compiler.content.to_s).to include('compatModules["connectors/outlet/blah-1"]').once
expect(compiler.content.to_s).to include('"connectors/outlet/blah-1":').once
expect(compiler.content.to_s).to include("templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).not_to include("setComponentTemplate")
expect(compiler.content.to_s).to include("createTemplateFactory")
@@ -61,7 +59,7 @@ RSpec.describe ThemeJavascriptCompiler do
"connectors/outlet/blah-1.js" => "export default {};",
},
)
expect(compiler.content.to_s).to include('compatModules["connectors/outlet/blah-1"]').once
expect(compiler.content.to_s).to include('"connectors/outlet/blah-1":').once
expect(compiler.content.to_s).to include("templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).not_to include("setComponentTemplate")
expect(compiler.content.to_s).to include("createTemplateFactory")
@@ -78,9 +76,7 @@ RSpec.describe ThemeJavascriptCompiler do
"discourse/connectors/outlet/blah-1.js" => "export default {};",
},
)
expect(compiler.content.to_s).to include(
'compatModules["discourse/connectors/outlet/blah-1"]',
).once
expect(compiler.content.to_s).to include('"discourse/connectors/outlet/blah-1":').once
expect(compiler.content.to_s).to include("discourse/templates/connectors/outlet/blah-1")
expect(compiler.content.to_s).not_to include("setComponentTemplate")
expect(JSON.parse(compiler.source_map)["sources"]).to include(
@@ -117,10 +113,8 @@ RSpec.describe ThemeJavascriptCompiler do
"discourse/templates/components/mycomponent.hbs" => "{{my-component-template}}",
},
)
expect(compiler.content).to include('compatModules["discourse/components/mycomponent"]')
expect(compiler.content).to include(
'compatModules["discourse/templates/components/mycomponent"]',
)
expect(compiler.content).to include('"discourse/components/mycomponent":')
expect(compiler.content).to include('"discourse/templates/components/mycomponent":')
end
it "handles colocated components" do
@@ -237,7 +231,7 @@ RSpec.describe ThemeJavascriptCompiler do
}
JS
expect(compiler.content).to include("compatModules[\"discourse/components/my-component\"]")
expect(compiler.content).to include("\"discourse/components/my-component\":")
expect(compiler.content).to include('value = "foo";')
expect(compiler.content).to include("setComponentTemplate")
expect(compiler.content).to include("createTemplateFactory")
+3 -9
View File
@@ -215,15 +215,9 @@ RSpec.describe ThemeField do
expect(js_field.value_baked).to eq("baked")
# All together
expect(theme.javascript_cache.content).to include(
'compatModules["discourse/templates/discovery"]',
)
expect(theme.javascript_cache.content).to include(
'compatModules["discourse/controllers/discovery"]',
)
expect(theme.javascript_cache.content).to include(
'compatModules["discourse/controllers/discovery-2"]',
)
expect(theme.javascript_cache.content).to include('"discourse/templates/discovery":')
expect(theme.javascript_cache.content).to include('"discourse/controllers/discovery":')
expect(theme.javascript_cache.content).to include('"discourse/controllers/discovery-2":')
expect(theme.javascript_cache.content).to include(
"[THEME #{theme.id}] Unsupported file type: discourse/controllers/discovery.blah",
)
+25 -2
View File
@@ -329,6 +329,29 @@ RSpec.describe Theme do
expect(theme.reload.cached_settings).to include(name: "bill")
end
it "records the plugins a theme statically imports from" do
theme.set_field(target: :extra_js, name: "discourse/initializers/my-init.js", value: <<~JS)
import Thing from "discourse/plugins/some-plugin/lib/thing";
export default { name: "test", initialize() { Thing(); } };
JS
theme.save!
expect(theme.reload.javascript_cache.external_plugin_imports).to eq(["some-plugin"])
end
it "exposes baked extra_js as javascript cache info" do
theme.set_field(target: :extra_js, name: "discourse/initializers/my-init.js", value: <<~JS)
import Thing from "discourse/plugins/some-plugin/lib/thing";
export default { name: "test", initialize() { Thing(); } };
JS
theme.save!
cache = theme.reload.javascript_cache
expect(Theme.js_asset_info(theme.id)).to eq(
[{ url: cache.url, theme_id: theme.id, external_plugin_imports: ["some-plugin"] }],
)
end
it "is empty when the settings are invalid" do
theme.set_field(target: :settings, name: :yaml, value: "nil_setting: ")
theme.save!
@@ -716,13 +739,13 @@ RSpec.describe Theme do
child.save!
first_common_value = Theme.lookup_field(child.id, :desktop, "header")
first_extra_js_value = Theme.lookup_field(child.id, :extra_js, nil)
first_extra_js_value = Theme.js_asset_info(child.id)
Theme
.stubs(:compiler_version)
.returns("SOME_NEW_HASH") do
second_common_value = Theme.lookup_field(child.id, :desktop, "header")
second_extra_js_value = Theme.lookup_field(child.id, :extra_js, nil)
second_extra_js_value = Theme.js_asset_info(child.id)
new_common_compiler_version =
ThemeField.find_by(theme_id: child.id, name: "header").compiler_version
+35
View File
@@ -0,0 +1,35 @@
# frozen_string_literal: true
RSpec.describe "Theme cross-bundle plugin imports" do
fab!(:theme)
before { Fabricate(:admin) } # so "/" renders the app instead of the install wizard
def import_map
map = response.body[%r{<script type="importmap"[^>]*>(.*?)</script>}m, 1]
JSON.parse(map)["imports"]
end
it "stubs imports of an absent plugin: null for optional, throwing for required" do
theme.set_field(target: :extra_js, name: "discourse/initializers/cross-bundle.js", value: <<~JS)
import Optional from "discourse/plugins/absent-optional-plugin/lib/thing" with { discourseImport: "optional" };
import Required from "discourse/plugins/absent-required-plugin/lib/thing" with { discourseImport: "required" };
export default { name: "cross-bundle", initialize() { Optional(); Required(); } };
JS
theme.save!
SiteSetting.default_theme_id = theme.id
get "/"
expect(response.status).to eq(200)
# Optional import of a missing plugin resolves to a null-returning stub.
expect(import_map["discourse/plugins/absent-optional-plugin?"]).to eq(
Plugin::JsManager.optional_plugin_stub,
)
# Required import of a missing plugin resolves to a stub that throws on import.
expect(import_map["discourse/plugins/absent-required-plugin"]).to eq(
Plugin::JsManager.required_plugin_stub("absent-required-plugin"),
)
end
end