Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: NPM install
run: npm ci

- name: Run tests
run: npm test

- name: Pack (includes rescript build)
run: npm pack

Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

- Make sure to use modern ReScript and not Reason syntax! Read https://rescript-lang.org/llms/manual/llm-small.txt to learn the language syntax.
- Formatting is enforced by `rescript format`; keep 2-space indentation and prefer pattern matching over chained conditionals.
- Prefer `result` values over exceptions for expected failure paths; only raise or throw at clear integration boundaries where the surrounding API requires it.
- Prefer pattern matching over `if`/`else` chains when branching on the shape or state of the same value; plain comparisons across different values are fine with `if`/`else`.
- Do not run `rescript`, `npm test`, or `npm run prepack` in parallel; ReScript compiler artifacts are not safe for concurrent builds in this repo.
- Module files are PascalCase (`Templates.res`), values/functions camelCase, types/variants PascalCase, and records snake_case fields only when matching external JSON.
- Keep `.resi` signatures accurate and minimal; avoid exposing helpers that are template-specific.
- When touching templates, mirror upstream defaults and keep package scripts consistent with the chosen toolchain.
Expand All @@ -31,11 +34,13 @@
- **`npm start`** - Run CLI directly from source (`src/Main.res.mjs`) for interactive testing and development
- **`npm run dev`** - Watch ReScript sources and rebuild automatically to `lib/` directory
- **`npm run prepack`** - Compile ReScript and bundle with Rollup into `out/create-rescript-app.cjs` (production build)
- **`npm test`** - Compile ReScript sources and run the Node.js regression tests
- **`npm run format`** - Apply ReScript formatter across all source files

## Testing and Validation

