mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
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:
co-authored by
Jarek Radosz
parent
9014e684a5
commit
e3054ef590
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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(`
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-14
@@ -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;
|
||||
|
||||
+12
-14
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
+6
-8
@@ -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) => {
|
||||
|
||||
+7
-12
@@ -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}} />
|
||||
|
||||
+17
-24
@@ -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>
|
||||
|
||||
+5
-10
@@ -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>
|
||||
|
||||
+3
-5
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user