Developing Custom Components
projen offers a number of useful components in the projen
package. However,
you may want to develop your own components to encapsulate common patterns in
your organization. This page describes how to develop custom components.
Extending SampleFile
The SampleFile
component is a low-level component that produces a file with
specific contents, but only once if the file doesn't exist. This is useful for
files that you don't want to place under projen management, but that you want
projen to create when the project is initialized.
Examples include:
README.md
CONTRIBUTING.md
- Example config files that will be updated and maintained by the user
projen does not create a .gitignore
file as a SampleFile
because projen
manages the contents of .gitignore
in most projects. However, if you want to
create a .gitignore
file as a sample and then let teams manage them how they
see fit, you can use the SampleFile
component to do so.
Example
Let's look at CONTRIBUTING.md
as an example.
import { SampleFile } from "projen";
export class ContributingFile extends SampleFile {
constructor(scope: Project) {
super(scope, "CONTRIBUTING.md", {
contents: ["# Contributing", "", "..."].join("\n"),
});
}
}
This is a very basic component that creates a CONTRIBUTING.md
file with some
contents. The SampleFile
component will only create the file if it doesn't
already exist. If the file exists, it will not be modified.
One way to make this component more useful is to allow users to specify some of
the file contents. If, for example, you have boilerplate CONTRIBUTING.md
contents but you want to make sure it has things such as the project's name,
author, and repository URL, you can do so by passing those values as props to
the component.
import { Project, SampleFile } from "projen";
export interface ContributingFileProps {
readonly projectName: string;
readonly author: string;
readonly repository: string;
}
export class ContributingFile extends SampleFile {
constructor(scope: Project, props: ContributingFileProps) {
super(scope, "CONTRIBUTING.md", {
contents: `# Contributing to [${props.projectName}](${props.repository})
...
On behalf of the ${props.projectName} team, welcome!
${props.author}
`,
});
}
}
Note that in this second example, we're using JavaScript template literals to create the contents of the file instead of joining an array of strings. One of the benefits of using something like projen is that you have the full power of your programming language at your disposal. You can use any language feature you want!
Now when you create a new ContributingFile
component, you can pass in the
project name, author, and repository URL, which are usually required props
for projen projects.
import { ContributingFile } from "./contributing-file";
new ContributingFile(this, {
projectName: "my-project",
author: "John Doe <john@doe.com>",
repository: "https://github.com/my-org/my-project.git",
});
Extending FileBase
FileBase
is projen's low-level managed file component. It will add a projen
marker to the file, mark it as being projen-managed in .projen/files.json
,
and will update the file's contents when the project is synthesized.
Examples include:
.gitignore
setup.py
Makefile
LICENSE.md
Example
Let's look at an example of a SECURITY.md
file that is managed by projen.
The use case here may be that the cybersecurity team wants to ensure that
projects have a SECURITY.md
file with some basic contents. Those contents
may change over time but will always be centrally controlled. Rather than
copy/pasting them into every project, you can use a FileBase
component to
manage the file.
FileBase
is considered an abstract class. It cannot be extended without
implementing its abstract members. For FileBase
, that means implementing
the synthesizeContent
method.
import { FileBase } from "projen";
export class SecurityFile extends FileBase {
public constructor(scope: IConstruct) {
super(scope, "SECURITY.md", {
readonly: true,
executable: false,
});
}
protected synthesizeContent(_: IResolver): string | undefined {
return [
"# Security",
"",
"## Reporting a Vulnerability",
"",
"Please report security vulnerabilities to ...",
].join("\n");
}
}
FileBase
defaults to adding a projen marker to every file it manages. In this
case, since SECURITY.md
is a public-facing file where a text marker would
look out of place, we simply do not add this.marker
to our synthesizeContent
method return. We also want to discourage manual updates to the file, so
set readonly: true
as well. Finally, this is not executable, so we set
executable: false
.
Extending Component
Component
is the base class for all projen objects. It is the most
flexible component type and is used to create most of the components in
the projen
package.
Since a Component
can be any part of projen, let's look at what needs to be
implemented to create a new component. Anything else is entirely up to your
needs.
export class ExampleComponent extends Component {
constructor(project: Project, id: string) {
super(project, id);
}
}
This ExampleComponent
is a very basic component that does nothing. It
simply extends Component
and calls super
in its constructor. It is part of
a project, not a project itself, and requires an ID to be created.
A Component
can be anything at all that you want. It can be a file, a
directory, a script, or a task. It can install Doom on your refrigerator,
as long as your refrigerator has a screen and you write the code to do so.
It can glob through the entire contents of your project source code and
send it to the OpenAI API to rewrite in a different language (please check
with your company's Cybersecurity team before implementing this Component
).
The sky is the limit!
Example
As an example, let's review the source code for the SampleFile
component. We
reviewed how to extend and use this component above, so let's see how it works.
import { writeFile } from "fs-extra";
export class SampleFile extends Component {
private readonly filePath: string;
private readonly options: SampleFileOptions;
/**
* Creates a new SampleFile object
* @param project - the project to tie this file to.
* @param filePath - the relative path in the project to put the file
* @param options - the options for the file.
*/
constructor(project: Project, filePath: string, options: SampleFileOptions) {
super(project);
if (options.contents && options.sourcePath) {
throw new Error("Cannot specify both 'contents' and 'source' fields.");
}
if (!options.contents && !options.sourcePath) {
throw new Error("Must specify at least one of 'contents' or 'source'.");
}
this.filePath = filePath;
this.options = options;
}
public synthesize() {
let contents;
if (this.options.contents) {
contents = this.options.contents;
} else if (this.options.sourcePath) {
contents = fs.readFileSync(this.options.sourcePath);
}
this.writeOnceFileContents(
this.project.outdir,
this.filePath,
contents ?? ""
);
}
/**
* A helper function that will write the file once and return if it was written or not.
* @param dir - the directory for the new file
* @param filename - the filename for the new file
* @param contents - the contents of the file to write
* @return boolean - whether a new file was written or not.
* @private
*/
private writeOnceFileContents(dir: string, filename: string, contents: any) {
const fullFilename = path.join(dir, filename);
if (fs.existsSync(fullFilename)) {
return;
}
writeFile(fullFilename, contents, { readonly: false });
}
}
This component is a bit more complex than the ExampleComponent
above, but
it's still a relatively simple component. It extends Component
and calls
super
in its constructor, only passing the project to its base class.
It also has a synthesize
method that is called
when the project is synthesized. This method is responsible for writing the
file to disk using the private writeOnceFileContents
method at the end of
the code block. This method will only write the file if it doesn't already
exist.
The SampleFile
component also has filePath
and options
properties. These
are used to determine where the file should be written and what its contents
should be. The filePath
is the relative path to the file in the project. The
options
are passed in as a prop to the component and are used to determine
the contents of the file. The options
can either be a string of contents or
a path to a file that contains the contents, but not both. This is enforced
in the constructor.