From 2f026ea9b3bab55c8085a65d4f1a37c9a163f8ef Mon Sep 17 00:00:00 2001 From: Josh <37798644+joshuaellis@users.noreply.github.com> Date: Mon, 13 Nov 2023 12:07:27 +0000 Subject: [PATCH] fix(admin): admin build errors (#18764) (#18770) * fix(admin): import & pass user customisations for admin panel * fix(admin): modules should be resolved with module paths not system paths * fix(admin): ensure webpack config is a type of function before calling, warn otherwise * fix: convert the pluginPath to a system path before trying to resolve relative * chore: update documentation --- .../01-core/admin/06-commands/01-build.md | 10 ++- .../01-core/admin/06-commands/02-develop.md | 6 +- examples/getstarted/src/admin/app.example.js | 9 --- examples/getstarted/src/admin/app.js | 11 +++ .../node/core/admin-customisations.ts | 32 ++++++++ .../core/admin/_internal/node/core/plugins.ts | 79 ++++++++++++++++++- .../_internal/node/createBuildContext.ts | 76 +++--------------- .../core/admin/_internal/node/staticFiles.ts | 22 ++++-- .../admin/_internal/node/webpack/config.ts | 15 +++- packages/core/admin/admin/src/render.ts | 8 +- packages/core/strapi/src/admin.ts | 3 +- 11 files changed, 176 insertions(+), 95 deletions(-) delete mode 100644 examples/getstarted/src/admin/app.example.js create mode 100644 examples/getstarted/src/admin/app.js create mode 100644 packages/core/admin/_internal/node/core/admin-customisations.ts diff --git a/docs/docs/docs/01-core/admin/06-commands/01-build.md b/docs/docs/docs/01-core/admin/06-commands/01-build.md index 3bcdf6d44c..350fe79bab 100644 --- a/docs/docs/docs/01-core/admin/06-commands/01-build.md +++ b/docs/docs/docs/01-core/admin/06-commands/01-build.md @@ -44,6 +44,10 @@ interface BuildContext { * this path so all asset paths will be rewritten accordingly */ basePath: string; + /** + * The customisations defined by the user in their app.js file + */ + customisations?: AppFile; /** * The current working directory */ @@ -64,9 +68,9 @@ interface BuildContext { * The environment variables to be included in the JS bundle */ env: Record; - logger: Logger; + logger: CLIContext['logger']; /** - * The build or develop options + * The build options */ options: Pick & Pick; /** @@ -90,7 +94,7 @@ interface BuildContext { * The browserslist target either loaded from the user's workspace or falling back to the default */ target: string[]; - tsconfig?: TSConfig; + tsconfig?: CLIContext['tsconfig']; } ``` diff --git a/docs/docs/docs/01-core/admin/06-commands/02-develop.md b/docs/docs/docs/01-core/admin/06-commands/02-develop.md index e42ade3b27..f5d50cd165 100644 --- a/docs/docs/docs/01-core/admin/06-commands/02-develop.md +++ b/docs/docs/docs/01-core/admin/06-commands/02-develop.md @@ -24,7 +24,7 @@ Start your Strapi application in development mode Options: --polling Watch for file changes (default: false) – Whether to use fs.watchFile (backed by polling), or fs.watch, this is passed directly to chokidar --no-build [deprecated]: there is middleware for the server, it is no longer a separate process - --watch-admin [deprecated]: there is now middleware for watching, it is no longer a separate process + --watch-admin Watch the admin for changes (default: false) --browser [deprecated]: use open instead --open Open the admin in your browser (default: true) -h, --help Display help for command @@ -66,6 +66,10 @@ interface DevelopOptions extends CLIContext { * The tsconfig to use for the build. If undefined, this is not a TS project. */ tsconfig?: TsConfig; + /** + * Watch the admin for changes + */ + watchAdmin?: boolean; } interface Logger { diff --git a/examples/getstarted/src/admin/app.example.js b/examples/getstarted/src/admin/app.example.js deleted file mode 100644 index 0ea34bef7a..0000000000 --- a/examples/getstarted/src/admin/app.example.js +++ /dev/null @@ -1,9 +0,0 @@ -const config = { - locales: ['fr'], -}; -const bootstrap = () => {}; - -export default { - config, - bootstrap, -}; diff --git a/examples/getstarted/src/admin/app.js b/examples/getstarted/src/admin/app.js new file mode 100644 index 0000000000..3acc1c2c16 --- /dev/null +++ b/examples/getstarted/src/admin/app.js @@ -0,0 +1,11 @@ +const config = { + locales: ['it', 'es', 'en'], +}; +const bootstrap = () => { + console.log('I AM BOOTSTRAPPED'); +}; + +export default { + config, + bootstrap, +}; diff --git a/packages/core/admin/_internal/node/core/admin-customisations.ts b/packages/core/admin/_internal/node/core/admin-customisations.ts new file mode 100644 index 0000000000..6e8612c6b1 --- /dev/null +++ b/packages/core/admin/_internal/node/core/admin-customisations.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { loadFile } from './files'; + +const ADMIN_APP_FILES = ['app.js', 'app.mjs', 'app.ts', 'app.jsx', 'app.tsx']; + +interface AdminCustomisations { + config?: { + locales?: string[]; + }; + bootstrap?: Function; +} + +interface AppFile { + path: string; + config: AdminCustomisations['config']; +} + +const loadUserAppFile = async (appDir: string): Promise => { + for (const file of ADMIN_APP_FILES) { + const filePath = path.join(appDir, 'src', 'admin', file); + const configFile = await loadFile(filePath); + + if (configFile) { + return { path: filePath, config: configFile }; + } + } + + return undefined; +}; + +export { loadUserAppFile }; +export type { AdminCustomisations, AppFile }; diff --git a/packages/core/admin/_internal/node/core/plugins.ts b/packages/core/admin/_internal/node/core/plugins.ts index f3f5003b75..f71459df2f 100644 --- a/packages/core/admin/_internal/node/core/plugins.ts +++ b/packages/core/admin/_internal/node/core/plugins.ts @@ -1,9 +1,12 @@ import os from 'node:os'; import path from 'node:path'; +import fs from 'node:fs'; +import camelCase from 'lodash/camelCase'; import { env } from '@strapi/utils'; import { getModule, PackageJson } from './dependencies'; import { loadFile } from './files'; -import { BuildContext, CreateBuildContextArgs } from '../createBuildContext'; +import { BuildContext } from '../createBuildContext'; +import { isError } from './errors'; interface PluginMeta { name: string; @@ -32,11 +35,11 @@ const validatePackageHasStrapi = ( const validatePackageIsPlugin = (pkg: PackageJson): pkg is StrapiPlugin => validatePackageHasStrapi(pkg) && pkg.strapi.kind === 'plugin'; -export const getEnabledPlugins = async ({ +const getEnabledPlugins = async ({ strapi, cwd, logger, -}: Pick) => { +}: Pick): Promise> => { const plugins: Record = {}; /** @@ -110,3 +113,73 @@ const loadUserPluginsFile = async (root: string): Promise return {}; }; + +const getMapOfPluginsWithAdmin = ( + plugins: Record, + { runtimeDir }: { runtimeDir: string } +) => + Object.values(plugins) + .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. + */ + try { + const isLocalPluginWithLegacyAdminFile = fs.existsSync( + //@ts-ignore + path.resolve(`${plugin.pathToPlugin}/strapi-admin.js`) + ); + + if (!isLocalPluginWithLegacyAdminFile) { + //@ts-ignore + let pathToPlugin = plugin.pathToPlugin; + + if (process.platform === 'win32') { + pathToPlugin = pathToPlugin.split(path.sep).join(path.posix.sep); + } + + const isModuleWithFE = require.resolve(`${pathToPlugin}/strapi-admin`); + + return isModuleWithFE; + } + + return isLocalPluginWithLegacyAdminFile; + } catch (err) { + if (isError(err) && 'code' in err && 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((plugin) => { + const systemPath = plugin.isLocal + ? path.relative(runtimeDir, plugin.pathToPlugin.split('/').join(path.sep)) + : undefined; + const modulePath = systemPath ? systemPath.split(path.sep).join('/') : undefined; + + return { + path: !plugin.isLocal + ? `${plugin.pathToPlugin}/strapi-admin` + : `${modulePath}/strapi-admin`, + name: plugin.name, + importName: camelCase(plugin.name), + }; + }); + +export { getEnabledPlugins, getMapOfPluginsWithAdmin }; diff --git a/packages/core/admin/_internal/node/createBuildContext.ts b/packages/core/admin/_internal/node/createBuildContext.ts index 959caedb1c..cc6130d59f 100644 --- a/packages/core/admin/_internal/node/createBuildContext.ts +++ b/packages/core/admin/_internal/node/createBuildContext.ts @@ -1,19 +1,17 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs/promises'; -import syncFs from 'node:fs'; -import camelCase from 'lodash/camelCase'; import browserslist from 'browserslist'; import strapiFactory, { CLIContext } from '@strapi/strapi'; import { getConfigUrls } from '@strapi/utils'; import { getStrapiAdminEnvVars, loadEnv } from './core/env'; -import { isError } from './core/errors'; import type { BuildOptions } from './build'; import { DevelopOptions } from './develop'; -import { getEnabledPlugins } from './core/plugins'; +import { getEnabledPlugins, getMapOfPluginsWithAdmin } from './core/plugins'; import { Strapi } from '@strapi/types'; +import { AppFile, loadUserAppFile } from './core/admin-customisations'; interface BuildContext { /** @@ -25,6 +23,10 @@ interface BuildContext { * this path so all asset paths will be rewritten accordingly */ basePath: string; + /** + * The customisations defined by the user in their app.js file + */ + customisations?: AppFile; /** * The current working directory */ @@ -152,23 +154,18 @@ const createBuildContext = async ({ logger.debug('Enabled plugins', os.EOL, plugins); - const pluginsWithFront = Object.values(plugins) - .filter(filterPluginsByAdminEntry) - .map((plugin) => ({ - path: !plugin.isLocal - ? `${plugin.pathToPlugin}/strapi-admin` - : `${path.relative(runtimeDir, plugin.pathToPlugin)}/strapi-admin`, - name: plugin.name, - importName: camelCase(plugin.name), - })); + const pluginsWithFront = getMapOfPluginsWithAdmin(plugins, { runtimeDir }); logger.debug('Enabled plugins with FE', os.EOL, plugins); const target = browserslist.loadConfig({ path: cwd }) ?? DEFAULT_BROWSERSLIST; + const customisations = await loadUserAppFile(strapiInstance.dirs.app.root); + const buildContext = { appDir: strapiInstance.dirs.app.root, basePath: `${adminPath}/`, + customisations, cwd, distDir, distPath, @@ -186,58 +183,5 @@ const createBuildContext = async ({ return buildContext; }; -interface Plugin extends Required<{}> { - name: string; -} - -const filterPluginsByAdminEntry = (plugin: 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. - */ - try { - const isLocalPluginWithLegacyAdminFile = syncFs.existsSync( - //@ts-ignore - path.resolve(`${plugin.pathToPlugin}/strapi-admin.js`) - ); - - if (!isLocalPluginWithLegacyAdminFile) { - //@ts-ignore - let pathToPlugin = plugin.pathToPlugin; - - if (process.platform === 'win32') { - pathToPlugin = pathToPlugin.split(path.sep).join(path.posix.sep); - } - - const isModuleWithFE = require.resolve(`${pathToPlugin}/strapi-admin`); - - return isModuleWithFE; - } - - return isLocalPluginWithLegacyAdminFile; - } catch (err) { - if (isError(err) && 'code' in err && 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; - } -}; - export { createBuildContext }; export type { BuildContext, CreateBuildContextArgs }; diff --git a/packages/core/admin/_internal/node/staticFiles.ts b/packages/core/admin/_internal/node/staticFiles.ts index 5ae9545c24..914e7725bf 100644 --- a/packages/core/admin/_internal/node/staticFiles.ts +++ b/packages/core/admin/_internal/node/staticFiles.ts @@ -8,16 +8,12 @@ import { DefaultDocument as Document } from '../../admin/src/components/DefaultD import type { BuildContext } from './createBuildContext'; -interface EntryModuleArgs { - plugins: BuildContext['plugins']; -} - -const getEntryModule = ({ plugins }: EntryModuleArgs): string => { - const pluginsObject = plugins +const getEntryModule = (ctx: BuildContext): string => { + const pluginsObject = ctx.plugins .map(({ name, importName }) => `'${name}': ${importName}`) .join(',\n'); - const pluginsImport = plugins + const pluginsImport = ctx.plugins .map(({ importName, path }) => `import ${importName} from '${path}';`) .join('\n'); @@ -29,9 +25,19 @@ const getEntryModule = ({ plugins }: EntryModuleArgs): string => { ${pluginsImport} import { renderAdmin } from "@strapi/strapi/admin" + ${ + ctx.customisations?.path + ? `import customisations from '${path.relative( + ctx.runtimeDir, + ctx.customisations.path + )}'` + : '' + } + renderAdmin( document.getElementById("strapi"), { + ${ctx.customisations?.path ? 'customisations,' : ''} plugins: { ${pluginsObject} } @@ -88,7 +94,7 @@ const writeStaticClientFiles = async (ctx: BuildContext) => { ctx.logger.debug('Wrote the index.html file'); await fs.writeFile( path.join(ctx.runtimeDir, 'app.js'), - format(getEntryModule({ plugins: ctx.plugins }), { + format(getEntryModule(ctx), { parser: 'babel', }) ); diff --git a/packages/core/admin/_internal/node/webpack/config.ts b/packages/core/admin/_internal/node/webpack/config.ts index 8cf2ad9ab2..35a55acda5 100644 --- a/packages/core/admin/_internal/node/webpack/config.ts +++ b/packages/core/admin/_internal/node/webpack/config.ts @@ -218,8 +218,19 @@ const mergeConfigWithUserConfig = async (config: Configuration, ctx: BuildContex const userConfig = await getUserConfig(ctx); if (userConfig) { - const webpack = await import('webpack'); - return userConfig(config, webpack); + if (typeof userConfig === 'function') { + const webpack = await import('webpack'); + return userConfig(config, webpack); + } else { + ctx.logger.warn( + `You've exported something other than a function from ${path.join( + ctx.appDir, + 'src', + 'admin', + 'webpack.config' + )}, this will ignored.` + ); + } } return config; diff --git a/packages/core/admin/admin/src/render.ts b/packages/core/admin/admin/src/render.ts index dd4a104c99..21f6265d8f 100644 --- a/packages/core/admin/admin/src/render.ts +++ b/packages/core/admin/admin/src/render.ts @@ -5,10 +5,14 @@ import { createRoot } from 'react-dom/client'; import { StrapiApp, StrapiAppConstructorArgs } from './StrapiApp'; interface RenderAdminArgs { + customisations: StrapiAppConstructorArgs['adminConfig']; plugins: StrapiAppConstructorArgs['appPlugins']; } -const renderAdmin = async (mountNode: HTMLElement | null, { plugins }: RenderAdminArgs) => { +const renderAdmin = async ( + mountNode: HTMLElement | null, + { plugins, customisations }: RenderAdminArgs +) => { if (!mountNode) { throw new Error('[@strapi/admin]: Could not find the root element to mount the admin app'); } @@ -73,7 +77,7 @@ const renderAdmin = async (mountNode: HTMLElement | null, { plugins }: RenderAdm } const app = new StrapiApp({ - adminConfig: {}, + adminConfig: customisations, appPlugins: plugins, }); diff --git a/packages/core/strapi/src/admin.ts b/packages/core/strapi/src/admin.ts index 0502ebd0b8..10bba0ac90 100644 --- a/packages/core/strapi/src/admin.ts +++ b/packages/core/strapi/src/admin.ts @@ -5,8 +5,9 @@ import email from '@strapi/plugin-email/strapi-admin'; // @ts-expect-error – No types, yet. import upload from '@strapi/plugin-upload/strapi-admin'; -const render = (mountNode: HTMLElement | null, { plugins }: RenderAdminArgs) => { +const render = (mountNode: HTMLElement | null, { plugins, ...restArgs }: RenderAdminArgs) => { return renderAdmin(mountNode, { + ...restArgs, plugins: { 'content-type-builder': contentTypeBuilder, // @ts-expect-error – TODO: fix this