Skip to content

Commit

Permalink
Merge pull request #13 from redwoodjs/generators
Browse files Browse the repository at this point in the history
Adds generators
  • Loading branch information
peterp authored Jan 6, 2020
2 parents 435e322 + 456e928 commit c91bd22
Show file tree
Hide file tree
Showing 25 changed files with 1,239 additions and 287 deletions.
23 changes: 20 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,31 @@ The first thing we'll want to do is create a new Redwood application by running
```terminal
$ yarn new ~/myprojects/todo
Created ~/myprojects/todo
Downloading https://github.com/hammerframework/create-hammer-app/archive/v0.0.1-alpha.7.zip...
Downloading https://github.com/redwoodjs/create-redwood-app/archive/v0.0.1-alpha.7.zip...
Extracting...
Added 50 files in ~/myprojects/todo
```

## Development

Run `yarn dev` to automatically reload the
Commands require a "redwood project structure" to be effectively tested.
You can use `create-redwood-app` to test your commands, but first you'll need link
to this repo with `create-redwood-app`.

```terminal
$ cd redwood/packages/cli
$ yarn link
success Registered "@redwoodjs/cli".
info You can now run `yarn link "@redwoodjs/cli"` in the projects where you want to use this package and it will be used instead.
$ cd ../../../create-redwood-app
$ `yarn link "@redwoodjs/cli"`
$ yarn redwood dev <command>
```

Run `yarn dev <command>` to automatically re-run your command when you make changes
during development.

### Adding new commands

Add a new command by creating `CommandName/CommandName.js` file in the
`./src/commands` directory.
Expand All @@ -34,7 +51,7 @@ A command should export the following:
export default ({ args }) => {} // The react-ink component.
export const commandProps = {
name: 'generate',
alias: 'g', // invoke with hammer s instead of hammer scaffold,
alias: 'g', // invoke with `redwood s` instead of `redwood scaffold`,
description: 'This command does a, b, but not c.',
}
```
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@
"test:watch": "yarn test --watch"
},
"dependencies": {
"@prisma/sdk": "0.0.103",
"@redwoodjs/core": "^0.0.1-alpha.15",
"axios": "^0.19.0",
"camelcase": "^5.3.1",
"concurrently": "^5.0.0",
"core-js": "3.6.0",
"decompress": "^4.2.0",
"ink": "^2.2.0",
"lodash": "^4.17.15",
"param-case": "^3.0.2",
"pascalcase": "^1.0.0",
"pluralize": "^8.0.0",
"react": "16.12.0",
"require-dir": "^1.2.0",
"tmp": "^0.1.0",
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/Dev/Dev.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// The `redwood dev` command runs the api and web development servers.
// Usage:
// $ redwood dev

import concurrently from 'concurrently'
import { getHammerBaseDir } from '@redwoodjs/core'

Expand Down
143 changes: 99 additions & 44 deletions packages/cli/src/commands/Generate/Generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,86 +4,141 @@ import React from 'react'
import { Box, Text, Color } from 'ink'
import { getHammerBaseDir } from '@redwoodjs/core'

import { writeFile, bytes } from 'src/lib'
import { readFile, writeFile, bytes } from 'src/lib'

import cell from './generators/cell'
import component from './generators/component'
import layout from './generators/layout'
import page from './generators/page'
import scaffold from './generators/scaffold'
import sdl from './generators/sdl'
import service from './generators/service'

/**
* A generator is a function that takes a name and returns a list of filenames
* and contents that should be written to the disk.
*/
const DEFAULT_GENERATORS = {
component,
}

const DEFAULT_COMPONENT_DIR = () =>
path.join(getHammerBaseDir(), './web/src/components/')
const GENERATORS = [cell, component, layout, page, scaffold, sdl, service]
const ROUTES_PATH = path.join(getHammerBaseDir(), 'web', 'src', 'Routes.js')

const Generate = ({
args,
generators = DEFAULT_GENERATORS,
generators = GENERATORS,
fileWriter = writeFile,
}) => {
if (!getHammerBaseDir()) {
return (
<Color red>
The `generate` command has to be run in your hammer project directory.
The `generate` command has to be run in your redwood project directory.
</Color>
)
}

const [
_commandName,
generatorName,
name,
targetDir = DEFAULT_COMPONENT_DIR(),
] = args
const writeFiles = (files) => {
return Object.keys(files).map((filename) => {
const contents = files[filename]
try {
fileWriter(path.join(getHammerBaseDir(), filename), contents)
return (
<Text key={`wrote-${filename}`}>
<Color green>Wrote {filename}</Color> {bytes(contents)} bytes
</Text>
)
} catch (e) {
return (
<Text key={`error-${filename}`}>
<Color red>{e}</Color>
</Text>
)
}
})
}

const generator = generators[generatorName]
const [_commandName, generatorCommand, name, ...rest] = args[0]
const flags = args[1]

const generator = generators.find(
(generator) => generator.command === generatorCommand
)

// If the generator command is not found in the list of generators, or a
// second "name" argument is not given, return usage text

if (!generator || !name) {
const generatorText = generators.map((generator, i) => {
return (
<Box key={i} marginLeft={1}>
<Box width={12}>
<Color yellow>{generator.command}</Color>
</Box>
<Box>{generator.description}</Box>
</Box>
)
})

return (
<>
<Box flexDirection="column" marginBottom={1}>
<Box marginBottom={1}>
<Text bold>Usage</Text>
</Box>
<Text>
hammer generate{' '}
<Color blue>{generatorName || 'generator'} name [path]</Color>
redwood generate{' '}
<Color blue>{generatorCommand || 'generator'} name [path]</Color>
</Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Box marginBottom={1}>
<Text bold underline>
Available generators:
</Text>
<Box marginX={2} flexDirection="column">
<Text> component</Text>
<Text> Generate a React component</Text>
</Box>
</Box>
{generatorText}
</>
)
}

const files = generator(name)
const results = Object.keys(files).map((filename) => {
const contents = files[filename]
try {
fileWriter(path.join(targetDir, filename), contents)
return (
<Text key={`wrote-${filename}`}>
Wrote {filename} {bytes(contents)} bytes
</Text>
)
} catch (e) {
return (
<Text key={`error-${filename}`}>
<Color red>{e}</Color>
</Text>
)
let results = []

// Do we need to create any files?

if ('files' in generator) {
const files = generator.files([args[0].slice(2), args[1]])

if (files instanceof Promise) {
files.then((f) => (results = results.concat(writeFiles(f))))
} else {
results = results.concat(writeFiles(files))
}
})
}

// Does this generator need to run any other generators?

if ('generate' in generator) {
results = results.concat(
generator.generate([args[0].slice(2), args[1]]).map((args) => {
console.info('Generate(args)', args)
return Generate({ args: [['g', ...args[0]], args[1]] })
})
)
}

// Do we need to append any routes?

if ('routes' in generator) {
const routeFile = readFile(ROUTES_PATH).toString()
let newRouteFile = routeFile

generator.routes([name, ...rest]).forEach((route) => {
newRouteFile = newRouteFile.replace(
/(\s*)\<Router\>/,
`$1<Router>$1 ${route}`
)
})

fileWriter(ROUTES_PATH, newRouteFile, { overwriteExisting: true })

results.push(
<Text key="route">
<Color green>Appened route</Color>
</Text>
)
}

return results
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/Generate/Generate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('Command: Generate', () => {
testGenerator: () => ({ 'a.js': 'a', 'a.test.js': 'b' }),
}

it('command usage is shown when no or a bad generator is selected', () => {
it('command usage is shown when no generator or an unknown generator is selected', () => {
const { lastFrame } = render(
<Generate args={['generate']} generators={generators} />
)
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/commands/Generate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
## Creating a Generator

Generators go in `src/commands/Generate/generators` and should be named for the generate command that invokes them (not required). For example, for page generator invoked with:

hammer generate page foo

you would create `src/commands/Generate/generators/page.js`. The file name does not have to match the generator command (the name is pulled from the object returned when importing the generator) but it is clearest to have the command and the name match.

You need to import your generator at the top of `src/commands/Generate/Generate.js` and include it in the list of `DEFAULT_GENERATORS`:

```javascript
import component from './generators/component'
import page from './generators/page'

