0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 01:57:29 +08:00
discourse/frontend/asset-processor/babel-resolve-plugin-imports.test.mjs
David Taylor de4c1da901 DEV: Let cross-plugin imports opt into being required via with { discoursePlugin: "required" }
Cross-plugin imports (`discourse/plugins/<name>/...`) resolve to `null` when the target plugin isn't installed. That's the right default for soft dependencies, but it silently hides genuine hard dependencies. This adds a `discoursePlugin` import attribute so a bundle can opt a specific import into being required: `import X from "discourse/plugins/<name>/..." with { discoursePlugin: "required" }`. Omitting the attribute (or passing `discoursePlugin: "optional"`) keeps the existing optional behaviour.

`discourse-external-loader` inspects the attribute in `resolveId` and, for everything except `discoursePlugin: "required"`, tags the external id with an `?optional` marker, so the optionality travels through rollup's module graph (and therefore `chunk.imports`) without a side channel. `babel-resolve-plugin-imports` reads the marker back off the import source and consolidates each plugin import to `discourse/plugins/<name>?` (optional) or `discourse/plugins/<name>` (required), keeping the existing live-binding use-site rewrite that the cross-plugin import loops rely on.

The import map then resolves both specifiers. For a loaded plugin, `discourse/plugins/<name>?` is aliased to the plugin's real module (the required `discourse/plugins/<name>` already maps to it). For a missing plugin, `discourse/plugins/<name>?` points at a null-returning stub, and `discourse/plugins/<name>` points at a module that declares a default export (so the importer still links) and then throws on evaluation with a helpful message, surfacing the missing hard dependency at boot instead of a cryptic module-resolution error. Generation is pinned to `importAttributesKey: "with"` so rollup emits modern import-attribute syntax.

Spike scope: because optional is the default, the cross-plugin call sites the parent commit converted keep working unchanged, and only genuine hard dependencies need the new attribute. Ruby specs for the import-map behaviour and a full mini_racer build verification are still outstanding.
2026-06-23 10:44:16 +00:00

117 lines
3.8 KiB
JavaScript
Vendored

/* 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("rewrites an optional import to a `discourse/plugins/<name>?` specifier", () => {
// After discourse-external-loader, an optional import arrives with an
// `?optional` marker on its id (and the original attribute still attached).
expect(
compile(`
import ChatChannel from "discourse/plugins/chat/models/chat-channel?optional" with { discoursePlugin: "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?optional" with { discoursePlugin: "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("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";"
`);
});