diff --git a/docs/docs/docs/01-core/strapi/commands/plugin/02-watch.md b/docs/docs/docs/01-core/strapi/commands/plugin/02-watch.md index 54401510f9..e8774710f7 100644 --- a/docs/docs/docs/01-core/strapi/commands/plugin/02-watch.md +++ b/docs/docs/docs/01-core/strapi/commands/plugin/02-watch.md @@ -1,6 +1,6 @@ --- -title: plugin:build -description: An in depth look at the plugin:build command of the Strapi CLI +title: plugin:watch +description: An in depth look at the plugin:watch command of the Strapi CLI tags: - CLI - commands diff --git a/docs/docs/docs/01-core/strapi/commands/plugin/03-init.md b/docs/docs/docs/01-core/strapi/commands/plugin/03-init.md new file mode 100644 index 0000000000..2be2fbe375 --- /dev/null +++ b/docs/docs/docs/01-core/strapi/commands/plugin/03-init.md @@ -0,0 +1,35 @@ +--- +title: plugin:init +description: An in depth look at the plugin:init command of the Strapi CLI +tags: + - CLI + - commands + - plugins + - initialization +--- + +The `plugin:init` command is used to create a plugin, by default in `src/plugins` – because this is the strapi CLI we assume we're in a user app by default. This is done by using `pack-up` underneath and a unique template configuration. + +## Usage + +```bash +strapi plugin:init [path] +``` + +### Options + +```bash +Create a new plugin at a given path. + +Options: + -d, --debug Enable debugging mode with verbose logs (default: false) + --silent Don't log anything (default: false) + -h, --help Display help for command +``` + +## How it works + +The command sequence can be visualised as follows: + +- Ask the user a series of questions via prompts +- Generate a plugin folder structure based on that template diff --git a/packages/core/strapi/package.json b/packages/core/strapi/package.json index 0c807ddbaf..08e124a525 100644 --- a/packages/core/strapi/package.json +++ b/packages/core/strapi/package.json @@ -142,6 +142,8 @@ "dotenv": "14.2.0", "execa": "5.1.1", "fs-extra": "10.0.0", + "get-latest-version": "5.1.0", + "git-url-parse": "13.1.0", "glob": "7.2.3", "http-errors": "1.8.1", "https-proxy-agent": "5.0.1", @@ -163,6 +165,7 @@ "node-schedule": "2.1.0", "open": "8.4.0", "ora": "5.4.1", + "outdent": "0.8.0", "package-json": "7.0.0", "pkg-up": "3.1.0", "qs": "6.11.1", diff --git a/packages/core/strapi/src/admin.ts b/packages/core/strapi/src/admin.ts index a6a4c2e8d5..71a5fbd479 100644 --- a/packages/core/strapi/src/admin.ts +++ b/packages/core/strapi/src/admin.ts @@ -1,5 +1,4 @@ import { RenderAdminArgs, renderAdmin, Store } from '@strapi/admin/strapi-admin'; -// @ts-expect-error – No types, yet. import contentTypeBuilder from '@strapi/plugin-content-type-builder/strapi-admin'; import email from '@strapi/plugin-email/strapi-admin'; // @ts-expect-error – No types, yet. diff --git a/packages/core/strapi/src/commands/actions/plugin/build-command/action.ts b/packages/core/strapi/src/commands/actions/plugin/build-command/action.ts index f9db9da023..fd395595a1 100644 --- a/packages/core/strapi/src/commands/actions/plugin/build-command/action.ts +++ b/packages/core/strapi/src/commands/actions/plugin/build-command/action.ts @@ -2,15 +2,18 @@ import boxen from 'boxen'; import chalk from 'chalk'; import { BuildCLIOptions, ConfigBundle, build } from '@strapi/pack-up'; import { notifyExperimentalCommand } from '../../../utils/helpers'; -import { createLogger } from '../../../utils/logger'; import { Export, loadPkg, validatePkg } from '../../../utils/pkg'; +import { CLIContext } from '../../../types'; interface ActionOptions extends BuildCLIOptions { force?: boolean; } -export default async ({ force, ...opts }: ActionOptions) => { - const logger = createLogger({ debug: opts.debug, silent: opts.silent, timestamp: false }); +export default async ( + { force, ...opts }: ActionOptions, + _cmd: unknown, + { logger, cwd }: CLIContext +) => { try { /** * ALWAYS set production for using plugin build CLI. @@ -18,12 +21,10 @@ export default async ({ force, ...opts }: ActionOptions) => { process.env.NODE_ENV = 'production'; /** * Notify users this is an experimental command and get them to approve first - * this can be opted out by setting the argument --yes + * this can be opted out by setting the argument --force */ await notifyExperimentalCommand('plugin:build', { force }); - const cwd = process.cwd(); - const pkg = await loadPkg({ cwd, logger }); const pkgJson = await validatePkg({ pkg }); diff --git a/packages/core/strapi/src/commands/actions/plugin/build-command/command.ts b/packages/core/strapi/src/commands/actions/plugin/build-command/command.ts index 56b2068e1e..bb65bfeb14 100644 --- a/packages/core/strapi/src/commands/actions/plugin/build-command/command.ts +++ b/packages/core/strapi/src/commands/actions/plugin/build-command/command.ts @@ -6,7 +6,7 @@ import action from './action'; /** * `$ strapi plugin:build` */ -const command: StrapiCommand = ({ command }) => { +const command: StrapiCommand = ({ command, ctx }) => { command .command('plugin:build') .description('Bundle your strapi plugin for publishing.') @@ -15,7 +15,7 @@ const command: StrapiCommand = ({ command }) => { .option('--silent', "Don't log anything", false) .option('--sourcemap', 'produce sourcemaps', false) .option('--minify', 'minify the output', false) - .action(runAction('plugin:build', action)); + .action((...args) => runAction('plugin:build', action)(...args, ctx)); }; export default command; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/action.ts b/packages/core/strapi/src/commands/actions/plugin/init/action.ts new file mode 100644 index 0000000000..7f16c28d33 --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/action.ts @@ -0,0 +1,545 @@ +import path from 'node:path'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import getLatestVersion from 'get-latest-version'; +import gitUrlParse from 'git-url-parse'; +import { + InitOptions, + definePackageFeature, + definePackageOption, + defineTemplate, + init, + TemplateFile, +} from '@strapi/pack-up'; +import { outdent } from 'outdent'; +import { notifyExperimentalCommand } from '../../../utils/helpers'; + +import { CLIContext } from '../../../types'; +import { gitIgnoreFile } from './files/gitIgnore'; + +interface ActionOptions extends Pick {} + +export default async ( + packagePath: string, + { silent, debug }: ActionOptions, + _cmd: unknown, + { logger, cwd }: CLIContext +) => { + try { + /** + * Notify users this is an experimental command. We don't need to get them to approve first. + */ + await notifyExperimentalCommand('plugin:init', { force: true }); + + /** + * Create the package // plugin + */ + await init({ + path: packagePath, + cwd, + silent, + debug, + template: PLUGIN_TEMPLATE, + }); + + logger.info("Don't forget to enable your plugin in your configuration files."); + } catch (err) { + logger.error( + 'There seems to be an unexpected error, try again with --debug for more information \n' + ); + if (err instanceof Error && err.stack) { + logger.log( + chalk.red( + boxen(err.stack, { + padding: 1, + align: 'left', + }) + ) + ); + } + process.exit(1); + } +}; + +const PACKAGE_NAME_REGEXP = /^(?:@(?:[a-z0-9-*~][a-z0-9-*._~]*)\/)?[a-z0-9-~][a-z0-9-._~]*$/i; + +interface PackageExport { + types?: string; + require: string; + import: string; + source: string; + default: string; +} + +interface PluginPackageJson { + name?: string; + description?: string; + version?: string; + keywords?: string[]; + type: 'commonjs'; + license?: string; + repository?: { + type: 'git'; + url: string; + }; + bugs?: { + url: string; + }; + homepage?: string; + author?: string; + exports: { + './strapi-admin'?: PackageExport; + './strapi-server'?: PackageExport; + './package.json': `${string}.json`; + }; + files: string[]; + scripts: Record; + dependencies: Record; + devDependencies: Record; + peerDependencies: Record; + strapi: { + name?: string; + displayName?: string; + description?: string; + kind: 'plugin'; + }; +} + +const PLUGIN_TEMPLATE = defineTemplate(async ({ logger, gitConfig, packagePath }) => { + let repo: { + source?: string; + owner?: string; + name?: string; + }; + + const [packageFolder] = packagePath.split(path.sep).slice(-1); + + return { + prompts: [ + definePackageOption({ + name: 'repo', + type: 'text', + message: 'git url', + validate(v) { + if (!v) { + return true; + } + + try { + const result = gitUrlParse(v); + + repo = { source: result.source, owner: result.owner, name: result.name }; + + return true; + } catch (err) { + return 'invalid git url'; + } + }, + }), + definePackageOption({ + name: 'pkgName', + type: 'text', + message: 'plugin name', + initial: () => repo?.name ?? '', + validate(v) { + if (!v) { + return 'package name is required'; + } + + const match = PACKAGE_NAME_REGEXP.exec(v); + + if (!match) { + return 'invalid package name'; + } + + return true; + }, + }), + definePackageOption({ + name: 'displayName', + type: 'text', + message: 'plugin display name', + }), + definePackageOption({ + name: 'description', + type: 'text', + message: 'plugin description', + }), + definePackageOption({ + name: 'authorName', + type: 'text', + message: 'plugin author name', + initial: gitConfig?.user?.name, + }), + definePackageOption({ + name: 'authorEmail', + type: 'text', + message: 'plugin author email', + initial: gitConfig?.user?.email, + }), + definePackageOption({ + name: 'license', + type: 'text', + message: 'plugin license', + initial: 'MIT', + validate(v) { + if (!v) { + return 'license is required'; + } + + return true; + }, + }), + definePackageOption({ + name: 'client-code', + type: 'confirm', + message: 'register with the admin panel?', + initial: true, + }), + definePackageOption({ + name: 'server-code', + type: 'confirm', + message: 'register with the server?', + initial: true, + }), + definePackageFeature({ + name: 'editorconfig', + initial: true, + optional: true, + }), + definePackageFeature({ + name: 'eslint', + initial: true, + optional: true, + }), + definePackageFeature({ + name: 'prettier', + initial: true, + optional: true, + }), + definePackageFeature({ + name: 'typescript', + initial: true, + optional: true, + }), + ], + async getFiles(answers) { + const author: string[] = []; + + const files: TemplateFile[] = []; + + // package.json + const pkgJson: PluginPackageJson = { + version: '0.0.0', + keywords: [], + type: 'commonjs', + exports: { + './package.json': './package.json', + }, + files: ['dist'], + scripts: { + build: 'strapi plugin:build', + watch: 'strapi plugin:watch', + }, + dependencies: {}, + devDependencies: { + /** + * We set * as a default version, but further down + * we try to resolve each package to their latest + * version, failing that we leave the fallback of *. + */ + '@strapi/strapi': '*', + prettier: '*', + }, + peerDependencies: { + '@strapi/strapi': '^4.0.0', + }, + strapi: { + kind: 'plugin', + }, + }; + + if (Array.isArray(answers)) { + for (const ans of answers) { + const { name, answer } = ans; + + switch (name) { + case 'pkgName': { + pkgJson.name = String(answer); + pkgJson.strapi.name = String(answer); + break; + } + case 'description': { + pkgJson.description = String(answer) ?? undefined; + pkgJson.strapi.description = String(answer) ?? undefined; + break; + } + case 'displayName': { + pkgJson.strapi.displayName = String(answer) ?? undefined; + break; + } + case 'authorName': { + author.push(String(answer)); + break; + } + case 'authorEmail': { + if (answer) { + author.push(`<${answer}>`); + } + break; + } + case 'license': { + pkgJson.license = String(answer); + break; + } + case 'client-code': { + if (answer) { + pkgJson.exports['./strapi-admin'] = { + source: './src/admin/index.js', + import: './dist/admin/index.mjs', + require: './dist/admin/index.js', + default: './dist/admin/index.js', + }; + + pkgJson.dependencies = { + ...pkgJson.dependencies, + '@strapi/helper-plugin': '*', + '@strapi/design-system': '*', + '@strapi/icons': '*', + }; + + pkgJson.devDependencies = { + ...pkgJson.dependencies, + react: '*', + 'react-dom': '*', + 'react-router-dom': '5.3.4', + 'styled-components': '5.3.3', + }; + + pkgJson.peerDependencies = { + ...pkgJson.peerDependencies, + react: '^17.0.0 || ^18.0.0', + 'react-dom': '^17.0.0 || ^18.0.0', + 'react-router-dom': '5.2.0', + 'styled-components': '5.2.1', + }; + } + + break; + } + case 'server-code': { + if (answer) { + pkgJson.exports['./strapi-server'] = { + source: './src/server/index.js', + import: './dist/server/index.mjs', + require: './dist/server/index.js', + default: './dist/server/index.js', + }; + + pkgJson.files.push('./strapi-server.js'); + + files.push({ + name: 'strapi-server.js', + contents: outdent` + 'use strict'; + + module.exports = require('./dist/server'); + `, + }); + } + + break; + } + case 'typescript': { + const isTypescript = Boolean(answer); + + if (isTypescript) { + if (isRecord(pkgJson.exports['./strapi-admin'])) { + pkgJson.exports['./strapi-admin'].source = './src/admin/index.ts'; + + pkgJson.exports['./strapi-admin'] = { + types: './dist/admin/src/index.d.ts', + ...pkgJson.exports['./strapi-admin'], + }; + + pkgJson.scripts = { + ...pkgJson.scripts, + 'test:ts:front': 'run -T tsc -p admin/tsconfig.json', + }; + + pkgJson.devDependencies = { + ...pkgJson.devDependencies, + '@types/react': '*', + '@types/react-dom': '*', + '@types/react-router-dom': '5.3.3', + '@types/styled-components': '5.1.26', + }; + + const { adminTsconfigFiles } = await import('./files/typescript'); + + files.push(adminTsconfigFiles.tsconfigBuildFile, adminTsconfigFiles.tsconfigFile); + } + + if (isRecord(pkgJson.exports['./strapi-server'])) { + pkgJson.exports['./strapi-server'].source = './src/server/index.ts'; + + pkgJson.exports['./strapi-server'] = { + types: './dist/server/src/index.d.ts', + ...pkgJson.exports['./strapi-server'], + }; + + pkgJson.scripts = { + ...pkgJson.scripts, + 'test:ts:back': 'run -T tsc -p server/tsconfig.json', + }; + + const { serverTsconfigFiles } = await import('./files/typescript'); + + files.push( + serverTsconfigFiles.tsconfigBuildFile, + serverTsconfigFiles.tsconfigFile + ); + } + + pkgJson.devDependencies = { + ...pkgJson.devDependencies, + '@strapi/typescript-utils': '*', + typescript: '*', + }; + } + + /** + * This is where we add all the source files regardless + * of whether they are typescript or javascript. + */ + if (isRecord(pkgJson.exports['./strapi-admin'])) { + files.push({ + name: isTypescript ? 'admin/src/pluginId.ts' : 'admin/src/pluginId.js', + contents: outdent` + export const PLUGIN_ID = '${pkgJson.name!.replace(/^strapi-plugin-/i, '')}'; + `, + }); + + if (isTypescript) { + const { adminTypescriptFiles } = await import('./files/admin'); + + files.push(...adminTypescriptFiles); + } else { + const { adminJavascriptFiles } = await import('./files/admin'); + + files.push(...adminJavascriptFiles); + } + } + + if (isRecord(pkgJson.exports['./strapi-server'])) { + if (isTypescript) { + const { serverTypescriptFiles } = await import('./files/server'); + + files.push(...serverTypescriptFiles(packageFolder)); + } else { + const { serverJavascriptFiles } = await import('./files/server'); + + files.push(...serverJavascriptFiles(packageFolder)); + } + } + + break; + } + case 'eslint': { + if (answer) { + const { eslintIgnoreFile } = await import('./files/eslint'); + + files.push(eslintIgnoreFile); + } + + break; + } + case 'prettier': { + if (answer) { + const { prettierFile, prettierIgnoreFile } = await import('./files/prettier'); + + files.push(prettierFile, prettierIgnoreFile); + } + break; + } + case 'editorconfig': { + if (answer) { + const { editorConfigFile } = await import('./files/editorConfig'); + + files.push(editorConfigFile); + } + break; + } + default: + break; + } + } + } + + if (repo) { + pkgJson.repository = { + type: 'git', + url: `git+ssh://git@${repo.source}/${repo.owner}/${repo.name}.git`, + }; + pkgJson.bugs = { + url: `https://${repo.source}/${repo.owner}/${repo.name}/issues`, + }; + pkgJson.homepage = `https://${repo.source}/${repo.owner}/${repo.name}#readme`; + } + + pkgJson.author = author.filter(Boolean).join(' ') ?? undefined; + + try { + pkgJson.devDependencies = await resolveLatestVerisonOfDeps(pkgJson.devDependencies); + pkgJson.dependencies = await resolveLatestVerisonOfDeps(pkgJson.dependencies); + pkgJson.peerDependencies = await resolveLatestVerisonOfDeps(pkgJson.peerDependencies); + } catch (err) { + if (err instanceof Error) { + logger.error(err.message); + } else { + logger.error(err); + } + } + + files.push({ + name: 'package.json', + contents: outdent` + ${JSON.stringify(pkgJson, null, 2)} + `, + }); + + files.push({ + name: 'README.md', + contents: outdent` + # ${pkgJson.name} + + ${pkgJson.description ?? ''} + `, + }); + + files.push(gitIgnoreFile); + + return files; + }, + }; +}); + +const isRecord = (value: unknown): value is Record => + Boolean(value) && !Array.isArray(value) && typeof value === 'object'; + +const resolveLatestVerisonOfDeps = async ( + deps: Record +): Promise> => { + const latestDeps: Record = {}; + + for (const [name, version] of Object.entries(deps)) { + try { + const latestVersion = await getLatestVersion(name, version); + latestDeps[name] = latestVersion ? `^${latestVersion}` : '*'; + } catch (err) { + latestDeps[name] = '*'; + } + } + + return latestDeps; +}; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/command.ts b/packages/core/strapi/src/commands/actions/plugin/init/command.ts new file mode 100644 index 0000000000..749f0ada13 --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/command.ts @@ -0,0 +1,18 @@ +import type { StrapiCommand } from '../../../types'; +import { runAction } from '../../../utils/helpers'; +import action from './action'; + +/** + * `$ strapi plugin:init` + */ +const command: StrapiCommand = ({ command, ctx }) => { + command + .command('plugin:init') + .description('Create a new plugin at a given path') + .argument('[path]', 'path to the plugin', './src/plugins/my-plugin') + .option('-d, --debug', 'Enable debugging mode with verbose logs', false) + .option('--silent', "Don't log anything", false) + .action((...args) => runAction('plugin:init', action)(...args, ctx)); +}; + +export default command; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/files/admin.ts b/packages/core/strapi/src/commands/actions/plugin/init/files/admin.ts new file mode 100644 index 0000000000..928a8a868f --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/files/admin.ts @@ -0,0 +1,286 @@ +import { TemplateFile } from '@strapi/pack-up'; +import { outdent } from 'outdent'; + +const PLUGIN_ICON_CODE = outdent` +import { Puzzle } from '@strapi/icons'; + +const PluginIcon = () => ; + +export { PluginIcon }; +`; + +const APP_CODE = outdent` +import { AnErrorOccurred } from '@strapi/helper-plugin'; +import { Switch, Route } from 'react-router-dom'; + +import { PLUGIN_ID } from '../pluginId'; + +import { HomePage } from './HomePage'; + +const App = () => { + return ( + + + + + ); +}; + +export { App }; +`; + +const HOMEPAGE_CODE = outdent` + import { Main } from '@strapi/design-system'; + import { useIntl } from 'react-intl'; + + import { getTranslation } from '../utils/getTranslation'; + + const HomePage = () => { + const { formatMessage } = useIntl(); + + return ( +
+