const DEFAULT_GENERATORS = [component, page]
```

The generator must export a default hash containing the following keys:

| Name | Value | Required |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `name` | The name of the generator | Yes |
| `command` | The command line input that triggers the generator | Yes |
| `description` | Text that is shown on the generator's help message | Yes |
| `files` | A function which accepts the array of arguments given to the `hammer` command. Returns an object containing filenames and contents of those files to be created | No |
| `routes` | A function which accepts the array of arguments given to the `hammer` command. Returns an array of `<Route>` tags to append to the Routes.js file | No |
| `generate` | A function which accepts the array of arguments given to the `hammer` command. Returns an array of an array of arguments that would be passed to the Generate function in the same order the commands would be sent in from a command line call to `hammer generate` | No |

An example generator's return:

```javascript
{
name: "Page",
command: "page",
description: "Generates a Hammer page component",
files: name => ({
'pages/FooPage/FooPage.js': 'const FooPage = () => { ... }'
}),
routes: name => (['<Route path="/foo" page={FooPage} name="foo" />']),
generate: name => ([['service', 'foo']])
}
```

## Templates

Templates for the files created by generators go in `src/commands/Generate/templates` and should be named after the command that invokes your generator. The files inside should end in `.template` to avoid being compiled by Babel.

src/commands/Generate/
├── generators
│ ├── component.js
│ └── page.js
└── templates
├── component
│ ├── component.js.template
│ ├── readme.mdx.template
│ └── test.js.template
└── page
└── page.js.template
29 changes: 29 additions & 0 deletions packages/cli/src/commands/Generate/generators/cell.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import path from 'path'

import camelcase from 'camelcase'
import pascalcase from 'pascalcase'
import pluralize from 'pluralize'

import { generateTemplate } from 'src/lib'

const OUTPUT_PATH = path.join('web', 'src', 'cells')

const files = (args) => {
const [[cellName, ..._rest], _flags] = args
const name = pascalcase(cellName) + 'Cell'
const camelName = camelcase(pluralize(cellName))
const outputPath = path.join(OUTPUT_PATH, name, `${name}.js`)
const template = generateTemplate(path.join('cell', 'cell.js.template'), {
name,
camelName,
})

return { [outputPath]: template }
}

export default {
name: 'Cell',
command: 'cell',
description: 'Generates a Hammer cell component',
files: (args) => files(args),
}
Loading

0 comments on commit c91bd22

Please sign in to comment.