Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[cli] Introduce --core #3304

Merged
merged 17 commits into from
Mar 26, 2024
Merged
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 326 additions & 2 deletions packages/create-toolpad-app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function getPackageManager(): PackageManager {
return 'npm';
}
}
return 'yarn';
return 'pnpm';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default should probably be npm as that is bundled with each node installation (at least for now)

}

// From https://github.com/vercel/next.js/blob/canary/packages/create-next-app/helpers/is-folder-empty.ts
Expand Down Expand Up @@ -161,6 +161,323 @@ const scaffoldProject = async (absolutePath: string, installFlag: boolean): Prom
}
};

const scaffoldCoreProject = async (absolutePath: string): Promise<void> => {
// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(
`${chalk.blue('info')} - Creating Toolpad Core project in ${chalk.blue(absolutePath)}`,
);
// eslint-disable-next-line no-console
console.log();

const packageJson: PackageJson = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have the generated template as a folder instead of all in the file? Probably would be easier to work on.
For example Next.js and Vite have it like that: https://github.com/vercel/next.js/tree/canary/packages/create-next-app/templates https://github.com/vitejs/vite/tree/main/packages/create-vite

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jan and I did discuss this and felt like we could eventually move to this structure, but begin with strings for simplicity and efficiency

Copy link
Member

@Janpot Janpot Mar 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the idea is that in the future we will do things like pre-configure different auth providers, we're going to need to have some templating mechanism. e.g. to have different modules in the package json, or to generate the code that initializes the providers.
I believe this mechanism will be much easier to write and maintain when it's just composition in functions, rather than try to adjust pre-existing file content from a template file.

It'll also make it easier to do things like re-using the templating logic to generate codesandboxes in the browser.

name: path.basename(absolutePath),
version: '0.1.0',
scripts: {
dev: 'next dev',
build: 'next build',
start: 'next start',
lint: 'next lint',
},
dependencies: {
react: '^18',
'react-dom': '^18',
next: '14.1.3',
'@mui/material': '^5',
'@mui/icons-material': '^5',
'@emotion/react': '^11',
'@emotion/styled': '^11',
'@mui/material-nextjs': '^5',
'@emotion/cache': '^11',
},
devDependencies: {
typescript: '^5',
'@types/node': '^20',
'@types/react': '^18',
'@types/react-dom': '^18',
eslint: '^8',
'eslint-config-next': '14.1.3',
},
};

const DEFAULT_GENERATED_GITIGNORE_FILE = '.gitignore';

await fs.writeFile(path.join(absolutePath, 'package.json'), JSON.stringify(packageJson, null, 2));

await fs.copyFile(
path.resolve(__dirname, `./gitignoreTemplate`),
path.join(absolutePath, DEFAULT_GENERATED_GITIGNORE_FILE),
);

// eslint-disable-next-line no-console
console.log(`${chalk.blue('info')} - Installing dependencies`);
// eslint-disable-next-line no-console
console.log();

const installVerb = 'install';
const command = `${packageManager} ${installVerb}`;
await execaCommand(command, { stdio: 'inherit', cwd: absolutePath });
Copy link
Member

@Janpot Janpot Mar 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, I'd use the safer

await execa(packageManager, ['install'], { stdio: 'inherit', cwd: absolutePath });

command. It'll correctly escape arguments. See https://www.npmjs.com/package/execa#execafile-arguments-options

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://www.npmjs.com/package/execa#execacommandcommand-options also provides automatic escaping of arguments - is execa safer in any other way?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it escapes spaces as well


