feat(experimental): add plugin:build command (#17747)

Co-authored-by: Alexandre BODIN <alexandrebodin@users.noreply.github.com>
This commit is contained in:
Josh 2023-09-05 10:25:14 +01:00 committed by GitHub
parent 7c01fb05be
commit 9756cafa00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2775 additions and 476 deletions

View File

@ -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:

View File

@ -0,0 +1,5 @@
{
"label": "CLI Commands",
"collapsible": true,
"collapsed": true
}

View 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

View 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"
]
}
```

View File

@ -0,0 +1,5 @@
{
"label": "plugin",
"collapsible": true,
"collapsed": true
}

View File

@ -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'],

View File

@ -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",

View File

@ -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,

View File

@ -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()

View File

@ -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))/
);
});
});

View 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))/
);
});
});
});

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View 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,
};

View File

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

View File

@ -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: {

View File

@ -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",

View File

@ -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);
}
};

View File

@ -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'));
};

View File

@ -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",
},
]
`);
});
});
});

View 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,
};

View 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 };

View 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,
};

View 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,
};

View File

@ -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'),

View File

@ -0,0 +1,4 @@
{
"name": "testing",
"version": "0.0.0"
}

View 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 'string'"`
);
expect(() =>
validatePkg({
pkg: {
name: 'testing',
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"'version' in 'package.json' is required as type 'string'"`
);
});
it('should fail if a required field does not match the correct type and call the error logger with the correct message', async () => {
expect(() =>
validatePkg({
pkg: {
name: 'testing',
version: 0,
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"'version' in 'package.json' must be of type 'string' (recieved 'number')"`
);
});
it("should fail if the regex for a field doesn't match and call the error logger with the correct message", async () => {
expect(() =>
validatePkg({
pkg: {
name: 'testing',
version: '0.0.0',
exports: {
apple: './apple.xyzx',
},
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"'exports.apple' in 'package.json' must be of type '/^\\.\\/.*\\.json$/' (recieved the value './apple.xyzx')"`
);
expect(() =>
validatePkg({
pkg: {
name: 'testing',
version: '0.0.0',
type: 'something',
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"'type' in 'package.json' must be of type '/(commonjs|module)/' (recieved the value 'something')"`
);
});
it('should fail if the exports object does not match expectations', async () => {
expect(() =>
validatePkg({
pkg: {
name: 'testing',
version: '0.0.0',
exports: 'hello',
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"'exports' in 'package.json' must be of type 'object' (recieved 'string')"`
);
expect(() =>
validatePkg({
pkg: {
name: 'testing',
version: '0.0.0',
exports: {
'./package.json': './package.json',
'./admin': {
import: './admin/index.js',
something: 'xyz',
},
},
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"'exports["./admin"]' in 'package.json' contains the unknown key something, for compatability only the following keys are allowed: ['types', 'source', 'import', 'require', 'default']"`
);
});
});
describe('validateExportsOrdering', () => {
it('should throw if there are no exports at all and log that error', async () => {
const pkg = {
name: 'testing',
version: '0.0.0',
};
await expect(
validateExportsOrdering({ pkg, logger: loggerMock })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"'package.json' must contain a 'main' and 'module' property"`
);
});
it("should return the package if there is at least a 'main' or 'module' property", async () => {
const pkg = {
name: 'testing',
version: '0.0.0',
main: './index.js',
};
const validatedPkg = await validateExportsOrdering({ pkg, logger: loggerMock });
expect(validatedPkg).toMatchInlineSnapshot(`
{
"main": "./index.js",
"name": "testing",
"version": "0.0.0",
}
`);
});
it('should return the package if there is an exports property with a valid structure', async () => {
const pkg = {
name: 'testing',
version: '0.0.0',
exports: {
'./package.json': './package.json',
'./admin': {
types: './admin/index.d.ts',
import: './admin/index.js',
require: './admin/index.cjs',
default: './admin/index.js',
},
},
};
const validatedPkg = await validateExportsOrdering({ pkg, logger: loggerMock });
expect(validatedPkg).toMatchInlineSnapshot(`
{
"exports": {
"./admin": {
"default": "./admin/index.js",
"import": "./admin/index.js",
"require": "./admin/index.cjs",
"types": "./admin/index.d.ts",
},
"./package.json": "./package.json",
},
"name": "testing",
"version": "0.0.0",
}
`);
});
it('should throw if the types property is not the first in an export object', async () => {
const pkg = {
name: 'testing',
version: '0.0.0',
exports: {
'./admin': {
import: './admin/index.js',
types: './admin/index.d.ts',
},
},
};
await expect(
validateExportsOrdering({ pkg, logger: loggerMock })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"exports["./admin"]: the 'types' property should be the first property"`
);
});
it('should log a warning if the require property comes before the import property in an export object', async () => {
const pkg = {
name: 'testing',
version: '0.0.0',
exports: {
'./admin': {
require: './admin/index.cjs',
import: './admin/index.js',
},
},
};
await validateExportsOrdering({ pkg, logger: loggerMock });
expect(loggerMock.warn.mock.calls[0]).toMatchInlineSnapshot(`
[
"exports["./admin"]: the 'import' property should come before the 'require' property",
]
`);
});
it('should log a warning if the import property comes the module property in an export object', async () => {
const pkg = {
name: 'testing',
version: '0.0.0',
exports: {
'./admin': {
import: './admin/index.js',
module: './admin/index.js',
},
},
};
await validateExportsOrdering({ pkg, logger: loggerMock });
expect(loggerMock.warn.mock.calls[0]).toMatchInlineSnapshot(`
[
"exports["./admin"]: the 'module' property should come before 'import' property",
]
`);
});
});
describe('getExportExtensionMap', () => {
it('should just return the default mapping', async () => {
expect(getExportExtensionMap()).toMatchInlineSnapshot(`
{
"commonjs": {
"cjs": ".js",
"es": ".mjs",
},
"module": {
"cjs": ".cjs",
"es": ".js",
},
}
`);
});
});
describe('parseExports', () => {
const extMap = getExportExtensionMap();
it('should by default return a root exports map using the standard export fields from the pkg.json', () => {
const pkg = {
types: './dist/index.d.ts',
main: './dist/index.js',
module: './dist/index.mjs',
source: './src/index.ts',
};
expect(parseExports({ pkg, extMap })).toMatchInlineSnapshot(`
[
{
"_path": ".",
"default": "./dist/index.mjs",
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
},
]
`);
});
it("should not return anything if the standard export fields don't exist and there is no export map", () => {
const pkg = {};
expect(parseExports({ pkg, extMap })).toMatchInlineSnapshot(`[]`);
});
it('should return a combination of the standard export fields and the export map if they both exist', () => {
const pkg = {
types: './dist/index.d.ts',
main: './dist/index.js',
module: './dist/index.mjs',
source: './src/index.ts',
exports: {
'./package.json': './package.json',
'./admin': {
types: './admin/index.d.ts',
import: './admin/index.mjs',
require: './admin/index.js',
default: './admin/index.js',
source: './src/admin/index.js',
},
},
};
expect(parseExports({ pkg, extMap })).toMatchInlineSnapshot(`
[
{
"_path": ".",
"default": "./dist/index.mjs",
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
},
{
"_exported": true,
"_path": "./admin",
"default": "./admin/index.js",
"import": "./admin/index.mjs",
"require": "./admin/index.js",
"source": "./src/admin/index.js",
"types": "./admin/index.d.ts",
},
]
`);
});
it('should return just the exports map if there are no standard export fields and the export map exists', () => {
const pkg = {
exports: {
'./package.json': './package.json',
'./admin': {
types: './admin/index.d.ts',
import: './admin/index.mjs',
require: './admin/index.js',
default: './admin/index.js',
source: './src/admin/index.js',
},
},
};
expect(parseExports({ pkg, extMap })).toMatchInlineSnapshot(`
[
{
"_exported": true,
"_path": "./admin",
"default": "./admin/index.js",
"import": "./admin/index.mjs",
"require": "./admin/index.js",
"source": "./src/admin/index.js",
"types": "./admin/index.d.ts",
},
]
`);
});
it('should throw an error if you try to use an exports map without supplying an export for the package.json file', () => {
const pkg = {
exports: {
'./admin': {
types: './admin/index.d.ts',
import: './admin/index.mjs',
require: './admin/index.js',
default: './admin/index.js',
source: './src/admin/index.js',
},
},
};
expect(() => parseExports({ pkg, extMap })).toThrowErrorMatchingInlineSnapshot(`
"
- package.json: \`exports["./package.json"] must be declared."
`);
});
it('should throw an error if the pkg.json type is undefined and you try to export like a module', () => {
const pkg = {
exports: {
'./package.json': './package.json',
'./admin': {
types: './admin/index.d.ts',
import: './admin/index.js',
require: './admin/index.cjs',
default: './admin/index.cjs',
source: './src/admin/index.js',
},
},
};
expect(() => parseExports({ pkg, extMap, type: 'module' }))
.toThrowErrorMatchingInlineSnapshot(`
"
- package.json with \`type: "undefined"\` - \`exports["./admin"].require\` must end with ".js"
- package.json with \`type: "undefined"\` - \`exports["./admin"].import\` must end with ".mjs""
`);
});
it('should throw an error if the pkg.json type is commonjs and you try to export like a module', () => {
const pkg = {
type: 'commonjs',
exports: {
'./package.json': './package.json',
'./admin': {
types: './admin/index.d.ts',
import: './admin/index.js',
require: './admin/index.cjs',
default: './admin/index.cjs',
source: './src/admin/index.js',
},
},
};
expect(() => parseExports({ pkg, extMap, type: 'module' }))
.toThrowErrorMatchingInlineSnapshot(`
"
- package.json with \`type: "commonjs"\` - \`exports["./admin"].require\` must end with ".js"
- package.json with \`type: "commonjs"\` - \`exports["./admin"].import\` must end with ".mjs""
`);
});
it('should throw an error if the pkg.json type is module and you try to export like a commonjs', () => {
const pkg = {
type: 'module',
exports: {
'./package.json': './package.json',
'./admin': {
types: './admin/index.d.ts',
import: './admin/index.mjs',
require: './admin/index.js',
default: './admin/index.js',
source: './src/admin/index.js',
},
},
};
expect(() => parseExports({ pkg, extMap, type: 'module' }))
.toThrowErrorMatchingInlineSnapshot(`
"
- package.json with \`type: "module"\` - \`exports["./admin"].require\` must end with ".cjs"
- package.json with \`type: "module"\` - \`exports["./admin"].import\` must end with ".js""
`);
});
});
});

View File

@ -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,
};

View 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,
};

View 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,
};