- **Manual Testing**: No automated test suite - perform smoke tests by running the CLI into a temp directory
- **Automated Tests**: Run `npm test` for automated coverage of CLI parsing and related helpers
- **Manual Testing**: Perform smoke tests by running the CLI into a temp directory
- **Template Validation**: After changes, test each template type (basic/Next.js/Vite) to ensure templates bootstrap cleanly
- **Build Verification**: Run `npm run prepack` to ensure the production bundle builds correctly

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ or
bun create rescript-app
```

You can also skip the interactive prompts by passing a project name and template flag.
Supported templates are defined [`here`](./src/Templates.res).

With npm, pass the template flag after `--`:

```sh
npm create rescript-app@latest my-app -- --template vite
```

With Yarn, pnpm, and Bun, you can pass the template flag directly:

```sh
yarn create rescript-app my-app --template vite
```

## Add to existing project

If you have an existing JavaScript project containing a `package.json`, you can execute one of the above commands directly in your project's directory to add ReScript to your project.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"start": "node src/Main.res.mjs",
"prepack": "rescript && rollup -c",
"test": "rescript && node --test test/*Test.res.mjs",
"format": "rescript format",
"dev": "rescript -w"
},
Expand Down
15 changes: 11 additions & 4 deletions rescript.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
{
"name": "create-rescript-app",
"sources": {
"dir": "src",
"subdirs": true
},
"sources": [
{
"dir": "src",
"subdirs": true
},
{
"dir": "test",
"subdirs": true,
"type": "dev"
}
],
"package-specs": {
"module": "esmodule",
"in-source": true
Expand Down
76 changes: 76 additions & 0 deletions src/CommandLineArguments.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
type t = {
projectName: option<string>,
templateName: option<string>,
}

let supportedOptionsHint = `Supported options: --template <${Templates.supportedTemplateNames->Array.join(
"|",
)}> or -t <${Templates.supportedTemplateNames->Array.join("|")}>.`

let getTemplateName = templateName =>
switch Templates.getTemplateName(templateName) {
| Some(templateName) => Ok(templateName)
| None =>
Error(
`Unknown template "${templateName}". Available templates: ${Templates.supportedTemplateNames->Array.join(
", ",
)}.`,
)
}

let parseError = message => Error(`${message} ${supportedOptionsHint}`)

let rec parseRemainingArguments = (remainingArguments, commandLineArguments) =>
switch remainingArguments {
| list{} => Ok(commandLineArguments)
| list{"-t", templateName, ...remainingArguments}
| list{"--template", templateName, ...remainingArguments} =>
switch getTemplateName(templateName) {
| Ok(templateName) =>
parseRemainingArguments(
remainingArguments,
{
...commandLineArguments,
templateName: Some(templateName),
},
)
| Error(message) => Error(message)
}
| list{"-t"} | list{"--template"} => parseError("Missing value for --template.")
| list{argument, ...remainingArguments} if argument->String.startsWith("--template=") =>
switch argument->String.split("=") {
| [_, templateName] =>
switch getTemplateName(templateName) {
| Ok(templateName) =>
parseRemainingArguments(
remainingArguments,
{
...commandLineArguments,
templateName: Some(templateName),
},
)
| Error(message) => Error(message)
}
| _ => parseError("Missing value for --template.")
}
| list{argument, ..._remainingArguments} if argument->String.startsWith("-") =>
parseError(`Unknown option "${argument}".`)
| list{argument, ...remainingArguments} =>
switch commandLineArguments.projectName {
| None =>
parseRemainingArguments(
remainingArguments,
{...commandLineArguments, projectName: Some(argument)},
)
| Some(_) => parseError(`Unexpected argument "${argument}".`)
}
}

let parse = remainingArguments =>
parseRemainingArguments(remainingArguments, {projectName: None, templateName: None})

let fromProcessArgv = argv =>
switch List.fromArray(argv) {
| list{_, _, ...remainingArguments} => parse(remainingArguments)
| _ => Ok({projectName: None, templateName: None})
}
7 changes: 7 additions & 0 deletions src/CommandLineArguments.resi
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type t = {
projectName: option<string>,
templateName: option<string>,
}

let parse: list<string> => result<t, string>
let fromProcessArgv: array<string> => result<t, string>
40 changes: 27 additions & 13 deletions src/NewProject.res
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,33 @@ let createNewProject = async () => {
~versions={rescriptVersion: "11.1.1", rescriptCoreVersion: Some("1.5.0")},
)
} else {
let projectName = await P.text({
message: "What is the name of your new ReScript project?",
placeholder: "my-rescript-app",
initialValue: ?Process.argv[2],
validate: validateProjectName,
})->P.resultOrRaise

let templateName = await P.select({
message: "Select a template",
options: getTemplateOptions(),
})->P.resultOrRaise

let versions = await RescriptVersions.promptVersions()
let commandLineArguments = CommandLineArguments.fromProcessArgv(Process.argv)->Result.getOrThrow
let useDefaultVersions = Option.isSome(commandLineArguments.templateName)

let projectName = switch commandLineArguments.projectName {
| Some(projectName) if useDefaultVersions => projectName->validateProjectName->Option.getOrThrow

| initialValue =>
await P.text({
message: "What is the name of your new ReScript project?",
placeholder: "my-rescript-app",
?initialValue,
validate: validateProjectName,
})->P.resultOrRaise
}

let templateName = switch commandLineArguments.templateName {
| Some(templateName) => templateName
| None =>
await P.select({
message: "Select a template",
options: getTemplateOptions(),
})->P.resultOrRaise
}

let versions = useDefaultVersions
? await RescriptVersions.getDefaultVersions()
: await RescriptVersions.promptVersions()

await createProject(~templateName, ~projectName, ~versions)
}
Expand Down
41 changes: 37 additions & 4 deletions src/RescriptVersions.res
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,42 @@ type versions = {rescriptVersion: string, rescriptCoreVersion: option<string>}

let spinnerMessage = "Loading available versions..."

let makeVersions = rescriptVersion => {
let includesStdlib = CompareVersions.satisfies(rescriptVersion, includesStdlibVersionRange)
let rescriptCoreVersion = includesStdlib ? None : Some(finalRescriptCoreVersion)

{rescriptVersion, rescriptCoreVersion}
}

let getDefaultVersions = async () => {
let s = P.spinner()

s->P.Spinner.start(spinnerMessage)

let rescriptVersionsResult = await NpmRegistry.getPackageVersions(
"rescript",
rescriptVersionRange,
)

switch rescriptVersionsResult {
| Ok(_) => s->P.Spinner.stop("Versions loaded.")
| Error(_) => s->P.Spinner.stop(spinnerMessage)
}

let rescriptVersion = switch rescriptVersionsResult {
| Ok([]) => JsError.throwWithMessage("No supported ReScript versions were found.")
| Ok([version]) => version
| Ok(rescriptVersions) =>
switch rescriptVersions->Array.find(version => !(version->String.includes("-"))) {
| Some(version) => version
| None => rescriptVersions[0]->Option.getOrThrow
}
| Error(error) => error->NpmRegistry.getFetchErrorMessage->JsError.throwWithMessage
}

makeVersions(rescriptVersion)
}

let promptVersions = async () => {
let s = P.spinner()

Expand Down Expand Up @@ -41,10 +77,7 @@ let promptVersions = async () => {
| Error(error) => error->NpmRegistry.getFetchErrorMessage->JsError.throwWithMessage
}

let includesStdlib = CompareVersions.satisfies(rescriptVersion, includesStdlibVersionRange)
let rescriptCoreVersion = includesStdlib ? None : Some(finalRescriptCoreVersion)

{rescriptVersion, rescriptCoreVersion}
makeVersions(rescriptVersion)
}

let ensureYarnNodeModulesLinker = async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/RescriptVersions.resi
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
type versions = {rescriptVersion: string, rescriptCoreVersion: option<string>}

let getDefaultVersions: unit => promise<versions>

let promptVersions: unit => promise<versions>

let installVersions: versions => promise<unit>
Expand Down
17 changes: 15 additions & 2 deletions src/Templates.res
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,28 @@ type t = {
}

let basicTemplateName = "rescript-template-basic"
let viteTemplateName = "rescript-template-vite"
let nextjsTemplateName = "rescript-template-nextjs"
let templateNamePrefix = "rescript-template-"

let supportedTemplateNames = ["vite", "nextjs", "basic"]

let getTemplateName = templateName => {
let templateName = templateName->String.toLowerCase

supportedTemplateNames
->Array.find(supportedTemplateName => supportedTemplateName === templateName)
->Option.map(_ => `${templateNamePrefix}${templateName}`)
}

let templates = [
{
name: "rescript-template-vite",
name: viteTemplateName,
displayName: "Vite",
shortDescription: "Vite 7, React and Tailwind 4",
},
{
name: "rescript-template-nextjs",
name: nextjsTemplateName,
displayName: "Next.js",
shortDescription: "Next.js 15 with static export and Tailwind 3",
},
Expand Down
16 changes: 16 additions & 0 deletions src/bindings/Node.res
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ module Process = {
@scope("process") external exitWithCode: int => unit = "exit"
}

module Assert = {
@module("node:assert/strict")
external strictEqual: ('a, 'a) => unit = "strictEqual"

@module("node:assert/strict")
external fail: string => unit = "fail"
}

module Test = {
@module("node:test")
external describe: (string, unit => unit) => unit = "describe"

@module("node:test")
external test: (string, unit => unit) => unit = "test"
}

module Url = {
type t

Expand Down
Loading
Loading