mirror of
https://github.com/strapi/strapi.git
synced 2025-12-25 22:23:10 +00:00
feat(experimental): add plugin:build command (#17747)
Co-authored-by: Alexandre BODIN <alexandrebodin@users.noreply.github.com>
This commit is contained in:
parent
7c01fb05be
commit
9756cafa00
27
.github/workflows/tests.yml
vendored
27
.github/workflows/tests.yml
vendored
@ -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:
|
||||
|
||||
5
docs/docs/docs/01-core/strapi/commands/_category_.json
Normal file
5
docs/docs/docs/01-core/strapi/commands/_category_.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"label": "CLI Commands",
|
||||
"collapsible": true,
|
||||
"collapsed": true
|
||||
}
|
||||
16
docs/docs/docs/01-core/strapi/commands/plugin/00-overview.md
Normal file
16
docs/docs/docs/01-core/strapi/commands/plugin/00-overview.md
Normal file
@ -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
|
||||
109
docs/docs/docs/01-core/strapi/commands/plugin/01-build.md
Normal file
109
docs/docs/docs/01-core/strapi/commands/plugin/01-build.md
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,5 @@
|
||||
{
|
||||
"label": "plugin",
|
||||
"collapsible": true,
|
||||
"collapsed": true
|
||||
}
|
||||
@ -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'],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))/
|
||||
);
|
||||
});
|
||||
});
|
||||
32
packages/core/admin/utils/__tests__/plugins.test.js
Normal file
32
packages/core/admin/utils/__tests__/plugins.test.js
Normal file
@ -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))/
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
217
packages/core/admin/utils/plugins.js
Normal file
217
packages/core/admin/utils/plugins.js
Normal file
@ -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,
|
||||
};
|
||||
@ -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;
|
||||
}, {}),
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<any>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
};
|
||||
@ -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'));
|
||||
};
|
||||
@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
252
packages/core/strapi/lib/commands/builders/packages.js
Normal file
252
packages/core/strapi/lib/commands/builders/packages.js
Normal file
@ -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<BuildContext>}
|
||||
*/
|
||||
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<BuildTask[]>}
|
||||
*/
|
||||
const createBuildTasks = async (ctx) => {
|
||||
/**
|
||||
* @type {BuildTask[]}
|
||||
*/
|
||||
const tasks = [];
|
||||
|
||||
/**
|
||||
* @type {import('./tasks/dts').DtsTask}
|
||||
*/
|
||||
const dtsTask = {
|
||||
type: 'build:dts',
|
||||
entries: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {Record<string, import('./tasks/vite').ViteTask>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
199
packages/core/strapi/lib/commands/builders/tasks/dts.js
Normal file
199
packages/core/strapi/lib/commands/builders/tasks/dts.js
Normal file
@ -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<ts.ParsedCommandLine>)}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<DtsTask>}
|
||||
*/
|
||||
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 };
|
||||
29
packages/core/strapi/lib/commands/builders/tasks/index.js
Normal file
29
packages/core/strapi/lib/commands/builders/tasks/index.js
Normal file
@ -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<void>} run
|
||||
* @property {(ctx: import("../packages").BuildContext, task: Task) => Promise<void>} success
|
||||
* @property {(ctx: import("../packages").BuildContext, task: Task, err: unknown) => Promise<void>} fail
|
||||
* @property {import('ora').Ora | null} _spinner
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {{ "build:js": TaskHandler<import("./vite").ViteTask>; "build:dts": TaskHandler<import("./dts").DtsTask>; }}}
|
||||
*/
|
||||
const buildTaskHandlers = {
|
||||
'build:js': viteTask,
|
||||
'build:dts': dtsTask,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildTaskHandlers,
|
||||
};
|
||||
144
packages/core/strapi/lib/commands/builders/tasks/vite.js
Normal file
144
packages/core/strapi/lib/commands/builders/tasks/vite.js
Normal file
@ -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<ViteTask>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
@ -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'),
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "testing",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
509
packages/core/strapi/lib/commands/utils/__tests__/pkg.test.js
Normal file
509
packages/core/strapi/lib/commands/utils/__tests__/pkg.test.js
Normal file
@ -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 '[35mstring[39m'"`
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
validatePkg({
|
||||
pkg: {
|
||||
name: 'testing',
|
||||
},
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"'version' in 'package.json' is required as type '[35mstring[39m'"`
|
||||
);
|
||||
});
|
||||
|
||||
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 '[35mstring[39m' (recieved '[35mnumber[39m')"`
|
||||
);
|
||||
});
|
||||
|
||||
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 '[35m/^\\.\\/.*\\.json$/[39m' (recieved the value '[35m./apple.xyzx[39m')"`
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
validatePkg({
|
||||
pkg: {
|
||||
name: 'testing',
|
||||
version: '0.0.0',
|
||||
type: 'something',
|
||||
},
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"'type' in 'package.json' must be of type '[35m/(commonjs|module)/[39m' (recieved the value '[35msomething[39m')"`
|
||||
);
|
||||
});
|
||||
|
||||
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 '[35mobject[39m' (recieved '[35mstring[39m')"`
|
||||
);
|
||||
|
||||
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 [35msomething[39m, for compatability only the following keys are allowed: [35m['types', 'source', 'import', 'require', 'default'][39m"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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""
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<void>}
|
||||
*
|
||||
* @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,
|
||||
};
|
||||
|
||||
97
packages/core/strapi/lib/commands/utils/logger.js
Normal file
97
packages/core/strapi/lib/commands/utils/logger.js
Normal file
@ -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,
|
||||
};
|
||||
421
packages/core/strapi/lib/commands/utils/pkg.js
Normal file
421
packages/core/strapi/lib/commands/utils/pkg.js
Normal file
@ -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<typeof packageJsonSchema>} 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<object>}
|
||||
*/
|
||||
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<PackageJson | null>}
|
||||
*/
|
||||
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<PackageJson>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -2,11 +2,7 @@ module.exports = {
|
||||
root: true,
|
||||
overrides: [
|
||||
{
|
||||
files: ['admin/**/*'],
|
||||
extends: ['custom/front'],
|
||||
},
|
||||
{
|
||||
files: ['**/*'],
|
||||
files: ['**'],
|
||||
excludedFiles: ['admin/**/*', 'server/**/*'],
|
||||
extends: ['custom/back'],
|
||||
},
|
||||
|
||||
7
packages/plugins/color-picker/admin/.eslintrc.js
Normal file
7
packages/plugins/color-picker/admin/.eslintrc.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['custom/front/typescript'],
|
||||
parserOptions: {
|
||||
project: ['./admin/tsconfig.eslint.json'],
|
||||
},
|
||||
};
|
||||
@ -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 (
|
||||
<IconBox justifyContent="center" alignItems="center" width={7} height={6} hasRadius aria-hidden>
|
||||
<Icon as={Paint} />
|
||||
</IconBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPickerIcon;
|
||||
@ -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<HTMLDivElement, ColorPickerInputProps>(
|
||||
(
|
||||
{
|
||||
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<HTMLDivElement> = (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;
|
||||
@ -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(
|
||||
<ColorPickerInput
|
||||
name="color"
|
||||
value=""
|
||||
onChange={jest.fn()}
|
||||
attribute={{
|
||||
customField: 'plugin::color-picker.color',
|
||||
pluginOptions: { i18n: { localized: true } },
|
||||
type: 'string',
|
||||
}}
|
||||
intlLabel={{ id: 'color-picker', defaultMessage: 'color-picker' }}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => {
|
||||
const locale = 'en';
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={{}} textComponent="span">
|
||||
<DesignSystemProvider locale={locale}>{children}</DesignSystemProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
},
|
||||
}
|
||||
),
|
||||
user: userEvent.setup(),
|
||||
});
|
||||
|
||||
describe('<ColorPickerInput />', () => {
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@ -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 = (
|
||||
<IntlProvider locale="en" messages={{}} textComponent="span">
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ColorPickerInput
|
||||
name="color"
|
||||
value=""
|
||||
onChange={jest.fn()}
|
||||
attribute={mockAttribute}
|
||||
intlLabel={{ id: 'color-picker', defaultMessage: 'color-picker' }}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<ColorPickerInput />', () => {
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
2
packages/plugins/color-picker/admin/src/global.d.ts
vendored
Normal file
2
packages/plugins/color-picker/admin/src/global.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
declare module '@strapi/helper-plugin';
|
||||
declare module '@strapi/design-system';
|
||||
@ -1,14 +1,16 @@
|
||||
import * as React from 'react';
|
||||
|
||||
type PossibleRef<T> = React.Ref<T> | 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<T>(ref: PossibleRef<T>, value: T) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
} else if (ref !== null && ref !== undefined) {
|
||||
ref.current = value;
|
||||
(ref as React.MutableRefObject<T>).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<T>(...refs: PossibleRef<T>[]) {
|
||||
return (node: T) => refs.forEach((ref) => setRef(ref, node));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -40,7 +42,7 @@ function composeRefs(...refs) {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
function useComposedRefs(...refs) {
|
||||
function useComposedRefs<T>(...refs: PossibleRef<T>[]) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return React.useCallback(composeRefs(...refs), refs);
|
||||
}
|
||||
@ -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`)
|
||||
@ -1,5 +0,0 @@
|
||||
const pluginPkg = require('../../package.json');
|
||||
|
||||
const pluginId = pluginPkg.name.replace(/^(@[^-,.][\w,-]+\/|strapi-)plugin-/i, '');
|
||||
|
||||
module.exports = pluginId;
|
||||
1
packages/plugins/color-picker/admin/src/pluginId.ts
Normal file
1
packages/plugins/color-picker/admin/src/pluginId.ts
Normal file
@ -0,0 +1 @@
|
||||
export const pluginId = 'color-picker';
|
||||
@ -1,5 +0,0 @@
|
||||
import pluginId from '../pluginId';
|
||||
|
||||
const getTrad = (id) => `${pluginId}.${id}`;
|
||||
|
||||
export default getTrad;
|
||||
3
packages/plugins/color-picker/admin/src/utils/getTrad.ts
Normal file
3
packages/plugins/color-picker/admin/src/utils/getTrad.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { pluginId } from '../pluginId';
|
||||
|
||||
export const getTrad = (id: string) => `${pluginId}.${id}`;
|
||||
9
packages/plugins/color-picker/admin/tsconfig.build.json
Normal file
9
packages/plugins/color-picker/admin/tsconfig.build.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"exclude": ["./src/**/*.test.tsx"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist/admin"
|
||||
}
|
||||
}
|
||||
5
packages/plugins/color-picker/admin/tsconfig.eslint.json
Normal file
5
packages/plugins/color-picker/admin/tsconfig.eslint.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
5
packages/plugins/color-picker/admin/tsconfig.json
Normal file
5
packages/plugins/color-picker/admin/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "tsconfig/client.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@ -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"
|
||||
|
||||
9
packages/plugins/color-picker/server/tsconfig.build.json
Normal file
9
packages/plugins/color-picker/server/tsconfig.build.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"exclude": ["./src/**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist/server"
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = require('./admin/src').default;
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
44
yarn.lock
44
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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user