View File

@ -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

View File

@ -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",

View File

@ -2,11 +2,7 @@ module.exports = {
root: true,
overrides: [
{
files: ['admin/**/*'],
extends: ['custom/front'],
},
{
files: ['**/*'],
files: ['**'],
excludedFiles: ['admin/**/*', 'server/**/*'],
extends: ['custom/back'],
},

View File

@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: ['custom/front/typescript'],
parserOptions: {
project: ['./admin/tsconfig.eslint.json'],
},
};

View File

@ -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;

View File

@ -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;

View File

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

View File

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

View File

@ -0,0 +1,2 @@
declare module '@strapi/helper-plugin';
declare module '@strapi/design-system';

View File

@ -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);
}

View File

@ -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`)

View File

@ -1,5 +0,0 @@
const pluginPkg = require('../../package.json');
const pluginId = pluginPkg.name.replace(/^(@[^-,.][\w,-]+\/|strapi-)plugin-/i, '');
module.exports = pluginId;

View File

@ -0,0 +1 @@
export const pluginId = 'color-picker';

View File

@ -1,5 +0,0 @@
import pluginId from '../pluginId';
const getTrad = (id) => `${pluginId}.${id}`;
export default getTrad;

View File

@ -0,0 +1,3 @@
import { pluginId } from '../pluginId';
export const getTrad = (id: string) => `${pluginId}.${id}`;

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"include": ["./src"],
"exclude": ["./src/**/*.test.tsx"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist/admin"
}
}

View File

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,5 @@
{
"extends": "tsconfig/client.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -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"

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"include": ["./src"],
"exclude": ["./src/**/*.test.ts"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist/server"
}
}

View File

@ -1,3 +0,0 @@
'use strict';
module.exports = require('./admin/src').default;

View File

@ -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',
},
};

View File

@ -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