From b9f57fc0827070a99b9715ac9829cea6ac55bfc8 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Wed, 12 Nov 2025 15:24:43 +0100 Subject: [PATCH] feat: add option to configure tools individually --- .github/workflows/build-templates.yml | 1 + .../create-react-native-library/src/index.ts | 12 + .../create-react-native-library/src/input.ts | 18 ++ .../src/template.ts | 4 +- .../src/utils/configureTools.ts | 207 ++++++++++++++++++ .../src/utils/prompt.ts | 40 +++- .../common/$.github/workflows/ci.yml | 2 + .../templates/common/$package.json | 61 +----- .../templates/common/CONTRIBUTING.md | 32 ++- .../eslint}/eslint.config.mjs | 0 .../jest}/src/__tests__/index.test.tsx | 0 .../{common => tools/lefthook}/lefthook.yml | 2 + .../templates/tools/turborepo/turbo.json | 42 ++++ 13 files changed, 342 insertions(+), 79 deletions(-) create mode 100644 packages/create-react-native-library/src/utils/configureTools.ts rename packages/create-react-native-library/templates/{common => tools/eslint}/eslint.config.mjs (100%) rename packages/create-react-native-library/templates/{common => tools/jest}/src/__tests__/index.test.tsx (100%) rename packages/create-react-native-library/templates/{common => tools/lefthook}/lefthook.yml (85%) create mode 100644 packages/create-react-native-library/templates/tools/turborepo/turbo.json diff --git a/.github/workflows/build-templates.yml b/.github/workflows/build-templates.yml index 460d456a3..55f0926fd 100644 --- a/.github/workflows/build-templates.yml +++ b/.github/workflows/build-templates.yml @@ -86,6 +86,7 @@ jobs: --type ${{ matrix.type.name }} --languages ${{ matrix.type.language }} --example ${{ matrix.type.language == 'js' && 'expo' || 'vanilla' }} + --tools eslint lefthook release-it jest ) if [[ ${{ github.event_name }} == 'schedule' ]]; then diff --git a/packages/create-react-native-library/src/index.ts b/packages/create-react-native-library/src/index.ts index df232b911..5e83ea87c 100644 --- a/packages/create-react-native-library/src/index.ts +++ b/packages/create-react-native-library/src/index.ts @@ -33,6 +33,7 @@ import { determinePackageManager } from './utils/packageManager'; import { prompt } from './utils/prompt'; import { resolveNpmPackageVersion } from './utils/resolveNpmPackageVersion'; import { hideBin } from 'yargs/helpers'; +import { configureTools } from './utils/configureTools'; type Args = Partial & { $0: string; @@ -120,6 +121,17 @@ async function create(_argv: Args) { await alignDependencyVersionsWithExampleApp(rootPackageJson, folder); } + if (!answers.local && answers.tools.length > 0) { + spinner.text = 'Configuring tools'; + + await configureTools({ + tools: answers.tools, + config, + root: folder, + packageJson: rootPackageJson, + }); + } + const libraryMetadata = createMetadata(answers); rootPackageJson['create-react-native-library'] = libraryMetadata; diff --git a/packages/create-react-native-library/src/input.ts b/packages/create-react-native-library/src/input.ts index c1232d686..b1def8179 100644 --- a/packages/create-react-native-library/src/input.ts +++ b/packages/create-react-native-library/src/input.ts @@ -7,6 +7,7 @@ import { version } from '../package.json'; import { SUPPORTED_REACT_NATIVE_VERSION } from './constants'; import type { Question } from './utils/prompt'; import { spawn } from './utils/spawn'; +import { AVAILABLE_TOOLS } from './utils/configureTools'; export type ProjectLanguages = 'kotlin-objc' | 'kotlin-swift' | 'js'; @@ -145,6 +146,11 @@ export const acceptedArgs = { type: 'string', choices: EXAMPLE_CHOICES.map(({ value }) => value), }, + 'tools': { + description: 'Tools to configure', + type: 'array', + choices: Object.keys(AVAILABLE_TOOLS), + }, 'interactive': { description: 'Whether to run in interactive mode', type: 'boolean', @@ -164,6 +170,7 @@ type PromptAnswers = { languages: ProjectLanguages; type: ProjectType; example: ExampleApp; + tools: string[]; local: boolean; }; @@ -386,6 +393,17 @@ export async function createQuestions({ }); }, }, + { + type: (_, answers) => ((answers.local ?? local) ? null : 'multiselect'), + name: 'tools', + message: 'Which tools do you want to configure?', + choices: Object.entries(AVAILABLE_TOOLS).map(([key, tool]) => ({ + value: key, + title: tool.name, + description: tool.description, + selected: true, + })), + }, ]; return questions; diff --git a/packages/create-react-native-library/src/template.ts b/packages/create-react-native-library/src/template.ts index 56e54cda4..d7fa96d54 100644 --- a/packages/create-react-native-library/src/template.ts +++ b/packages/create-react-native-library/src/template.ts @@ -42,6 +42,7 @@ export type TemplateConfiguration = { repo: string; example: ExampleApp; year: number; + tools: string[]; }; const BINARIES = [ @@ -144,6 +145,7 @@ export function generateTemplateConfiguration({ }, repo: answers.repoUrl, example: answers.example, + tools: answers.tools, year: new Date().getFullYear(), }; } @@ -241,7 +243,7 @@ export async function applyTemplates( /** * This copies the template files and renders them via ejs */ -async function applyTemplate( +export async function applyTemplate( config: TemplateConfiguration, source: string, destination: string diff --git a/packages/create-react-native-library/src/utils/configureTools.ts b/packages/create-react-native-library/src/utils/configureTools.ts new file mode 100644 index 000000000..035e3dc94 --- /dev/null +++ b/packages/create-react-native-library/src/utils/configureTools.ts @@ -0,0 +1,207 @@ +import fs from 'fs-extra'; +import path from 'node:path'; +import { applyTemplate, type TemplateConfiguration } from '../template'; +import sortObjectKeys from './sortObjectKeys'; + +type Tool = { + name: string; + description: string; + package: Record; + condition?: (config: TemplateConfiguration) => boolean; +}; + +type Options = { + tools: string[]; + root: string; + packageJson: Record; + config: TemplateConfiguration; +}; + +const ESLINT = { + name: 'ESLint with Prettier', + description: 'Lint and format code', + package: { + scripts: { + lint: 'eslint "**/*.{js,ts,tsx}"', + }, + prettier: { + quoteProps: 'consistent', + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', + useTabs: false, + }, + devDependencies: { + '@eslint/compat': '^1.3.2', + '@eslint/eslintrc': '^3.3.1', + '@eslint/js': '^9.35.0', + '@react-native/eslint-config': '^0.81.1', + 'eslint-config-prettier': '^10.1.8', + 'eslint-plugin-prettier': '^5.5.4', + 'eslint': '^9.35.0', + 'prettier': '^2.8.8', + }, + }, +}; + +const LEFTHOOK = { + name: 'Lefthook with Commitlint', + description: 'Manage Git hooks and lint commit messages', + package: { + commitlint: { + extends: ['@commitlint/config-conventional'], + }, + devDependencies: { + '@commitlint/config-conventional': '^19.8.1', + 'commitlint': '^19.8.1', + 'lefthook': '^2.0.3', + }, + }, +}; + +const RELEASE_IT = { + name: 'Release It', + description: 'Automate versioning and package publishing tasks', + package: { + 'scripts': { + release: 'release-it --only-version', + }, + 'release-it': { + git: { + // eslint-disable-next-line no-template-curly-in-string + commitMessage: 'chore: release ${version}', + // eslint-disable-next-line no-template-curly-in-string + tagName: 'v${version}', + }, + npm: { + publish: true, + }, + github: { + release: true, + }, + plugins: { + '@release-it/conventional-changelog': { + preset: { + name: 'angular', + }, + }, + }, + }, + 'devDependencies': { + 'release-it': '^19.0.4', + '@release-it/conventional-changelog': '^10.0.1', + }, + }, +}; + +const JEST = { + name: 'Jest', + description: 'Test JavaScript and TypeScript code', + package: { + scripts: { + test: 'jest', + }, + jest: { + preset: 'react-native', + modulePathIgnorePatterns: [ + '/example/node_modules', + '/lib/', + ], + }, + devDependencies: { + '@types/jest': '^29.5.14', + 'jest': '^29.7.0', + }, + }, +}; + +const TURBOREPO = { + name: 'Turborepo', + description: 'Cache build outputs on CI', + package: { + devDependencies: { + turbo: '^2.5.6', + }, + }, + condition: (config: TemplateConfiguration) => config.example !== 'expo', +}; + +export const AVAILABLE_TOOLS = { + 'eslint': ESLINT, + 'lefthook': LEFTHOOK, + 'release-it': RELEASE_IT, + 'jest': JEST, +} as const satisfies Record; + +const REQUIRED_TOOLS = { + turbo: TURBOREPO, +} as const satisfies Record; + +const ALL_TOOLS = { + ...AVAILABLE_TOOLS, + ...REQUIRED_TOOLS, +} as const; + +export async function configureTools({ + tools, + config, + root, + packageJson, +}: Options) { + for (const key of [ + ...tools, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ...(Object.keys(REQUIRED_TOOLS) as (keyof typeof REQUIRED_TOOLS)[]), + ]) { + if (!(key in ALL_TOOLS)) { + throw new Error( + `Invalid tool '${key}'. Available tools are: ${Object.keys( + AVAILABLE_TOOLS + ).join(', ')}.` + ); + } + + // @ts-expect-error: We checked the key above + const tool: Tool = ALL_TOOLS[key]; + + if (tool.condition && !tool.condition(config)) { + continue; + } + + const files = path.resolve(__dirname, `../../templates/tools/${key}`); + + if (fs.existsSync(files)) { + await applyTemplate(config, files, root); + } + + for (const [key, value] of Object.entries(tool.package)) { + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + if (typeof packageJson[key] === 'object' || packageJson[key] == null) { + packageJson[key] = { + ...packageJson[key], + ...value, + }; + + if ( + key === 'dependencies' || + key === 'devDependencies' || + key === 'peerDependencies' + ) { + // @ts-expect-error: We know they are objects here + packageJson[key] = sortObjectKeys(packageJson[key]); + } + } else { + throw new Error( + `Cannot merge '${key}' field because it is not an object (got '${String(packageJson[key])}').` + ); + } + } else { + packageJson[key] = value; + } + } + } +} diff --git a/packages/create-react-native-library/src/utils/prompt.ts b/packages/create-react-native-library/src/utils/prompt.ts index b58e1560a..871a976ab 100644 --- a/packages/create-react-native-library/src/utils/prompt.ts +++ b/packages/create-react-native-library/src/utils/prompt.ts @@ -231,15 +231,39 @@ function validate( ? question.choices(undefined, argv) : question.choices; - if (choices && choices.every((choice) => choice.value !== value)) { - if (choices.length > 1) { - validation = `Must be one of ${choices - .map((choice) => kleur.green(choice.value)) - .join(', ')}`; - } else if (choices[0]) { - validation = `Must be '${kleur.green(choices[0].value)}'`; + if (choices) { + let type = question.type; + + if (typeof question.type === 'function') { + type = question.type(null, argv); + } + + if (type === 'multiselect') { + if (Array.isArray(value)) { + const invalidChoices = value.filter((val) => + choices.every((choice) => choice.value !== val) + ); + + if (invalidChoices.length > 0) { + validation = `Must be an array of ${choices + .map((choice) => kleur.green(choice.value)) + .join(', ')}`; + } + } else { + validation = 'Must be an array'; + } } else { - validation = false; + if (choices.every((choice) => choice.value !== value)) { + if (choices.length > 1) { + validation = `Must be one of ${choices + .map((choice) => kleur.green(choice.value)) + .join(', ')}`; + } else if (choices[0]) { + validation = `Must be '${kleur.green(choices[0].value)}'`; + } else { + validation = false; + } + } } } } diff --git a/packages/create-react-native-library/templates/common/$.github/workflows/ci.yml b/packages/create-react-native-library/templates/common/$.github/workflows/ci.yml index 8f9d31b8e..14fb29218 100644 --- a/packages/create-react-native-library/templates/common/$.github/workflows/ci.yml +++ b/packages/create-react-native-library/templates/common/$.github/workflows/ci.yml @@ -25,8 +25,10 @@ jobs: - name: Setup uses: ./.github/actions/setup +<% if (tools.includes('eslint')) { -%> - name: Lint files run: yarn lint +<% } -%> - name: Typecheck files run: yarn typecheck diff --git a/packages/create-react-native-library/templates/common/$package.json b/packages/create-react-native-library/templates/common/$package.json index 39b45fce9..af933e4f5 100644 --- a/packages/create-react-native-library/templates/common/$package.json +++ b/packages/create-react-native-library/templates/common/$package.json @@ -39,9 +39,6 @@ <% if (example !== 'none') { -%> "example": "yarn workspace <%- project.slug -%>-example", <% } -%> - "test": "jest", - "typecheck": "tsc", - "lint": "eslint \"**/*.{js,ts,tsx}\"", <% if (example !== 'expo') { -%> "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", <% } else { -%> @@ -51,7 +48,7 @@ <% if (project.moduleConfig === 'nitro-modules' || project.viewConfig === 'nitro-view') { -%> "nitrogen": "nitrogen", <% } -%> - "release": "release-it --only-version" + "typecheck": "tsc" }, "keywords": [ "react-native", @@ -72,38 +69,20 @@ "registry": "https://registry.npmjs.org/" }, "devDependencies": { - "@commitlint/config-conventional": "^19.8.1", - "@eslint/compat": "^1.3.2", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.35.0", - "@evilmartians/lefthook": "^1.12.3", <% if (example === 'vanilla' && (project.moduleConfig === 'turbo-modules' || project.viewConfig === 'fabric-view')) { -%> "@react-native-community/cli": "20.0.1", <% } -%> "@react-native/babel-preset": "0.81.1", - "@react-native/eslint-config": "^0.81.1", - "@release-it/conventional-changelog": "^10.0.1", - "@types/jest": "^29.5.14", "@types/react": "^19.1.12", - "commitlint": "^19.8.1", "del-cli": "^6.0.0", - "eslint": "^9.35.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.4", - "jest": "^29.7.0", <% if (project.moduleConfig === 'nitro-modules' || project.viewConfig === 'nitro-view') { -%> "nitrogen": "^<%- versions.nitro %>", <% } -%> - "prettier": "^3.6.2", "react": "19.1.0", "react-native": "0.81.1", "react-native-builder-bob": "^<%- versions.bob %>", <% if (project.moduleConfig === 'nitro-modules' || project.viewConfig === 'nitro-view') { -%> "react-native-nitro-modules": "^<%- versions.nitro %>", -<% } -%> - "release-it": "^19.0.4", -<% if (example !== 'expo') { -%> - "turbo": "^2.5.6", <% } -%> "typescript": "^5.9.2" }, @@ -122,44 +101,6 @@ ], <% } -%> "packageManager": "yarn@3.6.1", - "jest": { - "preset": "react-native", - "modulePathIgnorePatterns": [ - "/example/node_modules", - "/lib/" - ] - }, - "commitlint": { - "extends": [ - "@commitlint/config-conventional" - ] - }, - "release-it": { - "git": { - "commitMessage": "chore: release ${version}", - "tagName": "v${version}" - }, - "npm": { - "publish": true - }, - "github": { - "release": true - }, - "plugins": { - "@release-it/conventional-changelog": { - "preset": { - "name": "angular" - } - } - } - }, - "prettier": { - "quoteProps": "consistent", - "singleQuote": true, - "tabWidth": 2, - "trailingComma": "es5", - "useTabs": false - }, "react-native-builder-bob": { "source": "src", "output": "lib", diff --git a/packages/create-react-native-library/templates/common/CONTRIBUTING.md b/packages/create-react-native-library/templates/common/CONTRIBUTING.md index e88631ea7..a118950bf 100644 --- a/packages/create-react-native-library/templates/common/CONTRIBUTING.md +++ b/packages/create-react-native-library/templates/common/CONTRIBUTING.md @@ -84,10 +84,16 @@ yarn example web ``` <% } -%> -Make sure your code passes TypeScript and ESLint. Run the following to verify: +Make sure your code passes TypeScript: ```sh yarn typecheck +``` + +<% if (tools.includes('eslint')) { -%> +To check for linting errors, run the following: + +```sh yarn lint ``` @@ -97,12 +103,17 @@ To fix formatting errors, run the following: yarn lint --fix ``` +<% } -%> +<% if (tools.includes('jest')) { -%> Remember to add tests for your change if possible. Run the unit tests by: ```sh yarn test ``` +<% } -%> +<% if (tools.includes('lefthook')) { -%> + ### Commit message convention We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: @@ -115,14 +126,9 @@ We follow the [conventional commits specification](https://www.conventionalcommi - `chore`: tooling changes, e.g. change CI config. Our pre-commit hooks verify that your commit message matches this format when committing. +<% } -%> -### Linting and tests - -[ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) - -We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. - -Our pre-commit hooks verify that the linter and tests pass when committing. +<% if (tools.includes('release-it')) { -%> ### Publishing to npm @@ -134,14 +140,20 @@ To publish new versions, run the following: yarn release ``` +<% } -%> + ### Scripts The `package.json` file contains various scripts for common tasks: - `yarn`: setup project by installing dependencies. - `yarn typecheck`: type-check files with TypeScript. -- `yarn lint`: lint files with ESLint. -- `yarn test`: run unit tests with Jest. +<% if (tools.includes('eslint')) { -%> +- `yarn lint`: lint files with [ESLint](https://eslint.org/). +<% } -%> +<% if (tools.includes('jest')) { -%> +- `yarn test`: run unit tests with [Jest](https://jestjs.io/). +<% } -%> - `yarn example start`: start the Metro server for the example app. - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. diff --git a/packages/create-react-native-library/templates/common/eslint.config.mjs b/packages/create-react-native-library/templates/tools/eslint/eslint.config.mjs similarity index 100% rename from packages/create-react-native-library/templates/common/eslint.config.mjs rename to packages/create-react-native-library/templates/tools/eslint/eslint.config.mjs diff --git a/packages/create-react-native-library/templates/common/src/__tests__/index.test.tsx b/packages/create-react-native-library/templates/tools/jest/src/__tests__/index.test.tsx similarity index 100% rename from packages/create-react-native-library/templates/common/src/__tests__/index.test.tsx rename to packages/create-react-native-library/templates/tools/jest/src/__tests__/index.test.tsx diff --git a/packages/create-react-native-library/templates/common/lefthook.yml b/packages/create-react-native-library/templates/tools/lefthook/lefthook.yml similarity index 85% rename from packages/create-react-native-library/templates/common/lefthook.yml rename to packages/create-react-native-library/templates/tools/lefthook/lefthook.yml index 9695c1295..05b7d0597 100644 --- a/packages/create-react-native-library/templates/common/lefthook.yml +++ b/packages/create-react-native-library/templates/tools/lefthook/lefthook.yml @@ -1,9 +1,11 @@ pre-commit: parallel: true commands: +<% if (tools.includes('eslint')) { %> lint: glob: "*.{js,ts,jsx,tsx}" run: npx eslint {staged_files} +<% } %> types: glob: "*.{js,ts, jsx, tsx}" run: npx tsc diff --git a/packages/create-react-native-library/templates/tools/turborepo/turbo.json b/packages/create-react-native-library/templates/tools/turborepo/turbo.json new file mode 100644 index 000000000..c4d78c496 --- /dev/null +++ b/packages/create-react-native-library/templates/tools/turborepo/turbo.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "android", + "!android/build", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/android", + "!example/android/.gradle", + "!example/android/build", + "!example/android/app/build" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/ios", + "!example/ios/build", + "!example/ios/Pods" + ], + "outputs": [] + } + } +}