Welcome to {formatMessage({ id: getTranslation("plugin.name") })}

+
+ ) + } + + export { HomePage }; +`; + +const TYPESCRIPT: TemplateFile[] = [ + { + name: 'admin/src/index.ts', + contents: outdent` + import { prefixPluginTranslations } from '@strapi/helper-plugin'; + import { PLUGIN_ID } from './pluginId'; + import { Initializer } from './components/Initializer'; + import { PluginIcon } from './components/PluginIcon'; + + export default { + register(app: any) { + app.addMenuLink({ + to: \`/plugins/\${PluginIcon}\`, + icon: PluginIcon, + intlLabel: { + id: \`\${PLUGIN_ID}.plugin.name\`, + defaultMessage: PLUGIN_ID, + }, + Component: async () => { + const { App } = await import('./pages/App'); + + return App; + }, + }); + + app.registerPlugin({ + id: PLUGIN_ID, + initializer: Initializer, + isReady: false, + name: PLUGIN_ID, + }); + }, + + async registerTrads(app: any) { + const { locales } = app; + + const importedTranslations = await Promise.all( + (locales as string[]).map((locale) => { + return import(\`./translations/\${locale}.json\`) + .then(({ default: data }) => { + return { + data: prefixPluginTranslations(data, PLUGIN_ID), + locale, + }; + }) + .catch(() => { + return { + data: {}, + locale, + }; + }); + }) + ); + + return importedTranslations; + }, + }; + `, + }, + { + name: 'admin/src/components/PluginIcon.tsx', + contents: PLUGIN_ICON_CODE, + }, + { + name: 'admin/src/components/Initializer.tsx', + contents: outdent` + import { useEffect, useRef } from 'react'; + + import { PLUGIN_ID } from '../pluginId'; + + type InitializerProps = { + setPlugin: (id: string) => void; + }; + + const Initializer = ({ setPlugin }: InitializerProps) => { + const ref = useRef(setPlugin); + + useEffect(() => { + ref.current(PLUGIN_ID); + }, []); + + return null; + }; + + export { Initializer }; + `, + }, + { + name: 'admin/src/pages/App.tsx', + contents: APP_CODE, + }, + { + name: 'admin/src/pages/HomePage.tsx', + contents: HOMEPAGE_CODE, + }, + { + name: 'admin/src/utils/getTranslation.ts', + contents: outdent` + import { PLUGIN_ID } from '../pluginId'; + + const getTranslation = (id: string) => \`\${PLUGIN_ID}.\${id}\`; + + export { getTranslation }; + `, + }, + { + name: 'admin/src/translations/en.json', + contents: outdent` + {} + `, + }, + { + /** + * TODO: remove this when we release design-system V2 + */ + name: 'admin/custom.d.ts', + contents: outdent` + declare module '@strapi/design-system/*'; + declare module '@strapi/design-system'; + `, + }, +]; + +const JAVASCRIPT: TemplateFile[] = [ + { + name: 'admin/src/index.js', + contents: outdent` + import { prefixPluginTranslations } from '@strapi/helper-plugin'; + import { PLUGIN_ID } from './pluginId'; + import { Initializer } from './components/Initializer'; + import { PluginIcon } from './components/PluginIcon'; + + export default { + register(app) { + app.addMenuLink({ + to: \`/plugins/\${PluginIcon}\`, + icon: PluginIcon, + intlLabel: { + id: \`\${PLUGIN_ID}.plugin.name\`, + defaultMessage: PLUGIN_ID, + }, + Component: async () => { + const { App } = await import('./pages/App'); + + return App; + }, + }); + + app.registerPlugin({ + id: PLUGIN_ID, + initializer: Initializer, + isReady: false, + name: PLUGIN_ID, + }); + }, + + async registerTrads(app) { + const { locales } = app; + + const importedTranslations = await Promise.all( + locales.map((locale) => { + return import(\`./translations/\${locale}.json\`) + .then(({ default: data }) => { + return { + data: prefixPluginTranslations(data, PLUGIN_ID), + locale, + }; + }) + .catch(() => { + return { + data: {}, + locale, + }; + }); + }) + ); + + return importedTranslations; + }, + }; + `, + }, + { + name: 'admin/src/components/PluginIcon.jsx', + contents: PLUGIN_ICON_CODE, + }, + { + name: 'admin/src/components/Initializer.jsx', + contents: outdent` + import { useEffect, useRef } from 'react'; + + import { PLUGIN_ID } from '../pluginId'; + + /** + * @type {import('react').FC<{ setPlugin: (id: string) => void }>} + */ + const Initializer = ({ setPlugin }) => { + const ref = useRef(setPlugin); + + useEffect(() => { + ref.current(PLUGIN_ID); + }, []); + + return null; + }; + + export { Initializer }; + `, + }, + { + name: 'admin/src/pages/App.jsx', + contents: APP_CODE, + }, + { + name: 'admin/src/pages/HomePage.jsx', + contents: HOMEPAGE_CODE, + }, + { + name: 'admin/src/utils/getTranslation.js', + contents: outdent` + import { PLUGIN_ID } from '../pluginId'; + + const getTranslation = (id) => \`\${PLUGIN_ID}.\${id}\`; + + export { getTranslation }; + `, + }, + { + name: 'admin/src/translations/en.json', + contents: outdent` + {} + `, + }, +]; + +export { TYPESCRIPT as adminTypescriptFiles, JAVASCRIPT as adminJavascriptFiles }; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/files/editorConfig.ts b/packages/core/strapi/src/commands/actions/plugin/init/files/editorConfig.ts new file mode 100644 index 0000000000..f5e8a94389 --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/files/editorConfig.ts @@ -0,0 +1,26 @@ +import { TemplateFile } from '@strapi/pack-up'; +import { outdent } from 'outdent'; + +const editorConfigFile: TemplateFile = { + name: '.editorconfig', + contents: outdent` + root = true + + [*] + indent_style = space + indent_size = 2 + end_of_line = lf + charset = utf-8 + trim_trailing_whitespace = true + insert_final_newline = true + + [{package.json,*.yml}] + indent_style = space + indent_size = 2 + + [*.md] + trim_trailing_whitespace = false + `, +}; + +export { editorConfigFile }; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/files/eslint.ts b/packages/core/strapi/src/commands/actions/plugin/init/files/eslint.ts new file mode 100644 index 0000000000..9781f08fac --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/files/eslint.ts @@ -0,0 +1,11 @@ +import { TemplateFile } from '@strapi/pack-up'; +import { outdent } from 'outdent'; + +const eslintIgnoreFile: TemplateFile = { + name: '.eslintignore', + contents: outdent` + dist + `, +}; + +export { eslintIgnoreFile }; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/files/gitIgnore.ts b/packages/core/strapi/src/commands/actions/plugin/init/files/gitIgnore.ts new file mode 100644 index 0000000000..45f9c8d546 --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/files/gitIgnore.ts @@ -0,0 +1,34 @@ +import { TemplateFile } from '@strapi/pack-up'; +import { outdent } from 'outdent'; + +const gitIgnoreFile: TemplateFile = { + name: '.gitignore', + contents: outdent` + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + + # dependencies + node_modules + .pnp + .pnp.js + + # testing + coverage + + # production + dist + + # misc + .DS_Store + *.pem + + # debug + npm-debug.log* + yarn-debug.log* + yarn-error.log* + + # local env files + .env + `, +}; + +export { gitIgnoreFile }; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/files/prettier.ts b/packages/core/strapi/src/commands/actions/plugin/init/files/prettier.ts new file mode 100644 index 0000000000..c001feb635 --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/files/prettier.ts @@ -0,0 +1,25 @@ +import { TemplateFile } from '@strapi/pack-up'; +import { outdent } from 'outdent'; + +const prettierFile: TemplateFile = { + name: '.prettierrc', + contents: outdent` + { + "endOfLine": 'lf', + "tabWidth": 2, + "printWidth": 100, + "singleQuote": true, + "trailingComma": 'es5', + } + `, +}; + +const prettierIgnoreFile: TemplateFile = { + name: '.prettierignore', + contents: outdent` + dist + coverage + `, +}; + +export { prettierFile, prettierIgnoreFile }; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/files/server.ts b/packages/core/strapi/src/commands/actions/plugin/init/files/server.ts new file mode 100644 index 0000000000..ec4efdd0dd --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/files/server.ts @@ -0,0 +1,360 @@ +import { TemplateFile } from '@strapi/pack-up'; +import { outdent } from 'outdent'; + +const TYPESCRIPT = (pluginName: string): TemplateFile[] => [ + { + name: 'server/src/index.ts', + contents: outdent` + /** + * Application methods + */ + import bootstrap from './bootstrap'; + import destroy from './destroy'; + import register from './register'; + + /** + * Plugin server methods + */ + import config from './config'; + import contentTypes from './content-types'; + import controllers from './controllers'; + import middlewares from './middlewares'; + import policies from './policies'; + import routes from './routes'; + import services from './services'; + + export default { + bootstrap, + destroy, + register, + + config, + controllers, + contentTypes, + middlewares, + policies, + routes, + services, + }; + `, + }, + { + name: 'server/src/bootstrap.ts', + contents: outdent` + import type { Strapi } from '@strapi/strapi'; + + const bootstrap = ({ strapi }: { strapi: Strapi }) => { + // bootstrap phase + }; + + export default bootstrap; + `, + }, + { + name: 'server/src/destroy.ts', + contents: outdent` + import type { Strapi } from '@strapi/strapi'; + + const destroy = ({ strapi }: { strapi: Strapi }) => { + // destroy phase + }; + + export default destroy; + `, + }, + { + name: 'server/src/register.ts', + contents: outdent` + import type { Strapi } from '@strapi/strapi'; + + const register = ({ strapi }: { strapi: Strapi }) => { + // register phase + }; + + export default register; + `, + }, + { + name: 'server/src/config/index.ts', + contents: outdent` + export default { + default: {}, + validator() {}, + }; + `, + }, + { + name: 'server/src/content-types/index.ts', + contents: outdent` + export default {}; + `, + }, + { + name: 'server/src/controllers/index.ts', + contents: outdent` + import controller from './controller'; + + export default { + controller, + }; + `, + }, + { + name: 'server/src/controllers/controller.ts', + contents: outdent` + import type { Strapi } from '@strapi/strapi'; + + const controller = ({ strapi }: { strapi: Strapi }) => ({ + index(ctx) { + ctx.body = strapi + .plugin('${pluginName}') + // the name of the service file & the method. + .service('service') + .getWelcomeMessage(); + }, + }); + + export default controller + `, + }, + { + name: 'server/src/middlewares/index.ts', + contents: outdent` + export default {}; + `, + }, + { + name: 'server/src/policies/index.ts', + contents: outdent` + export default {}; + `, + }, + { + name: 'server/src/routes/index.ts', + contents: outdent` + export default [ + { + method: 'GET', + path: '/', + // name of the controller file & the method. + handler: 'controller.index', + config: { + policies: [], + }, + }, + ]; + `, + }, + { + name: 'server/src/services/index.ts', + contents: outdent` + import service from './service'; + + export default { + service, + }; + `, + }, + { + name: 'server/src/services/service.ts', + contents: outdent` + import type { Strapi } from '@strapi/strapi'; + + const service = ({ strapi }: { strapi: Strapi }) => ({ + getWelcomeMessage() { + return 'Welcome to Strapi 🚀'; + }, + }); + + export default service + `, + }, +]; + +const JAVASCRIPT = (pluginName: string): TemplateFile[] => [ + { + name: 'server/src/index.ts', + contents: outdent` + 'use strict'; + + /** + * Application methods + */ + const bootstrap = require('./bootstrap'); + const destroy = require('./destroy'); + const register = require('./register'); + + /** + * Plugin server methods + */ + const config = require('./config'); + const contentTypes = require('./content-types'); + const controllers = require('./controllers'); + const middlewares = require('./middlewares'); + const policies = require('./policies'); + const routes = require('./routes'); + const services = require('./services'); + + module.exports = { + bootstrap, + destroy, + register, + + config, + controllers, + contentTypes, + middlewares, + policies, + routes, + services, + }; + `, + }, + { + name: 'server/src/bootstrap.ts', + contents: outdent` + 'use strict'; + + const bootstrap = ({ strapi }) => { + // bootstrap phase + }; + + module.exports = bootstrap; + `, + }, + { + name: 'server/src/destroy.ts', + contents: outdent` + 'use strict'; + + const destroy = ({ strapi }) => { + // destroy phase + }; + + module.exports = destroy; + `, + }, + { + name: 'server/src/register.ts', + contents: outdent` + 'use strict'; + + const register = ({ strapi }) => { + // register phase + }; + + module.exports = register; + `, + }, + { + name: 'server/src/config/index.ts', + contents: outdent` + 'use strict'; + + module.exports = { + default: {}, + validator() {}, + }; + `, + }, + { + name: 'server/src/content-types/index.ts', + contents: outdent` + 'use strict'; + + module.exports = {}; + `, + }, + { + name: 'server/src/controllers/index.ts', + contents: outdent` + 'use strict'; + + const controller = require('./controller'); + + module.exports = { + controller, + }; + `, + }, + { + name: 'server/src/controllers/controller.ts', + contents: outdent` + 'use strict'; + + const controller = ({ strapi }) => ({ + index(ctx) { + ctx.body = strapi + .plugin('${pluginName}') + // the name of the service file & the method. + .service('service') + .getWelcomeMessage(); + }, + }); + + module.exports = controller + `, + }, + { + name: 'server/src/middlewares/index.ts', + contents: outdent` + 'use strict'; + + module.exports = {}; + `, + }, + { + name: 'server/src/policies/index.ts', + contents: outdent` + 'use strict'; + + module.exports = {}; + `, + }, + { + name: 'server/src/routes/index.ts', + contents: outdent` + 'use strict'; + + module.exports = [ + { + method: 'GET', + path: '/', + // name of the controller file & the method. + handler: 'controller.index', + config: { + policies: [], + }, + }, + ]; + `, + }, + { + name: 'server/src/services/index.ts', + contents: outdent` + 'use strict'; + + const service = require('./service'); + + module.exports = { + service, + }; + `, + }, + { + name: 'server/src/services/service.ts', + contents: outdent` + 'use strict'; + + const service = ({ strapi }) => ({ + getWelcomeMessage() { + return 'Welcome to Strapi 🚀'; + }, + }); + + module.exports = service + `, + }, +]; + +export { TYPESCRIPT as serverTypescriptFiles, JAVASCRIPT as serverJavascriptFiles }; diff --git a/packages/core/strapi/src/commands/actions/plugin/init/files/typescript.ts b/packages/core/strapi/src/commands/actions/plugin/init/files/typescript.ts new file mode 100644 index 0000000000..46f9de2d47 --- /dev/null +++ b/packages/core/strapi/src/commands/actions/plugin/init/files/typescript.ts @@ -0,0 +1,71 @@ +import { TemplateFile } from '@strapi/pack-up'; +import { outdent } from 'outdent'; + +interface TsConfigFiles { + tsconfigFile: TemplateFile; + tsconfigBuildFile: TemplateFile; +} + +const ADMIN: TsConfigFiles = { + tsconfigFile: { + name: 'admin/tsconfig.json', + contents: outdent` + { + "extends": "@strapi/typescript-utils/tsconfigs/admin", + "include": ["./src", "./custom.d.ts"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": ".", + }, + } + `, + }, + tsconfigBuildFile: { + name: 'admin/tsconfig.build.json', + contents: outdent` + { + "extends": "./tsconfig", + "include": ["./src", "./custom.d.ts"], + "exclude": ["**/*.test.ts", "**/*.test.tsx"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": ".", + "outDir": "./dist", + } + } + `, + }, +}; + +const SERVER: TsConfigFiles = { + tsconfigFile: { + name: 'server/tsconfig.json', + contents: outdent` + { + "extends": "@strapi/typescript-utils/tsconfigs/server", + "include": ["./src"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": ".", + }, + } + `, + }, + tsconfigBuildFile: { + name: 'server/tsconfig.build.json', + contents: outdent` + { + "extends": "./tsconfig", + "include": ["./src"], + "exclude": ["**/*.test.ts"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": ".", + "outDir": "./dist", + } + } + `, + }, +}; + +export { ADMIN as adminTsconfigFiles, SERVER as serverTsconfigFiles }; diff --git a/packages/core/strapi/src/commands/actions/plugin/watch/action.ts b/packages/core/strapi/src/commands/actions/plugin/watch/action.ts index cf8d7e6eaf..9bc12477b6 100644 --- a/packages/core/strapi/src/commands/actions/plugin/watch/action.ts +++ b/packages/core/strapi/src/commands/actions/plugin/watch/action.ts @@ -2,23 +2,17 @@ import boxen from 'boxen'; import chalk from 'chalk'; import { ConfigBundle, WatchCLIOptions, watch } from '@strapi/pack-up'; import { notifyExperimentalCommand } from '../../../utils/helpers'; -import { createLogger } from '../../../utils/logger'; import { Export, loadPkg, validatePkg } from '../../../utils/pkg'; +import { CLIContext } from '../../../types'; -interface ActionOptions extends WatchCLIOptions { - force?: boolean; -} +interface ActionOptions extends WatchCLIOptions {} -export default async ({ force, ...opts }: ActionOptions) => { - const logger = createLogger({ debug: opts.debug, silent: opts.silent, timestamp: false }); +export default async (opts: ActionOptions, _cmd: unknown, { cwd, logger }: CLIContext) => { try { /** - * Notify users this is an experimental command and get them to approve first - * this can be opted out by setting the argument --yes + * Notify users this is an experimental command. */ - await notifyExperimentalCommand('plugin:watch', { force }); - - const cwd = process.cwd(); + await notifyExperimentalCommand('plugin:watch', { force: true }); const pkg = await loadPkg({ cwd, logger }); const pkgJson = await validatePkg({ pkg }); diff --git a/packages/core/strapi/src/commands/actions/plugin/watch/command.ts b/packages/core/strapi/src/commands/actions/plugin/watch/command.ts index 4d0142a4f5..07af86a61e 100644 --- a/packages/core/strapi/src/commands/actions/plugin/watch/command.ts +++ b/packages/core/strapi/src/commands/actions/plugin/watch/command.ts @@ -3,15 +3,15 @@ import { runAction } from '../../../utils/helpers'; import action from './action'; /** - * `$ strapi plugin:build` + * `$ strapi plugin:watch` */ -const command: StrapiCommand = ({ command }) => { +const command: StrapiCommand = ({ command, ctx }) => { command .command('plugin:watch') .description('Watch & compile your strapi plugin for local development.') .option('-d, --debug', 'Enable debugging mode with verbose logs', false) .option('--silent', "Don't log anything", false) - .action(runAction('plugin:watch', action)); + .action((...args) => runAction('plugin:watch', action)(...args, ctx)); }; export default command; diff --git a/packages/core/strapi/src/commands/index.ts b/packages/core/strapi/src/commands/index.ts index b5a6b400b3..33de0a1283 100644 --- a/packages/core/strapi/src/commands/index.ts +++ b/packages/core/strapi/src/commands/index.ts @@ -27,6 +27,7 @@ import versionCommand from './actions/version/command'; import watchAdminCommand from './actions/watch-admin/command'; import buildPluginCommand from './actions/plugin/build-command/command'; +import initPluginCommand from './actions/plugin/init/command'; import watchPluginCommand from './actions/plugin/watch/command'; import { createLogger } from './utils/logger'; @@ -63,6 +64,7 @@ const strapiCommands = { * Plugins */ buildPluginCommand, + initPluginCommand, watchPluginCommand, } as const; diff --git a/packages/core/strapi/tsconfig.json b/packages/core/strapi/tsconfig.json index 8bd9143db0..dfe2a3a5b5 100644 --- a/packages/core/strapi/tsconfig.json +++ b/packages/core/strapi/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "tsconfig/base.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext" }, "include": ["src"], "exclude": ["node_modules"] diff --git a/packages/utils/pack-up/src/index.ts b/packages/utils/pack-up/src/index.ts index 774759eddd..4d6f28b1ef 100644 --- a/packages/utils/pack-up/src/index.ts +++ b/packages/utils/pack-up/src/index.ts @@ -19,4 +19,5 @@ export type { TemplateOrTemplateResolver, TemplateFeature, TemplateOption, + TemplateFile, } from './node/templates/types'; diff --git a/packages/utils/pack-up/src/node/core/git.ts b/packages/utils/pack-up/src/node/core/git.ts index a1bb0bb5a8..d63d3d6af9 100644 --- a/packages/utils/pack-up/src/node/core/git.ts +++ b/packages/utils/pack-up/src/node/core/git.ts @@ -58,3 +58,4 @@ const parseGlobalGitConfig = async (): Promise => { }; export { parseGlobalGitConfig }; +export type { GitConfig }; diff --git a/packages/utils/pack-up/src/node/templates/create.ts b/packages/utils/pack-up/src/node/templates/create.ts index 4970ed0ab8..cf5919d3fb 100644 --- a/packages/utils/pack-up/src/node/templates/create.ts +++ b/packages/utils/pack-up/src/node/templates/create.ts @@ -5,6 +5,7 @@ import prettier, { Config as PrettierConfig } from 'prettier'; import prompts from 'prompts'; import { isError } from '../core/errors'; +import { parseGlobalGitConfig } from '../core/git'; import { Logger } from '../core/logger'; import { Template, TemplateFeature, TemplateOption, TemplateOrTemplateResolver } from './types'; @@ -27,9 +28,11 @@ const createPackageFromTemplate = async ( ) => { const { cwd, logger, template: templateOrResolver } = opts; + const gitConfig = await parseGlobalGitConfig(); + const template = typeof templateOrResolver === 'function' - ? await templateOrResolver({ cwd, logger, packagePath }) + ? await templateOrResolver({ cwd, logger, packagePath, gitConfig }) : templateOrResolver; logger.info('Creating a new package at: ', relative(cwd, packagePath)); diff --git a/packages/utils/pack-up/src/node/templates/internal/default.ts b/packages/utils/pack-up/src/node/templates/internal/default.ts index 7d6c4ae878..927c121aca 100644 --- a/packages/utils/pack-up/src/node/templates/internal/default.ts +++ b/packages/utils/pack-up/src/node/templates/internal/default.ts @@ -3,7 +3,6 @@ import gitUrlParse from 'git-url-parse'; import { outdent } from 'outdent'; import { isError } from '../../core/errors'; -import { parseGlobalGitConfig } from '../../core/git'; import { PackageJson } from '../../core/pkg'; import { definePackageFeature, definePackageOption, defineTemplate } from '../create'; import { TemplateFile } from '../types'; @@ -14,9 +13,7 @@ import { prettierFile, prettierIgnoreFile } from './files/prettier'; const PACKAGE_NAME_REGEXP = /^(?:@(?:[a-z0-9-*~][a-z0-9-*._~]*)\/)?[a-z0-9-~][a-z0-9-._~]*$/i; -const defaultTemplate = defineTemplate(async ({ logger }) => { - const gitConfig = await parseGlobalGitConfig(); - +const defaultTemplate = defineTemplate(async ({ logger, gitConfig }) => { let repo: { source?: string; owner?: string; diff --git a/packages/utils/pack-up/src/node/templates/types.ts b/packages/utils/pack-up/src/node/templates/types.ts index 566c7c64a9..c7ba2afcc0 100644 --- a/packages/utils/pack-up/src/node/templates/types.ts +++ b/packages/utils/pack-up/src/node/templates/types.ts @@ -1,5 +1,6 @@ import { PromptObject } from 'prompts'; +import { GitConfig } from '../core/git'; import { Logger } from '../core/logger'; interface TemplateFeature extends Pick, 'initial'> { @@ -45,6 +46,7 @@ interface Template { interface TemplateContext { cwd: string; + gitConfig: GitConfig | null; logger: Logger; packagePath: string; } diff --git a/yarn.lock b/yarn.lock index a587b4fa78..6ec512b1a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8476,6 +8476,8 @@ __metadata: eslint-config-custom: "npm:4.15.5" execa: "npm:5.1.1" fs-extra: "npm:10.0.0" + get-latest-version: "npm:5.1.0" + git-url-parse: "npm:13.1.0" glob: "npm:7.2.3" http-errors: "npm:1.8.1" https-proxy-agent: "npm:5.0.1" @@ -8497,6 +8499,7 @@ __metadata: node-schedule: "npm:2.1.0" open: "npm:8.4.0" ora: "npm:5.4.1" + outdent: "npm:0.8.0" package-json: "npm:7.0.0" pkg-up: "npm:3.1.0" qs: "npm:6.11.1"