0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-04 10:39:43 +08:00
discourse/frontend/asset-processor/babel-resolve-plugin-imports.js
David Taylor e3054ef590
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>
2026-06-30 16:11:38 +01:00

132 lines
4 KiB
JavaScript
Vendored

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();
},
},
};
}