// Create the `app` directory
await fs.mkdir(path.join(absolutePath, 'app'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could use Promise.all to make it faster? Probably not a huge difference though.

// Create the `api` directory inside the `app` directory
await fs.mkdir(path.join(absolutePath, 'app', 'api'));
// Create the `auth` directory inside the `api` directory
await fs.mkdir(path.join(absolutePath, 'app', 'api', 'auth'));
// Create the `[...nextAuth]` directory inside the `auth` directory
await fs.mkdir(path.join(absolutePath, 'app', 'api', 'auth', '[...nextAuth]'));
// Create the `route.ts` file inside the `[...nextAuth]` directory
await fs.writeFile(
path.join(absolutePath, 'app', 'api', 'auth', '[...nextAuth]', 'route.ts'),
'',
);
// Create the `auth` directory inside the `app` directory
await fs.mkdir(path.join(absolutePath, 'app', 'auth'));
// Create the `[...path]` directory inside the `auth` directory
await fs.mkdir(path.join(absolutePath, 'app', 'auth', '[...path]'));
// Create the `page.tsx` file inside the `[...path]` directory
await fs.writeFile(path.join(absolutePath, 'app', 'auth', '[...path]', 'page.tsx'), '');
// Create the `(dashboard)` directory inside the `app` directory
await fs.mkdir(path.join(absolutePath, 'app', '(dashboard)'));
// Create the `page` directory inside the `(dashboard)` directory
await fs.mkdir(path.join(absolutePath, 'app', '(dashboard)', 'page'));
const pageContent = `
import { Typography } from "@mui/material";

export default function Home() {
return (
<main>
<div>
<Typography variant="h6" color="grey.800">
Customize <code>page.tsx</code> to begin.
</Typography>
</div>
</main>
);
}
`;
// Create the `page.tsx` file inside the `page` directory
await fs.writeFile(
path.join(absolutePath, 'app', '(dashboard)', 'page', 'page.tsx'),
pageContent,
);

const dashboardLayoutContent = `
import * as React from "react";
import {
AppBar,
Badge,
Box,
Container,
Divider,
Drawer,
IconButton,
List,
ListItemButton,
ListItemIcon,
Toolbar,
} from "@mui/material";

import HomeIcon from "@mui/icons-material/Home";
import SettingsIcon from "@mui/icons-material/Settings";
import NotificationsIcon from "@mui/icons-material/Notifications";

export default function Layout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<Box sx={{ display: "flex" }}>
<AppBar position="absolute">
<Toolbar sx={{ justifyContent: "flex-end" }}>
<IconButton color="inherit">
<Badge badgeContent={4} color="secondary">
<NotificationsIcon />
</Badge>
</IconButton>
</Toolbar>
</AppBar>
<Drawer variant="permanent" anchor="left">
<Toolbar
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
px: [1],
}}
></Toolbar>
<Divider />
<List component="nav">
<ListItemButton>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
</ListItemButton>
<ListItemButton>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
</ListItemButton>
</List>
</Drawer>
<Box
component={"main"}
sx={{ flexGrow: 1, height: "100vh", overflow: "auto" }}
>
<Toolbar />
<Container maxWidth="lg">{children}</Container>
</Box>
</Box>
);
}
`;
// Create the `layout.tsx` file inside the `(dashboard)` directory
await fs.writeFile(
path.join(absolutePath, 'app', '(dashboard)', 'layout.tsx'),
dashboardLayoutContent,
);

const rootLayoutContent = `
import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter';
import { ThemeProvider } from '@mui/material/styles';
import theme from '../theme';


export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>
<AppRouterCacheProvider>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here will just need to use the AppProvider from @toolpad/core:

  <html lang="en">
      <body>
        <AppProvider theme={theme}>{props.children}</AppProvider>
      </body>
    </html>

It should be ready in #3291 I'm just sorting out some CI issues.
I can replace it if I merge after.

<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
`;
// Write the content of the `layout.tsx` file
await fs.writeFile(path.join(absolutePath, 'app', 'layout.tsx'), rootLayoutContent);

const themeContent = `
"use client";
import { Roboto } from "next/font/google";
import { createTheme } from "@mui/material/styles";

const roboto = Roboto({
weight: ["300", "400", "500", "700"],
subsets: ["latin"],
display: "swap",
});

const theme = createTheme({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All looks good, I was using the exact same project setup when setting up @toolpad/core!

typography: {
fontFamily: roboto.style.fontFamily,
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: "none",
},
},
},
MuiList: {
styleOverrides: {
root: {
padding: 0,
},
},
},
MuiListItemIcon: {
styleOverrides: {
root: {
minWidth: "28px",
},
},
},
},
});

export default theme;
`;

// Create the `theme.ts` file in the root directory
await fs.writeFile(path.join(absolutePath, 'theme.ts'), themeContent);
Copy link
Member

@apedroferreira apedroferreira Mar 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we're not using an src folder? I prefer to use one just to organize the files a bit more but any option is fine, we just never discussed it.


const nextTypes = `/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
`;
// Create the `next-env.d.ts` file in the root directory
await fs.writeFile(path.join(absolutePath, 'next-env.d.ts'), nextTypes);

const nextConfigContent = `
/** @type {import('next').NextConfig} */
const nextConfig = {
async redirects() {
return [
{
source: '/',
destination: '/page',
permanent: true,
},
]
},
};
export default nextConfig;
`;
// Create the `next.config.mjs` file in the root directory
await fs.writeFile(path.join(absolutePath, 'next.config.mjs'), nextConfigContent);

const eslintConfigContent = `{
"extends": "next/core-web-vitals"
}
`;
// Create the `.eslintrc.json` file in the root directory
await fs.writeFile(path.join(absolutePath, '.eslintrc.json'), eslintConfigContent);

const tsConfigContent = `{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
`;
// Create the `tsconfig.json` file in the root directory
await fs.writeFile(path.join(absolutePath, 'tsconfig.json'), tsConfigContent);

// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(
`${chalk.green('success')} - Created Toolpad Core project at ${chalk.blue(absolutePath)}`,
);
// eslint-disable-next-line no-console
console.log();
};

// Run the CLI interaction with Inquirer.js
const run = async () => {
const pkgJson: PackageJson = (await readJsonFile(
Expand All @@ -187,6 +504,11 @@ const run = async () => {
type: 'string',
describe: 'The path where the Toolpad Studio project directory will be created',
})
.option('core', {
type: 'boolean',
describe: 'Create a new project with Toolpad Core',
default: false,
})
.option('install', {
type: 'boolean',
describe: 'Install dependencies',
Expand All @@ -197,11 +519,11 @@ const run = async () => {
describe:
'The name of one of the available examples. See https://github.com/mui/mui-toolpad/tree/master/examples.',
})

.help().argv;

const pathArg = args._?.[0] as string;
const installFlag = args.install as boolean;
const coreFlag = args.core as boolean;

if (pathArg) {
const pathValidOrError = await validatePath(pathArg);
Expand Down Expand Up @@ -233,6 +555,8 @@ const run = async () => {

if (args.example) {
await downloadAndExtractExample(absolutePath, args.example);
} else if (coreFlag) {
await scaffoldCoreProject(absolutePath);
} else {
await scaffoldProject(absolutePath, installFlag);
}
Expand Down
Loading