diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 360a89639..9f0f0e331 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -188,7 +188,6 @@ module.exports = { "**/*.cjs", "scripts/tools/eslint-plugin-prettier-internal-rules/**/*.js", "website/**/*.js", - "tests/integration/plugins/automatic/prettier-plugin-bar.js", ], parserOptions: { sourceType: "script", diff --git a/changelog_unreleased/api/13201.md b/changelog_unreleased/api/13201.md new file mode 100644 index 000000000..31ad74cdc --- /dev/null +++ b/changelog_unreleased/api/13201.md @@ -0,0 +1,15 @@ +#### [BREAKING] Support plugins in ESM (#13201 by @fisker) + +Since v3.0.0, we load plugins via `import()` instead of `require()`, plugins can be ESM modules now. + +If you use `--plugin` by directory path, or file path without extensions, the plugin may not able to load. + +```diff +- prettier . --plugin=path/to/my-plugin-directory ++ prettier . --plugin=path/to/my-plugin-directory/index.js +``` + +```diff +- prettier . --plugin=path/to/my-plugin-file ++ prettier . --plugin=path/to/my-plugin-file.js +``` diff --git a/docs/plugins.md b/docs/plugins.md index 8c2a7ff15..1aadd5f91 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -24,7 +24,7 @@ When plugins cannot be found automatically, you can load them with: - The [API](api.md), via the `pluginSearchDirs` and `plugins` options: ```js - prettier.format("code", { + await prettier.format("code", { parser: "foo", pluginSearchDirs: ["./dir-with-plugins"], plugins: ["prettier-plugin-foo"], @@ -74,7 +74,7 @@ To turn off plugin autoloading, use `--no-plugin-search` when using Prettier CLI ## Developing Plugins -Prettier plugins are regular JavaScript modules with five exports: +Prettier plugins are regular JavaScript modules with the following five exports or default export with the following properties: - `languages` - `parsers` @@ -178,7 +178,9 @@ export const printers = { Prettier uses an intermediate representation, called a Doc, which Prettier then turns into a string (based on options like `printWidth`). A _printer_'s job is to take the AST generated by `parsers[].parse` and return a Doc. A Doc is constructed using [builder commands](https://github.com/prettier/prettier/blob/main/commands.md): ```js -const { join, line, ifBreak, group } = require("prettier").doc.builders; +import { doc } from "prettier"; + +const { join, line, ifBreak, group } = doc.builders; ``` The printing process works as follows: @@ -367,7 +369,7 @@ At the time of dispatching, Prettier will have annotated each AST comment node ( The `util.addTrailingComment`/`addLeadingComment`/`addDanglingComment` functions can be used to manually attach a comment to an AST node. An example `ownLine` function that ensures a comment does not follow a "punctuation" node (made up for demonstration purposes) might look like: ```js -const { util } = require("prettier"); +import { util } from "prettier"; function ownLine(comment, text, options, ast, isLastComment) { const { precedingNode } = comment; diff --git a/src/common/load-plugins.js b/src/common/load-plugins.js index 37f7eb180..318245f2f 100644 --- a/src/common/load-plugins.js +++ b/src/common/load-plugins.js @@ -1,14 +1,20 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import fastGlob from "fast-glob"; import mem, { memClear } from "mem"; import partition from "../utils/partition.js"; +import importFromDirectory from "../utils/import-from-directory.js"; import thirdParty from "./third-party.js"; -import requireFrom from "./require-from.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const importPlugin = async (name, directory) => { + const module = await importFromDirectory(name, directory); + const plugin = module.default ?? module; + return { name, ...plugin }; +}; + const memoizedLoad = mem(load, { cacheKey: JSON.stringify }); const memoizedSearch = mem(findPluginsInNodeModules); const clearCache = () => { @@ -40,18 +46,19 @@ async function load(plugins, pluginSearchDirs) { (plugin) => typeof plugin === "string" ); - const externalManualLoadPlugins = externalPluginNames.map((name) => { - let plugin; - try { - // try local files - plugin = requireFrom(path.resolve(name), process.cwd()); - } catch { - // try node modules - plugin = requireFrom(name, process.cwd()); - } - - return { name, ...plugin }; - }); + const externalManualLoadPlugins = await Promise.all( + externalPluginNames.map(async (name) => { + try { + // try local files + const module = await import(pathToFileURL(path.resolve(name)).href); + const plugin = module.default ?? module; + return { name, ...plugin }; + } catch { + // try node modules + return importPlugin(name, process.cwd()); + } + }) + ); const externalAutoLoadPlugins = ( await Promise.all( @@ -80,10 +87,9 @@ async function load(plugins, pluginSearchDirs) { const pluginNames = await memoizedSearch(nodeModulesDir); - return pluginNames.map((name) => ({ - name, - ...requireFrom(name, nodeModulesDir), - })); + return Promise.all( + pluginNames.map((name) => importPlugin(name, nodeModulesDir)) + ); }) ) ).flat(); diff --git a/src/common/require-from.js b/src/common/require-from.js deleted file mode 100644 index a2f8fcef2..000000000 --- a/src/common/require-from.js +++ /dev/null @@ -1,9 +0,0 @@ -import { createRequire } from "node:module"; -import path from "node:path"; - -function requireFrom(id, directory) { - const require = createRequire(path.join(directory, "noop.js")); - return require(id); -} - -export default requireFrom; diff --git a/src/utils/import-from-directory.js b/src/utils/import-from-directory.js new file mode 100644 index 000000000..b8b851884 --- /dev/null +++ b/src/utils/import-from-directory.js @@ -0,0 +1,8 @@ +import path from "node:path"; +import importFromFile from "./import-from-file.js"; + +function importFromDirectory(specifier, directory) { + return importFromFile(specifier, path.join(directory, "noop.js")); +} + +export default importFromDirectory; diff --git a/src/utils/import-from-file.js b/src/utils/import-from-file.js index 246b461a7..8fc83de8f 100644 --- a/src/utils/import-from-file.js +++ b/src/utils/import-from-file.js @@ -1,4 +1,5 @@ import { pathToFileURL } from "node:url"; +// TODO: Use `import.meta.resolve` when it's available import { resolve } from "import-meta-resolve"; async function importFromFile(specifier, parent) { diff --git a/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/index.cjs b/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/index.cjs deleted file mode 100644 index d5b6347da..000000000 --- a/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/index.cjs +++ /dev/null @@ -1,8 +0,0 @@ -"use strict"; - -const createPlugin = require("../../utils/create-plugin.cjs"); - -module.exports = createPlugin({ - name: "uppercase-rocks", - print: (text) => text.toUpperCase(), -}); diff --git a/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/index.js b/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/index.js new file mode 100644 index 000000000..88250fc8a --- /dev/null +++ b/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/index.js @@ -0,0 +1,8 @@ +import createPlugin from "../../utils/create-plugin.cjs"; + +const plugin = createPlugin({ + name: "uppercase-rocks", + print: (text) => text.toUpperCase(), +}); + +export default plugin; diff --git a/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/package.json b/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/package.json index 97f940de5..d16e23824 100644 --- a/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/package.json +++ b/tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/package.json @@ -2,5 +2,6 @@ "private": true, "name": "prettier-plugin-uppercase-rocks", "version": "1.0.0", - "main": "./index.cjs" + "main": "./index.js", + "type": "module" } diff --git a/tests/format/misc/plugins/embed-async-parser/jsfmt.spec.js b/tests/format/misc/plugins/embed-async-parser/jsfmt.spec.js index 86c1e6b30..86124ea72 100644 --- a/tests/format/misc/plugins/embed-async-parser/jsfmt.spec.js +++ b/tests/format/misc/plugins/embed-async-parser/jsfmt.spec.js @@ -1,10 +1,8 @@ -import createEsmUtils from "esm-utils"; - -const { require } = createEsmUtils(import.meta); - -const plugins = [ - require("../../../../config/prettier-plugins/prettier-plugin-async-parser/index.cjs"), - require("../../../../config/prettier-plugins/prettier-plugin-uppercase-rocks/index.cjs"), -]; +const plugins = await Promise.all( + [ + "../../../../config/prettier-plugins/prettier-plugin-async-parser/index.cjs", + "../../../../config/prettier-plugins/prettier-plugin-uppercase-rocks/index.js", + ].map(async (plugin) => (await import(plugin)).default) +); run_spec(import.meta, ["markdown"], { plugins }); diff --git a/tests/format/vue/with-plugins/jsfmt.spec.js b/tests/format/vue/with-plugins/jsfmt.spec.js index 6ccb205a7..f61ea9147 100644 --- a/tests/format/vue/with-plugins/jsfmt.spec.js +++ b/tests/format/vue/with-plugins/jsfmt.spec.js @@ -1,11 +1,9 @@ -import createEsmUtils from "esm-utils"; - -const { require } = createEsmUtils(import.meta); - -const plugins = [ - require("../../../config/prettier-plugins/prettier-plugin-uppercase-rocks/index.cjs"), - require("../../../config/prettier-plugins/prettier-plugin-dummy-stylus/index.cjs"), -]; +const plugins = await Promise.all( + [ + "../../../config/prettier-plugins/prettier-plugin-uppercase-rocks/index.js", + "../../../config/prettier-plugins/prettier-plugin-dummy-stylus/index.cjs", + ].map(async (plugin) => (await import(plugin)).default) +); run_spec(import.meta, ["vue"], { plugins }); run_spec(import.meta, ["vue"], { plugins, embeddedLanguageFormatting: "off" }); diff --git a/tests/integration/__tests__/__snapshots__/early-exit.js.snap b/tests/integration/__tests__/__snapshots__/early-exit.js.snap index 837b4c4db..d452ee797 100644 --- a/tests/integration/__tests__/__snapshots__/early-exit.js.snap +++ b/tests/integration/__tests__/__snapshots__/early-exit.js.snap @@ -20,7 +20,8 @@ exports[`show detailed usage with plugin options (automatic resolution) (stdout) Default: 2 Plugin defaults: -* prettier-plugin-bar: 4 +* prettier-plugin-bar: 4, +* prettier-plugin-baz.js: 4 " `; @@ -35,7 +36,7 @@ exports[`show detailed usage with plugin options (manual resolution) (stdout) 1` Default: 2 Plugin defaults: -* ../plugins/automatic/node_modules/prettier-plugin-bar: 4 +* ../plugins/automatic/node_modules/prettier-plugin-bar/index.js: 4 " `; diff --git a/tests/integration/__tests__/early-exit.js b/tests/integration/__tests__/early-exit.js index c9eafc685..60888d0fd 100644 --- a/tests/integration/__tests__/early-exit.js +++ b/tests/integration/__tests__/early-exit.js @@ -34,7 +34,7 @@ describe("show detailed usage with plugin options (manual resolution)", () => { runPrettier("cli", [ "--help", "tab-width", - "--plugin=../plugins/automatic/node_modules/prettier-plugin-bar", + "--plugin=../plugins/automatic/node_modules/prettier-plugin-bar/index.js", "--parser=bar", ]).test({ status: 0, diff --git a/tests/integration/__tests__/file-info.js b/tests/integration/__tests__/file-info.js index 15101bf01..6b517d216 100644 --- a/tests/integration/__tests__/file-info.js +++ b/tests/integration/__tests__/file-info.js @@ -128,7 +128,7 @@ describe("extracts file-info with inferredParser=foo when a plugin is hand-picke "--file-info", "file.foo", "--plugin", - "../plugins/automatic/node_modules/@prettier/plugin-foo", + "../plugins/automatic/node_modules/@prettier/plugin-foo/index.js", ]).test({ status: 0, }); @@ -383,7 +383,7 @@ test("API getFileInfo with hand-picked plugins", async () => { const pluginPath = path.resolve( path.join( __dirname, - "../plugins/automatic/node_modules/@prettier/plugin-foo" + "../plugins/automatic/node_modules/@prettier/plugin-foo/index.js" ) ); await expect(prettier.getFileInfo(file)).resolves.toMatchObject({ diff --git a/tests/integration/__tests__/patterns-dirs.js b/tests/integration/__tests__/patterns-dirs.js index f78f66d77..9df936273 100644 --- a/tests/integration/__tests__/patterns-dirs.js +++ b/tests/integration/__tests__/patterns-dirs.js @@ -95,7 +95,7 @@ testPatterns("Exclude yarn.lock when expanding directories", ["."], { const uppercaseRocksPlugin = path.join( projectRoot, - "tests/config/prettier-plugins/prettier-plugin-uppercase-rocks" + "tests/config/prettier-plugins/prettier-plugin-uppercase-rocks/index.js" ); describe("plugins `.`", () => { runPrettier("cli/dirs/plugins", [ diff --git a/tests/integration/__tests__/plugin-options-string.js b/tests/integration/__tests__/plugin-options-string.js index 36199c6b6..97749613f 100644 --- a/tests/integration/__tests__/plugin-options-string.js +++ b/tests/integration/__tests__/plugin-options-string.js @@ -58,7 +58,7 @@ describe("Non exists plugin", () => { { input: "hello-world" } ).test({ stdout: "", - stderr: expect.stringMatching(/Cannot find module '--invalid--'/), + stderr: expect.stringMatching(/Cannot find package '--invalid--'/), status: 1, write: [], }); diff --git a/tests/integration/__tests__/plugin-resolution.js b/tests/integration/__tests__/plugin-resolution.js index 1b4dec6b3..434613849 100644 --- a/tests/integration/__tests__/plugin-resolution.js +++ b/tests/integration/__tests__/plugin-resolution.js @@ -152,32 +152,6 @@ describe("loads --plugin by its relative path without leading ./", () => { }); }); -describe("loads --plugin by relative path to its directory (assuming index.js)", () => { - runPrettier("plugins", [ - "automatic/file.txt", - "--parser=bar", - "--plugin=./automatic/node_modules/prettier-plugin-bar", - ]).test({ - stdout: "content from `prettier-plugin-bar` package + contents" + EOL, - stderr: "", - status: 0, - write: [], - }); -}); - -describe("loads --plugin by relative path to its directory without leading ./ (assuming index.js)", () => { - runPrettier("plugins", [ - "automatic/file.txt", - "--parser=bar", - "--plugin=automatic/node_modules/prettier-plugin-bar", - ]).test({ - stdout: "content from `prettier-plugin-bar` package + contents" + EOL, - stderr: "", - status: 0, - write: [], - }); -}); - describe("loads --plugin by package name", () => { runPrettier("plugins/automatic", [ "file.txt", @@ -191,13 +165,13 @@ describe("loads --plugin by package name", () => { }); }); -describe("loads --plugin by filename without leading ./ and ext, should resolve to file, not package", () => { +describe("loads --plugin by filename without leading ./, should resolve to file, not package", () => { runPrettier("plugins/automatic", [ "file.txt", - "--parser=bar", - "--plugin=prettier-plugin-bar", + "--parser=baz", + "--plugin=prettier-plugin-baz.js", ]).test({ - stdout: "content from `prettier-plugin-bar.js` file + contents" + EOL, + stdout: "content from `prettier-plugin-baz.js` file + contents" + EOL, stderr: "", status: 0, write: [], @@ -208,7 +182,7 @@ describe("loads --plugin by bespoke plugin name (assuming it is installed in cwd runPrettier("plugins/bespoke", [ "../automatic/file.txt", "--parser=bespoke", - "--plugin=@company/prettier-plugin-bespoke", + "--plugin=@company/prettier-plugin-bespoke/main.js", ]).test({ stdout: "bespoke+contents" + EOL, stderr: "", @@ -232,12 +206,12 @@ test("--no-plugin-search", async () => { }); test("--no-plugin-search still allow use --plugin", async () => { - const args = ["file.txt", "--parser=bar"]; + const args = ["file.txt", "--parser=baz"]; const { stdout: stdoutWithoutPlugin } = await runPrettier( "plugins/automatic", args ); - const argsWithPlugin = [...args, "--plugin=./prettier-plugin-bar.js"]; + const argsWithPlugin = [...args, "--plugin=./prettier-plugin-baz.js"]; const { stdout: stdoutWithPlugin } = await runPrettier( "plugins/automatic", argsWithPlugin diff --git a/tests/integration/plugins/automatic/node_modules/prettier-plugin-baz.js/index.js b/tests/integration/plugins/automatic/node_modules/prettier-plugin-baz.js/index.js new file mode 100644 index 000000000..04dbc89d2 --- /dev/null +++ b/tests/integration/plugins/automatic/node_modules/prettier-plugin-baz.js/index.js @@ -0,0 +1,30 @@ +"use strict"; + +module.exports = { + languages: [ + { + name: "baz", + parsers: ["baz"], + }, + ], + parsers: { + baz: { + parse: text => ({ text }), + astFormat: "baz", + }, + }, + printers: { + baz: { + async print(path) { + const { default: prettier } = await import( + "../../../../../config/prettier-entry.js" + ); + const { concat } = prettier.doc.builders; + return concat(["content from `prettier-plugin-baz.js` package + ", path.getValue().text]); + }, + }, + }, + defaultOptions: { + tabWidth: 4, + }, +}; diff --git a/tests/integration/plugins/automatic/node_modules/prettier-plugin-baz.js/package.json b/tests/integration/plugins/automatic/node_modules/prettier-plugin-baz.js/package.json new file mode 100644 index 000000000..b731bd61b --- /dev/null +++ b/tests/integration/plugins/automatic/node_modules/prettier-plugin-baz.js/package.json @@ -0,0 +1 @@ +{"type": "commonjs"} diff --git a/tests/integration/plugins/automatic/package.json b/tests/integration/plugins/automatic/package.json deleted file mode 100644 index 5bbefffba..000000000 --- a/tests/integration/plugins/automatic/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} diff --git a/tests/integration/plugins/automatic/prettier-plugin-bar.js b/tests/integration/plugins/automatic/prettier-plugin-bar.js deleted file mode 100644 index 5f16f83ea..000000000 --- a/tests/integration/plugins/automatic/prettier-plugin-bar.js +++ /dev/null @@ -1,29 +0,0 @@ -"use strict"; - -module.exports = { - languages: [ - { - name: "bar", - parsers: ["bar"], - }, - ], - parsers: { - bar: { - parse: (text) => ({ text }), - astFormat: "bar", - }, - }, - printers: { - bar: { - print(path) { - return [ - "content from `prettier-plugin-bar.js` file + ", - path.getValue().text, - ]; - }, - }, - }, - defaultOptions: { - tabWidth: 4, - }, -}; diff --git a/tests/integration/plugins/automatic/prettier-plugin-baz.js b/tests/integration/plugins/automatic/prettier-plugin-baz.js new file mode 100644 index 000000000..bff6ac40b --- /dev/null +++ b/tests/integration/plugins/automatic/prettier-plugin-baz.js @@ -0,0 +1,29 @@ +const plugin = { + languages: [ + { + name: "baz", + parsers: ["baz"], + }, + ], + parsers: { + baz: { + parse: text => ({ text }), + astFormat: "baz", + }, + }, + printers: { + baz: { + print(path) { + return [ + "content from `prettier-plugin-baz.js` file + ", + path.getValue().text, + ]; + }, + }, + }, + defaultOptions: { + tabWidth: 4, + }, +}; + +export default plugin;