From 4a4ad713f432d3a1ae8ab05000f646d93017258e Mon Sep 17 00:00:00 2001 From: Nacho Vazquez Date: Sun, 17 Sep 2023 22:37:08 -0300 Subject: [PATCH] feat: add worker library generator (#23) * feat: create empty library generator * feat: modernize app generation to include the new nx projects format * feat: create cloudflare worker lib generator * docs: update readme to match new implementation * test: adjust test to schema simplification * fix: lint issue * test: create library e2e files * fix: library usage of the plugin * fix: missing config on schema * fix: failing test --- ...cloudflare.spec.ts => application.spec.ts} | 8 +- .../tests/library.spec.ts | 29 + packages/plugins/nx-cloudflare/README.md | 84 +- .../plugins/nx-cloudflare/generators.json | 6 + packages/plugins/nx-cloudflare/package.json | 2 +- .../src/generators/application/application.ts | 36 +- .../src/generators/application/schema.d.ts | 3 + .../src/generators/application/schema.json | 10 +- .../src/generators/library/library.spec.ts | 917 ++++++++++++++++++ .../src/generators/library/library.ts | 106 ++ .../src/generators/library/schema.d.ts | 22 + .../src/generators/library/schema.json | 98 ++ 12 files changed, 1273 insertions(+), 48 deletions(-) rename e2e/plugins-nx-cloudflare-e2e/tests/{plugins-nx-cloudflare.spec.ts => application.spec.ts} (85%) create mode 100644 e2e/plugins-nx-cloudflare-e2e/tests/library.spec.ts create mode 100644 packages/plugins/nx-cloudflare/src/generators/library/library.spec.ts create mode 100644 packages/plugins/nx-cloudflare/src/generators/library/library.ts create mode 100644 packages/plugins/nx-cloudflare/src/generators/library/schema.d.ts create mode 100644 packages/plugins/nx-cloudflare/src/generators/library/schema.json diff --git a/e2e/plugins-nx-cloudflare-e2e/tests/plugins-nx-cloudflare.spec.ts b/e2e/plugins-nx-cloudflare-e2e/tests/application.spec.ts similarity index 85% rename from e2e/plugins-nx-cloudflare-e2e/tests/plugins-nx-cloudflare.spec.ts rename to e2e/plugins-nx-cloudflare-e2e/tests/application.spec.ts index d03582f53..b211b0645 100644 --- a/e2e/plugins-nx-cloudflare-e2e/tests/plugins-nx-cloudflare.spec.ts +++ b/e2e/plugins-nx-cloudflare-e2e/tests/application.spec.ts @@ -26,7 +26,7 @@ describe('Cloudflare Worker Applications', () => { const workerapp = uniq('workerapp'); runNxCommand( - `generate @naxodev/nx-cloudflare:app ${workerapp} --directory="apps" --template="none"` + `generate @naxodev/nx-cloudflare:app --name ${workerapp} --directory="apps/${workerapp}" --template="none"` ); expect( @@ -39,7 +39,7 @@ describe('Cloudflare Worker Applications', () => { const port = 8787; runNxCommand( - `generate @naxodev/nx-cloudflare:app ${workerapp} --directory="apps" --template="fetch-handler"` + `generate @naxodev/nx-cloudflare:app --name ${workerapp} --directory="apps/${workerapp}" --template="fetch-handler"` ); const lintResults = runNxCommand(`lint ${workerapp}`); @@ -66,7 +66,7 @@ describe('Cloudflare Worker Applications', () => { const port = 8787; runNxCommand( - `generate @naxodev/nx-cloudflare:app ${workerapp} --directory="apps" --template="scheduled-handler"` + `generate @naxodev/nx-cloudflare:app --name ${workerapp} --directory="apps/${workerapp}" --template="scheduled-handler"` ); const lintResults = runNxCommand(`lint ${workerapp}`); @@ -93,7 +93,7 @@ describe('Cloudflare Worker Applications', () => { const port = 8787; runNxCommand( - `generate @naxodev/nx-cloudflare:app ${workerapp} --directory="apps" --template="hono"` + `generate @naxodev/nx-cloudflare:app --name ${workerapp} --directory="apps/${workerapp}" --template="hono"` ); const lintResults = runNxCommand(`lint ${workerapp}`); diff --git a/e2e/plugins-nx-cloudflare-e2e/tests/library.spec.ts b/e2e/plugins-nx-cloudflare-e2e/tests/library.spec.ts new file mode 100644 index 000000000..b517546bb --- /dev/null +++ b/e2e/plugins-nx-cloudflare-e2e/tests/library.spec.ts @@ -0,0 +1,29 @@ +import { + uniq, + fileExists, + tmpProjPath, + runNxCommand, +} from '@nx/plugin/testing'; +import { newNxProject, installPlugin, cleanup } from '@naxodev/e2e/utils'; +import { join } from 'path'; + +describe('Cloudflare Worker Library', () => { + beforeEach(() => { + newNxProject(); + installPlugin('nx-cloudflare'); + }); + + afterEach(() => cleanup()); + + it('should be able to generate a library', async () => { + const workerlib = uniq('workerlib'); + + runNxCommand( + `generate @naxodev/nx-cloudflare:lib --name=${workerlib} --directory="libs/${workerlib}" --unitTestRunner="none"` + ); + + expect( + fileExists(join(tmpProjPath(), `libs/${workerlib}/project.json`)) + ).toBeTruthy(); + }, 30_000); +}); diff --git a/packages/plugins/nx-cloudflare/README.md b/packages/plugins/nx-cloudflare/README.md index 366f1db33..39e7f2092 100644 --- a/packages/plugins/nx-cloudflare/README.md +++ b/packages/plugins/nx-cloudflare/README.md @@ -16,15 +16,16 @@
-Nx plugin for Cloudflare, in particular Cloudflare workers. +Nx plugin for Cloudflare. ## Features - ✅ Generate Cloudflare Worker Application -- ✅ Include Fetch Handler template -- ✅ Include Scheduled Handler template -- ✅ Vitest tests support -- ✅ Serve and Publish executors for workers. + - ✅ Include Fetch Handler template + - ✅ Include Scheduled Handler template + - ✅ Vitest tests support + - ✅ Serve and Publish executors for workers. +- ✅ Generate Cloudflare Worker Library - ✅ [Experimental] Cloudflare Pages builder for Next.JS projects. ## Installation @@ -39,29 +40,32 @@ Nx Cloudflare is published as the `@naxodev/nx-cloudflare` package. ## Usage -### Generating new Cloudflare Worker Application +### Cloudflare Worker Application + +#### Generating a new Cloudflare Worker Application ```bash nx g @naxodev/nx-cloudflare:application my-worker-app ``` -The available options are the following: - -| Option | Type | Default | Description | -| --------------- | -------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------- | -| name | string | \*required | What name would you like to use? | -| template | fetch-handler, scheduled-handler, hono, none | fetch-handler | Which worker template do you want to use? | -| port | number | 8787 | The port in which the worker will be run on development mode | -| accountId | string | null | The Cloudflare account identifier where the worker will be deployed | -| js | boolean | false | Use JavaScript instead of TypeScript | -| tags | string | null | Add tags to the application (used for linting). | -| frontendProject | string | null | Frontend project that needs to access this application. This sets up proxy configuration. | -| unitTestRunner | vitest, none | vitest | Test runner to use for unit tests. | -| directory | string | null | The directory of the new application. | -| rootProject | boolean | false | Create worker application at the root of the workspace | -| skipFormat | boolean | false | Skip formatting files. | - -### Serve worker on dev mode +Available options: + +| Option | Type | Default | Description | +| ------------------------ | -------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | string | \*required | What name would you like to use? | +| template | fetch-handler, scheduled-handler, hono, none | fetch-handler | Which worker template do you want to use? | +| projectNameAndRootFormat | as-provided, derived | as-provided | Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`). | +| port | number | 8787 | The port in which the worker will be run on development mode | +| accountId | string | null | The Cloudflare account identifier where the worker will be deployed | +| js | boolean | false | Use JavaScript instead of TypeScript | +| tags | string | null | Add tags to the application (used for linting). | +| frontendProject | string | null | Frontend project that needs to access this application. This sets up proxy configuration. | +| unitTestRunner | vitest, none | vitest | Test runner to use for unit tests. | +| directory | string | null | The directory of the new application. | +| rootProject | boolean | false | Create worker application at the root of the workspace | +| skipFormat | boolean | false | Skip formatting files. | + +#### Serve worker on dev mode ```bash nx serve @@ -73,13 +77,42 @@ The available options are the following: | ------ | ------ | ------- | ------------------------------------------------------------ | | port | number | 8787 | The port in which the worker will be run on development mode | -### Publish worker to Cloudflare +### Cloudflare Worker Library + +#### Generating a new Cloudflare Worker Library + +```bash +nx g @naxodev/nx-cloudflare:library my-worker-lib +``` + +Available options: + +| Option | Type | Default | Description | +| ------------------------ | ------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | string | \*required | What name would you like to use? | +| directory | string | null | The directory of the new application. | +| projectNameAndRootFormat | as-provided, derived | as-provided | Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`). | +| linter | eslint, none | eslint | The tool to use for running lint checks. | +| unitTestRunner | vitest, none | vitest | Test runner to use for unit tests. | +| tags | string | null | Add tags to the application (used for linting). | +| skipFormat | boolean | false | Skip formatting files. | +| js | boolean | false | Use JavaScript instead of TypeScript | +| strict | boolean | true | Whether to enable tsconfig strict mode or not. | +| publishable | boolean | false | Generate a publishable library. | +| importPath | string | null | The library name used to import it, like @myorg/my-awesome-lib. Required for publishable library. | +| bundler | swc, tsc, rollup, vite, esbuild, none | tsc | Which bundler would you like to use to build the library? Choose 'none' to skip build setup. | +| minimal | boolean | false | Generate a library with a minimal setup. No README.md generated. | +| simpleName | boolean | false | Don't include the directory in the generated file name. | + +#### Publish worker to Cloudflare ```bash nx publish ``` -### Build Next application. +### NextJS on Cloudflare + +#### Build Next application. > This feature is ⚠️ **experimental**. We appreciate that you let us know of any issues that you find. @@ -124,4 +157,5 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Nacho Vazquez
Nacho Vazquez

🐛 💻 📖 💡 🤔 🧑‍🏫 🚧 📆 👀 ⚠️ 🔧 📓 + = 16.0.0 <= 17.0.0", "tslib": "^2.3.0", "@svgr/webpack": "^8.0.1", "copy-webpack-plugin": "^11.0.0", @@ -52,7 +53,6 @@ "fs-extra": "^11.1.0", "semver": "^7.5.4", "url-loader": "^4.1.1", - "@nx/devkit": ">= 16.0.0 <= 17.0.0", "@nx/node": ">= 16.0.0 <= 17.0.0", "wrangler": ">= 3.8.0 <= 4.0.0" } diff --git a/packages/plugins/nx-cloudflare/src/generators/application/application.ts b/packages/plugins/nx-cloudflare/src/generators/application/application.ts index ab250091b..8b97ea22f 100644 --- a/packages/plugins/nx-cloudflare/src/generators/application/application.ts +++ b/packages/plugins/nx-cloudflare/src/generators/application/application.ts @@ -19,9 +19,10 @@ import initGenerator from '../init/init'; import { vitestImports } from './utils/vitest-imports'; import { getAccountId } from './utils/get-account-id'; import { vitestScript } from './utils/vitest-script'; +import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils'; export async function applicationGenerator(tree: Tree, schema: Schema) { - const options = normalizeOptions(tree, schema); + const options = await normalizeOptions(tree, schema); // Set up the needed packages. const initTask = await initGenerator(tree, { @@ -169,21 +170,24 @@ function removeTestFiles(tree: Tree, options: NormalizedSchema) { } // Transform the options to the normalized schema. Loads defaults options. -function normalizeOptions(host: Tree, options: Schema): NormalizedSchema { - const { layoutDirectory, projectDirectory } = extractLayoutDirectory( - options.directory - ); - const appsDir = layoutDirectory ?? getWorkspaceLayout(host).appsDir; - - const appDirectory = projectDirectory - ? `${names(projectDirectory).fileName}/${names(options.name).fileName}` - : names(options.name).fileName; - - const appProjectName = appDirectory.replace(new RegExp('/', 'g'), '-'); - - const appProjectRoot = options.rootProject - ? '.' - : joinPathFragments(appsDir, appDirectory); +async function normalizeOptions( + host: Tree, + options: Schema +): Promise { + const { + projectName: appProjectName, + projectRoot: appProjectRoot, + projectNameAndRootFormat, + } = await determineProjectNameAndRootOptions(host, { + name: options.name, + projectType: 'application', + directory: options.directory, + projectNameAndRootFormat: options.projectNameAndRootFormat, + rootProject: options.rootProject, + callingGenerator: '@nx/node:application', + }); + options.rootProject = appProjectRoot === '.'; + options.projectNameAndRootFormat = projectNameAndRootFormat; return { ...options, diff --git a/packages/plugins/nx-cloudflare/src/generators/application/schema.d.ts b/packages/plugins/nx-cloudflare/src/generators/application/schema.d.ts index c00930be2..44c8c7009 100644 --- a/packages/plugins/nx-cloudflare/src/generators/application/schema.d.ts +++ b/packages/plugins/nx-cloudflare/src/generators/application/schema.d.ts @@ -1,9 +1,12 @@ +import type { ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils'; + export interface Schema { name: string; template?: 'fetch-handler' | 'scheduled-handler' | 'hono' | 'none'; js?: boolean; unitTestRunner?: 'vitest' | 'none'; directory?: string; + projectNameAndRootFormat?: ProjectNameAndRootFormat; rootProject?: boolean; tags?: string; frontendProject?: string; diff --git a/packages/plugins/nx-cloudflare/src/generators/application/schema.json b/packages/plugins/nx-cloudflare/src/generators/application/schema.json index 24d8336fc..638428a5b 100644 --- a/packages/plugins/nx-cloudflare/src/generators/application/schema.json +++ b/packages/plugins/nx-cloudflare/src/generators/application/schema.json @@ -1,7 +1,8 @@ { "$schema": "http://json-schema.org/schema", - "$id": "Application", - "title": "", + "$id": "NxCloudflareWorkerApplication", + "title": "Create a Cloudflare Worker Application", + "description": "Create a Cloudflare Worker Application", "type": "object", "properties": { "name": { @@ -17,6 +18,11 @@ "type": "boolean", "description": "Use JavaScript instead of TypeScript" }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, "tags": { "type": "string", "description": "Add tags to the application (used for linting)." diff --git a/packages/plugins/nx-cloudflare/src/generators/library/library.spec.ts b/packages/plugins/nx-cloudflare/src/generators/library/library.spec.ts new file mode 100644 index 000000000..a8efded0c --- /dev/null +++ b/packages/plugins/nx-cloudflare/src/generators/library/library.spec.ts @@ -0,0 +1,917 @@ +import { + getProjects, + readJson, + readProjectConfiguration, + Tree, + updateJson, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { NxCloudflareLibraryGeneratorSchema } from './schema'; +import libraryGenerator from './library'; + +describe('lib', () => { + let tree: Tree; + const defaultOptions: Omit = { + unitTestRunner: 'vitest', + skipFormat: false, + linter: 'eslint', + js: false, + strict: true, + }; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write('/.gitignore', ''); + tree.write('/.gitignore', ''); + }); + + describe('shared options', () => { + describe('not-nested', () => { + it('should update tags', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + tags: 'one,two', + projectNameAndRootFormat: 'as-provided', + }); + const projects = Object.fromEntries(getProjects(tree)); + expect(projects).toMatchObject({ + 'my-lib': { + tags: ['one', 'two'], + }, + }); + }); + + it('should update root tsconfig.base.json', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, '/tsconfig.base.json'); + expect(tsconfigJson.compilerOptions.paths['@proj/my-lib']).toEqual([ + 'my-lib/src/index.ts', + ]); + }); + + it('should update tsconfig.lib.json with worker types', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, 'my-lib/tsconfig.lib.json'); + expect(tsconfigJson.compilerOptions.types).toEqual([ + 'node', + '@cloudflare/workers-types', + ]); + }); + + it('should update root tsconfig.json when no tsconfig.base.json', async () => { + tree.rename('tsconfig.base.json', 'tsconfig.json'); + + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, 'tsconfig.json'); + expect(tsconfigJson.compilerOptions.paths['@proj/my-lib']).toEqual([ + 'my-lib/src/index.ts', + ]); + }); + + it('should update root tsconfig.base.json (no existing path mappings)', async () => { + updateJson(tree, 'tsconfig.base.json', (json) => { + json.compilerOptions.paths = undefined; + return json; + }); + + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, '/tsconfig.base.json'); + expect(tsconfigJson.compilerOptions.paths['@proj/my-lib']).toEqual([ + 'my-lib/src/index.ts', + ]); + }); + + it('should create a local tsconfig.json', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, 'my-lib/tsconfig.json'); + expect(tsconfigJson).toMatchInlineSnapshot(` + { + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "module": "commonjs", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "strict": true, + "types": [ + "vitest", + ], + }, + "extends": "../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json", + }, + { + "path": "./tsconfig.spec.json", + }, + ], + } + `); + }); + + it('should extend from root tsconfig.json when no tsconfig.base.json', async () => { + tree.rename('tsconfig.base.json', 'tsconfig.json'); + + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, 'my-lib/tsconfig.json'); + expect(tsconfigJson.extends).toBe('../tsconfig.json'); + }); + }); + + describe('nested', () => { + it('should update tags', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + tags: 'one', + projectNameAndRootFormat: 'as-provided', + }); + let projects = Object.fromEntries(getProjects(tree)); + expect(projects).toMatchObject({ + 'my-lib': { + tags: ['one'], + }, + }); + + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib2', + directory: 'my-dir/my-lib-2', + tags: 'one,two', + projectNameAndRootFormat: 'as-provided', + }); + projects = Object.fromEntries(getProjects(tree)); + expect(projects).toMatchObject({ + 'my-lib': { + tags: ['one'], + }, + 'my-lib2': { + tags: ['one', 'two'], + }, + }); + }); + + it('should generate files', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + expect(tree.exists('my-dir/my-lib/src/index.ts')).toBeTruthy(); + expect(tree.exists('my-dir/my-lib/src/lib/my-lib.ts')).toBeTruthy(); + expect( + tree.exists('my-dir/my-lib/src/lib/my-lib.spec.ts') + ).toBeTruthy(); + expect(tree.exists('my-dir/my-lib/src/index.ts')).toBeTruthy(); + expect(tree.exists(`my-dir/my-lib/.eslintrc.json`)).toBeTruthy(); + expect(tree.exists(`my-dir/my-lib/package.json`)).toBeTruthy(); + }); + + it('should update project configuration', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + expect(readProjectConfiguration(tree, 'my-lib').root).toEqual( + 'my-dir/my-lib' + ); + }); + + it('should update root tsconfig.base.json', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, '/tsconfig.base.json'); + expect(tsconfigJson.compilerOptions.paths['@proj/my-lib']).toEqual([ + 'my-dir/my-lib/src/index.ts', + ]); + expect(tsconfigJson.compilerOptions.paths['my-lib/*']).toBeUndefined(); + }); + + it('should update tsconfig.lib.json with worker types', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, 'my-dir/my-lib/tsconfig.lib.json'); + expect(tsconfigJson.compilerOptions.types).toEqual([ + 'node', + '@cloudflare/workers-types', + ]); + }); + + it('should update root tsconfig.json when no tsconfig.base.json', async () => { + tree.rename('tsconfig.base.json', 'tsconfig.json'); + + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, '/tsconfig.json'); + expect(tsconfigJson.compilerOptions.paths['@proj/my-lib']).toEqual([ + 'my-dir/my-lib/src/index.ts', + ]); + expect(tsconfigJson.compilerOptions.paths['my-lib/*']).toBeUndefined(); + }); + + it('should create a local tsconfig.json', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, 'my-dir/my-lib/tsconfig.json'); + expect(tsconfigJson.references).toEqual([ + { + path: './tsconfig.lib.json', + }, + { + path: './tsconfig.spec.json', + }, + ]); + }); + + it('should extend from root tsconfig.base.json', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, 'my-dir/my-lib/tsconfig.json'); + expect(tsconfigJson.extends).toBe('../../tsconfig.base.json'); + }); + + it('should extend from root tsconfig.json when no tsconfig.base.json', async () => { + tree.rename('tsconfig.base.json', 'tsconfig.json'); + + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, 'my-dir/my-lib/tsconfig.json'); + expect(tsconfigJson.extends).toBe('../../tsconfig.json'); + }); + }); + + describe('--no-strict', () => { + it('should update the projects tsconfig with strict false', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + strict: false, + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, '/my-lib/tsconfig.json'); + + expect( + tsconfigJson.compilerOptions?.forceConsistentCasingInFileNames + ).not.toBeDefined(); + expect(tsconfigJson.compilerOptions?.strict).not.toBeDefined(); + expect( + tsconfigJson.compilerOptions?.noImplicitOverride + ).not.toBeDefined(); + expect( + tsconfigJson.compilerOptions?.noPropertyAccessFromIndexSignature + ).not.toBeDefined(); + expect( + tsconfigJson.compilerOptions?.noImplicitReturns + ).not.toBeDefined(); + expect( + tsconfigJson.compilerOptions?.noFallthroughCasesInSwitch + ).not.toBeDefined(); + }); + + it('should default to strict true', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, '/my-lib/tsconfig.json'); + + expect(tsconfigJson.compilerOptions.strict).toBeTruthy(); + expect( + tsconfigJson.compilerOptions.forceConsistentCasingInFileNames + ).toBeTruthy(); + expect(tsconfigJson.compilerOptions.noImplicitReturns).toBeTruthy(); + expect( + tsconfigJson.compilerOptions.noFallthroughCasesInSwitch + ).toBeTruthy(); + }); + }); + + describe('--importPath', () => { + it('should update the tsconfig with the given import path', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + importPath: '@myorg/lib', + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, '/tsconfig.base.json'); + + expect(tsconfigJson.compilerOptions.paths['@myorg/lib']).toBeDefined(); + }); + + it('should fail if the same importPath has already been used', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib1', + importPath: '@myorg/lib', + projectNameAndRootFormat: 'as-provided', + }); + + try { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib2', + importPath: '@myorg/lib', + projectNameAndRootFormat: 'as-provided', + }); + } catch (e) { + expect(e.message).toContain( + 'You already have a library using the import path' + ); + } + + expect.assertions(1); + }); + + it('should provide a default import path using npm scope', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, '/tsconfig.base.json'); + expect( + tsconfigJson.compilerOptions.paths['@proj/my-lib'] + ).toBeDefined(); + }); + + it('should read import path from existing name in package.json', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = '@acme/core'; + return json; + }); + await libraryGenerator(tree, { + ...defaultOptions, + rootProject: true, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + + const tsconfigJson = readJson(tree, '/tsconfig.base.json'); + expect(tsconfigJson.compilerOptions.paths['@acme/core']).toBeDefined(); + }); + }); + }); + + describe('--linter', () => { + it('should add eslint dependencies', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + + const packageJson = readJson(tree, 'package.json'); + expect(packageJson.devDependencies['eslint']).toBeDefined(); + expect(packageJson.devDependencies['@nx/linter']).toBeDefined(); + expect(packageJson.devDependencies['@nx/eslint-plugin']).toBeDefined(); + }); + + describe('not nested', () => { + it('should update configuration', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + expect(readProjectConfiguration(tree, 'my-lib').targets.lint).toEqual({ + executor: '@nx/linter:eslint', + outputs: ['{options.outputFile}'], + options: { + lintFilePatterns: ['my-lib/**/*.ts', 'my-lib/package.json'], + }, + }); + }); + + it('should create a local .eslintrc.json', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + projectNameAndRootFormat: 'as-provided', + }); + + const eslintJson = readJson(tree, 'my-lib/.eslintrc.json'); + expect(eslintJson).toMatchInlineSnapshot(` + { + "extends": [ + "../.eslintrc.json", + ], + "ignorePatterns": [ + "!**/*", + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": {}, + }, + { + "files": [ + "*.ts", + "*.tsx", + ], + "rules": {}, + }, + { + "files": [ + "*.js", + "*.jsx", + ], + "rules": {}, + }, + { + "files": [ + "*.json", + ], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": [ + "{projectRoot}/vite.config.{js,ts,mjs,mts}", + ], + }, + ], + }, + }, + ], + } + `); + }); + }); + + describe('nested', () => { + it('should update configuration', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + expect(readProjectConfiguration(tree, 'my-lib').targets.lint).toEqual({ + executor: '@nx/linter:eslint', + outputs: ['{options.outputFile}'], + options: { + lintFilePatterns: [ + 'my-dir/my-lib/**/*.ts', + 'my-dir/my-lib/package.json', + ], + }, + }); + }); + + it('should create a local .eslintrc.json', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + const eslintJson = readJson(tree, 'my-dir/my-lib/.eslintrc.json'); + expect(eslintJson).toMatchInlineSnapshot(` + { + "extends": [ + "../../.eslintrc.json", + ], + "ignorePatterns": [ + "!**/*", + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": {}, + }, + { + "files": [ + "*.ts", + "*.tsx", + ], + "rules": {}, + }, + { + "files": [ + "*.js", + "*.jsx", + ], + "rules": {}, + }, + { + "files": [ + "*.json", + ], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": [ + "{projectRoot}/vite.config.{js,ts,mjs,mts}", + ], + }, + ], + }, + }, + ], + } + `); + }); + }); + + describe('--js flag', () => { + it('should generate js files instead of ts files', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + js: true, + projectNameAndRootFormat: 'as-provided', + }); + expect(tree.exists('my-lib/src/index.js')).toBeTruthy(); + expect(tree.exists('my-lib/src/lib/my-lib.js')).toBeTruthy(); + expect(tree.exists('my-lib/src/lib/my-lib.spec.js')).toBeTruthy(); + }); + + it('should update tsconfig.json with compilerOptions.allowJs: true', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + js: true, + projectNameAndRootFormat: 'as-provided', + }); + expect( + readJson(tree, 'my-lib/tsconfig.json').compilerOptions.allowJs + ).toBeTruthy(); + }); + + it('should update tsconfig.lib.json include with src/**/*.js glob', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + js: true, + projectNameAndRootFormat: 'as-provided', + }); + expect(readJson(tree, 'my-lib/tsconfig.lib.json').include).toEqual([ + 'src/**/*.ts', + 'src/**/*.js', + ]); + }); + + it('should update root tsconfig.json with a js file path', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + js: true, + projectNameAndRootFormat: 'as-provided', + }); + const tsconfigJson = readJson(tree, '/tsconfig.base.json'); + expect(tsconfigJson.compilerOptions.paths['@proj/my-lib']).toEqual([ + 'my-lib/src/index.js', + ]); + }); + + it('should generate js files for nested libs as well', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + js: true, + projectNameAndRootFormat: 'as-provided', + }); + expect(tree.exists('my-dir/my-lib/src/index.js')).toBeTruthy(); + expect(tree.exists('my-dir/my-lib/src/lib/my-lib.js')).toBeTruthy(); + expect( + tree.exists('my-dir/my-lib/src/lib/my-lib.spec.js') + ).toBeTruthy(); + expect(tree.exists('my-dir/my-lib/src/index.js')).toBeTruthy(); + }); + + it('should configure the project for linting js files', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + directory: 'my-dir/my-lib', + js: true, + projectNameAndRootFormat: 'as-provided', + }); + expect( + readProjectConfiguration(tree, 'my-lib').targets.lint.options + .lintFilePatterns + ).toEqual(['my-dir/my-lib/**/*.js', 'my-dir/my-lib/package.json']); + expect(readJson(tree, 'my-dir/my-lib/.eslintrc.json')) + .toMatchInlineSnapshot(` + { + "extends": [ + "../../.eslintrc.json", + ], + "ignorePatterns": [ + "!**/*", + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": {}, + }, + { + "files": [ + "*.ts", + "*.tsx", + ], + "rules": {}, + }, + { + "files": [ + "*.js", + "*.jsx", + ], + "rules": {}, + }, + { + "files": [ + "*.json", + ], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": [ + "{projectRoot}/vite.config.{js,ts,mjs,mts}", + ], + }, + ], + }, + }, + ], + } + `); + }); + }); + }); + + describe('--bundler=vite', () => { + it('should add build and test targets with vite and vitest', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + bundler: 'vite', + unitTestRunner: undefined, + projectNameAndRootFormat: 'as-provided', + }); + + const project = readProjectConfiguration(tree, 'my-lib'); + expect(project.targets.build).toMatchObject({ + executor: '@nx/vite:build', + }); + expect(project.targets.test).toMatchObject({ + executor: '@nx/vite:test', + }); + expect(tree.exists('my-lib/vite.config.ts')).toBeTruthy(); + expect(readJson(tree, 'my-lib/.eslintrc.json').overrides).toContainEqual({ + files: ['*.json'], + parser: 'jsonc-eslint-parser', + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/vite.config.{js,ts,mjs,mts}'], + }, + ], + }, + }); + }); + + it.each` + unitTestRunner | executor + ${'none'} | ${undefined} + `( + 'should respect unitTestRunner if passed', + async ({ unitTestRunner, executor }) => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + bundler: 'vite', + unitTestRunner, + projectNameAndRootFormat: 'as-provided', + }); + + const project = readProjectConfiguration(tree, 'my-lib'); + expect(project.targets.test?.executor).toEqual(executor); + } + ); + }); + + describe('--bundler=esbuild', () => { + it('should add build with esbuild', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + bundler: 'esbuild', + unitTestRunner: 'none', + projectNameAndRootFormat: 'as-provided', + }); + + const project = readProjectConfiguration(tree, 'my-lib'); + expect(project.targets.build).toMatchObject({ + executor: '@nx/esbuild:esbuild', + }); + expect(readJson(tree, 'my-lib/.eslintrc.json').overrides).toContainEqual({ + files: ['*.json'], + parser: 'jsonc-eslint-parser', + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/esbuild.config.{js,ts,mjs,mts}'], + }, + ], + }, + }); + }); + }); + + describe('--bundler=rollup', () => { + it('should add build with rollup', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + bundler: 'rollup', + unitTestRunner: 'none', + projectNameAndRootFormat: 'as-provided', + }); + + const project = readProjectConfiguration(tree, 'my-lib'); + expect(project.targets.build).toMatchObject({ + executor: '@nx/rollup:rollup', + }); + expect(readJson(tree, 'my-lib/.eslintrc.json').overrides).toContainEqual({ + files: ['*.json'], + parser: 'jsonc-eslint-parser', + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/rollup.config.{js,ts,mjs,mts}'], + }, + ], + }, + }); + }); + }); + + describe('--minimal', () => { + it('should generate a README.md when minimal is set to false', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + minimal: false, + projectNameAndRootFormat: 'as-provided', + }); + + expect(tree.exists('my-lib/README.md')).toBeTruthy(); + }); + + it('should not generate a README.md when minimal is set to true', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + minimal: true, + projectNameAndRootFormat: 'as-provided', + }); + + expect(tree.exists('my-lib/README.md')).toBeFalsy(); + }); + + it('should generate a README.md and add it to the build assets when buildable is true and minimal is false', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + bundler: 'tsc', + minimal: false, + projectNameAndRootFormat: 'as-provided', + }); + + expect(tree.exists('my-lib/README.md')).toBeTruthy(); + + const project = readProjectConfiguration(tree, 'my-lib'); + expect(project.targets.build.options.assets).toStrictEqual([ + 'my-lib/*.md', + ]); + }); + + it('should not generate a README.md when both bundler and minimal are set', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + bundler: 'tsc', + minimal: true, + projectNameAndRootFormat: 'as-provided', + }); + + expect(tree.exists('my-lib/README.md')).toBeFalsy(); + + const project = readProjectConfiguration(tree, 'my-lib'); + expect(project.targets.build.options.assets).toEqual([]); + }); + }); + + describe('--simpleName', () => { + it('should generate a simple name', async () => { + await libraryGenerator(tree, { + ...defaultOptions, + name: 'myLib', + simpleName: true, + directory: 'web/my-lib', + projectNameAndRootFormat: 'as-provided', + }); + + expect(tree.read('web/my-lib/src/index.ts', 'utf-8')).toContain( + `export * from './lib/my-lib';` + ); + expect(tree.exists('web/my-lib/src/lib/my-lib.ts')).toBeTruthy(); + }); + }); +}); diff --git a/packages/plugins/nx-cloudflare/src/generators/library/library.ts b/packages/plugins/nx-cloudflare/src/generators/library/library.ts new file mode 100644 index 000000000..e03f0eb8f --- /dev/null +++ b/packages/plugins/nx-cloudflare/src/generators/library/library.ts @@ -0,0 +1,106 @@ +import { Tree, formatFiles, updateJson } from '@nx/devkit'; +import type { + NormalizedSchema, + NxCloudflareLibraryGeneratorSchema, +} from './schema'; +import { libraryGenerator } from '@nx/js'; +import { join } from 'path'; +import initGenerator from '../init/init'; +import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils'; + +export async function nxCloudflareWorkerLibraryGenerator( + tree: Tree, + schema: NxCloudflareLibraryGeneratorSchema +) { + const options = await normalizeOptions(tree, schema); + + // Set up the needed packages. + const initTask = await initGenerator(tree, { + ...options, + skipFormat: true, + }); + + const libraryTask = await libraryGenerator(tree, options); + updateTsLibConfig(tree, options); + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return async () => { + await initTask(); + await libraryTask(); + }; +} + +async function normalizeOptions( + tree: Tree, + options: NxCloudflareLibraryGeneratorSchema +): Promise { + options.projectNameAndRootFormat = + options.projectNameAndRootFormat ?? 'as-provided'; + + // ensure programmatic runs have an expected default + if (!options.config) { + options.config = 'project'; + } + + if (options.publishable) { + if (!options.importPath) { + throw new Error( + `For publishable libs you have to provide a proper "--importPath" which needs to be a valid npm package name (e.g. my-awesome-lib or @myorg/my-lib)` + ); + } + + if (options.bundler === 'none') { + options.bundler = 'tsc'; + } + } + + if (options.config === 'npm-scripts') { + options.unitTestRunner = 'none'; + options.linter = 'none'; + options.bundler = 'none'; + } + + if (!options.linter && options.config !== 'npm-scripts') { + options.linter = 'none'; + } + + const { projectName, projectRoot, importPath } = + await determineProjectNameAndRootOptions(tree, { + name: options.name, + projectType: 'library', + directory: options.directory, + importPath: options.importPath, + projectNameAndRootFormat: options.projectNameAndRootFormat, + rootProject: options.rootProject, + callingGenerator: '@nx/js:library', + }); + options.rootProject = projectRoot === '.'; + + options.minimal ??= false; + + return { + ...options, + name: projectName, + libProjectRoot: projectRoot, + importPath, + }; +} + +function updateTsLibConfig(tree: Tree, options: NormalizedSchema) { + updateJson( + tree, + join(options.libProjectRoot, 'tsconfig.lib.json'), + (json) => { + json.compilerOptions.types = [ + ...json.compilerOptions.types, + '@cloudflare/workers-types', + ]; + return json; + } + ); +} + +export default nxCloudflareWorkerLibraryGenerator; diff --git a/packages/plugins/nx-cloudflare/src/generators/library/schema.d.ts b/packages/plugins/nx-cloudflare/src/generators/library/schema.d.ts new file mode 100644 index 000000000..060fb4536 --- /dev/null +++ b/packages/plugins/nx-cloudflare/src/generators/library/schema.d.ts @@ -0,0 +1,22 @@ +export interface NxCloudflareLibraryGeneratorSchema { + name: string; + directory?: string; + projectNameAndRootFormat?: ProjectNameAndRootFormat; + skipFormat?: boolean; + tags?: string; + unitTestRunner?: 'vitest' | 'none'; + linter?: Linter; + importPath?: string; + js?: boolean; + strict?: boolean; + publishable?: boolean; + bundler?: Bundler; + minimal?: boolean; + config?: 'workspace' | 'project' | 'npm-scripts'; + rootProject?: boolean; + simpleName?: boolean; +} + +export interface NormalizedSchema extends NxCloudflareLibraryGeneratorSchema { + libProjectRoot: string; +} diff --git a/packages/plugins/nx-cloudflare/src/generators/library/schema.json b/packages/plugins/nx-cloudflare/src/generators/library/schema.json new file mode 100644 index 000000000..444229adb --- /dev/null +++ b/packages/plugins/nx-cloudflare/src/generators/library/schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxCloudflareWorkerLibrary", + "title": "Create a Cloudflare Worker Library", + "description": "Create a Cloudflare Worker Library", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Library name.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the library?", + "pattern": "(?:^@[a-zA-Z0-9-*~][a-zA-Z0-9-*._~]*\\/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*|^[a-zA-Z][^:]*)$" + }, + "directory": { + "type": "string", + "description": "A directory where the lib is placed.", + "x-priority": "important" + }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "none"], + "default": "eslint" + }, + "unitTestRunner": { + "type": "string", + "enum": ["vitest", "none"], + "description": "Test runner to use for unit tests.", + "x-prompt": "Which unit test runner would you like to use?" + }, + "tags": { + "type": "string", + "description": "Add tags to the library (used for linting)." + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false, + "x-priority": "internal" + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files.", + "default": false + }, + "strict": { + "type": "boolean", + "description": "Whether to enable tsconfig strict mode or not.", + "default": true + }, + "publishable": { + "type": "boolean", + "default": false, + "description": "Generate a publishable library.", + "x-priority": "important" + }, + "importPath": { + "type": "string", + "description": "The library name used to import it, like @myorg/my-awesome-lib. Required for publishable library.", + "x-priority": "important" + }, + "bundler": { + "description": "The bundler to use. Choosing 'none' means this library is not buildable.", + "type": "string", + "enum": ["swc", "tsc", "rollup", "vite", "esbuild", "none"], + "default": "tsc", + "x-prompt": "Which bundler would you like to use to build the library? Choose 'none' to skip build setup.", + "x-priority": "important" + }, + "minimal": { + "type": "boolean", + "description": "Generate a library with a minimal setup. No README.md generated.", + "default": false + }, + "simpleName": { + "description": "Don't include the directory in the generated file name.", + "type": "boolean", + "default": false + }, + "config": { + "type": "string", + "enum": ["workspace", "project", "npm-scripts"], + "default": "project", + "description": "Determines whether the project's executors should be configured in `workspace.json`, `project.json` or as npm scripts.", + "x-priority": "internal" + } + }, + "required": ["name"] +}