From 9756cafa000a3cb2e15702b1f73ccaf1af22bf82 Mon Sep 17 00:00:00 2001 From: Josh <37798644+joshuaellis@users.noreply.github.com> Date: Tue, 5 Sep 2023 10:25:14 +0100 Subject: [PATCH] feat(experimental): add plugin:build command (#17747) Co-authored-by: Alexandre BODIN --- .github/workflows/tests.yml | 27 +- .../01-core/strapi/commands/_category_.json | 5 + .../strapi/commands/plugin/00-overview.md | 16 + .../strapi/commands/plugin/01-build.md | 109 ++++ .../strapi/commands/plugin/_category_.json | 5 + jest-preset.front.js | 2 +- package.json | 2 + packages/core/admin/scripts/build.js | 5 +- .../admin/scripts/create-dev-plugins-file.js | 5 +- .../create-plugins-exclude-path.test.js | 30 -- .../admin/utils/__tests__/plugins.test.js | 32 ++ packages/core/admin/utils/create-cache-dir.js | 107 +--- .../utils/create-plugins-exclude-path.js | 20 - packages/core/admin/utils/get-plugins.js | 110 ---- packages/core/admin/utils/plugins.js | 217 ++++++++ packages/core/admin/webpack.config.dev.js | 4 +- packages/core/admin/webpack.config.js | 4 +- packages/core/helper-plugin/package.json | 1 + .../actions/plugin/build-command/action.js | 137 +++++ .../actions/plugin/build-command/command.js | 17 + .../builders/__tests__/packages.test.js | 220 ++++++++ .../strapi/lib/commands/builders/packages.js | 252 +++++++++ .../strapi/lib/commands/builders/tasks/dts.js | 199 +++++++ .../lib/commands/builders/tasks/index.js | 29 + .../lib/commands/builders/tasks/vite.js | 144 +++++ packages/core/strapi/lib/commands/index.js | 1 + .../utils/__tests__/fixtures/test.pkg.json | 4 + .../lib/commands/utils/__tests__/pkg.test.js | 509 ++++++++++++++++++ .../core/strapi/lib/commands/utils/helpers.js | 54 +- .../core/strapi/lib/commands/utils/logger.js | 97 ++++ .../core/strapi/lib/commands/utils/pkg.js | 421 +++++++++++++++ .../strapi/lib/types/core/strapi/index.d.ts | 2 +- packages/core/strapi/package.json | 7 +- packages/plugins/color-picker/.eslintrc.js | 6 +- .../plugins/color-picker/admin/.eslintrc.js | 7 + .../index.js => ColorPickerIcon.tsx} | 6 +- .../index.js => ColorPickerInput.tsx} | 67 ++- .../tests/ColorPickerInput.test.tsx | 66 +++ ...js.snap => ColorPickerInput.test.tsx.snap} | 0 .../tests/color-picker-input.test.js | 65 --- .../color-picker/admin/src/global.d.ts | 2 + .../{useComposeRefs.js => useComposeRefs.ts} | 12 +- .../admin/src/{index.js => index.ts} | 19 +- .../color-picker/admin/src/pluginId.js | 5 - .../color-picker/admin/src/pluginId.ts | 1 + .../color-picker/admin/src/utils/getTrad.js | 5 - .../color-picker/admin/src/utils/getTrad.ts | 3 + .../color-picker/admin/tsconfig.build.json | 9 + .../color-picker/admin/tsconfig.eslint.json | 5 + .../plugins/color-picker/admin/tsconfig.json | 5 + packages/plugins/color-picker/package.json | 114 ++-- .../color-picker/server/tsconfig.build.json | 9 + packages/plugins/color-picker/strapi-admin.js | 3 - .../eslint-config-custom/front/typescript.js | 4 +- yarn.lock | 44 +- 55 files changed, 2775 insertions(+), 476 deletions(-) create mode 100644 docs/docs/docs/01-core/strapi/commands/_category_.json create mode 100644 docs/docs/docs/01-core/strapi/commands/plugin/00-overview.md create mode 100644 docs/docs/docs/01-core/strapi/commands/plugin/01-build.md create mode 100644 docs/docs/docs/01-core/strapi/commands/plugin/_category_.json delete mode 100644 packages/core/admin/utils/__tests__/create-plugins-exclude-path.test.js create mode 100644 packages/core/admin/utils/__tests__/plugins.test.js delete mode 100644 packages/core/admin/utils/create-plugins-exclude-path.js delete mode 100644 packages/core/admin/utils/get-plugins.js create mode 100644 packages/core/admin/utils/plugins.js create mode 100644 packages/core/strapi/lib/commands/actions/plugin/build-command/action.js create mode 100644 packages/core/strapi/lib/commands/actions/plugin/build-command/command.js create mode 100644 packages/core/strapi/lib/commands/builders/__tests__/packages.test.js create mode 100644 packages/core/strapi/lib/commands/builders/packages.js create mode 100644 packages/core/strapi/lib/commands/builders/tasks/dts.js create mode 100644 packages/core/strapi/lib/commands/builders/tasks/index.js create mode 100644 packages/core/strapi/lib/commands/builders/tasks/vite.js create mode 100644 packages/core/strapi/lib/commands/utils/__tests__/fixtures/test.pkg.json create mode 100644 packages/core/strapi/lib/commands/utils/__tests__/pkg.test.js create mode 100644 packages/core/strapi/lib/commands/utils/logger.js create mode 100644 packages/core/strapi/lib/commands/utils/pkg.js create mode 100644 packages/plugins/color-picker/admin/.eslintrc.js rename packages/plugins/color-picker/admin/src/components/{ColorPicker/ColorPickerIcon/index.js => ColorPickerIcon.tsx} (85%) rename packages/plugins/color-picker/admin/src/components/{ColorPicker/ColorPickerInput/index.js => ColorPickerInput.tsx} (81%) create mode 100644 packages/plugins/color-picker/admin/src/components/tests/ColorPickerInput.test.tsx rename packages/plugins/color-picker/admin/src/components/tests/__snapshots__/{color-picker-input.test.js.snap => ColorPickerInput.test.tsx.snap} (100%) delete mode 100644 packages/plugins/color-picker/admin/src/components/tests/color-picker-input.test.js create mode 100644 packages/plugins/color-picker/admin/src/global.d.ts rename packages/plugins/color-picker/admin/src/hooks/{useComposeRefs.js => useComposeRefs.ts} (76%) rename packages/plugins/color-picker/admin/src/{index.js => index.ts} (80%) delete mode 100644 packages/plugins/color-picker/admin/src/pluginId.js create mode 100644 packages/plugins/color-picker/admin/src/pluginId.ts delete mode 100644 packages/plugins/color-picker/admin/src/utils/getTrad.js create mode 100644 packages/plugins/color-picker/admin/src/utils/getTrad.ts create mode 100644 packages/plugins/color-picker/admin/tsconfig.build.json create mode 100644 packages/plugins/color-picker/admin/tsconfig.eslint.json create mode 100644 packages/plugins/color-picker/admin/tsconfig.json create mode 100644 packages/plugins/color-picker/server/tsconfig.build.json delete mode 100644 packages/plugins/color-picker/strapi-admin.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9ac23848c..0ce90f2aa3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,9 +53,30 @@ jobs: uses: ./.github/actions/yarn-nm-install - name: Run build:ts run: yarn nx run-many --target=build:ts --nx-ignore-cycles --skip-nx-cache + - name: Run build + run: yarn nx run-many --target=build --nx-ignore-cycles --skip-nx-cache - name: Run lint run: yarn nx affected --target=lint --parallel --nx-ignore-cycles + typescript_front: + name: 'typescript_front' + needs: [changes, lint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - uses: nrwl/nx-set-shas@v3 + - name: Monorepo install + uses: ./.github/actions/yarn-nm-install + - name: Run build:ts for admin-test-utils & helper-plugin + run: yarn build --projects=@strapi/admin-test-utils,@strapi/helper-plugin --skip-nx-cache + - name: Run test + run: yarn nx affected --target=test:ts:front --nx-ignore-cycles + unit_back: name: 'unit_back (node: ${{ matrix.node }})' needs: [changes, lint] @@ -80,7 +101,7 @@ jobs: unit_front: name: 'unit_front (node: ${{ matrix.node }})' - needs: [changes, lint] + needs: [changes, lint, typescript_front] runs-on: ubuntu-latest strategy: matrix: @@ -102,7 +123,7 @@ jobs: build: name: 'build (node: ${{ matrix.node }})' - needs: [changes, lint, unit_front] + needs: [changes, lint, typescript_front, unit_front] runs-on: ubuntu-latest strategy: matrix: @@ -119,7 +140,7 @@ jobs: e2e: timeout-minutes: 60 - needs: [changes, lint, unit_front, build] + needs: [changes, lint, typescript_front, unit_front, build] name: 'e2e (browser: ${{ matrix.project }})' runs-on: ubuntu-latest strategy: diff --git a/docs/docs/docs/01-core/strapi/commands/_category_.json b/docs/docs/docs/01-core/strapi/commands/_category_.json new file mode 100644 index 0000000000..54b5654620 --- /dev/null +++ b/docs/docs/docs/01-core/strapi/commands/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "CLI Commands", + "collapsible": true, + "collapsed": true +} diff --git a/docs/docs/docs/01-core/strapi/commands/plugin/00-overview.md b/docs/docs/docs/01-core/strapi/commands/plugin/00-overview.md new file mode 100644 index 0000000000..90abab0101 --- /dev/null +++ b/docs/docs/docs/01-core/strapi/commands/plugin/00-overview.md @@ -0,0 +1,16 @@ +--- +title: Introduction +description: An intro into the plugin commands of the Strapi CLI +tags: + - CLI + - commands + - plugins +--- + +:::caution +This is an experimental API that is subject to change at any moment, hence why it is not documented in the [Strapi documentation](https://docs.strapi.io/dev-docs/cli). +::: + +## Available Commands + +- [plugin:build](build) - Build a plugin for publishing diff --git a/docs/docs/docs/01-core/strapi/commands/plugin/01-build.md b/docs/docs/docs/01-core/strapi/commands/plugin/01-build.md new file mode 100644 index 0000000000..6cee3e3928 --- /dev/null +++ b/docs/docs/docs/01-core/strapi/commands/plugin/01-build.md @@ -0,0 +1,109 @@ +--- +title: plugin:build +description: An in depth look at the plugin:build command of the Strapi CLI +tags: + - CLI + - commands + - plugins + - building +--- + +The `plugin:build` command is used to build plugins in a CJS/ESM compatible format that can be instantly published to NPM. +This is done by looking at the export fields of a package.json e.g. `main`, `module`, `types` and `exports`. By using the +exports map specifically we can build dual plugins that support a server & client output. + +## Usage + +```bash +strapi plugin:build +``` + +### Options + +```bash +Bundle your strapi plugin for publishing. + +Options: + -y, --yes Skip all confirmation prompts (default: false) + -d, --debug Enable debugging mode with verbose logs (default: false) + -h, --help Display help for command +``` + +## Setting up your package + +In order to build a plugin you need to have a `package.json` that must contain the following fields: + +- `name` +- `version` + +In regards to the export keys of your package.json because a plugin _typically_ has both a server and client +side output we recommend doing the following: + +```json +{ + "name": "@strapi/plugin", + "version": "1.0.0", + "exports": { + "./strapi-admin": { + "types": "./dist/admin/index.d.ts", + "source": "./admin/src/index.ts", + "import": "./dist/admin/index.mjs", + "require": "./dist/admin/index.js", + "default": "./dist/admin/index.js" + }, + "./strapi-server": { + "types": "./dist/server/index.d.ts", + "source": "./server/src/index.ts", + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.js", + "default": "./dist/server/index.js" + }, + "./package.json": "./package.json" + } +} +``` + +We don't use `main`, `module` or `types` on the root level of the package.json because of the aforementioned reason (plugins don't have one entry). +If you've not written your plugin in typescript, you can omit the `types` value of an export map. This is the minimum setup required to build a plugin. + +## How it works + +The command sequence can be visualised as follows: + +- Load package.json +- Validate that package.json against a `yup` schema +- Validate the ordering of an export map if `pkg.exports` is defined +- Create a build context, this holds information like: + - The transpilation target + - The external dependencies (that we don't want to bundle) + - Where the output should go e.g. `dist` + - The exports we're about to use to create build tasks +- Create a list of build tasks based on the `exports` from the build context, these can currently either be `"build:js"` or `"build:dts"` +- Pass the build task to a specific task handler e.g. `vite` or `tsc` +- Finish + +## Transpilation target + +There are three different runtimes available for plugins: + +- `node` which equates to a `node16` target +- `web` which equates to a `esnext` target +- `*` (universal) which equates to `["last 3 major versions", "Firefox ESR", "last 2 Opera versions", "not dead", "node 16.0.0"]` + +The `node` and `web` targets are specifically used for the export maps with they keys `./strapi-server` and `./strapi-admin` respectively. +Any other export map values will be transpiled to the universal target. The universal target can be overwritten by adding the `browserslist` +key to your `package.json` (seen below): + +```json +{ + "name": "@strapi/plugin", + "version": "1.0.0", + "browserslist": [ + "last 3 major versions", + "Firefox ESR", + "last 2 Opera versions", + "not dead", + "node 16.0.0" + ] +} +``` diff --git a/docs/docs/docs/01-core/strapi/commands/plugin/_category_.json b/docs/docs/docs/01-core/strapi/commands/plugin/_category_.json new file mode 100644 index 0000000000..e74aeb452c --- /dev/null +++ b/docs/docs/docs/01-core/strapi/commands/plugin/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "plugin", + "collapsible": true, + "collapsed": true +} diff --git a/jest-preset.front.js b/jest-preset.front.js index 560d223a9f..ae8512dc1f 100644 --- a/jest-preset.front.js +++ b/jest-preset.front.js @@ -22,7 +22,7 @@ module.exports = { moduleNameMapper, /* Tells jest to ignore duplicated manual mock files, such as index.js */ modulePathIgnorePatterns: ['.*__mocks__.*'], - testPathIgnorePatterns: ['node_modules/', '__tests__'], + testPathIgnorePatterns: ['node_modules/', '__tests__', 'dist/'], globalSetup: '@strapi/admin-test-utils/global-setup', setupFiles: ['@strapi/admin-test-utils/environment'], setupFilesAfterEnv: ['@strapi/admin-test-utils/after-env'], diff --git a/package.json b/package.json index 7d009ad3f0..2ce6f01cd3 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "test:front:ce": "cross-env IS_EE=false run test:front", "test:front:watch:ce": "cross-env IS_EE=false run test:front --watch", "test:front:update:ce": "yarn test:front:ce -u", + "test:ts": "yarn test:ts:front", + "test:ts:front": "nx run-many --target=test:ts:front --nx-ignore-cycles", "test:unit:all": "nx run-many --target=test:unit --nx-ignore-cycles", "test:unit": "jest --config jest.config.js", "test:unit:watch": "run test:unit --watch", diff --git a/packages/core/admin/scripts/build.js b/packages/core/admin/scripts/build.js index 23b6697d20..4d90497e88 100644 --- a/packages/core/admin/scripts/build.js +++ b/packages/core/admin/scripts/build.js @@ -7,8 +7,7 @@ const { isObject } = require('lodash'); const SpeedMeasurePlugin = require('speed-measure-webpack-plugin'); const webpackConfig = require('../webpack.config'); -const { getPlugins } = require('../utils/get-plugins'); -const { createPluginsJs } = require('../utils/create-cache-dir'); +const { getPlugins, createPluginFile } = require('../utils/plugins'); // Wrapper that outputs the webpack speed const smp = new SpeedMeasurePlugin(); @@ -30,7 +29,7 @@ const buildAdmin = async () => { '@strapi/plugin-users-permissions', ]); - await createPluginsJs(plugins, path.join(__dirname, '..')); + await createPluginFile(plugins, path.join(__dirname, '..')); const args = { entry, diff --git a/packages/core/admin/scripts/create-dev-plugins-file.js b/packages/core/admin/scripts/create-dev-plugins-file.js index 9a60aee7d7..d5a66b8509 100644 --- a/packages/core/admin/scripts/create-dev-plugins-file.js +++ b/packages/core/admin/scripts/create-dev-plugins-file.js @@ -2,8 +2,7 @@ const { join } = require('path'); const fs = require('fs-extra'); -const { getPlugins } = require('../utils/get-plugins'); -const { createPluginsJs } = require('../utils/create-cache-dir'); +const { getPlugins, createPluginFile } = require('../utils/plugins'); /** * Write the plugins.js file or copy the plugins-dev.js file if it exists @@ -20,7 +19,7 @@ const createFile = async () => { const plugins = getPlugins(); - return createPluginsJs(plugins, join(__dirname, '..')); + return createPluginFile(plugins, join(__dirname, '..')); }; createFile() diff --git a/packages/core/admin/utils/__tests__/create-plugins-exclude-path.test.js b/packages/core/admin/utils/__tests__/create-plugins-exclude-path.test.js deleted file mode 100644 index 7dde10799e..0000000000 --- a/packages/core/admin/utils/__tests__/create-plugins-exclude-path.test.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const { createPluginsExcludePath } = require('../create-plugins-exclude-path'); - -describe('createPluginsExcludePath', () => { - test('given there are no plugins it should just return the node_modules regexp', () => { - const result = createPluginsExcludePath([]); - expect(result).toEqual(/node_modules/); - }); - - test('given there are plugins, it should return a regex with these included', () => { - const result = createPluginsExcludePath([ - 'strapi-plugin-custom-upload', - 'strapi-plugin-custom-plugin', - ]); - expect(result).toEqual( - /node_modules\/(?!(strapi-plugin-custom-upload|strapi-plugin-custom-plugin))/ - ); - }); - - test('given there are scoped plugins, it should return a regex with these included', () => { - const result = createPluginsExcludePath([ - '@scope/strapi-plugin-custom-upload', - '@scope/strapi-plugin-custom-plugin', - ]); - expect(result).toEqual( - /node_modules\/(?!(@scope\/strapi-plugin-custom-upload|@scope\/strapi-plugin-custom-plugin))/ - ); - }); -}); diff --git a/packages/core/admin/utils/__tests__/plugins.test.js b/packages/core/admin/utils/__tests__/plugins.test.js new file mode 100644 index 0000000000..4115a00293 --- /dev/null +++ b/packages/core/admin/utils/__tests__/plugins.test.js @@ -0,0 +1,32 @@ +'use strict'; + +const { createPluginsExcludePath } = require('../plugins'); + +describe('plugins', () => { + describe('createPluginsExcludePath', () => { + test('given there are no plugins it should just return the node_modules regexp', () => { + const result = createPluginsExcludePath([]); + expect(result).toEqual(/node_modules/); + }); + + test('given there are plugins, it should return a regex with these included', () => { + const result = createPluginsExcludePath([ + 'strapi-plugin-custom-upload', + 'strapi-plugin-custom-plugin', + ]); + expect(result).toEqual( + /node_modules\/(?!(strapi-plugin-custom-upload|strapi-plugin-custom-plugin))/ + ); + }); + + test('given there are scoped plugins, it should return a regex with these included', () => { + const result = createPluginsExcludePath([ + '@scope/strapi-plugin-custom-upload', + '@scope/strapi-plugin-custom-plugin', + ]); + expect(result).toEqual( + /node_modules\/(?!(@scope\/strapi-plugin-custom-upload|@scope\/strapi-plugin-custom-plugin))/ + ); + }); + }); +}); diff --git a/packages/core/admin/utils/create-cache-dir.js b/packages/core/admin/utils/create-cache-dir.js index 84ec6c9c42..0c3690b5ac 100644 --- a/packages/core/admin/utils/create-cache-dir.js +++ b/packages/core/admin/utils/create-cache-dir.js @@ -1,69 +1,13 @@ 'use strict'; const path = require('path'); -const _ = require('lodash'); const fs = require('fs-extra'); const tsUtils = require('@strapi/typescript-utils'); const getCustomAppConfigFile = require('./get-custom-app-config-file'); +const { filterPluginsByAdminEntry, createPluginFile } = require('./plugins'); const getPkgPath = (name) => path.dirname(require.resolve(`${name}/package.json`)); -async function createPluginsJs(plugins, dest) { - const pluginsArray = plugins.map(({ pathToPlugin, name, info }) => { - const shortName = _.camelCase(name); - - let realPath = ''; - - /** - * We're using a module here so we want to keep using the module resolution procedure. - */ - if (info?.packageName || info?.required) { - /** - * path.join, on windows, it uses backslashes to resolve path. - * The problem is that Webpack does not windows paths - * With this tool, we need to rely on "/" and not "\". - * This is the reason why '..\\..\\..\\node_modules\\@strapi\\plugin-content-type-builder/strapi-admin.js' was not working. - * The regexp at line 105 aims to replace the windows backslashes by standard slash so that webpack can deal with them. - * Backslash looks to work only for absolute paths with webpack => https://webpack.js.org/concepts/module-resolution/#absolute-paths - */ - realPath = path.join(pathToPlugin, 'strapi-admin').replace(/\\/g, '/'); - } else { - realPath = path - .join(path.relative(path.resolve(dest, 'admin', 'src'), pathToPlugin), 'strapi-admin') - .replace(/\\/g, '/'); - } - - return { - name, - pathToPlugin: realPath, - shortName, - }; - }); - - const content = ` -${pluginsArray - .map(({ pathToPlugin, shortName }) => { - const req = `'${pathToPlugin}'`; - - return `import ${shortName} from ${req};`; - }) - .join('\n')} - - -const plugins = { -${[...pluginsArray] - .map(({ name, shortName }) => { - return ` '${name}': ${shortName},`; - }) - .join('\n')} -}; - -export default plugins; -`; - - return fs.writeFile(path.resolve(dest, 'admin', 'src', 'plugins.js'), content); -} - async function copyAdmin(dest) { const adminPath = getPkgPath('@strapi/admin'); @@ -86,49 +30,8 @@ async function createCacheDir({ appDir, plugins }) { ); const pluginsWithFront = Object.entries(plugins) - .filter(([, plugin]) => { - /** - * There are two ways a plugin should be imported, either it's local to the strapi app, - * or it's an actual npm module that's installed and resolved via node_modules. - * - * We first check if the plugin is local to the strapi app, using a regular `resolve` because - * the pathToPlugin will be relative i.e. `/Users/my-name/strapi-app/src/plugins/my-plugin`. - * - * If the file doesn't exist well then it's probably a node_module, so instead we use `require.resolve` - * which will resolve the path to the module in node_modules. If it fails with the specific code `MODULE_NOT_FOUND` - * then it doesn't have an admin part to the package. - * - * NOTE: we should try to move to `./package.json[exports]` map with bundling of our own plugins, - * because these entry files are written in commonjs restricting features e.g. tree-shaking. - */ - try { - const isLocalPluginWithLegacyAdminFile = fs.existsSync( - path.resolve(`${plugin.pathToPlugin}/strapi-admin.js`) - ); - - if (!isLocalPluginWithLegacyAdminFile) { - const isModulewithLegacyAdminFile = require.resolve( - `${plugin.pathToPlugin}/strapi-admin.js` - ); - - return isModulewithLegacyAdminFile; - } - - return isLocalPluginWithLegacyAdminFile; - } catch (err) { - if (err.code === 'MODULE_NOT_FOUND') { - /** - * the plugin does not contain FE code, so we - * don't want to import it anyway - */ - return false; - } - - throw err; - } - }) - .map(([name, plugin]) => ({ name, ...plugin })); - + .map(([name, plugin]) => ({ name, ...plugin })) + .filter(filterPluginsByAdminEntry); // create .cache dir await fs.emptyDir(cacheDir); @@ -166,7 +69,7 @@ async function createCacheDir({ appDir, plugins }) { } // create plugins.js with plugins requires - await createPluginsJs(pluginsWithFront, cacheDir); + await createPluginFile(pluginsWithFront, cacheDir); // create the tsconfig.json file so we can develop plugins in ts while being in a JS project if (!useTypeScript) { @@ -174,4 +77,4 @@ async function createCacheDir({ appDir, plugins }) { } } -module.exports = { createCacheDir, createPluginsJs }; +module.exports = { createCacheDir }; diff --git a/packages/core/admin/utils/create-plugins-exclude-path.js b/packages/core/admin/utils/create-plugins-exclude-path.js deleted file mode 100644 index dfe1fdbcc1..0000000000 --- a/packages/core/admin/utils/create-plugins-exclude-path.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict'; - -const NODE_MODULES = 'node_modules'; -/** - * @param {string[]} pluginsPath – an array of paths to the plugins from the user's directory - * @returns {RegExp} a regex that will exclude _all_ node_modules except for the plugins in the pluginsPath array. - */ -const createPluginsExcludePath = (pluginsPath = []) => { - /** - * If there aren't any plugins in the node_modules array, just return the node_modules regex - * without complicating it. - */ - if (pluginsPath.length === 0) { - return /node_modules/; - } - - return new RegExp(`${NODE_MODULES}/(?!(${pluginsPath.join('|')}))`); -}; - -module.exports = { createPluginsExcludePath }; diff --git a/packages/core/admin/utils/get-plugins.js b/packages/core/admin/utils/get-plugins.js deleted file mode 100644 index ace231fa14..0000000000 --- a/packages/core/admin/utils/get-plugins.js +++ /dev/null @@ -1,110 +0,0 @@ -'use strict'; - -const { join, resolve, sep, posix } = require('path'); -const fs = require('fs'); -// eslint-disable-next-line import/no-extraneous-dependencies -const glob = require('glob'); - -const getPlugins = (pluginsAllowlist) => { - const rootPath = resolve(__dirname, '..', join('..', '..', '..', 'packages')); - /** - * So `glob` only supports '/' as a path separator, so we need to replace - * the path separator for the current OS with '/'. e.g. on windows it's `\`. - * - * see https://github.com/isaacs/node-glob/#windows for more information - * - * and see https://github.com/isaacs/node-glob/issues/467#issuecomment-1114240501 for the recommended fix. - */ - let corePath = join(rootPath, 'core', '*'); - let pluginsPath = join(rootPath, 'plugins', '*'); - - if (process.platform === 'win32') { - corePath = corePath.split(sep).join(posix.sep); - pluginsPath = pluginsPath.split(sep).join(posix.sep); - } - - const corePackageDirs = glob.sync(corePath); - const pluginsPackageDirs = glob.sync(pluginsPath); - - const plugins = [...corePackageDirs, ...pluginsPackageDirs] - .map((directory) => { - const isCoreAdmin = directory.includes('packages/core/admin'); - - if (isCoreAdmin) { - return null; - } - - const { name, strapi } = require(join(directory, 'package.json')); - - /** - * this will remove any of our packages that are - * not actually plugins for the application - */ - if (!strapi) { - return null; - } - - /** - * we want the name of the node_module - */ - return { - pathToPlugin: name, - name: strapi.name, - info: { ...strapi, packageName: name }, - directory, - }; - }) - .filter((plugin) => { - if (!plugin) { - return false; - } - - /** - * There are two ways a plugin should be imported, either it's local to the strapi app, - * or it's an actual npm module that's installed and resolved via node_modules. - * - * We first check if the plugin is local to the strapi app, using a regular `resolve` because - * the pathToPlugin will be relative i.e. `/Users/my-name/strapi-app/src/plugins/my-plugin`. - * - * If the file doesn't exist well then it's probably a node_module, so instead we use `require.resolve` - * which will resolve the path to the module in node_modules. If it fails with the specific code `MODULE_NOT_FOUND` - * then it doesn't have an admin part to the package. - * - * NOTE: we should try to move to `./package.json[exports]` map with bundling of our own plugins, - * because these entry files are written in commonjs restricting features e.g. tree-shaking. - */ - try { - const isLocalPluginWithLegacyAdminFile = fs.existsSync( - resolve(`${plugin.pathToPlugin}/strapi-admin.js`) - ); - - if (!isLocalPluginWithLegacyAdminFile) { - const isModulewithLegacyAdminFile = require.resolve( - `${plugin.pathToPlugin}/strapi-admin.js` - ); - - return isModulewithLegacyAdminFile; - } - - return isLocalPluginWithLegacyAdminFile; - } catch (err) { - if (err.code === 'MODULE_NOT_FOUND') { - /** - * the plugin does not contain FE code, so we - * don't want to import it anyway - */ - return false; - } - - throw err; - } - }); - - if (Array.isArray(pluginsAllowlist)) { - return plugins.filter((plugin) => pluginsAllowlist.includes(plugin.pathToPlugin)); - } - - return plugins; -}; - -module.exports = { getPlugins }; diff --git a/packages/core/admin/utils/plugins.js b/packages/core/admin/utils/plugins.js new file mode 100644 index 0000000000..87e8a25371 --- /dev/null +++ b/packages/core/admin/utils/plugins.js @@ -0,0 +1,217 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const asyncFs = require('fs/promises'); +const { camelCase } = require('lodash'); +// eslint-disable-next-line import/no-extraneous-dependencies +const glob = require('glob'); + +/** + * @typedef {Object} PluginInfo + * @property {string} packageName + * @property {string} description + * @property {boolean=} required + */ + +/** + * @typedef {Object} Plugin + * @property {string} pathToPlugin + * @property {string} name + * @property {PluginInfo} info + * @property {string=} directory + */ + +/** + * @param {string[]} pluginsAllowlist + * @returns {Plugin[]} + */ +const getPlugins = (pluginsAllowlist) => { + const rootPath = path.resolve(__dirname, '..', path.join('..', '..', '..', 'packages')); + /** + * So `glob` only supports '/' as a path separator, so we need to replace + * the path separator for the current OS with '/'. e.g. on windows it's `\`. + * + * see https://github.com/isaacs/node-glob/#windows for more information + * + * and see https://github.com/isaacs/node-glob/issues/467#issuecomment-1114240501 for the recommended fix. + */ + let corePath = path.join(rootPath, 'core', '*'); + let pluginsPath = path.join(rootPath, 'plugins', '*'); + + if (process.platform === 'win32') { + corePath = corePath.split(path.sep).join(path.posix.sep); + pluginsPath = pluginsPath.split(path.sep).join(path.posix.sep); + } + + const corePackageDirs = glob.sync(corePath); + const pluginsPackageDirs = glob.sync(pluginsPath); + + const plugins = [...corePackageDirs, ...pluginsPackageDirs] + .map((directory) => { + const isCoreAdmin = directory.includes('packages/core/admin'); + + if (isCoreAdmin) { + return null; + } + + const { name, strapi } = require(path.join(directory, 'package.json')); + + /** + * this will remove any of our packages that are + * not actually plugins for the application + */ + if (!strapi || strapi.kind !== 'plugin') { + return null; + } + + /** + * we want the name of the node_module + */ + return { + pathToPlugin: name, + name: strapi.name, + info: { ...strapi, packageName: name }, + directory, + }; + }) + .filter(filterPluginsByAdminEntry); + + if (Array.isArray(pluginsAllowlist)) { + return plugins.filter((plugin) => pluginsAllowlist.includes(plugin.pathToPlugin)); + } + + return plugins; +}; + +/** + * @type {(plugin: Plugin) => boolean} + */ +const filterPluginsByAdminEntry = (plugin) => { + if (!plugin) { + return false; + } + + /** + * There are two ways a plugin should be imported, either it's local to the strapi app, + * or it's an actual npm module that's installed and resolved via node_modules. + * + * We first check if the plugin is local to the strapi app, using a regular `resolve` because + * the pathToPlugin will be relative i.e. `/Users/my-name/strapi-app/src/plugins/my-plugin`. + * + * If the file doesn't exist well then it's probably a node_module, so instead we use `require.resolve` + * which will resolve the path to the module in node_modules. If it fails with the specific code `MODULE_NOT_FOUND` + * then it doesn't have an admin part to the package. + * + * NOTE: we should try to move to `./package.json[exports]` map with bundling of our own plugins, + * because these entry files are written in commonjs restricting features e.g. tree-shaking. + */ + try { + const isLocalPluginWithLegacyAdminFile = fs.existsSync( + path.resolve(`${plugin.pathToPlugin}/strapi-admin.js`) + ); + + if (!isLocalPluginWithLegacyAdminFile) { + const isModuleWithFE = require.resolve(`${plugin.pathToPlugin}/strapi-admin`); + + return isModuleWithFE; + } + + return isLocalPluginWithLegacyAdminFile; + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') { + /** + * the plugin does not contain FE code, so we + * don't want to import it anyway + */ + return false; + } + + throw err; + } +}; + +/** + * + * @param {Plugin[]} plugins + * @param {string} dest + * @returns {void} + */ +async function createPluginFile(plugins, dest) { + const pluginsArray = plugins.map(({ pathToPlugin, name, info }) => { + const shortName = camelCase(name); + + let realPath = ''; + + /** + * We're using a module here so we want to keep using the module resolution procedure. + */ + if (info?.packageName || info?.required) { + /** + * path.join, on windows, it uses backslashes to resolve path. + * The problem is that Webpack does not windows paths + * With this tool, we need to rely on "/" and not "\". + * This is the reason why '..\\..\\..\\node_modules\\@strapi\\plugin-content-type-builder/strapi-admin.js' was not working. + * The regexp at line 105 aims to replace the windows backslashes by standard slash so that webpack can deal with them. + * Backslash looks to work only for absolute paths with webpack => https://webpack.js.org/concepts/module-resolution/#absolute-paths + */ + realPath = path.join(pathToPlugin, 'strapi-admin').replace(/\\/g, '/'); + } else { + realPath = path + .join(path.relative(path.resolve(dest, 'admin', 'src'), pathToPlugin), 'strapi-admin') + .replace(/\\/g, '/'); + } + + return { + name, + pathToPlugin: realPath, + shortName, + }; + }); + + const content = ` +${pluginsArray + .map(({ pathToPlugin, shortName }) => { + const req = `'${pathToPlugin}'`; + + return `import ${shortName} from ${req};`; + }) + .join('\n')} + + +const plugins = { +${[...pluginsArray] + .map(({ name, shortName }) => { + return ` '${name}': ${shortName},`; + }) + .join('\n')} +}; + +export default plugins; +`; + + return asyncFs.writeFile(path.resolve(dest, 'admin', 'src', 'plugins.js'), content); +} + +/** + * @param {string[]} pluginsPath – an array of paths to the plugins from the user's directory + * @returns {RegExp} a regex that will exclude _all_ node_modules except for the plugins in the pluginsPath array. + */ +const createPluginsExcludePath = (pluginsPath = []) => { + /** + * If there aren't any plugins in the node_modules array, just return the node_modules regex + * without complicating it. + */ + if (pluginsPath.length === 0) { + return /node_modules/; + } + + return new RegExp(`node_modules/(?!(${pluginsPath.join('|')}))`); +}; + +module.exports = { + getPlugins, + filterPluginsByAdminEntry, + createPluginFile, + createPluginsExcludePath, +}; diff --git a/packages/core/admin/webpack.config.dev.js b/packages/core/admin/webpack.config.dev.js index e25ae5cf2a..1979fc6f0e 100644 --- a/packages/core/admin/webpack.config.dev.js +++ b/packages/core/admin/webpack.config.dev.js @@ -5,7 +5,7 @@ const path = require('path'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { DuplicateReporterPlugin } = require('duplicate-dependencies-webpack-plugin'); -const { getPlugins } = require('./utils/get-plugins'); +const { getPlugins } = require('./utils/plugins'); const webpackConfig = require('./webpack.config'); module.exports = () => { @@ -58,7 +58,7 @@ module.exports = () => { * as opposed to the compiled version of the code. This is useful for a better local DX. */ ...plugins.reduce((acc, plugin) => { - acc[`${plugin.name}/strapi-admin`] = path.join(plugin.directory, 'admin', 'src'); + acc[`${plugin.pathToPlugin}/strapi-admin`] = path.join(plugin.directory, 'admin', 'src'); return acc; }, {}), diff --git a/packages/core/admin/webpack.config.js b/packages/core/admin/webpack.config.js index dcd3bc2d94..a965d6c6f6 100644 --- a/packages/core/admin/webpack.config.js +++ b/packages/core/admin/webpack.config.js @@ -13,7 +13,7 @@ const browserslistToEsbuild = require('browserslist-to-esbuild'); const alias = require('./webpack.alias'); const getClientEnvironment = require('./env'); -const { createPluginsExcludePath } = require('./utils/create-plugins-exclude-path'); +const { createPluginsExcludePath } = require('./utils/plugins'); module.exports = ({ dest, @@ -85,7 +85,7 @@ module.exports = ({ module: { rules: [ { - test: /\.tsx?$/, + test: /\.(ts|tsx)$/, loader: require.resolve('esbuild-loader'), exclude: excludeRegex, options: { diff --git a/packages/core/helper-plugin/package.json b/packages/core/helper-plugin/package.json index a0a1e9e9c7..ad1be8215f 100644 --- a/packages/core/helper-plugin/package.json +++ b/packages/core/helper-plugin/package.json @@ -64,6 +64,7 @@ "@storybook/addon-mdx-gfm": "7.4.0", "@storybook/builder-vite": "7.4.0", "@storybook/react-vite": "7.4.0", + "@strapi/admin-test-utils": "4.13.2", "@strapi/design-system": "1.9.0", "@strapi/icons": "1.9.0", "@testing-library/react": "14.0.0", diff --git a/packages/core/strapi/lib/commands/actions/plugin/build-command/action.js b/packages/core/strapi/lib/commands/actions/plugin/build-command/action.js new file mode 100644 index 0000000000..cc29dbc894 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/plugin/build-command/action.js @@ -0,0 +1,137 @@ +'use strict'; + +const fs = require('fs/promises'); +const boxen = require('boxen'); +const chalk = require('chalk'); +const ora = require('ora'); +const { createLogger } = require('../../../utils/logger'); +const { notifyExperimentalCommand } = require('../../../utils/helpers'); +const { + loadPkg, + validatePkg, + validateExportsOrdering, + getExportExtensionMap, +} = require('../../../utils/pkg'); +const { createBuildContext, createBuildTasks } = require('../../../builders/packages'); +const { buildTaskHandlers } = require('../../../builders/tasks'); + +/** + * + * @param {object} args + * @param {boolean} args.force + * @param {boolean} args.debug + */ +module.exports = async ({ force, debug }) => { + const logger = createLogger({ debug, timestamp: false }); + try { + /** + * Notify users this is an experimental command and get them to approve first + * this can be opted out by setting the argument --yes + */ + await notifyExperimentalCommand({ force }); + + const cwd = process.cwd(); + + /** + * Load the closest package.json and then verify the structure against what we expect. + */ + const packageJsonLoader = ora('Verifying package.json \n').start(); + + const rawPkg = await loadPkg({ cwd, logger }).catch((err) => { + packageJsonLoader.fail(); + logger.error(err.message); + logger.debug(`Path checked – ${cwd}`); + process.exit(1); + }); + + const validatedPkg = await validatePkg({ + pkg: rawPkg, + }).catch((err) => { + packageJsonLoader.fail(); + logger.error(err.message); + process.exit(1); + }); + + /** + * Validate the exports of the package incl. the order of the + * exports within the exports map if applicable + */ + const packageJson = await validateExportsOrdering({ pkg: validatedPkg, logger }).catch( + (err) => { + packageJsonLoader.fail(); + logger.error(err.message); + process.exit(1); + } + ); + + packageJsonLoader.succeed('Verified package.json'); + + /** + * We create tasks based on the exports of the package.json + * their handlers are then ran in the order of the exports map + * and results are logged to see gradual progress. + */ + + const buildContextLoader = ora('Creating build context \n').start(); + + const extMap = getExportExtensionMap(); + + const ctx = await createBuildContext({ + cwd, + extMap, + logger, + pkg: packageJson, + }).catch((err) => { + buildContextLoader.fail(); + logger.error(err.message); + process.exit(1); + }); + + logger.debug('Build context: \n', ctx); + + const buildTasks = await createBuildTasks(ctx); + + buildContextLoader.succeed('Created build context'); + + /** + * If the distPath already exists, clean it + */ + try { + logger.debug(`Cleaning dist folder: ${ctx.distPath}`); + await fs.rm(ctx.distPath, { recursive: true, force: true }); + logger.debug('Cleaned dist folder'); + } catch { + // do nothing, it will fail if the folder does not exist + logger.debug('There was no dist folder to clean'); + } + + for (const task of buildTasks) { + /** + * @type {import('../../../builders/tasks').TaskHandler} + */ + const handler = buildTaskHandlers[task.type]; + handler.print(ctx, task); + + await handler.run(ctx, task).catch((err) => { + if (err instanceof Error) { + logger.error(err.message); + } + + process.exit(1); + }); + } + } catch (err) { + logger.error( + 'There seems to be an unexpected error, try again with --debug for more information \n' + ); + console.log( + chalk.red( + boxen(err.stack, { + padding: 1, + align: 'left', + }) + ) + ); + process.exit(1); + } +}; diff --git a/packages/core/strapi/lib/commands/actions/plugin/build-command/command.js b/packages/core/strapi/lib/commands/actions/plugin/build-command/command.js new file mode 100644 index 0000000000..0c387dd64e --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/plugin/build-command/command.js @@ -0,0 +1,17 @@ +'use strict'; + +const { forceOption } = require('../../../utils/commander'); +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi plugin:build` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('plugin:build') + .description('Bundle your strapi plugin for publishing.') + .addOption(forceOption) + .option('-d, --debug', 'Enable debugging mode with verbose logs', false) + .action(getLocalScript('plugin/build-command')); +}; diff --git a/packages/core/strapi/lib/commands/builders/__tests__/packages.test.js b/packages/core/strapi/lib/commands/builders/__tests__/packages.test.js new file mode 100644 index 0000000000..bd9b782d58 --- /dev/null +++ b/packages/core/strapi/lib/commands/builders/__tests__/packages.test.js @@ -0,0 +1,220 @@ +'use strict'; + +const { getExportExtensionMap } = require('../../utils/pkg'); +const { createBuildContext, createBuildTasks } = require('../packages'); + +describe('packages', () => { + const loggerMock = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const extMap = getExportExtensionMap(); + + const pkg = { + name: 'test', + version: '0.0.1', + exports: { + './package.json': './package.json', + '.': { + types: './dist/index.d.ts', + import: './dist/index.mjs', + require: './dist/index.js', + }, + }, + module: './dist/index.mjs', + main: './dist/index.js', + types: './dist/index.d.ts', + dependencies: { + react: '^17.0.2', + }, + devDependencies: { + typescript: '^4.3.5', + }, + peerDependencies: { + 'styled-components': '^5.3.1', + }, + }; + + describe('createBuildContext', () => { + it('should return a valid exports list', async () => { + const ctx = await createBuildContext({ + cwd: '/', + extMap, + logger: loggerMock, + pkg, + }); + + expect(ctx.exports).toMatchInlineSnapshot(` + { + ".": { + "default": "./dist/index.mjs", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "source": undefined, + "types": "./dist/index.d.ts", + }, + } + `); + }); + + it('should return a valid externals list', async () => { + const ctx = await createBuildContext({ + cwd: '/', + extMap, + logger: loggerMock, + pkg, + }); + + expect(ctx.external).toMatchInlineSnapshot(` + [ + "react", + "styled-components", + ] + `); + }); + + it("should return a valid 'dist' path", async () => { + const ctx = await createBuildContext({ + cwd: '/', + extMap, + logger: loggerMock, + pkg, + }); + + expect(ctx.distPath).toMatchInlineSnapshot(`"/dist"`); + }); + + it('should return a valid targets map', async () => { + const ctx = await createBuildContext({ + cwd: '/', + extMap, + logger: loggerMock, + pkg, + }); + + expect(ctx.targets).toMatchInlineSnapshot(` + { + "*": [ + "chrome114", + "edge113", + "firefox102", + "ios14", + "node16.0", + "safari14", + ], + "node": [ + "node16.0", + ], + "web": [ + "esnext", + ], + } + `); + }); + + it('parse the browserslist property in the pkg.json if available and set as the universal target in the targets map', async () => { + const ctx = await createBuildContext({ + cwd: '/', + extMap, + logger: loggerMock, + pkg: { + ...pkg, + browserslist: ['node 18'], + }, + }); + + expect(ctx.targets['*']).toMatchInlineSnapshot(` + [ + "node18.5", + ] + `); + }); + + it('should throw an error if the cwd and dist path are the same', async () => { + await expect( + createBuildContext({ + cwd: '/', + extMap, + logger: loggerMock, + pkg: { + ...pkg, + exports: { + './package.json': './package.json', + '.': { + types: './index.d.ts', + import: './index.mjs', + require: './index.js', + }, + }, + + module: './index.mjs', + main: './index.js', + types: './index.d.ts', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"all output files must share a common parent directory which is not the root package directory"` + ); + }); + }); + + describe('createBuildTasks', () => { + let ctx; + + beforeAll(async () => { + ctx = await createBuildContext({ + cwd: '/', + extMap, + logger: loggerMock, + pkg, + }); + }); + + it('should produce a valid list of build tasks', async () => { + const tasks = await createBuildTasks(ctx); + + expect(tasks).toMatchInlineSnapshot(` + [ + { + "entries": [ + { + "exportPath": ".", + "importId": "test", + "sourcePath": undefined, + "targetPath": "./dist/index.d.ts", + }, + ], + "type": "build:dts", + }, + { + "entries": [ + { + "entry": undefined, + "path": ".", + }, + ], + "format": "cjs", + "output": "./dist/index.js", + "runtime": "*", + "type": "build:js", + }, + { + "entries": [ + { + "entry": undefined, + "path": ".", + }, + ], + "format": "es", + "output": "./dist/index.mjs", + "runtime": "*", + "type": "build:js", + }, + ] + `); + }); + }); +}); diff --git a/packages/core/strapi/lib/commands/builders/packages.js b/packages/core/strapi/lib/commands/builders/packages.js new file mode 100644 index 0000000000..cb5ddbf09e --- /dev/null +++ b/packages/core/strapi/lib/commands/builders/packages.js @@ -0,0 +1,252 @@ +'use strict'; + +const path = require('path'); +const browserslistToEsbuild = require('browserslist-to-esbuild'); + +const { parseExports } = require('../utils/pkg'); + +/** + * @typedef {Object} BuildContextArgs + * @property {string} cwd + * @property {import('../utils/pkg').ExtMap} extMap + * @property {import('../utils/logger').Logger} logger + * @property {import('../utils/pkg').PackageJson} pkg + */ + +/** + * @typedef {Object} Targets + * @property {string[]} node + * @property {string[]} web + * @property {string[]} * + */ + +/** + * @typedef {Object} BuildContext + * @property {string} cwd + * @property {import('../utils/pkg').Export[]} exports + * @property {string[]} external + * @property {import('../utils/pkg').ExtMap} extMap + * @property {import('../utils/logger').Logger} logger + * @property {import('../utils/pkg').PackageJson} pkg + * @property {Targets} targets + */ + +const DEFAULT_BROWSERS_LIST_CONFIG = [ + 'last 3 major versions', + 'Firefox ESR', + 'last 2 Opera versions', + 'not dead', + 'node 16.0.0', +]; + +/** + * @description Create a build context for the pipeline we're creating, + * this is shared among tasks so they all use the same settings for core pieces + * such as a target, distPath, externals etc. + * + * @type {(args: BuildContextArgs) => Promise} + */ +const createBuildContext = async ({ cwd, extMap, logger, pkg }) => { + const targets = { + '*': browserslistToEsbuild(pkg.browserslist ?? DEFAULT_BROWSERS_LIST_CONFIG), + node: browserslistToEsbuild(['node 16.0.0']), + web: ['esnext'], + }; + + const exports = parseExports({ extMap, pkg }).reduce((acc, x) => { + const { _path: exportPath, ...exportEntry } = x; + + return { ...acc, [exportPath]: exportEntry }; + }, {}); + + const external = [ + ...(pkg.dependencies ? Object.keys(pkg.dependencies) : []), + ...(pkg.peerDependencies ? Object.keys(pkg.peerDependencies) : []), + ]; + + const outputPaths = Object.values(exports) + .flatMap((exportEntry) => { + return [exportEntry.import, exportEntry.require].filter(Boolean); + }) + .map((p) => path.resolve(cwd, p)); + + const distPath = findCommonDirPath(outputPaths); + + if (distPath === cwd) { + throw new Error( + 'all output files must share a common parent directory which is not the root package directory' + ); + } + + if (!distPath) { + throw new Error("could not detect 'dist' path"); + } + + return { + logger, + cwd, + pkg, + exports, + external, + distPath, + targets, + extMap, + }; +}; + +/** + * @type {(containerPath: string, itemPath: string) => boolean} + */ +const pathContains = (containerPath, itemPath) => { + return !path.relative(containerPath, itemPath).startsWith('..'); +}; + +/** + * @type {(filePaths: string[]) => string | undefined} + */ +const findCommonDirPath = (filePaths) => { + /** + * @type {string | undefined} + */ + let commonPath; + + for (const filePath of filePaths) { + let dirPath = path.dirname(filePath); + + if (!commonPath) { + commonPath = dirPath; + // eslint-disable-next-line no-continue + continue; + } + + while (dirPath !== commonPath) { + dirPath = path.dirname(dirPath); + + if (dirPath === commonPath) { + break; + } + + if (pathContains(dirPath, commonPath)) { + commonPath = dirPath; + break; + } + + if (dirPath === '.') return undefined; + } + } + + return commonPath; +}; + +/** + * @typedef {import('./tasks/vite').ViteTask | import('./tasks/dts').DtsTask} BuildTask + */ + +/** + * @description Create the build tasks for the pipeline, this + * comes from the exports map we've created in the build context. + * But handles each export line uniquely with space to add more + * as the standard develops. + * + * @type {(args: BuildContext) => Promise} + */ +const createBuildTasks = async (ctx) => { + /** + * @type {BuildTask[]} + */ + const tasks = []; + + /** + * @type {import('./tasks/dts').DtsTask} + */ + const dtsTask = { + type: 'build:dts', + entries: [], + }; + + /** + * @type {Record} + */ + const viteTasks = {}; + + const createViteTask = (format, runtime, { output, ...restEntry }) => { + const buildId = `${format}:${output}`; + + if (viteTasks[buildId]) { + viteTasks[buildId].entries.push(restEntry); + + if (output !== viteTasks[buildId].output) { + ctx.logger.warn( + 'Multiple entries with different outputs for the same format are not supported. The first output will be used.' + ); + } + } else { + viteTasks[buildId] = { + type: 'build:js', + format, + output, + runtime, + entries: [restEntry], + }; + } + }; + + const exps = Object.entries(ctx.exports).map(([exportPath, exportEntry]) => ({ + ...exportEntry, + _path: exportPath, + })); + + for (const exp of exps) { + if (exp.types) { + const importId = path.join(ctx.pkg.name, exp._path); + + dtsTask.entries.push({ + importId, + exportPath: exp._path, + sourcePath: exp.source, + targetPath: exp.types, + }); + } + + /** + * @type {keyof Target} + */ + // eslint-disable-next-line no-nested-ternary + const runtime = exp._path.includes('strapi-admin') + ? 'web' + : exp._path.includes('strapi-server') + ? 'node' + : '*'; + + if (exp.require) { + /** + * register CJS task + */ + createViteTask('cjs', runtime, { + path: exp._path, + entry: exp.source, + output: exp.require, + }); + } + + if (exp.import) { + /** + * register ESM task + */ + createViteTask('es', runtime, { + path: exp._path, + entry: exp.source, + output: exp.import, + }); + } + } + + tasks.push(dtsTask, ...Object.values(viteTasks)); + + return tasks; +}; + +module.exports = { + createBuildContext, + createBuildTasks, +}; diff --git a/packages/core/strapi/lib/commands/builders/tasks/dts.js b/packages/core/strapi/lib/commands/builders/tasks/dts.js new file mode 100644 index 0000000000..e9e741770f --- /dev/null +++ b/packages/core/strapi/lib/commands/builders/tasks/dts.js @@ -0,0 +1,199 @@ +'use strict'; + +const path = require('path'); +const chalk = require('chalk'); +const ora = require('ora'); +const ts = require('typescript'); + +/** + * @description Load a tsconfig.json file and return the parsed config + * + * @internal + * + * @type {(args: { cwd: string; path: string }) => Promise)} + */ +const loadTsConfig = async ({ cwd, path }) => { + const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, path); + + if (!configPath) { + throw new TSConfigNotFoundError(`could not find a valid '${path}'`); + } + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + + return ts.parseJsonConfigFileContent(configFile.config, ts.sys, cwd); +}; + +class TSConfigNotFoundError extends Error { + // eslint-disable-next-line no-useless-constructor + constructor(message, options) { + super(message, options); + } + + get code() { + return 'TS_CONFIG_NOT_FOUND'; + } +} + +/** + * @description + * + * @internal + * + * @type {(args: { cwd: string; logger: import('../../utils/logger').Logger; outDir: string; tsconfig: ts.ParsedCommandLine }) => Promise} + */ +const buildTypes = ({ cwd, logger, outDir, tsconfig }) => { + const compilerOptions = { + ...tsconfig.options, + declaration: true, + declarationDir: outDir, + emitDeclarationOnly: true, + noEmit: false, + outDir, + }; + + const program = ts.createProgram(tsconfig.fileNames, compilerOptions); + + const emitResult = program.emit(); + + const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics); + + for (const diagnostic of allDiagnostics) { + if (diagnostic.file && diagnostic.start) { + const { line, character } = ts.getLineAndCharacterOfPosition( + diagnostic.file, + diagnostic.start + ); + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + + const file = path.relative(cwd, diagnostic.file.fileName); + + const output = [ + `${chalk.cyan(file)}:${chalk.cyan(line + 1)}:${chalk.cyan(character + 1)} - `, + `${chalk.gray(`TS${diagnostic.code}:`)} ${message}`, + ].join(''); + + if (diagnostic.category === ts.DiagnosticCategory.Error) { + logger.error(output); + } + + if (diagnostic.category === ts.DiagnosticCategory.Warning) { + logger.warn(output); + } + + if (diagnostic.category === ts.DiagnosticCategory.Message) { + logger.info(output); + } + + if (diagnostic.category === ts.DiagnosticCategory.Suggestion) { + logger.info(output); + } + } else { + logger.info(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); + } + } + + if (emitResult.emitSkipped) { + const errors = allDiagnostics.filter((diag) => diag.category === ts.DiagnosticCategory.Error); + + if (errors.length) { + throw new Error('Failed to compile TypeScript definitions'); + } + } +}; + +/** + * @typedef {Object} DtsTaskEntry + * @property {string} exportPath + * @property {string} sourcePath + * @property {string} targetPath + */ + +/** + * @typedef {Object} DtsTask + * @property {"build:dts"} type + * @property {DtsTaskEntry[]} entries + */ + +/** + * @type {import('./index').TaskHandler} + */ +const dtsTask = { + _spinner: null, + print(ctx, task) { + const entries = [ + ' entries:', + ...task.entries.map((entry) => + [ + ` – `, + chalk.green(`${entry.importId} `), + `${chalk.cyan(entry.sourcePath)} ${chalk.gray('→')} ${chalk.cyan(entry.targetPath)}`, + ].join('') + ), + '', + ]; + + this._spinner = ora(`Building type files:\n`).start(); + + ctx.logger.log([...entries].join('\n')); + }, + async run(ctx, task) { + try { + await Promise.all( + task.entries.map(async (entry) => { + const config = await loadTsConfig({ + /** + * TODO: this will not scale and assumes all project sourcePaths are `src/index.ts` + * so we can go back to the "root" of the project... + */ + cwd: path.join(ctx.cwd, entry.sourcePath, '..', '..'), + path: 'tsconfig.build.json', + }).catch((err) => { + if (err instanceof TSConfigNotFoundError) { + return undefined; + } + + throw err; + }); + + if (config) { + ctx.logger.debug(`TS config for '${entry.sourcePath}': \n`, config); + } else { + ctx.logger.warn( + `You've added a types entry but no tsconfig.json was found for ${entry.targetPath}. Skipping...` + ); + + return; + } + + const { outDir } = config.raw.compilerOptions; + + if (!outDir) { + throw new Error("tsconfig.json is missing 'compilerOptions.outDir'"); + } + + await buildTypes({ + cwd: ctx.cwd, + logger: ctx.logger, + outDir: path.relative(ctx.cwd, outDir), + tsconfig: config, + }); + }) + ); + + await this.success(ctx, task); + } catch (err) { + this.fail(ctx, task, err); + } + }, + async success() { + this._spinner.succeed('Built type files'); + }, + async fail(ctx, task, err) { + this._spinner.fail('Failed to build type files'); + + throw err; + }, +}; + +module.exports = { dtsTask }; diff --git a/packages/core/strapi/lib/commands/builders/tasks/index.js b/packages/core/strapi/lib/commands/builders/tasks/index.js new file mode 100644 index 0000000000..4fc4ebfefa --- /dev/null +++ b/packages/core/strapi/lib/commands/builders/tasks/index.js @@ -0,0 +1,29 @@ +'use strict'; + +const { dtsTask } = require('./dts'); +const { viteTask } = require('./vite'); + +/** + * @template Task + * @param {Task} + * @returns {Task} + * + * @typedef {Object} TaskHandler + * @property {(ctx: import("../packages").BuildContext, task: Task) => import('ora').Ora} print + * @property {(ctx: import("../packages").BuildContext, task: Task) => Promise} run + * @property {(ctx: import("../packages").BuildContext, task: Task) => Promise} success + * @property {(ctx: import("../packages").BuildContext, task: Task, err: unknown) => Promise} fail + * @property {import('ora').Ora | null} _spinner + */ + +/** + * @type {{ "build:js": TaskHandler; "build:dts": TaskHandler; }}} + */ +const buildTaskHandlers = { + 'build:js': viteTask, + 'build:dts': dtsTask, +}; + +module.exports = { + buildTaskHandlers, +}; diff --git a/packages/core/strapi/lib/commands/builders/tasks/vite.js b/packages/core/strapi/lib/commands/builders/tasks/vite.js new file mode 100644 index 0000000000..a262b2111f --- /dev/null +++ b/packages/core/strapi/lib/commands/builders/tasks/vite.js @@ -0,0 +1,144 @@ +'use strict'; + +const path = require('path'); +const { build, createLogger } = require('vite'); +const react = require('@vitejs/plugin-react'); +const ora = require('ora'); +const chalk = require('chalk'); + +/** + * @internal + * + * @type {(ctx: import('../packages').BuildContext, task: ViteTask) => import('vite').UserConfig} + */ +const resolveViteConfig = (ctx, task) => { + const { cwd, distPath, targets, external, extMap, pkg } = ctx; + const { entries, format, output, runtime } = task; + const outputExt = extMap[pkg.type || 'commonjs'][format]; + const outDir = path.relative(cwd, distPath); + + const customLogger = createLogger(); + customLogger.warn = (msg) => ctx.logger.warn(msg); + customLogger.warnOnce = (msg) => ctx.logger.warn(msg); + customLogger.error = (msg) => ctx.logger.error(msg); + + /** + * @type {import('vite').InlineConfig} + */ + const config = { + configFile: false, + root: cwd, + mode: 'production', + logLevel: 'warn', + clearScreen: false, + customLogger, + build: { + sourcemap: true, + /** + * The task runner will clear this for us + */ + emptyOutDir: false, + target: targets[runtime], + outDir, + lib: { + entry: entries.map((e) => e.entry), + formats: [format], + /** + * this enforces the file name to match what the output we've + * determined from the package.json exports. + */ + fileName() { + return `${path.relative(outDir, output).replace(/\.[^/.]+$/, '')}${outputExt}`; + }, + }, + rollupOptions: { + external, + output: { + chunkFileNames() { + const parts = outputExt.split('.'); + + if (parts.length === 3) { + return `_chunks/[name]-[hash].${parts[2]}`; + } + + return `_chunks/[name]-[hash]${outputExt}`; + }, + }, + }, + }, + /** + * We _could_ omit this, but we'd need to introduce the + * concept of a custom config for the scripts straight away + * + * and since this is isolated to the Strapi CLI, we can make + * some assumptions and add some weight until we move it outside. + */ + plugins: runtime === 'node' ? [] : [react()], + }; + + return config; +}; + +/** + * @typedef {Object} ViteTaskEntry + * @property {string} path + * @property {string} entry + */ + +/** + * @typedef {Object} ViteTask + * @property {"build:js"} type + * @property {ViteTaskEntry[]} entries + * @property {string} format + * @property {string} output + * @property {keyof import('../packages').Targets} runtime + */ + +/** + * @type {import('./index').TaskHandler} + */ +const viteTask = { + _spinner: null, + print(ctx, task) { + const targetLines = [ + ' target:', + ...ctx.targets[task.runtime].map((t) => chalk.cyan(` - ${t}`)), + ]; + const entries = [ + ' entries:', + ...task.entries.map((entry) => + [ + ` – `, + chalk.green(`${path.join(ctx.pkg.name, entry.path)}: `), + `${chalk.cyan(entry.entry)} ${chalk.gray('→')} ${chalk.cyan(task.output)}`, + ].join('') + ), + ]; + + this._spinner = ora(`Building javascript files:\n`).start(); + + ctx.logger.log([` format: ${task.format}`, ...targetLines, ...entries].join('\n')); + }, + async run(ctx, task) { + try { + const config = resolveViteConfig(ctx, task); + ctx.logger.debug('Vite config: \n', config); + await build(config); + await this.success(ctx, task); + } catch (err) { + this.fail(ctx, task, err); + } + }, + async success() { + this._spinner.succeed('Built javascript files'); + }, + async fail(ctx, task, err) { + this._spinner.fail('Failed to build javascript files'); + + throw err; + }, +}; + +module.exports = { + viteTask, +}; diff --git a/packages/core/strapi/lib/commands/index.js b/packages/core/strapi/lib/commands/index.js index 3830ed0bbb..ca053e715f 100644 --- a/packages/core/strapi/lib/commands/index.js +++ b/packages/core/strapi/lib/commands/index.js @@ -20,6 +20,7 @@ const strapiCommands = { install: require('./actions/install/command'), 'middlewares/list': require('./actions/middlewares/list/command'), new: require('./actions/new/command'), + 'plugin/build': require('./actions/plugin/build-command/command'), 'policies/list': require('./actions/policies/list/command'), report: require('./actions/report/command'), 'routes/list': require('./actions/routes/list/command'), diff --git a/packages/core/strapi/lib/commands/utils/__tests__/fixtures/test.pkg.json b/packages/core/strapi/lib/commands/utils/__tests__/fixtures/test.pkg.json new file mode 100644 index 0000000000..85a32fd882 --- /dev/null +++ b/packages/core/strapi/lib/commands/utils/__tests__/fixtures/test.pkg.json @@ -0,0 +1,4 @@ +{ + "name": "testing", + "version": "0.0.0" +} diff --git a/packages/core/strapi/lib/commands/utils/__tests__/pkg.test.js b/packages/core/strapi/lib/commands/utils/__tests__/pkg.test.js new file mode 100644 index 0000000000..e3252dfb0f --- /dev/null +++ b/packages/core/strapi/lib/commands/utils/__tests__/pkg.test.js @@ -0,0 +1,509 @@ +'use strict'; + +const fs = require('fs/promises'); +const path = require('path'); + +const { + loadPkg, + validatePkg, + validateExportsOrdering, + parseExports, + getExportExtensionMap, +} = require('../pkg'); + +const loggerMock = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +describe('pkg', () => { + const tmpfolder = path.resolve(__dirname, '.tmp'); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('loadPkg', () => { + beforeEach(async () => { + await fs.mkdir(tmpfolder); + await fs.copyFile( + path.resolve(__dirname, 'fixtures', 'test.pkg.json'), + path.resolve(tmpfolder, 'package.json') + ); + }); + + afterEach(async () => { + await fs.rm(tmpfolder, { recursive: true }); + }); + + it('should succesfully load the package.json closest to the cwd provided & call the debug logger', async () => { + const pkg = await loadPkg({ cwd: tmpfolder, logger: loggerMock }); + + expect(pkg).toMatchInlineSnapshot(` + { + "name": "testing", + "version": "0.0.0", + } + `); + + expect(loggerMock.debug).toHaveBeenCalled(); + }); + + it('should throw an error if it cannot find a package.json', async () => { + await expect( + loadPkg({ cwd: '/', logger: loggerMock }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not find a package.json in the current directory"` + ); + }); + }); + + describe('validatePkg', () => { + it("should return the validated package.json if it's valid", async () => { + const pkg = { + name: 'testing', + version: '0.0.0', + }; + + const validatedPkg = await validatePkg({ pkg }); + + expect(validatedPkg).toMatchInlineSnapshot(` + { + "name": "testing", + "version": "0.0.0", + } + `); + }); + + it('should fail if a required field is missing and call the error logger with the correct message', async () => { + expect(() => + validatePkg({ + pkg: { + version: '0.0.0', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"'name' in 'package.json' is required as type 'string'"` + ); + + expect(() => + validatePkg({ + pkg: { + name: 'testing', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"'version' in 'package.json' is required as type 'string'"` + ); + }); + + it('should fail if a required field does not match the correct type and call the error logger with the correct message', async () => { + expect(() => + validatePkg({ + pkg: { + name: 'testing', + version: 0, + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"'version' in 'package.json' must be of type 'string' (recieved 'number')"` + ); + }); + + it("should fail if the regex for a field doesn't match and call the error logger with the correct message", async () => { + expect(() => + validatePkg({ + pkg: { + name: 'testing', + version: '0.0.0', + exports: { + apple: './apple.xyzx', + }, + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"'exports.apple' in 'package.json' must be of type '/^\\.\\/.*\\.json$/' (recieved the value './apple.xyzx')"` + ); + + expect(() => + validatePkg({ + pkg: { + name: 'testing', + version: '0.0.0', + type: 'something', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"'type' in 'package.json' must be of type '/(commonjs|module)/' (recieved the value 'something')"` + ); + }); + + it('should fail if the exports object does not match expectations', async () => { + expect(() => + validatePkg({ + pkg: { + name: 'testing', + version: '0.0.0', + exports: 'hello', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"'exports' in 'package.json' must be of type 'object' (recieved 'string')"` + ); + + expect(() => + validatePkg({ + pkg: { + name: 'testing', + version: '0.0.0', + exports: { + './package.json': './package.json', + './admin': { + import: './admin/index.js', + something: 'xyz', + }, + }, + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"'exports["./admin"]' in 'package.json' contains the unknown key something, for compatability only the following keys are allowed: ['types', 'source', 'import', 'require', 'default']"` + ); + }); + }); + + describe('validateExportsOrdering', () => { + it('should throw if there are no exports at all and log that error', async () => { + const pkg = { + name: 'testing', + version: '0.0.0', + }; + + await expect( + validateExportsOrdering({ pkg, logger: loggerMock }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"'package.json' must contain a 'main' and 'module' property"` + ); + }); + + it("should return the package if there is at least a 'main' or 'module' property", async () => { + const pkg = { + name: 'testing', + version: '0.0.0', + main: './index.js', + }; + + const validatedPkg = await validateExportsOrdering({ pkg, logger: loggerMock }); + + expect(validatedPkg).toMatchInlineSnapshot(` + { + "main": "./index.js", + "name": "testing", + "version": "0.0.0", + } + `); + }); + + it('should return the package if there is an exports property with a valid structure', async () => { + const pkg = { + name: 'testing', + version: '0.0.0', + exports: { + './package.json': './package.json', + './admin': { + types: './admin/index.d.ts', + import: './admin/index.js', + require: './admin/index.cjs', + default: './admin/index.js', + }, + }, + }; + + const validatedPkg = await validateExportsOrdering({ pkg, logger: loggerMock }); + + expect(validatedPkg).toMatchInlineSnapshot(` + { + "exports": { + "./admin": { + "default": "./admin/index.js", + "import": "./admin/index.js", + "require": "./admin/index.cjs", + "types": "./admin/index.d.ts", + }, + "./package.json": "./package.json", + }, + "name": "testing", + "version": "0.0.0", + } + `); + }); + + it('should throw if the types property is not the first in an export object', async () => { + const pkg = { + name: 'testing', + version: '0.0.0', + exports: { + './admin': { + import: './admin/index.js', + types: './admin/index.d.ts', + }, + }, + }; + + await expect( + validateExportsOrdering({ pkg, logger: loggerMock }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"exports["./admin"]: the 'types' property should be the first property"` + ); + }); + + it('should log a warning if the require property comes before the import property in an export object', async () => { + const pkg = { + name: 'testing', + version: '0.0.0', + exports: { + './admin': { + require: './admin/index.cjs', + import: './admin/index.js', + }, + }, + }; + + await validateExportsOrdering({ pkg, logger: loggerMock }); + + expect(loggerMock.warn.mock.calls[0]).toMatchInlineSnapshot(` + [ + "exports["./admin"]: the 'import' property should come before the 'require' property", + ] + `); + }); + + it('should log a warning if the import property comes the module property in an export object', async () => { + const pkg = { + name: 'testing', + version: '0.0.0', + exports: { + './admin': { + import: './admin/index.js', + module: './admin/index.js', + }, + }, + }; + + await validateExportsOrdering({ pkg, logger: loggerMock }); + + expect(loggerMock.warn.mock.calls[0]).toMatchInlineSnapshot(` + [ + "exports["./admin"]: the 'module' property should come before 'import' property", + ] + `); + }); + }); + + describe('getExportExtensionMap', () => { + it('should just return the default mapping', async () => { + expect(getExportExtensionMap()).toMatchInlineSnapshot(` + { + "commonjs": { + "cjs": ".js", + "es": ".mjs", + }, + "module": { + "cjs": ".cjs", + "es": ".js", + }, + } + `); + }); + }); + + describe('parseExports', () => { + const extMap = getExportExtensionMap(); + + it('should by default return a root exports map using the standard export fields from the pkg.json', () => { + const pkg = { + types: './dist/index.d.ts', + main: './dist/index.js', + module: './dist/index.mjs', + source: './src/index.ts', + }; + + expect(parseExports({ pkg, extMap })).toMatchInlineSnapshot(` + [ + { + "_path": ".", + "default": "./dist/index.mjs", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + }, + ] + `); + }); + + it("should not return anything if the standard export fields don't exist and there is no export map", () => { + const pkg = {}; + + expect(parseExports({ pkg, extMap })).toMatchInlineSnapshot(`[]`); + }); + + it('should return a combination of the standard export fields and the export map if they both exist', () => { + const pkg = { + types: './dist/index.d.ts', + main: './dist/index.js', + module: './dist/index.mjs', + source: './src/index.ts', + exports: { + './package.json': './package.json', + './admin': { + types: './admin/index.d.ts', + import: './admin/index.mjs', + require: './admin/index.js', + default: './admin/index.js', + source: './src/admin/index.js', + }, + }, + }; + + expect(parseExports({ pkg, extMap })).toMatchInlineSnapshot(` + [ + { + "_path": ".", + "default": "./dist/index.mjs", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + }, + { + "_exported": true, + "_path": "./admin", + "default": "./admin/index.js", + "import": "./admin/index.mjs", + "require": "./admin/index.js", + "source": "./src/admin/index.js", + "types": "./admin/index.d.ts", + }, + ] + `); + }); + + it('should return just the exports map if there are no standard export fields and the export map exists', () => { + const pkg = { + exports: { + './package.json': './package.json', + './admin': { + types: './admin/index.d.ts', + import: './admin/index.mjs', + require: './admin/index.js', + default: './admin/index.js', + source: './src/admin/index.js', + }, + }, + }; + + expect(parseExports({ pkg, extMap })).toMatchInlineSnapshot(` + [ + { + "_exported": true, + "_path": "./admin", + "default": "./admin/index.js", + "import": "./admin/index.mjs", + "require": "./admin/index.js", + "source": "./src/admin/index.js", + "types": "./admin/index.d.ts", + }, + ] + `); + }); + + it('should throw an error if you try to use an exports map without supplying an export for the package.json file', () => { + const pkg = { + exports: { + './admin': { + types: './admin/index.d.ts', + import: './admin/index.mjs', + require: './admin/index.js', + default: './admin/index.js', + source: './src/admin/index.js', + }, + }, + }; + + expect(() => parseExports({ pkg, extMap })).toThrowErrorMatchingInlineSnapshot(` + " + - package.json: \`exports["./package.json"] must be declared." + `); + }); + + it('should throw an error if the pkg.json type is undefined and you try to export like a module', () => { + const pkg = { + exports: { + './package.json': './package.json', + './admin': { + types: './admin/index.d.ts', + import: './admin/index.js', + require: './admin/index.cjs', + default: './admin/index.cjs', + source: './src/admin/index.js', + }, + }, + }; + + expect(() => parseExports({ pkg, extMap, type: 'module' })) + .toThrowErrorMatchingInlineSnapshot(` + " + - package.json with \`type: "undefined"\` - \`exports["./admin"].require\` must end with ".js" + - package.json with \`type: "undefined"\` - \`exports["./admin"].import\` must end with ".mjs"" + `); + }); + + it('should throw an error if the pkg.json type is commonjs and you try to export like a module', () => { + const pkg = { + type: 'commonjs', + exports: { + './package.json': './package.json', + './admin': { + types: './admin/index.d.ts', + import: './admin/index.js', + require: './admin/index.cjs', + default: './admin/index.cjs', + source: './src/admin/index.js', + }, + }, + }; + + expect(() => parseExports({ pkg, extMap, type: 'module' })) + .toThrowErrorMatchingInlineSnapshot(` + " + - package.json with \`type: "commonjs"\` - \`exports["./admin"].require\` must end with ".js" + - package.json with \`type: "commonjs"\` - \`exports["./admin"].import\` must end with ".mjs"" + `); + }); + + it('should throw an error if the pkg.json type is module and you try to export like a commonjs', () => { + const pkg = { + type: 'module', + exports: { + './package.json': './package.json', + './admin': { + types: './admin/index.d.ts', + import: './admin/index.mjs', + require: './admin/index.js', + default: './admin/index.js', + source: './src/admin/index.js', + }, + }, + }; + + expect(() => parseExports({ pkg, extMap, type: 'module' })) + .toThrowErrorMatchingInlineSnapshot(` + " + - package.json with \`type: "module"\` - \`exports["./admin"].require\` must end with ".cjs" + - package.json with \`type: "module"\` - \`exports["./admin"].import\` must end with ".js"" + `); + }); + }); +}); diff --git a/packages/core/strapi/lib/commands/utils/helpers.js b/packages/core/strapi/lib/commands/utils/helpers.js index c106fccf7b..58090698ae 100644 --- a/packages/core/strapi/lib/commands/utils/helpers.js +++ b/packages/core/strapi/lib/commands/utils/helpers.js @@ -8,6 +8,9 @@ const { yellow, red, green } = require('chalk'); const { isString, isArray } = require('lodash/fp'); const resolveCwd = require('resolve-cwd'); const { has } = require('lodash/fp'); +const { prompt } = require('inquirer'); +const boxen = require('boxen'); +const chalk = require('chalk'); const bytesPerKb = 1024; const sizes = ['B ', 'KB', 'MB', 'GB', 'TB', 'PB']; @@ -121,7 +124,10 @@ const assertCwdContainsStrapiProject = (name) => { try { const pkgJSON = require(`${process.cwd()}/package.json`); - if (!has('dependencies.@strapi/strapi', pkgJSON)) { + if ( + !has('dependencies.@strapi/strapi', pkgJSON) && + !has('devDependencies.@strapi/strapi', pkgJSON) + ) { logErrorAndExit(name); } } catch (err) { @@ -156,6 +162,51 @@ const getLocalScript = }); }; +/** + * @description Notify users this is an experimental command and get them to approve first + * this can be opted out by passing `yes` as a property of the args object. + * + * @type {(args?: { force?: boolean }) => Promise} + * + * @example + * ```ts + * const { notifyExperimentalCommand } = require('../utils/helpers'); + * + * const myCommand = async ({ force }) => { + * await notifyExperimentalCommand({ force }); + * } + * ``` + */ +const notifyExperimentalCommand = async ({ force } = {}) => { + console.log( + boxen( + `The ${chalk.bold( + chalk.underline('plugin:build') + )} command is considered experimental, use at your own risk.`, + { + title: 'Warning', + padding: 1, + margin: 1, + align: 'center', + borderColor: 'yellow', + borderStyle: 'bold', + } + ) + ); + + if (!force) { + const { confirmed } = await prompt({ + type: 'confirm', + name: 'confirmed', + message: 'Do you want to continue?', + }); + + if (!confirmed) { + process.exit(0); + } + } +}; + module.exports = { exitWith, assertUrlHasProtocol, @@ -163,4 +214,5 @@ module.exports = { readableBytes, getLocalScript, assertCwdContainsStrapiProject, + notifyExperimentalCommand, }; diff --git a/packages/core/strapi/lib/commands/utils/logger.js b/packages/core/strapi/lib/commands/utils/logger.js new file mode 100644 index 0000000000..d9ca0e6e23 --- /dev/null +++ b/packages/core/strapi/lib/commands/utils/logger.js @@ -0,0 +1,97 @@ +'use strict'; + +const chalk = require('chalk'); + +/** + * @typedef {{ silent?: boolean; debug?: boolean; timestamp?: boolean; }} LoggerOptions + */ + +/** + * @typedef {object} Logger + * @property {number} warnings + * @property {number} errors + * @property {(...args: any[]) => void} debug + * @property {(...args: any[]) => void} info + * @property {(...args: any[]) => void} warn + * @property {(...args: any[]) => void} error + * @property {(...args: any[]) => void} log + */ + +/** + * @type {(options: LoggerOptions) => Logger} + */ +const createLogger = (options = {}) => { + const { silent = false, debug = false, timestamp = true } = options; + + const state = { errors: 0, warning: 0 }; + + return { + get warnings() { + return state.warning; + }, + + get errors() { + return state.errors; + }, + + debug(...args) { + if (silent || !debug) { + return; + } + + console.log( + chalk.cyan(`[DEBUG]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args + ); + }, + + info(...args) { + if (silent) { + return; + } + + console.info( + chalk.blue(`[INFO]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args + ); + }, + + log(...args) { + if (silent) { + return; + } + + console.info(chalk.blue(`${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), ...args); + }, + + warn(...args) { + state.warning += 1; + + if (silent) { + return; + } + + console.warn( + chalk.yellow(`[WARN]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args + ); + }, + + error(...args) { + state.errors += 1; + + if (silent) { + return; + } + + console.error( + chalk.red(`[ERROR]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args + ); + }, + }; +}; + +module.exports = { + createLogger, +}; diff --git a/packages/core/strapi/lib/commands/utils/pkg.js b/packages/core/strapi/lib/commands/utils/pkg.js new file mode 100644 index 0000000000..45f599ecaa --- /dev/null +++ b/packages/core/strapi/lib/commands/utils/pkg.js @@ -0,0 +1,421 @@ +'use strict'; + +const fs = require('fs/promises'); +const path = require('path'); +const chalk = require('chalk'); +const yup = require('yup'); + +/** + * Utility functions for loading and validating package.json + * this includes the specific validation of specific parts of + * the package.json. + */ + +/** + * The schema for the package.json that we expect, + * currently pretty loose. + */ +const packageJsonSchema = yup.object({ + name: yup.string().required(), + version: yup.string().required(), + type: yup.string().matches(/(commonjs|module)/), + license: yup.string(), + bin: yup.mixed().oneOf([ + yup.string(), + yup.object({ + [yup.string()]: yup.string(), + }), + ]), + main: yup.string(), + module: yup.string(), + source: yup.string(), + types: yup.string(), + exports: yup.lazy((value) => + yup.object( + typeof value === 'object' + ? Object.entries(value).reduce((acc, [key, value]) => { + if (typeof value === 'object') { + acc[key] = yup + .object({ + types: yup.string(), + source: yup.string(), + import: yup.string(), + require: yup.string(), + default: yup.string(), + }) + .noUnknown(true); + } else { + acc[key] = yup + .string() + .matches(/^\.\/.*\.json$/) + .required(); + } + + return acc; + }, {}) + : undefined + ) + ), + files: yup.array(yup.string()), + scripts: yup.object(), + dependencies: yup.object(), + devDependencies: yup.object(), + peerDependencies: yup.object(), + engines: yup.object(), +}); + +/** + * @typedef {import('yup').Asserts} PackageJson + */ + +/** + * @description being a task to load the package.json starting from the current working directory + * using a shallow find for the package.json and `fs` to read the file. If no package.json is found, + * the process will throw with an appropriate error message. + * + * @type {(args: { cwd: string, logger: import('./logger').Logger }) => Promise} + */ +const loadPkg = async ({ cwd, logger }) => { + const directory = path.resolve(cwd); + + const pkgPath = path.join(directory, 'package.json'); + + const buffer = await fs.readFile(pkgPath).catch((err) => { + logger.debug(err); + throw new Error('Could not find a package.json in the current directory'); + }); + + const pkg = JSON.parse(buffer.toString()); + + logger.debug('Loaded package.json: \n', pkg); + + return pkg; +}; + +/** + * @description validate the package.json against a standardised schema using `yup`. + * If the validation fails, the process will throw with an appropriate error message. + * + * @type {(args: { pkg: object }) => Promise} + */ +const validatePkg = async ({ pkg }) => { + try { + const validatedPkg = await packageJsonSchema.validate(pkg, { + strict: true, + }); + + return validatedPkg; + } catch (err) { + if (err instanceof yup.ValidationError) { + switch (err.type) { + case 'required': + throw new Error( + `'${err.path}' in 'package.json' is required as type '${chalk.magenta( + yup.reach(packageJsonSchema, err.path).type + )}'` + ); + case 'matches': + throw new Error( + `'${err.path}' in 'package.json' must be of type '${chalk.magenta( + err.params.regex + )}' (recieved the value '${chalk.magenta(err.params.value)}')` + ); + /** + * This will only be thrown if there are keys in the export map + * that we don't expect so we can therefore make some assumptions + */ + case 'noUnknown': + throw new Error( + `'${err.path}' in 'package.json' contains the unknown key ${chalk.magenta( + err.params.unknown + )}, for compatability only the following keys are allowed: ${chalk.magenta( + "['types', 'source', 'import', 'require', 'default']" + )}` + ); + default: + throw new Error( + `'${err.path}' in 'package.json' must be of type '${chalk.magenta( + err.params.type + )}' (recieved '${chalk.magenta(typeof err.params.value)}')` + ); + } + } + + throw err; + } +}; + +/** + * @description validate the `exports` property of the package.json against a set of rules. + * If the validation fails, the process will throw with an appropriate error message. If + * there is no `exports` property we check the standard export-like properties on the root + * of the package.json. + * + * @type {(args: { pkg: object, logger: import('./logger').Logger }) => Promise} + */ +const validateExportsOrdering = async ({ pkg, logger }) => { + if (pkg.exports) { + const exports = Object.entries(pkg.exports); + + for (const [expPath, exp] of exports) { + if (typeof exp === 'string') { + // eslint-disable-next-line no-continue + continue; + } + + const keys = Object.keys(exp); + + if (!assertFirst('types', keys)) { + throw new Error(`exports["${expPath}"]: the 'types' property should be the first property`); + } + + if (!assertOrder('import', 'require', keys)) { + logger.warn( + `exports["${expPath}"]: the 'import' property should come before the 'require' property` + ); + } + + if (!assertOrder('module', 'import', keys)) { + logger.warn( + `exports["${expPath}"]: the 'module' property should come before 'import' property` + ); + } + + if (!assertLast('default', keys)) { + throw new Error( + `exports["${expPath}"]: the 'default' property should be the last property` + ); + } + } + } else if (!['main', 'module'].some((key) => Object.prototype.hasOwnProperty.call(pkg, key))) { + throw new Error(`'package.json' must contain a 'main' and 'module' property`); + } + + return pkg; +}; + +/** @internal */ +function assertFirst(key, arr) { + const aIdx = arr.indexOf(key); + + // if not found, then we don't care + if (aIdx === -1) { + return true; + } + + return aIdx === 0; +} + +/** @internal */ +function assertLast(key, arr) { + const aIdx = arr.indexOf(key); + + // if not found, then we don't care + if (aIdx === -1) { + return true; + } + + return aIdx === arr.length - 1; +} + +/** @internal */ +function assertOrder(keyA, keyB, arr) { + const aIdx = arr.indexOf(keyA); + const bIdx = arr.indexOf(keyB); + + // if either is not found, then we don't care + if (aIdx === -1 || bIdx === -1) { + return true; + } + + return aIdx < bIdx; +} + +/** + * @typedef {Object} Extensions + * @property {string} commonjs + * @property {string} esm + */ + +/** + * @typedef {Object} ExtMap + * @property {Extensions} commonjs + * @property {Extensions} esm + */ + +/** + * @internal + * + * @type {ExtMap} + */ +const DEFAULT_PKG_EXT_MAP = { + // pkg.type: "commonjs" + commonjs: { + cjs: '.js', + es: '.mjs', + }, + + // pkg.type: "module" + module: { + cjs: '.cjs', + es: '.js', + }, +}; + +/** + * We potentially might need to support legacy exports or as package + * development continues we have space to tweak this. + * + * @type {() => ExtMap} + */ +const getExportExtensionMap = () => { + return DEFAULT_PKG_EXT_MAP; +}; + +/** + * @internal + * + * @description validate the `require` and `import` properties of a given exports maps from the package.json + * returning if any errors are found. + * + * @type {(_exports: unknown, options: {extMap: ExtMap; pkg: PackageJson}) => string[]} + */ +const validateExports = (_exports, options) => { + const { extMap, pkg } = options; + const ext = extMap[pkg.type || 'commonjs']; + + const errors = []; + + for (const exp of _exports) { + if (exp.require && !exp.require.endsWith(ext.cjs)) { + errors.push( + `package.json with \`type: "${pkg.type}"\` - \`exports["${exp._path}"].require\` must end with "${ext.cjs}"` + ); + } + + if (exp.import && !exp.import.endsWith(ext.es)) { + errors.push( + `package.json with \`type: "${pkg.type}"\` - \`exports["${exp._path}"].import\` must end with "${ext.es}"` + ); + } + } + + return errors; +}; + +/** + * @typedef {Object} Export + * @property {string} _path the path of the export, `.` for the root. + * @property {string=} types the path to the types file + * @property {string} source the path to the source file + * @property {string=} require the path to the commonjs require file + * @property {string=} import the path to the esm import file + * @property {string=} default the path to the default file + */ + +/** + * @description parse the exports map from the package.json into a standardised + * format that we can use to generate build tasks from. + * + * @type {(args: { extMap: ExtMap, pkg: PackageJson }) => Export[]} + */ +const parseExports = ({ extMap, pkg }) => { + /** + * @type {Export} + */ + const rootExport = { + _path: '.', + types: pkg.types, + source: pkg.source, + require: pkg.main, + import: pkg.module, + default: pkg.module || pkg.main, + }; + + /** + * @type {Export[]} + */ + const extraExports = []; + + /** + * @type {string[]} + */ + const errors = []; + + if (pkg.exports) { + if (!pkg.exports['./package.json']) { + errors.push('package.json: `exports["./package.json"] must be declared.'); + } + + Object.entries(pkg.exports).forEach(([path, entry]) => { + if (path.endsWith('.json')) { + if (path === './package.json' && entry !== './package.json') { + errors.push(`package.json: 'exports["./package.json"]' must be './package.json'.`); + } + } else if (Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry)) { + if (path === '.') { + if (entry.require && rootExport.require && entry.require !== rootExport.require) { + errors.push( + `package.json: mismatch between 'main' and 'exports.require'. These must be equal.` + ); + } + + if (entry.import && rootExport.import && entry.import !== rootExport.import) { + errors.push( + `package.json: mismatch between 'module' and 'exports.import' These must be equal.` + ); + } + + if (entry.types && rootExport.types && entry.types !== rootExport.types) { + errors.push( + `package.json: mismatch between 'types' and 'exports.types'. These must be equal.` + ); + } + + if (entry.source && rootExport.source && entry.source !== rootExport.source) { + errors.push( + `package.json: mismatch between 'source' and 'exports.source'. These must be equal.` + ); + } + + Object.assign(rootExport, entry); + } else { + const extraExport = { + _exported: true, + _path: path, + ...entry, + }; + + extraExports.push(extraExport); + } + } else { + errors.push('package.json: exports must be an object'); + } + }); + } + + const _exports = [ + /** + * In the case of strapi plugins, we don't have a root export because we + * ship a server side and client side package. So this can be completely omitted. + */ + Object.values(rootExport).some((exp) => exp !== rootExport._path && Boolean(exp)) && rootExport, + ...extraExports, + ].filter(Boolean); + + errors.push(...validateExports(_exports, { extMap, pkg })); + + if (errors.length) { + throw new Error(`\n- ${errors.join('\n- ')}`); + } + + return _exports; +}; + +module.exports = { + loadPkg, + validatePkg, + validateExportsOrdering, + getExportExtensionMap, + parseExports, +}; diff --git a/packages/core/strapi/lib/types/core/strapi/index.d.ts b/packages/core/strapi/lib/types/core/strapi/index.d.ts index a29b603fd3..47b68bc14b 100644 --- a/packages/core/strapi/lib/types/core/strapi/index.d.ts +++ b/packages/core/strapi/lib/types/core/strapi/index.d.ts @@ -11,7 +11,7 @@ interface CustomFieldServerOptions { /** * The name of the plugin creating the custom field */ - plugin?: string; + pluginId?: string; /** * The existing Strapi data type the custom field uses diff --git a/packages/core/strapi/package.json b/packages/core/strapi/package.json index adefe5d058..1b9afb943d 100644 --- a/packages/core/strapi/package.json +++ b/packages/core/strapi/package.json @@ -94,8 +94,10 @@ "@strapi/plugin-upload": "4.13.2", "@strapi/typescript-utils": "4.13.2", "@strapi/utils": "4.13.2", + "@vitejs/plugin-react": "4.0.4", "bcryptjs": "2.4.3", "boxen": "5.1.2", + "browserslist-to-esbuild": "1.2.0", "chalk": "4.1.2", "chokidar": "3.5.3", "ci-info": "3.8.0", @@ -132,7 +134,10 @@ "qs": "6.11.1", "resolve-cwd": "3.0.0", "semver": "7.5.4", - "statuses": "2.0.1" + "statuses": "2.0.1", + "typescript": "5.1.3", + "vite": "4.4.9", + "yup": "0.32.9" }, "devDependencies": { "supertest": "6.3.3", diff --git a/packages/plugins/color-picker/.eslintrc.js b/packages/plugins/color-picker/.eslintrc.js index 6f2604d0b1..a202ab3a60 100644 --- a/packages/plugins/color-picker/.eslintrc.js +++ b/packages/plugins/color-picker/.eslintrc.js @@ -2,11 +2,7 @@ module.exports = { root: true, overrides: [ { - files: ['admin/**/*'], - extends: ['custom/front'], - }, - { - files: ['**/*'], + files: ['**'], excludedFiles: ['admin/**/*', 'server/**/*'], extends: ['custom/back'], }, diff --git a/packages/plugins/color-picker/admin/.eslintrc.js b/packages/plugins/color-picker/admin/.eslintrc.js new file mode 100644 index 0000000000..a8b383f02d --- /dev/null +++ b/packages/plugins/color-picker/admin/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + root: true, + extends: ['custom/front/typescript'], + parserOptions: { + project: ['./admin/tsconfig.eslint.json'], + }, +}; diff --git a/packages/plugins/color-picker/admin/src/components/ColorPicker/ColorPickerIcon/index.js b/packages/plugins/color-picker/admin/src/components/ColorPickerIcon.tsx similarity index 85% rename from packages/plugins/color-picker/admin/src/components/ColorPicker/ColorPickerIcon/index.js rename to packages/plugins/color-picker/admin/src/components/ColorPickerIcon.tsx index 7a4dfa6725..d9c6e45a12 100644 --- a/packages/plugins/color-picker/admin/src/components/ColorPicker/ColorPickerIcon/index.js +++ b/packages/plugins/color-picker/admin/src/components/ColorPickerIcon.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { Flex, Icon } from '@strapi/design-system'; import { Paint } from '@strapi/icons'; import styled from 'styled-components'; @@ -15,12 +13,10 @@ const IconBox = styled(Flex)` } `; -const ColorPickerIcon = () => { +export const ColorPickerIcon = () => { return ( ); }; - -export default ColorPickerIcon; diff --git a/packages/plugins/color-picker/admin/src/components/ColorPicker/ColorPickerInput/index.js b/packages/plugins/color-picker/admin/src/components/ColorPickerInput.tsx similarity index 81% rename from packages/plugins/color-picker/admin/src/components/ColorPicker/ColorPickerInput/index.js rename to packages/plugins/color-picker/admin/src/components/ColorPickerInput.tsx index 1c64f5b128..4d92828ccb 100644 --- a/packages/plugins/color-picker/admin/src/components/ColorPicker/ColorPickerInput/index.js +++ b/packages/plugins/color-picker/admin/src/components/ColorPickerInput.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useRef, useState } from 'react'; +import * as React from 'react'; import { BaseButton, @@ -14,13 +14,12 @@ import { Typography, } from '@strapi/design-system'; import { CarretDown } from '@strapi/icons'; -import PropTypes from 'prop-types'; import { HexColorPicker } from 'react-colorful'; -import { useIntl } from 'react-intl'; +import { useIntl, MessageDescriptor } from 'react-intl'; import styled from 'styled-components'; -import { useComposedRefs } from '../../../hooks/useComposeRefs'; -import getTrad from '../../../utils/getTrad'; +import { useComposedRefs } from '../hooks/useComposeRefs'; +import { getTrad } from '../utils/getTrad'; const ColorPreview = styled.div` border-radius: 50%; @@ -75,29 +74,49 @@ const ColorPickerPopover = styled(Popover)` min-height: 270px; `; -const ColorPickerInput = forwardRef( +/** + * TODO: A lot of these props should extend `FieldProps` + */ +interface ColorPickerInputProps { + intlLabel: MessageDescriptor; + /** + * TODO: this should be extended from `FieldInputProps['onChange'] + * but that conflicts with it's secondary usage in `HexColorPicker` + */ + onChange: (event: { target: { name: string; value: string; type: string } }) => void; + attribute: { type: string; [key: string]: unknown }; + name: string; + description?: MessageDescriptor; + disabled?: boolean; + error?: string; + labelAction?: React.ReactNode; + required?: boolean; + value?: string; +} + +export const ColorPickerInput = React.forwardRef( ( { attribute, description, - disabled, + disabled = false, error, intlLabel, labelAction, name, onChange, - required, - value, + required = false, + value = '', }, forwardedRef ) => { - const [showColorPicker, setShowColorPicker] = useState(false); - const colorPickerButtonRef = useRef(); + const [showColorPicker, setShowColorPicker] = React.useState(false); + const colorPickerButtonRef = React.useRef(); const { formatMessage } = useIntl(); const color = value || '#000000'; const styleUppercase = { textTransform: 'uppercase' }; - const handleBlur = (e) => { + const handleBlur: React.FocusEventHandler = (e) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget)) { @@ -188,27 +207,3 @@ const ColorPickerInput = forwardRef( ); } ); - -ColorPickerInput.defaultProps = { - description: null, - disabled: false, - error: null, - labelAction: null, - required: false, - value: '', -}; - -ColorPickerInput.propTypes = { - intlLabel: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, - attribute: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - description: PropTypes.object, - disabled: PropTypes.bool, - error: PropTypes.string, - labelAction: PropTypes.object, - required: PropTypes.bool, - value: PropTypes.string, -}; - -export default ColorPickerInput; diff --git a/packages/plugins/color-picker/admin/src/components/tests/ColorPickerInput.test.tsx b/packages/plugins/color-picker/admin/src/components/tests/ColorPickerInput.test.tsx new file mode 100644 index 0000000000..8ed3ec1797 --- /dev/null +++ b/packages/plugins/color-picker/admin/src/components/tests/ColorPickerInput.test.tsx @@ -0,0 +1,66 @@ +import { DesignSystemProvider } from '@strapi/design-system'; +import { render as renderRTL } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; + +import { ColorPickerInput } from '../ColorPickerInput'; + +const render = () => ({ + ...renderRTL( + , + { + wrapper: ({ children }) => { + const locale = 'en'; + return ( + + {children} + + ); + }, + } + ), + user: userEvent.setup(), +}); + +describe('', () => { + /** + * We do this because – + * https://github.com/facebook/jest/issues/12670 + */ + beforeAll(() => { + jest.setTimeout(30000); + }); + + /** + * Reset timeout to what is expected + */ + afterAll(() => { + jest.setTimeout(5000); + }); + + it('renders and matches the snapshot', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it('toggles the popover', async () => { + const { user, getByRole } = render(); + await user.click(getByRole('button', { name: 'Color picker toggle' })); + + expect(getByRole('dialog')).toBeVisible(); + expect(getByRole('slider', { name: 'Color' })).toBeVisible(); + expect(getByRole('slider', { name: 'Hue' })).toBeVisible(); + expect(getByRole('textbox', { name: 'Color picker input' })).toBeVisible(); + }); +}); diff --git a/packages/plugins/color-picker/admin/src/components/tests/__snapshots__/color-picker-input.test.js.snap b/packages/plugins/color-picker/admin/src/components/tests/__snapshots__/ColorPickerInput.test.tsx.snap similarity index 100% rename from packages/plugins/color-picker/admin/src/components/tests/__snapshots__/color-picker-input.test.js.snap rename to packages/plugins/color-picker/admin/src/components/tests/__snapshots__/ColorPickerInput.test.tsx.snap diff --git a/packages/plugins/color-picker/admin/src/components/tests/color-picker-input.test.js b/packages/plugins/color-picker/admin/src/components/tests/color-picker-input.test.js deleted file mode 100644 index b104028c05..0000000000 --- a/packages/plugins/color-picker/admin/src/components/tests/color-picker-input.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; - -import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { IntlProvider } from 'react-intl'; - -import ColorPickerInput from '../ColorPicker/ColorPickerInput'; - -const mockAttribute = { - customField: 'plugin::color-picker.color', - pluginOptions: { i18n: { localized: true } }, - type: 'string', -}; - -const App = ( - - - - - -); - -describe('', () => { - /** - * We do this because – - * https://github.com/facebook/jest/issues/12670 - */ - beforeAll(() => { - jest.setTimeout(30000); - }); - - /** - * Reset timeout to what is expected - */ - afterAll(() => { - jest.setTimeout(5000); - }); - - it('renders and matches the snapshot', () => { - const { container } = render(App); - - expect(container).toMatchSnapshot(); - }); - - it('toggles the popover', () => { - render(App); - const colorPickerToggle = screen.getByRole('button', { name: 'Color picker toggle' }); - fireEvent.click(colorPickerToggle); - - const popover = screen.getByRole('dialog'); - const saturation = screen.getByRole('slider', { name: 'Color' }); - const hue = screen.getByRole('slider', { name: 'Hue' }); - const input = screen.getByRole('textbox', { name: 'Color picker input' }); - expect(popover).toBeVisible(); - expect(saturation).toBeVisible(); - expect(hue).toBeVisible(); - expect(input).toBeVisible(); - }); -}); diff --git a/packages/plugins/color-picker/admin/src/global.d.ts b/packages/plugins/color-picker/admin/src/global.d.ts new file mode 100644 index 0000000000..0d6d4c76aa --- /dev/null +++ b/packages/plugins/color-picker/admin/src/global.d.ts @@ -0,0 +1,2 @@ +declare module '@strapi/helper-plugin'; +declare module '@strapi/design-system'; diff --git a/packages/plugins/color-picker/admin/src/hooks/useComposeRefs.js b/packages/plugins/color-picker/admin/src/hooks/useComposeRefs.ts similarity index 76% rename from packages/plugins/color-picker/admin/src/hooks/useComposeRefs.js rename to packages/plugins/color-picker/admin/src/hooks/useComposeRefs.ts index bb91c71d0e..8244b36d3a 100644 --- a/packages/plugins/color-picker/admin/src/hooks/useComposeRefs.js +++ b/packages/plugins/color-picker/admin/src/hooks/useComposeRefs.ts @@ -1,14 +1,16 @@ import * as React from 'react'; +type PossibleRef = React.Ref | undefined; + /** * Set a given ref to a given value * This utility takes care of different types of refs: callback refs and RefObject(s) */ -function setRef(ref, value) { +function setRef(ref: PossibleRef, value: T) { if (typeof ref === 'function') { ref(value); } else if (ref !== null && ref !== undefined) { - ref.current = value; + (ref as React.MutableRefObject).current = value; } } @@ -16,8 +18,8 @@ function setRef(ref, value) { * A utility to compose multiple refs together * Accepts callback refs and RefObject(s) */ -function composeRefs(...refs) { - return (node) => refs.forEach((ref) => setRef(ref, node)); +function composeRefs(...refs: PossibleRef[]) { + return (node: T) => refs.forEach((ref) => setRef(ref, node)); } /** @@ -40,7 +42,7 @@ function composeRefs(...refs) { * } * ``` */ -function useComposedRefs(...refs) { +function useComposedRefs(...refs: PossibleRef[]) { // eslint-disable-next-line react-hooks/exhaustive-deps return React.useCallback(composeRefs(...refs), refs); } diff --git a/packages/plugins/color-picker/admin/src/index.js b/packages/plugins/color-picker/admin/src/index.ts similarity index 80% rename from packages/plugins/color-picker/admin/src/index.js rename to packages/plugins/color-picker/admin/src/index.ts index 19e118fd73..3f3239d61d 100644 --- a/packages/plugins/color-picker/admin/src/index.js +++ b/packages/plugins/color-picker/admin/src/index.ts @@ -1,11 +1,16 @@ import { prefixPluginTranslations } from '@strapi/helper-plugin'; -import ColorPickerIcon from './components/ColorPicker/ColorPickerIcon'; -import pluginId from './pluginId'; -import getTrad from './utils/getTrad'; +import { ColorPickerIcon } from './components/ColorPickerIcon'; +import { pluginId } from './pluginId'; +import { getTrad } from './utils/getTrad'; +// eslint-disable-next-line import/no-default-export export default { - register(app) { + /** + * TODO: we need to have the type for StrapiApp done from `@strapi/admin` package. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + register(app: any) { app.customFields.register({ name: 'color', pluginId: 'color-picker', @@ -22,8 +27,8 @@ export default { components: { Input: async () => import( - /* webpackChunkName: "color-picker-input-component" */ './components/ColorPicker/ColorPickerInput' - ), + /* webpackChunkName: "color-picker-input-component" */ './components/ColorPickerInput' + ).then((module) => ({ default: module.ColorPickerInput })), }, options: { advanced: [ @@ -64,7 +69,7 @@ export default { }, }); }, - async registerTrads({ locales }) { + async registerTrads({ locales }: { locales: string[] }) { const importedTrads = await Promise.all( locales.map((locale) => { return import(`./translations/${locale}.json`) diff --git a/packages/plugins/color-picker/admin/src/pluginId.js b/packages/plugins/color-picker/admin/src/pluginId.js deleted file mode 100644 index 3c66397d54..0000000000 --- a/packages/plugins/color-picker/admin/src/pluginId.js +++ /dev/null @@ -1,5 +0,0 @@ -const pluginPkg = require('../../package.json'); - -const pluginId = pluginPkg.name.replace(/^(@[^-,.][\w,-]+\/|strapi-)plugin-/i, ''); - -module.exports = pluginId; diff --git a/packages/plugins/color-picker/admin/src/pluginId.ts b/packages/plugins/color-picker/admin/src/pluginId.ts new file mode 100644 index 0000000000..3314e91ddb --- /dev/null +++ b/packages/plugins/color-picker/admin/src/pluginId.ts @@ -0,0 +1 @@ +export const pluginId = 'color-picker'; diff --git a/packages/plugins/color-picker/admin/src/utils/getTrad.js b/packages/plugins/color-picker/admin/src/utils/getTrad.js deleted file mode 100644 index d0a071b26a..0000000000 --- a/packages/plugins/color-picker/admin/src/utils/getTrad.js +++ /dev/null @@ -1,5 +0,0 @@ -import pluginId from '../pluginId'; - -const getTrad = (id) => `${pluginId}.${id}`; - -export default getTrad; diff --git a/packages/plugins/color-picker/admin/src/utils/getTrad.ts b/packages/plugins/color-picker/admin/src/utils/getTrad.ts new file mode 100644 index 0000000000..7ff529163c --- /dev/null +++ b/packages/plugins/color-picker/admin/src/utils/getTrad.ts @@ -0,0 +1,3 @@ +import { pluginId } from '../pluginId'; + +export const getTrad = (id: string) => `${pluginId}.${id}`; diff --git a/packages/plugins/color-picker/admin/tsconfig.build.json b/packages/plugins/color-picker/admin/tsconfig.build.json new file mode 100644 index 0000000000..20cf2264e0 --- /dev/null +++ b/packages/plugins/color-picker/admin/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src"], + "exclude": ["./src/**/*.test.tsx"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist/admin" + } +} diff --git a/packages/plugins/color-picker/admin/tsconfig.eslint.json b/packages/plugins/color-picker/admin/tsconfig.eslint.json new file mode 100644 index 0000000000..9b62409191 --- /dev/null +++ b/packages/plugins/color-picker/admin/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/plugins/color-picker/admin/tsconfig.json b/packages/plugins/color-picker/admin/tsconfig.json new file mode 100644 index 0000000000..af927d2d82 --- /dev/null +++ b/packages/plugins/color-picker/admin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/client.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/plugins/color-picker/package.json b/packages/plugins/color-picker/package.json index 55f5cc93ed..31ab5c2694 100644 --- a/packages/plugins/color-picker/package.json +++ b/packages/plugins/color-picker/package.json @@ -2,52 +2,6 @@ "name": "@strapi/plugin-color-picker", "version": "4.13.2", "description": "Strapi maintained Custom Fields", - "strapi": { - "name": "color-picker", - "description": "Color picker custom field", - "kind": "plugin", - "displayName": "Color Picker" - }, - "dependencies": { - "@strapi/design-system": "1.9.0", - "@strapi/helper-plugin": "4.13.2", - "@strapi/icons": "1.9.0", - "prop-types": "^15.8.1", - "react-colorful": "5.6.1", - "react-intl": "6.4.1" - }, - "devDependencies": { - "@testing-library/react": "14.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "5.3.4", - "styled-components": "5.3.3" - }, - "peerDependencies": { - "@strapi/strapi": "^4.4.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0", - "react-router-dom": "5.3.4", - "styled-components": "5.3.3" - }, - "files": [ - "./dist", - "./admin", - "strapi-admin.js", - "strapi-server.js" - ], - "scripts": { - "build": "run -T tsc -p server/tsconfig.json --outDir ./dist/server", - "build:ts": "run build", - "watch": "run -T tsc -w --preserveWatchOutput", - "clean": "run -T rimraf ./dist", - "prepublishOnly": "yarn clean && yarn build", - "test:front": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js", - "test:front:watch": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js --watchAll", - "test:front:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js", - "test:front:watch:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js --watchAll", - "lint": "run -T eslint ." - }, "repository": { "type": "git", "url": "https://github.com/strapi/strapi.git", @@ -66,6 +20,74 @@ "url": "https://strapi.io" } ], + "exports": { + "./strapi-admin": { + "types": "./dist/admin/index.d.ts", + "source": "./admin/src/index.ts", + "import": "./dist/admin/index.mjs", + "require": "./dist/admin/index.js", + "default": "./dist/admin/index.js" + }, + "./strapi-server": { + "types": "./dist/server/index.d.ts", + "source": "./server/src/index.ts", + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.js", + "default": "./dist/server/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "./dist", + "strapi-server.js" + ], + "strapi": { + "name": "color-picker", + "description": "Color picker custom field", + "kind": "plugin", + "displayName": "Color Picker" + }, + "scripts": { + "build": "NODE_ENV=production strapi plugin:build --force", + "watch": "run -T tsc -w --preserveWatchOutput", + "clean": "run -T rimraf ./dist", + "prepublishOnly": "yarn clean && yarn build", + "test:front": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js", + "test:front:watch": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js --watchAll", + "test:front:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js", + "test:front:watch:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js --watchAll", + "test:ts:front": "run -T tsc -p admin/tsconfig.json", + "lint": "yarn lint:project && yarn lint:back && yarn lint:front", + "lint:project": "run -T eslint . -c ./.eslintrc.js", + "lint:back": "run -T eslint ./server -c ./server/.eslintrc.js", + "lint:front": "run -T eslint ./admin -c ./admin/.eslintrc.js" + }, + "dependencies": { + "@strapi/design-system": "1.9.0", + "@strapi/helper-plugin": "4.13.2", + "@strapi/icons": "1.9.0", + "prop-types": "^15.8.1", + "react-colorful": "5.6.1", + "react-intl": "6.4.1" + }, + "devDependencies": { + "@strapi/strapi": "4.13.2", + "@testing-library/react": "14.0.0", + "@testing-library/user-event": "14.4.3", + "@types/styled-components": "5.1.26", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "5.3.4", + "styled-components": "5.3.3", + "typescript": "5.1.3" + }, + "peerDependencies": { + "@strapi/strapi": "^4.4.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router-dom": "5.3.4", + "styled-components": "5.3.3" + }, "engines": { "node": ">=16.0.0 <=20.x.x", "npm": ">=6.0.0" diff --git a/packages/plugins/color-picker/server/tsconfig.build.json b/packages/plugins/color-picker/server/tsconfig.build.json new file mode 100644 index 0000000000..49d94a869f --- /dev/null +++ b/packages/plugins/color-picker/server/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src"], + "exclude": ["./src/**/*.test.ts"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist/server" + } +} diff --git a/packages/plugins/color-picker/strapi-admin.js b/packages/plugins/color-picker/strapi-admin.js deleted file mode 100644 index 2d1a3d93ac..0000000000 --- a/packages/plugins/color-picker/strapi-admin.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; - -module.exports = require('./admin/src').default; diff --git a/packages/utils/eslint-config-custom/front/typescript.js b/packages/utils/eslint-config-custom/front/typescript.js index 95a64a0b7c..11a8f938e0 100644 --- a/packages/utils/eslint-config-custom/front/typescript.js +++ b/packages/utils/eslint-config-custom/front/typescript.js @@ -3,7 +3,7 @@ module.exports = { extends: ['@strapi/eslint-config/front/typescript'], overrides: [ { - files: ['**/*.test.js', '**/*.test.jsx', '**/__mocks__/**/*'], + files: ['**/*.test.[j|t]s', '**/*.test.[j|t]sx', '**/__mocks__/**/*'], env: { jest: true, }, @@ -23,6 +23,7 @@ module.exports = { * we can remove this rule back to the recommended setting. */ 'import/no-named-as-default-member': 'off', + 'import/no-extraneous-dependencies': 'error', 'no-restricted-imports': [ 'error', { @@ -73,5 +74,6 @@ module.exports = { message: 'Use window.strapi instead.', }, ], + 'react/display-name': 'off', }, }; diff --git a/yarn.lock b/yarn.lock index cde4d2d09a..4968ca523e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1814,7 +1814,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-jsx-self@npm:^7.18.6": +"@babel/plugin-transform-react-jsx-self@npm:^7.18.6, @babel/plugin-transform-react-jsx-self@npm:^7.22.5": version: 7.22.5 resolution: "@babel/plugin-transform-react-jsx-self@npm:7.22.5" dependencies: @@ -1825,7 +1825,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-jsx-source@npm:^7.19.6": +"@babel/plugin-transform-react-jsx-source@npm:^7.19.6, @babel/plugin-transform-react-jsx-source@npm:^7.22.5": version: 7.22.5 resolution: "@babel/plugin-transform-react-jsx-source@npm:7.22.5" dependencies: @@ -6922,7 +6922,7 @@ __metadata: languageName: node linkType: hard -"@strapi/admin-test-utils@workspace:*, @strapi/admin-test-utils@workspace:packages/admin-test-utils": +"@strapi/admin-test-utils@4.13.2, @strapi/admin-test-utils@workspace:*, @strapi/admin-test-utils@workspace:packages/admin-test-utils": version: 0.0.0-use.local resolution: "@strapi/admin-test-utils@workspace:packages/admin-test-utils" dependencies: @@ -7193,6 +7193,7 @@ __metadata: "@storybook/addon-mdx-gfm": 7.4.0 "@storybook/builder-vite": 7.4.0 "@storybook/react-vite": 7.4.0 + "@strapi/admin-test-utils": 4.13.2 "@strapi/design-system": 1.9.0 "@strapi/icons": 1.9.0 "@testing-library/react": 14.0.0 @@ -7279,7 +7280,10 @@ __metadata: "@strapi/design-system": 1.9.0 "@strapi/helper-plugin": 4.13.2 "@strapi/icons": 1.9.0 + "@strapi/strapi": 4.13.2 "@testing-library/react": 14.0.0 + "@testing-library/user-event": 14.4.3 + "@types/styled-components": 5.1.26 prop-types: ^15.8.1 react: ^18.2.0 react-colorful: 5.6.1 @@ -7287,6 +7291,7 @@ __metadata: react-intl: 6.4.1 react-router-dom: 5.3.4 styled-components: 5.3.3 + typescript: 5.1.3 peerDependencies: "@strapi/strapi": ^4.4.0 react: ^17.0.0 || ^18.0.0 @@ -7712,8 +7717,10 @@ __metadata: "@strapi/plugin-upload": 4.13.2 "@strapi/typescript-utils": 4.13.2 "@strapi/utils": 4.13.2 + "@vitejs/plugin-react": 4.0.4 bcryptjs: 2.4.3 boxen: 5.1.2 + browserslist-to-esbuild: 1.2.0 chalk: 4.1.2 chokidar: 3.5.3 ci-info: 3.8.0 @@ -7754,6 +7761,8 @@ __metadata: supertest: 6.3.3 ts-zen: "https://github.com/strapi/ts-zen#41af3f8c6422de048bf9976ae551566b2c2b590a" typescript: 5.1.3 + vite: 4.4.9 + yup: 0.32.9 bin: strapi: ./bin/strapi.js languageName: unknown @@ -9440,6 +9449,20 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-react@npm:4.0.4": + version: 4.0.4 + resolution: "@vitejs/plugin-react@npm:4.0.4" + dependencies: + "@babel/core": ^7.22.9 + "@babel/plugin-transform-react-jsx-self": ^7.22.5 + "@babel/plugin-transform-react-jsx-source": ^7.22.5 + react-refresh: ^0.14.0 + peerDependencies: + vite: ^4.2.0 + checksum: ec25400dc7c5fce914122d1f57de0fbaff9216addb8cd6187308ad2c7a3d3b73ea3a6f2dd0a8c7ec5e90e56b37046fe90d3e0ec285a9446e73695cb174377f84 + languageName: node + linkType: hard + "@vitejs/plugin-react@npm:^3.0.1": version: 3.1.0 resolution: "@vitejs/plugin-react@npm:3.1.0" @@ -11806,17 +11829,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001400": - version: 1.0.30001449 - resolution: "caniuse-lite@npm:1.0.30001449" - checksum: f1b395f0a5495c1931c53f58441e0db79b8b0f8ef72bb6d241d13c49b05827630efe6793d540610e0a014d8fdda330dd42f981c82951bd4bdcf635480e1a0102 - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001517": - version: 1.0.30001519 - resolution: "caniuse-lite@npm:1.0.30001519" - checksum: 66085133ede05d947e30b62fed2cbae18e5767afda8b0de38840883e1cfe5846bf1568ddbafd31647544e59112355abedaf9c867ac34541bfc20d69e7a19d94c +"caniuse-lite@npm:^1.0.30001400, caniuse-lite@npm:^1.0.30001517": + version: 1.0.30001522 + resolution: "caniuse-lite@npm:1.0.30001522" + checksum: 56e3551c02ae595085114073cf242f7d9d54d32255c80893ca9098a44f44fc6eef353936f234f31c7f4cb894dd2b6c9c4626e30649ee29e04d70aa127eeefeb0 languageName: node linkType: hard