;
+ }
+}
+"
+`;
+
+exports[`ErrorBoundary --nameAndDirectoryFormat=as-provided --apiVersion=2 should correctly add the ErrorBoundary to the route file 2`] = `
+"import { useRouteError, isRouteErrorResponse } from '@remix-run/react';
+export function ErrorBoundary() {
+ const error = useRouteError();
+
+ // when true, this is what used to go to 'CatchBoundary'
+ if (isRouteErrorResponse(error)) {
+ return (
+
;
+ }
+ }
+ `
+ );
+}
diff --git a/packages/remix/src/generators/error-boundary/lib/index.ts b/packages/remix/src/generators/error-boundary/lib/index.ts
new file mode 100644
index 0000000000..0cce967d13
--- /dev/null
+++ b/packages/remix/src/generators/error-boundary/lib/index.ts
@@ -0,0 +1,2 @@
+export * from './add-v2-error-boundary';
+export * from './normalize-options';
diff --git a/packages/remix/src/generators/error-boundary/lib/normalize-options.ts b/packages/remix/src/generators/error-boundary/lib/normalize-options.ts
new file mode 100644
index 0000000000..d1505fc3b0
--- /dev/null
+++ b/packages/remix/src/generators/error-boundary/lib/normalize-options.ts
@@ -0,0 +1,24 @@
+import { type Tree } from '@nx/devkit';
+import { resolveRemixRouteFile } from '../../../utils/remix-route-utils';
+import type { ErrorBoundarySchema } from '../schema';
+
+export async function normalizeOptions(
+ tree: Tree,
+ schema: ErrorBoundarySchema
+): Promise {
+ const pathToRouteFile =
+ schema.nameAndDirectoryFormat === 'as-provided'
+ ? schema.path
+ : await resolveRemixRouteFile(tree, schema.path, schema.project);
+
+ if (!tree.exists(pathToRouteFile)) {
+ throw new Error(
+ `Route file specified does not exist "${pathToRouteFile}". Please ensure you pass a correct path to the file.`
+ );
+ }
+
+ return {
+ ...schema,
+ path: pathToRouteFile,
+ };
+}
diff --git a/packages/remix/src/generators/error-boundary/schema.d.ts b/packages/remix/src/generators/error-boundary/schema.d.ts
new file mode 100644
index 0000000000..9c4893aa62
--- /dev/null
+++ b/packages/remix/src/generators/error-boundary/schema.d.ts
@@ -0,0 +1,11 @@
+import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+
+export interface ErrorBoundarySchema {
+ path: string;
+ skipFormat?: false;
+ nameAndDirectoryFormat?: NameAndDirectoryFormat;
+ /**
+ * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18.
+ */
+ project?: string;
+}
diff --git a/packages/remix/src/generators/error-boundary/schema.json b/packages/remix/src/generators/error-boundary/schema.json
new file mode 100644
index 0000000000..c04381b047
--- /dev/null
+++ b/packages/remix/src/generators/error-boundary/schema.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "NxRemixErrorBoundary",
+ "title": "Create an ErrorBoundary for a Route",
+ "description": "Generate an ErrorBoundary for a given route.",
+ "type": "object",
+ "examples": [
+ {
+ "command": "g error-boundary --routePath=apps/demo/app/routes/my-route.tsx",
+ "description": "Generate an ErrorBoundary for my-route.tsx"
+ }
+ ],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The path to route file relative to the project root."
+ },
+ "nameAndDirectoryFormat": {
+ "description": "Whether to generate the error boundary in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "project": {
+ "type": "string",
+ "description": "The name of the project.",
+ "$default": {
+ "$source": "projectName"
+ },
+ "x-prompt": "What project contains the route file that this ErrorBoundary is for?",
+ "pattern": "^[a-zA-Z].*$",
+ "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18."
+ },
+ "skipFormat": {
+ "type": "boolean",
+ "description": "Skip formatting files after generation.",
+ "default": false,
+ "x-priority": "internal"
+ }
+ },
+ "required": ["path"]
+}
diff --git a/packages/remix/src/generators/library/__snapshots__/library.impl.spec.ts.snap b/packages/remix/src/generators/library/__snapshots__/library.impl.spec.ts.snap
new file mode 100644
index 0000000000..cd70040d87
--- /dev/null
+++ b/packages/remix/src/generators/library/__snapshots__/library.impl.spec.ts.snap
@@ -0,0 +1,157 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Remix Library Generator -projectNameAndRootFormat=as-provided --unitTestRunner should create the correct config files for testing with jest 1`] = `
+"/* eslint-disable */
+export default {
+ setupFilesAfterEnv: ['./src/test-setup.ts'],
+ displayName: 'test',
+ preset: '../jest.preset.js',
+ transform: {
+ '^(?!.*\\\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
+ '^.+\\\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }],
+ },
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
+ coverageDirectory: '../coverage/test',
+};
+"
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=as-provided --unitTestRunner should create the correct config files for testing with jest 2`] = `
+"import { installGlobals } from '@remix-run/node';
+import '@testing-library/jest-dom/matchers';
+installGlobals();
+"
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=as-provided --unitTestRunner should create the correct config files for testing with vitest 1`] = `
+"import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+
+export default defineConfig({
+ root: __dirname,
+ cacheDir: '../node_modules/.vite/test',
+
+ plugins: [react(), nxViteTsPaths()],
+
+ // Uncomment this if you are using workers.
+ // worker: {
+ // plugins: [ nxViteTsPaths() ],
+ // },
+
+ test: {
+ setupFiles: ['./src/test-setup.ts'],
+ globals: true,
+ cache: { dir: '../node_modules/.vitest' },
+ environment: 'jsdom',
+ include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ reporters: ['default'],
+ coverage: { reportsDirectory: '../coverage/test', provider: 'v8' },
+ },
+});
+"
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=as-provided --unitTestRunner should create the correct config files for testing with vitest 2`] = `
+"import { installGlobals } from '@remix-run/node';
+import '@testing-library/jest-dom/matchers';
+installGlobals();
+"
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=as-provided should generate a library correctly 1`] = `
+[
+ "test.module.css",
+ "test.spec.tsx",
+ "test.tsx",
+]
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=as-provided should generate a library correctly 2`] = `
+{
+ "@proj/test": [
+ "test/src/index.ts",
+ ],
+ "@proj/test/server": [
+ "test/src/server.ts",
+ ],
+}
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=derived --unitTestRunner should create the correct config files for testing with jest 1`] = `
+"/* eslint-disable */
+export default {
+ setupFilesAfterEnv: ['./src/test-setup.ts'],
+ displayName: 'test',
+ preset: '../../jest.preset.js',
+ transform: {
+ '^(?!.*\\\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
+ '^.+\\\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }],
+ },
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
+ coverageDirectory: '../../coverage/libs/test',
+};
+"
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=derived --unitTestRunner should create the correct config files for testing with jest 2`] = `
+"import { installGlobals } from '@remix-run/node';
+import '@testing-library/jest-dom/matchers';
+installGlobals();
+"
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=derived --unitTestRunner should create the correct config files for testing with vitest 1`] = `
+"import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+
+export default defineConfig({
+ root: __dirname,
+ cacheDir: '../../node_modules/.vite/libs/test',
+
+ plugins: [react(), nxViteTsPaths()],
+
+ // Uncomment this if you are using workers.
+ // worker: {
+ // plugins: [ nxViteTsPaths() ],
+ // },
+
+ test: {
+ setupFiles: ['./src/test-setup.ts'],
+ globals: true,
+ cache: { dir: '../../node_modules/.vitest' },
+ environment: 'jsdom',
+ include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ reporters: ['default'],
+ coverage: { reportsDirectory: '../../coverage/libs/test', provider: 'v8' },
+ },
+});
+"
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=derived --unitTestRunner should create the correct config files for testing with vitest 2`] = `
+"import { installGlobals } from '@remix-run/node';
+import '@testing-library/jest-dom/matchers';
+installGlobals();
+"
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=derived should generate a library correctly 1`] = `
+[
+ "test.module.css",
+ "test.spec.tsx",
+ "test.tsx",
+]
+`;
+
+exports[`Remix Library Generator -projectNameAndRootFormat=derived should generate a library correctly 2`] = `
+{
+ "@proj/libs/test": [
+ "libs/test/src/index.ts",
+ ],
+ "@proj/libs/test/server": [
+ "libs/test/src/server.ts",
+ ],
+}
+`;
diff --git a/packages/remix/src/generators/library/lib/add-tsconfig-entry-points.ts b/packages/remix/src/generators/library/lib/add-tsconfig-entry-points.ts
new file mode 100644
index 0000000000..f1081b544c
--- /dev/null
+++ b/packages/remix/src/generators/library/lib/add-tsconfig-entry-points.ts
@@ -0,0 +1,35 @@
+import type { Tree } from '@nx/devkit';
+import {
+ joinPathFragments,
+ readProjectConfiguration,
+ updateJson,
+} from '@nx/devkit';
+import { getRootTsConfigPathInTree } from '@nx/js';
+import type { RemixLibraryOptions } from './normalize-options';
+
+export function addTsconfigEntryPoints(
+ tree: Tree,
+ options: RemixLibraryOptions
+) {
+ const { sourceRoot } = readProjectConfiguration(tree, options.projectName);
+ const serverFilePath = joinPathFragments(sourceRoot, 'server.ts');
+
+ tree.write(
+ serverFilePath,
+ `// This file should be used to export ONLY server-code from the library.`
+ );
+
+ const baseTsConfig = getRootTsConfigPathInTree(tree);
+ updateJson(tree, baseTsConfig, (json) => {
+ if (
+ json.compilerOptions.paths &&
+ json.compilerOptions.paths[options.importPath]
+ ) {
+ json.compilerOptions.paths[
+ joinPathFragments(options.importPath, 'server')
+ ] = [serverFilePath];
+ }
+
+ return json;
+ });
+}
diff --git a/packages/remix/src/generators/library/lib/add-unit-testing.ts b/packages/remix/src/generators/library/lib/add-unit-testing.ts
new file mode 100644
index 0000000000..8698227b9e
--- /dev/null
+++ b/packages/remix/src/generators/library/lib/add-unit-testing.ts
@@ -0,0 +1,62 @@
+import {
+ addDependenciesToPackageJson,
+ joinPathFragments,
+ stripIndents,
+ type Tree,
+} from '@nx/devkit';
+import {
+ updateJestTestSetup,
+ updateViteTestSetup,
+} from '../../../utils/testing-config-utils';
+import {
+ getRemixVersion,
+ testingLibraryJestDomVersion,
+ testingLibraryReactVersion,
+ testingLibraryUserEventsVersion,
+} from '../../../utils/versions';
+import type { RemixLibraryOptions } from './normalize-options';
+
+export function addUnitTestingSetup(tree: Tree, options: RemixLibraryOptions) {
+ const pathToTestSetup = joinPathFragments(
+ options.projectRoot,
+ 'src/test-setup.ts'
+ );
+ let testSetupFileContents = '';
+
+ if (tree.exists(pathToTestSetup)) {
+ testSetupFileContents = tree.read(pathToTestSetup, 'utf-8');
+ }
+
+ tree.write(
+ pathToTestSetup,
+ stripIndents`${testSetupFileContents}
+ import { installGlobals } from '@remix-run/node';
+ import "@testing-library/jest-dom/matchers";
+ installGlobals();`
+ );
+
+ if (options.unitTestRunner === 'vitest') {
+ const pathToVitestConfig = joinPathFragments(
+ options.projectRoot,
+ `vite.config.ts`
+ );
+ updateViteTestSetup(tree, pathToVitestConfig, './src/test-setup.ts');
+ } else if (options.unitTestRunner === 'jest') {
+ const pathToJestConfig = joinPathFragments(
+ options.projectRoot,
+ `jest.config.ts`
+ );
+ updateJestTestSetup(tree, pathToJestConfig, './src/test-setup.ts');
+ }
+
+ return addDependenciesToPackageJson(
+ tree,
+ {},
+ {
+ '@testing-library/jest-dom': testingLibraryJestDomVersion,
+ '@testing-library/react': testingLibraryReactVersion,
+ '@testing-library/user-event': testingLibraryUserEventsVersion,
+ '@remix-run/node': getRemixVersion(tree),
+ }
+ );
+}
diff --git a/packages/remix/src/generators/library/lib/index.ts b/packages/remix/src/generators/library/lib/index.ts
new file mode 100644
index 0000000000..95f271206e
--- /dev/null
+++ b/packages/remix/src/generators/library/lib/index.ts
@@ -0,0 +1,4 @@
+export * from './add-tsconfig-entry-points';
+export * from './add-unit-testing';
+export * from './normalize-options';
+export * from './update-buildable-config';
diff --git a/packages/remix/src/generators/library/lib/normalize-options.ts b/packages/remix/src/generators/library/lib/normalize-options.ts
new file mode 100644
index 0000000000..a4e1bdf382
--- /dev/null
+++ b/packages/remix/src/generators/library/lib/normalize-options.ts
@@ -0,0 +1,33 @@
+import type { Tree } from '@nx/devkit';
+import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils';
+import { getImportPath } from '@nx/js/src/utils/get-import-path';
+import type { NxRemixGeneratorSchema } from '../schema';
+
+export interface RemixLibraryOptions extends NxRemixGeneratorSchema {
+ projectName: string;
+ projectRoot: string;
+}
+
+export async function normalizeOptions(
+ tree: Tree,
+ options: NxRemixGeneratorSchema
+): Promise {
+ const { projectName, projectRoot, projectNameAndRootFormat } =
+ await determineProjectNameAndRootOptions(tree, {
+ name: options.name,
+ projectType: 'library',
+ directory: options.directory,
+ projectNameAndRootFormat: options.projectNameAndRootFormat,
+ callingGenerator: '@nx/remix:library',
+ });
+
+ const importPath = options.importPath ?? getImportPath(tree, projectRoot);
+
+ return {
+ ...options,
+ unitTestRunner: options.unitTestRunner ?? 'vitest',
+ importPath,
+ projectName,
+ projectRoot,
+ };
+}
diff --git a/packages/remix/src/generators/library/lib/update-buildable-config.ts b/packages/remix/src/generators/library/lib/update-buildable-config.ts
new file mode 100644
index 0000000000..a095853954
--- /dev/null
+++ b/packages/remix/src/generators/library/lib/update-buildable-config.ts
@@ -0,0 +1,25 @@
+import type { Tree } from '@nx/devkit';
+import {
+ joinPathFragments,
+ readProjectConfiguration,
+ updateJson,
+ updateProjectConfiguration,
+} from '@nx/devkit';
+
+export function updateBuildableConfig(tree: Tree, name: string) {
+ // Nest dist under project root to we can link it
+ const project = readProjectConfiguration(tree, name);
+ project.targets.build.options = {
+ ...project.targets.build.options,
+ format: ['cjs'],
+ outputPath: joinPathFragments(project.root, 'dist'),
+ };
+ updateProjectConfiguration(tree, name, project);
+
+ // Point to nested dist for yarn/npm/pnpm workspaces
+ updateJson(tree, joinPathFragments(project.root, 'package.json'), (json) => {
+ json.main = './dist/index.cjs.js';
+ json.typings = './dist/index.d.ts';
+ return json;
+ });
+}
diff --git a/packages/remix/src/generators/library/library.impl.spec.ts b/packages/remix/src/generators/library/library.impl.spec.ts
new file mode 100644
index 0000000000..110f855e93
--- /dev/null
+++ b/packages/remix/src/generators/library/library.impl.spec.ts
@@ -0,0 +1,147 @@
+import { readJson, readProjectConfiguration } from '@nx/devkit';
+import { type ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import applicationGenerator from '../application/application.impl';
+import libraryGenerator from './library.impl';
+
+describe('Remix Library Generator', () => {
+ describe.each([
+ ['derived', 'libs/test'],
+ ['as-provided', 'test'],
+ ])(
+ '-projectNameAndRootFormat=%s',
+ (projectNameAndRootFormat: ProjectNameAndRootFormat, libDir) => {
+ it('should generate a library correctly', async () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+
+ // ACT
+ await libraryGenerator(tree, {
+ name: 'test',
+ style: 'css',
+ projectNameAndRootFormat,
+ });
+
+ // ASSERT
+ const tsconfig = readJson(tree, 'tsconfig.base.json');
+ expect(tree.exists(`${libDir}/src/server.ts`));
+ expect(tree.children(`${libDir}/src/lib`)).toMatchSnapshot();
+ expect(tsconfig.compilerOptions.paths).toMatchSnapshot();
+ }, 25_000);
+
+ describe('Standalone Project Repo', () => {
+ it('should update the tsconfig paths correctly', async () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace();
+ await applicationGenerator(tree, {
+ name: 'demo',
+ rootProject: true,
+ });
+ const originalBaseTsConfig = readJson(tree, 'tsconfig.json');
+
+ // ACT
+ await libraryGenerator(tree, {
+ name: 'test',
+ style: 'css',
+ projectNameAndRootFormat,
+ });
+
+ // ASSERT
+ const updatedBaseTsConfig = readJson(tree, 'tsconfig.base.json');
+ expect(
+ Object.keys(originalBaseTsConfig.compilerOptions.paths)
+ ).toContain('~/*');
+ expect(
+ Object.keys(updatedBaseTsConfig.compilerOptions.paths)
+ ).toContain('~/*');
+ });
+ });
+
+ describe('--unitTestRunner', () => {
+ it('should not create config files when unitTestRunner=none', async () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+
+ // ACT
+ await libraryGenerator(tree, {
+ name: 'test',
+ style: 'css',
+ unitTestRunner: 'none',
+ projectNameAndRootFormat,
+ });
+
+ // ASSERT
+ expect(tree.exists(`${libDir}/jest.config.ts`)).toBeFalsy();
+ expect(tree.exists(`${libDir}/vite.config.ts`)).toBeFalsy();
+ });
+
+ it('should create the correct config files for testing with jest', async () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+
+ // ACT
+ await libraryGenerator(tree, {
+ name: 'test',
+ style: 'css',
+ unitTestRunner: 'jest',
+ projectNameAndRootFormat,
+ });
+
+ // ASSERT
+ expect(
+ tree.read(`${libDir}/jest.config.ts`, 'utf-8')
+ ).toMatchSnapshot();
+ expect(
+ tree.read(`${libDir}/src/test-setup.ts`, 'utf-8')
+ ).toMatchSnapshot();
+ });
+
+ it('should create the correct config files for testing with vitest', async () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+
+ // ACT
+ await libraryGenerator(tree, {
+ name: 'test',
+ style: 'css',
+ unitTestRunner: 'vitest',
+ projectNameAndRootFormat,
+ });
+
+ // ASSERT
+ expect(
+ tree.read(`${libDir}/vite.config.ts`, 'utf-8')
+ ).toMatchSnapshot();
+
+ expect(
+ tree.read(`${libDir}/src/test-setup.ts`, 'utf-8')
+ ).toMatchSnapshot();
+ }, 25_000);
+ });
+
+ // TODO(Colum): Unskip this when buildable is investigated correctly
+ xit('should generate the config files correctly when the library is buildable', async () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+
+ // ACT
+ await libraryGenerator(tree, {
+ name: 'test',
+ style: 'css',
+ buildable: true,
+ projectNameAndRootFormat,
+ });
+
+ // ASSERT
+ const project = readProjectConfiguration(tree, 'test');
+ const pkgJson = readJson(tree, `${libDir}/package.json`);
+ expect(project.targets.build.options.format).toEqual(['cjs']);
+ expect(project.targets.build.options.outputPath).toEqual(
+ `${libDir}/dist`
+ );
+ expect(pkgJson.main).toEqual('./dist/index.cjs.js');
+ expect(pkgJson.typings).toEqual('./dist/index.d.ts');
+ });
+ }
+ );
+});
diff --git a/packages/remix/src/generators/library/library.impl.ts b/packages/remix/src/generators/library/library.impl.ts
new file mode 100644
index 0000000000..8eebe8b360
--- /dev/null
+++ b/packages/remix/src/generators/library/library.impl.ts
@@ -0,0 +1,49 @@
+import type { Tree } from '@nx/devkit';
+import { formatFiles, GeneratorCallback, runTasksInSerial } from '@nx/devkit';
+import { Linter } from '@nx/eslint';
+import { libraryGenerator } from '@nx/react';
+import {
+ addTsconfigEntryPoints,
+ addUnitTestingSetup,
+ normalizeOptions,
+ updateBuildableConfig,
+} from './lib';
+import type { NxRemixGeneratorSchema } from './schema';
+
+export default async function (tree: Tree, schema: NxRemixGeneratorSchema) {
+ const tasks: GeneratorCallback[] = [];
+ const options = await normalizeOptions(tree, schema);
+
+ const libGenTask = await libraryGenerator(tree, {
+ name: options.projectName,
+ style: options.style,
+ unitTestRunner: options.unitTestRunner,
+ tags: options.tags,
+ importPath: options.importPath,
+ directory: options.projectRoot,
+ projectNameAndRootFormat: 'as-provided',
+ skipFormat: true,
+ skipTsConfig: false,
+ linter: Linter.EsLint,
+ component: true,
+ buildable: options.buildable,
+ });
+ tasks.push(libGenTask);
+
+ if (options.unitTestRunner && options.unitTestRunner !== 'none') {
+ const pkgInstallTask = addUnitTestingSetup(tree, options);
+ tasks.push(pkgInstallTask);
+ }
+
+ addTsconfigEntryPoints(tree, options);
+
+ if (options.buildable) {
+ updateBuildableConfig(tree, options.projectName);
+ }
+
+ if (!options.skipFormat) {
+ await formatFiles(tree);
+ }
+
+ return runTasksInSerial(...tasks);
+}
diff --git a/packages/remix/src/generators/library/schema.d.ts b/packages/remix/src/generators/library/schema.d.ts
new file mode 100644
index 0000000000..05ce3c85ae
--- /dev/null
+++ b/packages/remix/src/generators/library/schema.d.ts
@@ -0,0 +1,15 @@
+import { ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils';
+import { SupportedStyles } from '@nx/react';
+
+export interface NxRemixGeneratorSchema {
+ name: string;
+ style: SupportedStyles;
+ directory?: string;
+ projectNameAndRootFormat?: ProjectNameAndRootFormat;
+ tags?: string;
+ importPath?: string;
+ buildable?: boolean;
+ unitTestRunner?: 'jest' | 'vitest' | 'none';
+ js?: boolean;
+ skipFormat?: boolean;
+}
diff --git a/packages/remix/src/generators/library/schema.json b/packages/remix/src/generators/library/schema.json
new file mode 100644
index 0000000000..a4fb026cd6
--- /dev/null
+++ b/packages/remix/src/generators/library/schema.json
@@ -0,0 +1,74 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "NxRemixLibrary",
+ "title": "Create a Library",
+ "description": "Generate a Remix library to help structure workspace and application.",
+ "type": "object",
+ "examples": [
+ {
+ "command": "g lib mylib --directory=myapp",
+ "description": "Generate libs/myapp/mylib"
+ }
+ ],
+ "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-Z].*$"
+ },
+ "directory": {
+ "type": "string",
+ "description": "A directory where the lib is placed.",
+ "alias": "dir",
+ "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"]
+ },
+ "tags": {
+ "type": "string",
+ "description": "Add tags to the library (used for linting)"
+ },
+ "style": {
+ "type": "string",
+ "description": "Generate a stylesheet",
+ "enum": ["none", "css"],
+ "default": "css"
+ },
+ "buildable": {
+ "type": "boolean",
+ "description": "Should the library be buildable?",
+ "default": false
+ },
+ "unitTestRunner": {
+ "type": "string",
+ "enum": ["jest", "vitest", "none"],
+ "description": "Test Runner to use for Unit Tests",
+ "x-prompt": "What test runner should be used?",
+ "default": "vitest"
+ },
+ "importPath": {
+ "type": "string",
+ "description": "The library name used to import it, like @myorg/my-awesome-lib"
+ },
+ "js": {
+ "type": "boolean",
+ "description": "Generate JavaScript files rather than TypeScript files",
+ "default": false
+ },
+ "skipFormat": {
+ "type": "boolean",
+ "description": "Skip formatting files after generator runs",
+ "default": false,
+ "x-priority": "internal"
+ }
+ },
+ "required": ["name"]
+}
diff --git a/packages/remix/src/generators/loader/loader.impl.spec.ts b/packages/remix/src/generators/loader/loader.impl.spec.ts
new file mode 100644
index 0000000000..fbe3869afe
--- /dev/null
+++ b/packages/remix/src/generators/loader/loader.impl.spec.ts
@@ -0,0 +1,90 @@
+import { Tree } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import applicationGenerator from '../application/application.impl';
+import routeGenerator from '../route/route.impl';
+import loaderGenerator from './loader.impl';
+
+describe('loader', () => {
+ let tree: Tree;
+
+ beforeEach(async () => {
+ tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+ tree.write('.gitignore', `/node_modules/dist`);
+
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ path: 'example',
+ project: 'demo',
+ style: 'none',
+ loader: false,
+ action: false,
+ meta: false,
+ skipChecks: false,
+ });
+ });
+
+ [
+ {
+ path: 'apps/demo/app/routes/example.tsx',
+ },
+ {
+ path: 'example',
+ },
+ {
+ path: 'example.tsx',
+ },
+ ].forEach((config) => {
+ describe(`add loader using route path "${config.path}"`, () => {
+ beforeEach(async () => {
+ await loaderGenerator(tree, {
+ path: config.path,
+ project: 'demo',
+ });
+ });
+
+ it('should add imports', async () => {
+ const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8');
+ expect(content).toMatch(`import { json } from '@remix-run/node';`);
+ expect(content).toMatch(
+ `import type { LoaderFunctionArgs } from '@remix-run/node';`
+ );
+ expect(content).toMatch(
+ `import { useLoaderData } from '@remix-run/react';`
+ );
+ });
+
+ it('should add loader function', () => {
+ const loaderFunction = `export const loader = async`;
+ const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8');
+ expect(content).toMatch(loaderFunction);
+ });
+
+ it('should add useLoaderData to component', () => {
+ const useLoaderData = `const data = useLoaderData();`;
+
+ const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8');
+ expect(content).toMatch(useLoaderData);
+ });
+ });
+ });
+
+ describe('--nameAndDirectoryFormat=as-provided', () => {
+ it('should add imports', async () => {
+ // ACT
+ await loaderGenerator(tree, {
+ path: 'apps/demo/app/routes/example.tsx',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+
+ // ASSERT
+ const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8');
+ expect(content).toMatch(`import { json } from '@remix-run/node';`);
+ expect(content).toMatch(
+ `import type { LoaderFunctionArgs } from '@remix-run/node';`
+ );
+ expect(content).toMatch(
+ `import { useLoaderData } from '@remix-run/react';`
+ );
+ });
+ });
+});
diff --git a/packages/remix/src/generators/loader/loader.impl.ts b/packages/remix/src/generators/loader/loader.impl.ts
new file mode 100644
index 0000000000..d575f1c228
--- /dev/null
+++ b/packages/remix/src/generators/loader/loader.impl.ts
@@ -0,0 +1,48 @@
+import { formatFiles, Tree } from '@nx/devkit';
+import { insertImport } from '../../utils/insert-import';
+import { insertStatementAfterImports } from '../../utils/insert-statement-after-imports';
+import { insertStatementInDefaultFunction } from '../../utils/insert-statement-in-default-function';
+import { resolveRemixRouteFile } from '../../utils/remix-route-utils';
+import { LoaderSchema } from './schema';
+
+export default async function (tree: Tree, schema: LoaderSchema) {
+ const routeFilePath =
+ schema.nameAndDirectoryFormat === 'as-provided'
+ ? schema.path
+ : await resolveRemixRouteFile(tree, schema.path, schema.project);
+
+ if (!tree.exists(routeFilePath)) {
+ throw new Error(
+ `Route path does not exist: ${routeFilePath}. Please generate a Remix route first.`
+ );
+ }
+
+ insertImport(tree, routeFilePath, 'useLoaderData', '@remix-run/react');
+ insertImport(tree, routeFilePath, 'json', '@remix-run/node');
+ insertImport(tree, routeFilePath, 'LoaderFunctionArgs', '@remix-run/node', {
+ typeOnly: true,
+ });
+
+ insertStatementAfterImports(
+ tree,
+ routeFilePath,
+ `
+ export const loader = async ({request}: LoaderFunctionArgs ) => {
+ return json({
+ message: 'Hello, world!',
+ })
+ };
+
+ `
+ );
+
+ const statement = `\nconst data = useLoaderData();`;
+
+ try {
+ insertStatementInDefaultFunction(tree, routeFilePath, statement);
+ // eslint-disable-next-line no-empty
+ } catch (err) {
+ } finally {
+ await formatFiles(tree);
+ }
+}
diff --git a/packages/remix/src/generators/loader/schema.d.ts b/packages/remix/src/generators/loader/schema.d.ts
new file mode 100644
index 0000000000..9315409e92
--- /dev/null
+++ b/packages/remix/src/generators/loader/schema.d.ts
@@ -0,0 +1,10 @@
+import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+
+export interface LoaderSchema {
+ path: string;
+ nameAndDirectoryFormat?: NameAndDirectoryFormat;
+ /**
+ * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18.
+ */
+ project?: string;
+}
diff --git a/packages/remix/src/generators/loader/schema.json b/packages/remix/src/generators/loader/schema.json
new file mode 100644
index 0000000000..b2e1795770
--- /dev/null
+++ b/packages/remix/src/generators/loader/schema.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "data-loader",
+ "type": "object",
+ "description": "Generate an loader for a given route.",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The route path or path to the filename of the route.",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')"
+ },
+ "nameAndDirectoryFormat": {
+ "description": "Whether to generate the loader in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "project": {
+ "type": "string",
+ "description": "The name of the project.",
+ "$default": {
+ "$source": "projectName"
+ },
+ "x-prompt": "What project is this route for?",
+ "pattern": "^[a-zA-Z].*$",
+ "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18."
+ }
+ },
+ "required": ["path"]
+}
diff --git a/packages/remix/src/generators/meta/lib/v2.impl.spec.ts b/packages/remix/src/generators/meta/lib/v2.impl.spec.ts
new file mode 100644
index 0000000000..17154a0e3b
--- /dev/null
+++ b/packages/remix/src/generators/meta/lib/v2.impl.spec.ts
@@ -0,0 +1,41 @@
+import { Tree } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import applicationGenerator from '../../application/application.impl';
+import routeGenerator from '../../route/route.impl';
+import { v2MetaGenerator } from './v2.impl';
+
+describe('meta v2', () => {
+ let tree: Tree;
+
+ test.each([['apps/demo/app/routes/example.tsx', 'example', 'example.tsx']])(
+ 'add meta using route path "%s"',
+ async (path) => {
+ tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+ tree.write('.gitignore', `/node_modules/dist`);
+
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ path: 'example',
+ project: 'demo',
+ style: 'none',
+ loader: false,
+ action: false,
+ meta: false,
+ skipChecks: false,
+ });
+
+ await v2MetaGenerator(tree, {
+ path,
+ project: 'demo',
+ });
+
+ const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8');
+ expect(content).toMatch(
+ `import type { MetaFunction } from '@remix-run/node';`
+ );
+
+ expect(content).toMatch(`export const meta: MetaFunction`);
+ expect(content).toMatch(`return [`);
+ }
+ );
+});
diff --git a/packages/remix/src/generators/meta/lib/v2.impl.ts b/packages/remix/src/generators/meta/lib/v2.impl.ts
new file mode 100644
index 0000000000..ede4596141
--- /dev/null
+++ b/packages/remix/src/generators/meta/lib/v2.impl.ts
@@ -0,0 +1,36 @@
+import { formatFiles, Tree } from '@nx/devkit';
+import { getDefaultExportName } from '../../../utils/get-default-export-name';
+import { insertImport } from '../../../utils/insert-import';
+import { insertStatementAfterImports } from '../../../utils/insert-statement-after-imports';
+import { resolveRemixRouteFile } from '../../../utils/remix-route-utils';
+import { MetaSchema } from '../schema';
+
+export async function v2MetaGenerator(tree: Tree, schema: MetaSchema) {
+ const routeFilePath =
+ schema.nameAndDirectoryFormat === 'as-provided'
+ ? schema.path
+ : await resolveRemixRouteFile(tree, schema.path, schema.project);
+
+ if (!tree.exists(routeFilePath)) {
+ throw new Error(
+ `Route path does not exist: ${routeFilePath}. Please generate a Remix route first.`
+ );
+ }
+
+ insertImport(tree, routeFilePath, 'MetaFunction', '@remix-run/node', {
+ typeOnly: true,
+ });
+
+ const defaultExportName = getDefaultExportName(tree, routeFilePath);
+ insertStatementAfterImports(
+ tree,
+ routeFilePath,
+ `
+ export const meta: MetaFunction = () => {
+ return [{ title: '${defaultExportName} Route' }];
+ };
+
+ `
+ );
+ await formatFiles(tree);
+}
diff --git a/packages/remix/src/generators/meta/meta.impl.spec.ts b/packages/remix/src/generators/meta/meta.impl.spec.ts
new file mode 100644
index 0000000000..db2159938b
--- /dev/null
+++ b/packages/remix/src/generators/meta/meta.impl.spec.ts
@@ -0,0 +1,54 @@
+import { Tree } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import applicationGenerator from '../application/application.impl';
+import routeGenerator from '../route/route.impl';
+import metaGenerator from './meta.impl';
+
+describe('meta', () => {
+ let tree: Tree;
+ beforeEach(async () => {
+ tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+ tree.write('.gitignore', `/node_modules/dist`);
+
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ path: 'example',
+ project: 'demo',
+ style: 'none',
+ loader: false,
+ action: false,
+ meta: false,
+ skipChecks: false,
+ });
+ });
+
+ it('should use v2 when specified', async () => {
+ await metaGenerator(tree, {
+ path: 'example',
+ project: 'demo',
+ });
+
+ const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8');
+ expect(content).toMatch(
+ `import type { MetaFunction } from '@remix-run/node';`
+ );
+
+ expect(content).toMatch(`export const meta: MetaFunction`);
+ expect(content).toMatch(`return [`);
+ });
+
+ it('--nameAndDirectoryFormat=as=provided', async () => {
+ await metaGenerator(tree, {
+ path: 'apps/demo/app/routes/example.tsx',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+
+ const content = tree.read('apps/demo/app/routes/example.tsx', 'utf-8');
+ expect(content).toMatch(
+ `import type { MetaFunction } from '@remix-run/node';`
+ );
+
+ expect(content).toMatch(`export const meta: MetaFunction`);
+ expect(content).toMatch(`return [`);
+ });
+});
diff --git a/packages/remix/src/generators/meta/meta.impl.ts b/packages/remix/src/generators/meta/meta.impl.ts
new file mode 100644
index 0000000000..308edd0ca7
--- /dev/null
+++ b/packages/remix/src/generators/meta/meta.impl.ts
@@ -0,0 +1,7 @@
+import { Tree } from '@nx/devkit';
+import { v2MetaGenerator } from './lib/v2.impl';
+import { MetaSchema } from './schema';
+
+export default async function (tree: Tree, schema: MetaSchema) {
+ await v2MetaGenerator(tree, schema);
+}
diff --git a/packages/remix/src/generators/meta/schema.d.ts b/packages/remix/src/generators/meta/schema.d.ts
new file mode 100644
index 0000000000..c81caf4076
--- /dev/null
+++ b/packages/remix/src/generators/meta/schema.d.ts
@@ -0,0 +1,10 @@
+import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+
+export interface MetaSchema {
+ path: string;
+ nameAndDirectoryFormat?: NameAndDirectoryFormat;
+ /**
+ * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18.
+ */
+ project?: string;
+}
diff --git a/packages/remix/src/generators/meta/schema.json b/packages/remix/src/generators/meta/schema.json
new file mode 100644
index 0000000000..bc7a179d6f
--- /dev/null
+++ b/packages/remix/src/generators/meta/schema.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "meta",
+ "type": "object",
+ "description": "Generate a meta function for a given route.",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The route path or path to the filename of the route.",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')"
+ },
+ "nameAndDirectoryFormat": {
+ "description": "Whether to generate the meta function in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "project": {
+ "type": "string",
+ "description": "The name of the project.",
+ "$default": {
+ "$source": "projectName"
+ },
+ "x-prompt": "What project is this route for?",
+ "pattern": "^[a-zA-Z].*$",
+ "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18."
+ }
+ },
+ "required": ["path"]
+}
diff --git a/packages/remix/src/generators/preset/lib/normalize-options.ts b/packages/remix/src/generators/preset/lib/normalize-options.ts
new file mode 100644
index 0000000000..7ebd7f5447
--- /dev/null
+++ b/packages/remix/src/generators/preset/lib/normalize-options.ts
@@ -0,0 +1,32 @@
+import { Tree } from '@nx/devkit';
+import { RemixGeneratorSchema } from '../schema';
+
+export interface NormalizedSchema extends RemixGeneratorSchema {
+ appName: string;
+ projectRoot: string;
+ parsedTags: string[];
+ unitTestRunner?: 'jest' | 'none' | 'vitest';
+ e2eTestRunner?: 'cypress' | 'none';
+ js?: boolean;
+}
+
+export function normalizeOptions(
+ tree: Tree,
+ options: RemixGeneratorSchema
+): NormalizedSchema {
+ // There is a bug in Nx core where custom preset args are not passed correctly for boolean values, thus causing the name to be "commit" or "nx-cloud" when not passed.
+ // TODO(jack): revert this hack once Nx core is fixed for custom preset args.
+ // TODO(philip): presets should probably be using the `appName` flag to name the app, but it's not getting passed down to this generator properly and is always an empty string
+ const appName = options.name;
+ const projectRoot = `packages/${appName}`;
+ const parsedTags = options.tags
+ ? options.tags.split(',').map((s) => s.trim())
+ : [];
+
+ return {
+ ...options,
+ appName,
+ projectRoot,
+ parsedTags,
+ };
+}
diff --git a/packages/remix/src/generators/preset/preset.impl.ts b/packages/remix/src/generators/preset/preset.impl.ts
new file mode 100644
index 0000000000..a64081e3ea
--- /dev/null
+++ b/packages/remix/src/generators/preset/preset.impl.ts
@@ -0,0 +1,33 @@
+import { formatFiles, GeneratorCallback, Tree } from '@nx/devkit';
+
+import { runTasksInSerial } from '@nx/devkit';
+import applicationGenerator from '../application/application.impl';
+import setupGenerator from '../setup/setup.impl';
+import { normalizeOptions } from './lib/normalize-options';
+import { RemixGeneratorSchema } from './schema';
+
+export default async function (tree: Tree, _options: RemixGeneratorSchema) {
+ const options = normalizeOptions(tree, _options);
+ const tasks: GeneratorCallback[] = [];
+
+ const setupGenTask = await setupGenerator(tree);
+ tasks.push(setupGenTask);
+
+ const appGenTask = await applicationGenerator(tree, {
+ name: options.appName,
+ tags: options.tags,
+ skipFormat: true,
+ rootProject: true,
+ unitTestRunner: options.unitTestRunner ?? 'vitest',
+ e2eTestRunner: options.e2eTestRunner ?? 'cypress',
+ js: options.js ?? false,
+ });
+ tasks.push(appGenTask);
+
+ tree.delete('apps');
+ tree.delete('libs');
+
+ await formatFiles(tree);
+
+ return runTasksInSerial(...tasks);
+}
diff --git a/packages/remix/src/generators/preset/schema.d.ts b/packages/remix/src/generators/preset/schema.d.ts
new file mode 100644
index 0000000000..041395c7d0
--- /dev/null
+++ b/packages/remix/src/generators/preset/schema.d.ts
@@ -0,0 +1,4 @@
+export interface RemixGeneratorSchema {
+ name: string;
+ tags?: string;
+}
diff --git a/packages/remix/src/generators/preset/schema.json b/packages/remix/src/generators/preset/schema.json
new file mode 100644
index 0000000000..776b067e94
--- /dev/null
+++ b/packages/remix/src/generators/preset/schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "Remix",
+ "title": "",
+ "type": "object",
+ "description": "Generate a Remix application in a standalone workspace. Can be used with `create-nx-workspace --preset=@nx/remix`.",
+ "properties": {
+ "tags": {
+ "type": "string",
+ "description": "Add tags to the app (used for linting).",
+ "alias": "t"
+ }
+ }
+}
diff --git a/packages/remix/src/generators/resource-route/__snapshots__/resource-route.impl.spec.ts.snap b/packages/remix/src/generators/resource-route/__snapshots__/resource-route.impl.spec.ts.snap
new file mode 100644
index 0000000000..4b664673dd
--- /dev/null
+++ b/packages/remix/src/generators/resource-route/__snapshots__/resource-route.impl.spec.ts.snap
@@ -0,0 +1,21 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`resource route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 1`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 3`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 4`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 6`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 1`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 3`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 4`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 6`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 7`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`resource route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 9`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
diff --git a/packages/remix/src/generators/resource-route/resource-route.impl.spec.ts b/packages/remix/src/generators/resource-route/resource-route.impl.spec.ts
new file mode 100644
index 0000000000..a25d48b6f7
--- /dev/null
+++ b/packages/remix/src/generators/resource-route/resource-route.impl.spec.ts
@@ -0,0 +1,155 @@
+import { Tree } from '@nx/devkit';
+import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import { dirname } from 'path';
+import applicationGenerator from '../application/application.impl';
+import resourceRouteGenerator from './resource-route.impl';
+
+describe('resource route', () => {
+ let tree: Tree;
+
+ beforeEach(async () => {
+ tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+ tree.write('.gitignore', `/node_modules/dist`);
+
+ await applicationGenerator(tree, { name: 'demo' });
+ });
+
+ it('should not create a component', async () => {
+ await resourceRouteGenerator(tree, {
+ project: 'demo',
+ path: '/example/',
+ action: false,
+ loader: true,
+ skipChecks: false,
+ });
+ const fileContents = tree.read('apps/demo/app/routes/example.ts', 'utf-8');
+ expect(fileContents).not.toMatch('export default function');
+ });
+
+ it('should throw an error if loader and action are both false', async () => {
+ await expect(
+ async () =>
+ await resourceRouteGenerator(tree, {
+ project: 'demo',
+ path: 'example',
+ action: false,
+ loader: false,
+ skipChecks: false,
+ })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"The resource route generator requires either \`loader\` or \`action\` to be true"`
+ );
+ });
+
+ describe.each([
+ ['derived', 'apps/demo/app/routes/example.ts', 'demo'],
+ ['derived', 'example', 'demo'],
+ ['derived', 'example.ts', 'demo'],
+ ['as-provided', 'apps/demo/app/routes/example', ''],
+ ['as-provided', 'apps/demo/app/routes/example.ts', ''],
+ ])(
+ '--nameAndDirectoryFormat=%s',
+ (
+ nameAndDirectoryFormat: NameAndDirectoryFormat,
+ path: string,
+ project: string
+ ) => {
+ it(`should create correct file for path ${path}`, async () => {
+ await resourceRouteGenerator(tree, {
+ project,
+ path,
+ action: false,
+ loader: true,
+ skipChecks: false,
+ nameAndDirectoryFormat,
+ });
+
+ expect(tree.exists('apps/demo/app/routes/example.ts')).toBeTruthy();
+ });
+
+ it('should error if it detects a possible missing route param because of un-escaped dollar sign', async () => {
+ expect.assertions(3);
+
+ await resourceRouteGenerator(tree, {
+ project,
+ path: `${dirname(path)}/route1/.ts`, // route.$withParams.tsx => route..tsx
+ loader: true,
+ action: true,
+ skipChecks: false,
+ nameAndDirectoryFormat,
+ }).catch((e) => expect(e).toMatchSnapshot());
+
+ await resourceRouteGenerator(tree, {
+ project,
+ path: `${dirname(path)}/route2//index.ts`, // route/$withParams/index.tsx => route//index.tsx
+ loader: true,
+ action: true,
+ skipChecks: false,
+ nameAndDirectoryFormat,
+ }).catch((e) =>
+ expect(e).toMatchInlineSnapshot(
+ `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`
+ )
+ );
+
+ await resourceRouteGenerator(tree, {
+ project,
+ path: `${dirname(path)}/route3/.ts`, // route/$withParams.tsx => route/.tsx
+ loader: true,
+ action: true,
+ skipChecks: false,
+ nameAndDirectoryFormat,
+ }).catch((e) => expect(e).toMatchSnapshot());
+ });
+
+ it(`should succeed if skipChecks flag is passed, and it detects a possible missing route param because of un-escaped dollar sign for ${path}`, async () => {
+ const basePath =
+ nameAndDirectoryFormat === 'as-provided'
+ ? ''
+ : 'apps/demo/app/routes';
+ const normalizedPath = (
+ dirname(path) === '' ? '' : `${dirname(path)}/`
+ ).replace(basePath, '');
+ await resourceRouteGenerator(tree, {
+ project,
+ path: `${normalizedPath}route1/..ts`, // route.$withParams.tsx => route..tsx
+ loader: true,
+ action: true,
+ skipChecks: true,
+ nameAndDirectoryFormat,
+ });
+
+ expect(tree.exists(`${basePath}/${normalizedPath}route1/..ts`)).toBe(
+ true
+ );
+
+ await resourceRouteGenerator(tree, {
+ project,
+ path: `${normalizedPath}route2//index.ts`, // route/$withParams/index.tsx => route//index.tsx
+ loader: true,
+ action: true,
+ skipChecks: true,
+ nameAndDirectoryFormat,
+ });
+
+ expect(
+ tree.exists(`${basePath}/${normalizedPath}route2/index.ts`)
+ ).toBe(true);
+
+ await resourceRouteGenerator(tree, {
+ project,
+ path: `${normalizedPath}route3/.ts`, // route/$withParams.tsx => route/.tsx
+ loader: true,
+ action: true,
+ skipChecks: true,
+ nameAndDirectoryFormat,
+ });
+
+ expect(tree.exists(`${basePath}/${normalizedPath}route3/.ts`)).toBe(
+ true
+ );
+ });
+ }
+ );
+});
diff --git a/packages/remix/src/generators/resource-route/resource-route.impl.ts b/packages/remix/src/generators/resource-route/resource-route.impl.ts
new file mode 100644
index 0000000000..d366fb3f28
--- /dev/null
+++ b/packages/remix/src/generators/resource-route/resource-route.impl.ts
@@ -0,0 +1,64 @@
+import { formatFiles, joinPathFragments, Tree } from '@nx/devkit';
+import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+import {
+ checkRoutePathForErrors,
+ resolveRemixRouteFile,
+} from '../../utils/remix-route-utils';
+import actionGenerator from '../action/action.impl';
+import loaderGenerator from '../loader/loader.impl';
+import { RemixRouteSchema } from './schema';
+
+export default async function (tree: Tree, options: RemixRouteSchema) {
+ const {
+ artifactName: name,
+ directory,
+ project: projectName,
+ } = await determineArtifactNameAndDirectoryOptions(tree, {
+ artifactType: 'resource-route',
+ callingGenerator: '@nx/remix:resource-route',
+ name: options.path.replace(/^\//, '').replace(/\/$/, ''),
+ nameAndDirectoryFormat: options.nameAndDirectoryFormat,
+ project: options.project,
+ });
+
+ if (!options.skipChecks && checkRoutePathForErrors(options.path)) {
+ throw new Error(
+ `Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.`
+ );
+ }
+
+ const routeFilePath = await resolveRemixRouteFile(
+ tree,
+ options.nameAndDirectoryFormat === 'as-provided'
+ ? joinPathFragments(directory, name)
+ : options.path,
+ options.nameAndDirectoryFormat === 'as-provided' ? undefined : projectName,
+ '.ts'
+ );
+
+ if (tree.exists(routeFilePath))
+ throw new Error(`Path already exists: ${options.path}`);
+
+ if (!options.loader && !options.action)
+ throw new Error(
+ 'The resource route generator requires either `loader` or `action` to be true'
+ );
+
+ tree.write(routeFilePath, '');
+
+ if (options.loader) {
+ await loaderGenerator(tree, {
+ path: routeFilePath,
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ }
+
+ if (options.action) {
+ await actionGenerator(tree, {
+ path: routeFilePath,
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ }
+
+ await formatFiles(tree);
+}
diff --git a/packages/remix/src/generators/resource-route/schema.d.ts b/packages/remix/src/generators/resource-route/schema.d.ts
new file mode 100644
index 0000000000..2512f88757
--- /dev/null
+++ b/packages/remix/src/generators/resource-route/schema.d.ts
@@ -0,0 +1,13 @@
+import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+
+export interface RemixRouteSchema {
+ path: string;
+ nameAndDirectoryFormat?: NameAndDirectoryFormat;
+ action: boolean;
+ loader: boolean;
+ skipChecks: boolean;
+ /**
+ * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18.
+ */
+ project?: string;
+}
diff --git a/packages/remix/src/generators/resource-route/schema.json b/packages/remix/src/generators/resource-route/schema.json
new file mode 100644
index 0000000000..d5217bc6d5
--- /dev/null
+++ b/packages/remix/src/generators/resource-route/schema.json
@@ -0,0 +1,55 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "NxRemixResourceRoute",
+ "title": "Create a Resource Route",
+ "type": "object",
+ "description": "Generate a resource route.",
+ "examples": [
+ {
+ "command": "g resource-route 'path/to/page'",
+ "description": "Generate resource route at /path/to/page"
+ }
+ ],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The route path or path to the filename of the route.",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar')"
+ },
+ "nameAndDirectoryFormat": {
+ "description": "Whether to generate the styles in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "project": {
+ "type": "string",
+ "description": "The name of the project.",
+ "$default": {
+ "$source": "projectName"
+ },
+ "x-prompt": "What project is this route for?",
+ "pattern": "^[a-zA-Z].*$",
+ "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18."
+ },
+ "action": {
+ "type": "boolean",
+ "description": "Generate an action function",
+ "default": false
+ },
+ "loader": {
+ "type": "boolean",
+ "description": "Generate a loader function",
+ "default": true
+ },
+ "skipChecks": {
+ "type": "boolean",
+ "description": "Skip route error detection",
+ "default": false
+ }
+ },
+ "required": ["path"]
+}
diff --git a/packages/remix/src/generators/route/__snapshots__/route.impl.spec.ts.snap b/packages/remix/src/generators/route/__snapshots__/route.impl.spec.ts.snap
new file mode 100644
index 0000000000..f807b61913
--- /dev/null
+++ b/packages/remix/src/generators/route/__snapshots__/route.impl.spec.ts.snap
@@ -0,0 +1,95 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`route --nameAndDirectoryFormat=as-provided should add route component 1`] = `
+"import { useLoaderData, useActionData } from '@remix-run/react';
+import { json } from '@remix-run/node';
+import type {
+ LoaderFunctionArgs,
+ MetaFunction,
+ ActionFunctionArgs,
+ LinksFunction,
+} from '@remix-run/node';
+
+import stylesUrl from '../../../styles/path/to/example.css';
+
+export const links: LinksFunction = () => {
+ return [{ rel: 'stylesheet', href: stylesUrl }];
+};
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ let formData = await request.formData();
+
+ return json({ message: formData.toString() }, { status: 200 });
+};
+
+export const meta: MetaFunction = () => {
+ return [{ title: 'Example Route' }];
+};
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ return json({
+ message: 'Hello, world!',
+ });
+};
+
+export default function Example() {
+ const actionMessage = useActionData();
+ const data = useLoaderData();
+
+ return
Message: {data.message}
;
+}
+"
+`;
+
+exports[`route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 1`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 2`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`route --nameAndDirectoryFormat=as-provided should error if it detects a possible missing route param because of un-escaped dollar sign 3`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`route --nameAndDirectoryFormat=derived should add route component 1`] = `
+"import { useLoaderData, useActionData } from '@remix-run/react';
+import { json } from '@remix-run/node';
+import type {
+ LoaderFunctionArgs,
+ MetaFunction,
+ ActionFunctionArgs,
+ LinksFunction,
+} from '@remix-run/node';
+
+import stylesUrl from '../../../styles/path/to/example.css';
+
+export const links: LinksFunction = () => {
+ return [{ rel: 'stylesheet', href: stylesUrl }];
+};
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ let formData = await request.formData();
+
+ return json({ message: formData.toString() }, { status: 200 });
+};
+
+export const meta: MetaFunction = () => {
+ return [{ title: 'PathToExample Route' }];
+};
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ return json({
+ message: 'Hello, world!',
+ });
+};
+
+export default function PathToExample() {
+ const actionMessage = useActionData();
+ const data = useLoaderData();
+
+ return
Message: {data.message}
;
+}
+"
+`;
+
+exports[`route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 1`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 2`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
+
+exports[`route --nameAndDirectoryFormat=derived should error if it detects a possible missing route param because of un-escaped dollar sign 3`] = `[Error: Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.]`;
diff --git a/packages/remix/src/generators/route/route.impl.spec.ts b/packages/remix/src/generators/route/route.impl.spec.ts
new file mode 100644
index 0000000000..1370389bdf
--- /dev/null
+++ b/packages/remix/src/generators/route/route.impl.spec.ts
@@ -0,0 +1,282 @@
+import { Tree } from '@nx/devkit';
+import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import applicationGenerator from '../application/application.impl';
+import presetGenerator from '../preset/preset.impl';
+import routeGenerator from './route.impl';
+
+describe('route', () => {
+ let tree: Tree;
+
+ beforeEach(() => {
+ tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+ tree.write('.gitignore', `/node_modules/dist`);
+ });
+ describe.each([
+ [
+ 'derived',
+ 'path/to/example',
+ '',
+ 'apps/demo/app/routes/path/to/example.tsx',
+ 'apps/demo/app/styles/path/to/example.css',
+ 'PathToExample',
+ 'demo',
+ ],
+ [
+ 'as-provided',
+ 'apps/demo/app/routes/path/to/example',
+ 'app/routes',
+ 'apps/demo/app/routes/path/to/example.tsx',
+ 'apps/demo/app/styles/path/to/example.css',
+ 'Example',
+ '',
+ ],
+ ])(
+ `--nameAndDirectoryFormat=%s`,
+ (
+ nameAndDirectoryFormat: NameAndDirectoryFormat,
+ path,
+ standalonePath,
+ expectedRoutePath,
+ expectedStylePath,
+ expectedComponentName,
+ project: string
+ ) => {
+ it('should add route component', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ project,
+ path,
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ });
+
+ const content = tree.read(expectedRoutePath, 'utf-8');
+ expect(content).toMatchSnapshot();
+ expect(content).toMatch('LinksFunction');
+ expect(content).toMatch(`function ${expectedComponentName}(`);
+ expect(tree.exists(expectedStylePath)).toBeTruthy();
+ }, 25_000);
+
+ it('should support --style=none', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ project,
+ path,
+ nameAndDirectoryFormat,
+ style: 'none',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ });
+
+ const content = tree.read(expectedRoutePath).toString();
+ expect(content).not.toMatch('LinksFunction');
+ expect(tree.exists(expectedStylePath)).toBeFalsy();
+ });
+
+ it('should handle trailing and prefix slashes', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ project,
+ path: `/${path}/`,
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ });
+
+ const content = tree.read(expectedRoutePath).toString();
+ expect(content).toMatch(`function ${expectedComponentName}(`);
+ });
+
+ it('should handle routes that end in a file', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ project: 'demo',
+ path: `${path}.tsx`,
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ });
+
+ const content = tree.read(expectedRoutePath).toString();
+ expect(content).toMatch(`function ${expectedComponentName}(`);
+ });
+
+ it('should handle routes that have a param', async () => {
+ const componentName =
+ nameAndDirectoryFormat === 'as-provided'
+ ? 'WithParam'
+ : `${expectedComponentName}WithParam`;
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ project,
+ path: `/${path}/$withParam.tsx`,
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ });
+
+ const content = tree
+ .read('apps/demo/app/routes/path/to/example/$withParam.tsx')
+ .toString();
+ expect(content).toMatch(`function ${componentName}(`);
+ });
+
+ it('should error if it detects a possible missing route param because of un-escaped dollar sign', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+
+ expect.assertions(3);
+
+ await routeGenerator(tree, {
+ project,
+ path: `${path}/route1/.tsx`, // route.$withParams.tsx => route..tsx
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ }).catch((e) => expect(e).toMatchSnapshot());
+
+ await routeGenerator(tree, {
+ project,
+ path: `${path}/route2//index.tsx`, // route/$withParams/index.tsx => route//index.tsx
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ }).catch((e) => expect(e).toMatchSnapshot());
+
+ await routeGenerator(tree, {
+ project: 'demo',
+ path: `${path}/route3/.tsx`, // route/$withParams.tsx => route/.tsx
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ }).catch((e) => expect(e).toMatchSnapshot());
+ });
+
+ it('should succeed if skipChecks flag is passed, and it detects a possible missing route param because of un-escaped dollar sign', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+
+ await routeGenerator(tree, {
+ project,
+ path: `${path}/route1/..tsx`, // route.$withParams.tsx => route..tsx
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: true,
+ });
+
+ expect(
+ tree.exists('apps/demo/app/routes/path/to/example/route1/..tsx')
+ ).toBe(true);
+
+ await routeGenerator(tree, {
+ project,
+ path: `${path}/route2//index.tsx`, // route/$withParams/index.tsx => route//index.tsx
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: true,
+ });
+
+ expect(
+ tree.exists('apps/demo/app/routes/path/to/example/route2/index.tsx')
+ ).toBe(true);
+
+ await routeGenerator(tree, {
+ project,
+ path: `${path}/route3/.tsx`, // route/$withParams.tsx => route/.tsx
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: true,
+ });
+
+ expect(
+ tree.exists('apps/demo/app/routes/path/to/example/route3/.tsx')
+ ).toBe(true);
+ }, 120000);
+
+ if (nameAndDirectoryFormat === 'derived') {
+ it('should place routes correctly when app dir is changed', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+
+ tree.write(
+ 'apps/demo/remix.config.cjs',
+ `
+ /**
+ * @type {import('@remix-run/dev').AppConfig}
+ */
+ module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ appDirectory: "my-custom-dir",
+ };`
+ );
+
+ await routeGenerator(tree, {
+ project: 'demo',
+ path: 'route.tsx',
+ nameAndDirectoryFormat,
+ style: 'css',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ });
+
+ expect(tree.exists('apps/demo/my-custom-dir/routes/route.tsx')).toBe(
+ true
+ );
+ expect(tree.exists('apps/demo/my-custom-dir/styles/route.css')).toBe(
+ true
+ );
+ });
+ }
+
+ it('should place the route correctly in a standalone app', async () => {
+ await presetGenerator(tree, { name: 'demo' });
+
+ await routeGenerator(tree, {
+ project,
+ path: `${standalonePath}/route.tsx`,
+ nameAndDirectoryFormat,
+ style: 'none',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ });
+
+ expect(tree.exists('app/routes/route.tsx')).toBe(true);
+ });
+ }
+ );
+});
diff --git a/packages/remix/src/generators/route/route.impl.ts b/packages/remix/src/generators/route/route.impl.ts
new file mode 100644
index 0000000000..fa55c8d2d8
--- /dev/null
+++ b/packages/remix/src/generators/route/route.impl.ts
@@ -0,0 +1,116 @@
+import {
+ formatFiles,
+ joinPathFragments,
+ names,
+ readProjectConfiguration,
+ stripIndents,
+ Tree,
+} from '@nx/devkit';
+import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+import { basename, dirname } from 'path';
+import {
+ checkRoutePathForErrors,
+ resolveRemixRouteFile,
+} from '../../utils/remix-route-utils';
+import ActionGenerator from '../action/action.impl';
+import LoaderGenerator from '../loader/loader.impl';
+import MetaGenerator from '../meta/meta.impl';
+import StyleGenerator from '../style/style.impl';
+import { RemixRouteSchema } from './schema';
+
+export default async function (tree: Tree, options: RemixRouteSchema) {
+ const {
+ artifactName: name,
+ directory,
+ project: projectName,
+ } = await determineArtifactNameAndDirectoryOptions(tree, {
+ artifactType: 'route',
+ callingGenerator: '@nx/remix:route',
+ name: options.path.replace(/^\//, '').replace(/\/$/, ''),
+ nameAndDirectoryFormat: options.nameAndDirectoryFormat,
+ project: options.project,
+ });
+
+ const project = readProjectConfiguration(tree, projectName);
+ if (!project) throw new Error(`Project does not exist: ${projectName}`);
+
+ if (!options.skipChecks && checkRoutePathForErrors(options.path)) {
+ throw new Error(
+ `Your route path has an indicator of an un-escaped dollar sign for a route param. If this was intended, include the --skipChecks flag.`
+ );
+ }
+
+ const routeFilePath = await resolveRemixRouteFile(
+ tree,
+ options.nameAndDirectoryFormat === 'as-provided'
+ ? joinPathFragments(directory, name)
+ : options.path,
+ options.nameAndDirectoryFormat === 'as-provided' ? undefined : projectName,
+ '.tsx'
+ );
+
+ const nameToUseForComponent =
+ options.nameAndDirectoryFormat === 'as-provided'
+ ? name.replace('.tsx', '')
+ : options.path.replace(/^\//, '').replace(/\/$/, '').replace('.tsx', '');
+
+ const { className: componentName } = names(
+ nameToUseForComponent === '.' || nameToUseForComponent === ''
+ ? basename(dirname(routeFilePath))
+ : nameToUseForComponent
+ );
+
+ if (tree.exists(routeFilePath))
+ throw new Error(`Path already exists: ${routeFilePath}`);
+
+ tree.write(
+ routeFilePath,
+ stripIndents`
+
+
+ export default function ${componentName}() {
+ ${
+ options.loader
+ ? `
+ return (
+
+ Message: {data.message}
+
+ );
+ `
+ : `return (
${componentName} works!
)`
+ }
+ }
+ `
+ );
+
+ if (options.loader) {
+ await LoaderGenerator(tree, {
+ path: routeFilePath,
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ }
+
+ if (options.meta) {
+ await MetaGenerator(tree, {
+ path: routeFilePath,
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ }
+
+ if (options.action) {
+ await ActionGenerator(tree, {
+ path: routeFilePath,
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ }
+
+ if (options.style === 'css') {
+ await StyleGenerator(tree, {
+ project: projectName,
+ path: routeFilePath,
+ });
+ }
+
+ await formatFiles(tree);
+}
diff --git a/packages/remix/src/generators/route/schema.d.ts b/packages/remix/src/generators/route/schema.d.ts
new file mode 100644
index 0000000000..b94691c6bd
--- /dev/null
+++ b/packages/remix/src/generators/route/schema.d.ts
@@ -0,0 +1,15 @@
+import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+
+export interface RemixRouteSchema {
+ path: string;
+ nameAndDirectoryFormat?: NameAndDirectoryFormat;
+ style: 'css' | 'none';
+ action: boolean;
+ meta: boolean;
+ loader: boolean;
+ skipChecks: boolean;
+ /**
+ * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18.
+ */
+ project?: string;
+}
diff --git a/packages/remix/src/generators/route/schema.json b/packages/remix/src/generators/route/schema.json
new file mode 100644
index 0000000000..2d52ab448f
--- /dev/null
+++ b/packages/remix/src/generators/route/schema.json
@@ -0,0 +1,66 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "NxRemixRoute",
+ "title": "Create a Route",
+ "description": "Generate a route.",
+ "type": "object",
+ "examples": [
+ {
+ "command": "g route 'path/to/page'",
+ "description": "Generate route at /path/to/page"
+ }
+ ],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The route path or path to the filename of the route. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar')"
+ },
+ "nameAndDirectoryFormat": {
+ "description": "Whether to generate the route in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and path relative to the workspace root (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "project": {
+ "type": "string",
+ "description": "The name of the project.",
+ "$default": {
+ "$source": "projectName"
+ },
+ "x-prompt": "What project is this route for?",
+ "pattern": "^[a-zA-Z].*$",
+ "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18."
+ },
+ "style": {
+ "type": "string",
+ "description": "Generate a stylesheet",
+ "enum": ["none", "css"],
+ "default": "css"
+ },
+ "meta": {
+ "type": "boolean",
+ "description": "Generate a meta function",
+ "default": false
+ },
+ "action": {
+ "type": "boolean",
+ "description": "Generate an action function",
+ "default": false
+ },
+ "loader": {
+ "type": "boolean",
+ "description": "Generate a loader function",
+ "default": false
+ },
+ "skipChecks": {
+ "type": "boolean",
+ "description": "Skip route error detection",
+ "default": false
+ }
+ },
+ "required": ["path"]
+}
diff --git a/packages/remix/src/generators/setup-tailwind/__snapshots__/setup-tailwind.impl.spec.ts.snap b/packages/remix/src/generators/setup-tailwind/__snapshots__/setup-tailwind.impl.spec.ts.snap
new file mode 100644
index 0000000000..18e26fdb54
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/__snapshots__/setup-tailwind.impl.spec.ts.snap
@@ -0,0 +1,156 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`setup-tailwind generator should add a js tailwind config to an application correctly 1`] = `
+"import { createGlobPatternsForDependencies } from '@nx/react/tailwind';
+export default {
+ content: [
+ './app/**/*.{js,jsx,ts,tsx}',
+ ...createGlobPatternsForDependencies(__dirname),
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
+"
+`;
+
+exports[`setup-tailwind generator should add a js tailwind config to an application correctly 2`] = `
+"@tailwind base;
+@tailwind components;
+@tailwind utilities;
+"
+`;
+
+exports[`setup-tailwind generator should add a js tailwind config to an application correctly 3`] = `
+"import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+} from '@remix-run/react';
+import styles from './tailwind.css';
+export const links = () => [{ rel: 'stylesheet', href: styles }];
+export const meta = () => [
+ {
+ charset: 'utf-8',
+ title: 'New Remix App',
+ viewport: 'width=device-width,initial-scale=1',
+ },
+];
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+"
+`;
+
+exports[`setup-tailwind generator should add a js tailwind config to an application correctly 4`] = `
+"/**
+ * @type {import('@remix-run/dev').AppConfig}
+ */
+module.exports = {
+ tailwind: true,
+ ignoredRouteFiles: ['**/.*'],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+ watchPaths: () => require('@nx/remix').createWatchPaths(__dirname),
+};
+"
+`;
+
+exports[`setup-tailwind generator should add a tailwind config to an application correctly 1`] = `
+"import type { Config } from "tailwindcss";
+import { createGlobPatternsForDependencies } from '@nx/react/tailwind';
+
+export default {
+ content: [
+ "./app/**/*.{js,jsx,ts,tsx}",
+ ...createGlobPatternsForDependencies(__dirname)
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+} satisfies Config;
+"
+`;
+
+exports[`setup-tailwind generator should add a tailwind config to an application correctly 2`] = `
+"@tailwind base;
+@tailwind components;
+@tailwind utilities;
+"
+`;
+
+exports[`setup-tailwind generator should add a tailwind config to an application correctly 3`] = `
+"import type { MetaFunction, LinksFunction } from '@remix-run/node';
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+} from '@remix-run/react';
+import styles from './tailwind.css';
+export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }];
+
+export const meta: MetaFunction = () => [
+ {
+ charset: 'utf-8',
+ title: 'New Remix App',
+ viewport: 'width=device-width,initial-scale=1',
+ },
+];
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+"
+`;
+
+exports[`setup-tailwind generator should add a tailwind config to an application correctly 4`] = `
+"/**
+ * @type {import('@remix-run/dev').AppConfig}
+ */
+module.exports = {
+ tailwind: true,
+ ignoredRouteFiles: ['**/.*'],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+ watchPaths: () => require('@nx/remix').createWatchPaths(__dirname),
+};
+"
+`;
diff --git a/packages/remix/src/generators/setup-tailwind/files/app/tailwind.css__tpl__ b/packages/remix/src/generators/setup-tailwind/files/app/tailwind.css__tpl__
new file mode 100644
index 0000000000..b5c61c9567
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/files/app/tailwind.css__tpl__
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/packages/remix/src/generators/setup-tailwind/files/tailwind.config.ts__tpl__ b/packages/remix/src/generators/setup-tailwind/files/tailwind.config.ts__tpl__
new file mode 100644
index 0000000000..14bac87243
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/files/tailwind.config.ts__tpl__
@@ -0,0 +1,13 @@
+import type { Config } from "tailwindcss";
+import { createGlobPatternsForDependencies } from '@nx/react/tailwind';
+
+export default {
+ content: [
+ "./app/**/*.{js,jsx,ts,tsx}",
+ ...createGlobPatternsForDependencies(__dirname)
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/packages/remix/src/generators/setup-tailwind/lib/index.ts b/packages/remix/src/generators/setup-tailwind/lib/index.ts
new file mode 100644
index 0000000000..f614834a1b
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/lib/index.ts
@@ -0,0 +1 @@
+export * from './update-remix-config';
diff --git a/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.spec.ts b/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.spec.ts
new file mode 100644
index 0000000000..5d9fb29d87
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.spec.ts
@@ -0,0 +1,79 @@
+import { stripIndents } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import { updateRemixConfig } from './update-remix-config';
+
+describe('updateRemixConfig', () => {
+ it('should add tailwind property to an existing config that doesnt have it', () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace();
+ tree.write(
+ `remix.config.cjs`,
+ stripIndents`module.exports = {
+ ignoredRouteFiles: ['**/.*'],
+ watchPaths: ['../../libs']
+ };`
+ );
+
+ // ACT
+ updateRemixConfig(tree, '.');
+
+ // ASSERT
+ expect(tree.read('remix.config.cjs', 'utf-8')).toMatchInlineSnapshot(`
+ "module.exports = {
+ tailwind: true,
+ ignoredRouteFiles: ['**/.*'],
+ watchPaths: ['../../libs']
+ };"
+ `);
+ });
+
+ it('should update tailwind property if the config has it and set to false', () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace();
+ tree.write(
+ `remix.config.cjs`,
+ stripIndents`module.exports = {
+ ignoredRouteFiles: ['**/.*'],
+ tailwind: false,
+ watchPaths: ['../../libs']
+ };`
+ );
+
+ // ACT
+ updateRemixConfig(tree, '.');
+
+ // ASSERT
+ expect(tree.read('remix.config.cjs', 'utf-8')).toMatchInlineSnapshot(`
+ "module.exports = {
+ ignoredRouteFiles: ['**/.*'],
+ tailwind: true,
+ watchPaths: ['../../libs']
+ };"
+ `);
+ });
+
+ it('should not update tailwind property if the config has it and set to true', () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace();
+ tree.write(
+ `remix.config.cjs`,
+ stripIndents`module.exports = {
+ ignoredRouteFiles: ['**/.*'],
+ tailwind: true,
+ watchPaths: ['../../libs']
+ };`
+ );
+
+ // ACT
+ updateRemixConfig(tree, '.');
+
+ // ASSERT
+ expect(tree.read('remix.config.cjs', 'utf-8')).toMatchInlineSnapshot(`
+ "module.exports = {
+ ignoredRouteFiles: ['**/.*'],
+ tailwind: true,
+ watchPaths: ['../../libs']
+ };"
+ `);
+ });
+});
diff --git a/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.ts b/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.ts
new file mode 100644
index 0000000000..e1fda1aab3
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/lib/update-remix-config.ts
@@ -0,0 +1,52 @@
+import { joinPathFragments, type Tree } from '@nx/devkit';
+import { tsquery } from '@phenomnomnominal/tsquery';
+
+export function updateRemixConfig(tree: Tree, projectRoot: string) {
+ const pathToRemixConfig = joinPathFragments(projectRoot, 'remix.config.cjs');
+
+ if (!tree.exists(pathToRemixConfig)) {
+ throw new Error(
+ `Could not find "${pathToRemixConfig}". Please ensure a "remix.config.cjs" exists at the root of your project.`
+ );
+ }
+
+ const fileContents = tree.read(pathToRemixConfig, 'utf-8');
+
+ const REMIX_CONFIG_OBJECT_SELECTOR =
+ 'PropertyAccessExpression:has(Identifier[name=module], Identifier[name=exports])~ObjectLiteralExpression';
+ const ast = tsquery.ast(fileContents);
+
+ const nodes = tsquery(ast, REMIX_CONFIG_OBJECT_SELECTOR, {
+ visitAllChildren: true,
+ });
+ if (nodes.length === 0) {
+ throw new Error(`Remix Config is not valid, unable to update the file.`);
+ }
+
+ const configObjectNode = nodes[0];
+
+ const propertyNodes = tsquery(configObjectNode, 'PropertyAssignment', {
+ visitAllChildren: true,
+ });
+
+ for (const propertyNode of propertyNodes) {
+ const nodeText = propertyNode.getText();
+ if (nodeText.includes('tailwind') && nodeText.includes('true')) {
+ return;
+ } else if (nodeText.includes('tailwind') && nodeText.includes('false')) {
+ const updatedFileContents = `${fileContents.slice(
+ 0,
+ propertyNode.getStart()
+ )}tailwind: true${fileContents.slice(propertyNode.getEnd())}`;
+ tree.write(pathToRemixConfig, updatedFileContents);
+ return;
+ }
+ }
+
+ const updatedFileContents = `${fileContents.slice(
+ 0,
+ configObjectNode.getStart() + 1
+ )}\ntailwind: true,${fileContents.slice(configObjectNode.getStart() + 1)}`;
+
+ tree.write(pathToRemixConfig, updatedFileContents);
+}
diff --git a/packages/remix/src/generators/setup-tailwind/schema.d.ts b/packages/remix/src/generators/setup-tailwind/schema.d.ts
new file mode 100644
index 0000000000..2a098d99ba
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/schema.d.ts
@@ -0,0 +1,5 @@
+export interface SetupTailwindSchema {
+ project: string;
+ js?: boolean;
+ skipFormat?: boolean;
+}
diff --git a/packages/remix/src/generators/setup-tailwind/schema.json b/packages/remix/src/generators/setup-tailwind/schema.json
new file mode 100644
index 0000000000..33ca126e04
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/schema.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "NxRemixTailwind",
+ "title": "Add TailwindCSS to a Remix App",
+ "description": "Setup tailwindcss for a given project.",
+ "type": "object",
+ "examples": [
+ {
+ "command": "g setup-tailwind --project=myapp",
+ "description": "Generate a TailwindCSS config for your Remix app"
+ }
+ ],
+ "properties": {
+ "project": {
+ "type": "string",
+ "description": "The name of the project to add tailwind to",
+ "$default": {
+ "$source": "projectName"
+ },
+ "x-prompt": "What project would you like to add Tailwind to?",
+ "pattern": "^[a-zA-Z].*$"
+ },
+ "js": {
+ "type": "boolean",
+ "description": "Generate a JavaScript config file instead of a TypeScript config file",
+ "default": false
+ },
+ "skipFormat": {
+ "type": "boolean",
+ "description": "Skip formatting files after generator runs",
+ "default": false,
+ "x-priority": "internal"
+ }
+ },
+ "required": ["project"]
+}
diff --git a/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.spec.ts b/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.spec.ts
new file mode 100644
index 0000000000..3be07313c0
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.spec.ts
@@ -0,0 +1,53 @@
+import { readJson } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import applicationGenerator from '../application/application.impl';
+import setupTailwind from './setup-tailwind.impl';
+
+describe('setup-tailwind generator', () => {
+ it('should add a tailwind config to an application correctly', async () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace();
+ await applicationGenerator(tree, {
+ name: 'test',
+ rootProject: true,
+ });
+
+ // ACT
+ await setupTailwind(tree, { project: 'test' });
+
+ // ASSERT
+ expect(tree.exists('tailwind.config.ts')).toBeTruthy();
+ expect(tree.read('tailwind.config.ts', 'utf-8')).toMatchSnapshot();
+ expect(tree.exists('app/tailwind.css')).toBeTruthy();
+ expect(tree.read('app/tailwind.css', 'utf-8')).toMatchSnapshot();
+ expect(tree.read('app/root.tsx', 'utf-8')).toMatchSnapshot();
+ expect(tree.read('remix.config.cjs', 'utf-8')).toMatchSnapshot();
+ expect(
+ readJson(tree, 'package.json').dependencies['tailwindcss']
+ ).toBeTruthy();
+ });
+
+ it('should add a js tailwind config to an application correctly', async () => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace();
+ await applicationGenerator(tree, {
+ name: 'test',
+ js: true,
+ rootProject: true,
+ });
+
+ // ACT
+ await setupTailwind(tree, { project: 'test', js: true });
+
+ // ASSERT
+ expect(tree.exists('tailwind.config.js')).toBeTruthy();
+ expect(tree.read('tailwind.config.js', 'utf-8')).toMatchSnapshot();
+ expect(tree.exists('app/tailwind.css')).toBeTruthy();
+ expect(tree.read('app/tailwind.css', 'utf-8')).toMatchSnapshot();
+ expect(tree.read('app/root.js', 'utf-8')).toMatchSnapshot();
+ expect(tree.read('remix.config.cjs', 'utf-8')).toMatchSnapshot();
+ expect(
+ readJson(tree, 'package.json').dependencies['tailwindcss']
+ ).toBeTruthy();
+ });
+});
diff --git a/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.ts b/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.ts
new file mode 100644
index 0000000000..bf53eee653
--- /dev/null
+++ b/packages/remix/src/generators/setup-tailwind/setup-tailwind.impl.ts
@@ -0,0 +1,68 @@
+import {
+ addDependenciesToPackageJson,
+ formatFiles,
+ generateFiles,
+ installPackagesTask,
+ joinPathFragments,
+ readProjectConfiguration,
+ toJS,
+ type Tree,
+} from '@nx/devkit';
+
+import { upsertLinksFunction } from '../../utils/upsert-links-function';
+import { tailwindVersion } from '../../utils/versions';
+import { updateRemixConfig } from './lib';
+import type { SetupTailwindSchema } from './schema';
+
+export default async function setupTailwind(
+ tree: Tree,
+ options: SetupTailwindSchema
+) {
+ const project = readProjectConfiguration(tree, options.project);
+ if (project.projectType !== 'application') {
+ throw new Error(
+ `Project "${options.project}" is not an application. Please ensure the project is an application.`
+ );
+ }
+
+ updateRemixConfig(tree, project.root);
+
+ generateFiles(tree, joinPathFragments(__dirname, 'files'), project.root, {
+ tpl: '',
+ });
+
+ if (options.js) {
+ tree.rename(
+ joinPathFragments(project.root, 'app/root.js'),
+ joinPathFragments(project.root, 'app/root.tsx')
+ );
+ }
+ const pathToRoot = joinPathFragments(project.root, 'app/root.tsx');
+ upsertLinksFunction(
+ tree,
+ pathToRoot,
+ 'styles',
+ './tailwind.css',
+ `{ rel: "stylesheet", href: styles }`
+ );
+
+ addDependenciesToPackageJson(
+ tree,
+ {
+ tailwindcss: tailwindVersion,
+ },
+ {}
+ );
+
+ if (options.js) {
+ toJS(tree);
+ }
+
+ if (!options.skipFormat) {
+ await formatFiles(tree);
+ }
+
+ return () => {
+ installPackagesTask(tree);
+ };
+}
diff --git a/packages/remix/src/generators/setup/schema.json b/packages/remix/src/generators/setup/schema.json
new file mode 100644
index 0000000000..14d6d7e89a
--- /dev/null
+++ b/packages/remix/src/generators/setup/schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "NxRemixSetup",
+ "title": "",
+ "type": "object",
+ "description": "Generate initial files required for Remix to work within the workspace.",
+ "properties": {
+ "packageManager": {
+ "type": "string",
+ "description": "The package manager to setup for",
+ "enum": ["yarn", "npm", "pnpm"]
+ }
+ }
+}
diff --git a/packages/remix/src/generators/setup/setup.impl.spec.ts b/packages/remix/src/generators/setup/setup.impl.spec.ts
new file mode 100644
index 0000000000..f94c9c3c09
--- /dev/null
+++ b/packages/remix/src/generators/setup/setup.impl.spec.ts
@@ -0,0 +1,30 @@
+import { Tree } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import setupGenerator from './setup.impl';
+
+describe('app', () => {
+ let tree: Tree;
+
+ beforeEach(() => {
+ tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+ tree.write(
+ '.gitignore',
+ `/node_modules
+/dist`
+ );
+ });
+
+ it('should update ignore file', async () => {
+ // Idempotency
+ await setupGenerator(tree);
+ await setupGenerator(tree);
+
+ const ignoreFile = tree.read('.gitignore').toString();
+ expect(ignoreFile).toEqual(`node_modules
+dist
+# Remix files
+apps/**/build
+apps/**/.cache
+ `);
+ });
+});
diff --git a/packages/remix/src/generators/setup/setup.impl.ts b/packages/remix/src/generators/setup/setup.impl.ts
new file mode 100644
index 0000000000..e90bf0ed1a
--- /dev/null
+++ b/packages/remix/src/generators/setup/setup.impl.ts
@@ -0,0 +1,43 @@
+import {
+ formatFiles,
+ GeneratorCallback,
+ runTasksInSerial,
+ Tree,
+ updateJson,
+} from '@nx/devkit';
+import { initGenerator as jsInitGenerator } from '@nx/js';
+
+export default async function (tree: Tree) {
+ const tasks: GeneratorCallback[] = [];
+
+ const jsInitTask = await jsInitGenerator(tree, {
+ skipFormat: true,
+ });
+ tasks.push(jsInitTask);
+
+ // Ignore nested project files
+ let ignoreFile = tree.read('.gitignore').toString();
+ if (ignoreFile.indexOf('/dist') !== -1) {
+ ignoreFile = ignoreFile.replace('/dist', 'dist');
+ }
+ if (ignoreFile.indexOf('/node_modules') !== -1) {
+ ignoreFile = ignoreFile.replace('/node_modules', 'node_modules');
+ }
+ if (ignoreFile.indexOf('# Remix files') === -1) {
+ ignoreFile = `${ignoreFile}
+# Remix files
+apps/**/build
+apps/**/.cache
+ `;
+ }
+ tree.write('.gitignore', ignoreFile);
+
+ updateJson(tree, `package.json`, (json) => {
+ json.type = 'module';
+ return json;
+ });
+
+ await formatFiles(tree);
+
+ return runTasksInSerial(...tasks);
+}
diff --git a/packages/remix/src/generators/storybook-configuration/__snapshots__/storybook-configuration.impl.spec.ts.snap b/packages/remix/src/generators/storybook-configuration/__snapshots__/storybook-configuration.impl.spec.ts.snap
new file mode 100644
index 0000000000..f79f750776
--- /dev/null
+++ b/packages/remix/src/generators/storybook-configuration/__snapshots__/storybook-configuration.impl.spec.ts.snap
@@ -0,0 +1,85 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Storybook Configuration it should create a storybook configuration and use react-vite framework with testing framework jest 1`] = `
+"import type { StorybookConfig } from '@storybook/react-vite';
+
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import { mergeConfig } from 'vite';
+
+const config: StorybookConfig = {
+ stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
+ addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
+ framework: {
+ name: '@storybook/react-vite',
+ options: {},
+ },
+
+ viteFinal: async (config) =>
+ mergeConfig(config, {
+ plugins: [nxViteTsPaths()],
+ }),
+};
+
+export default config;
+
+// To customize your Vite configuration you can use the viteFinal field.
+// Check https://storybook.js.org/docs/react/builders/vite#configuration
+// and https://nx.dev/recipes/storybook/custom-builder-configs
+"
+`;
+
+exports[`Storybook Configuration it should create a storybook configuration and use react-vite framework with testing framework none 1`] = `
+"import type { StorybookConfig } from '@storybook/react-vite';
+
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import { mergeConfig } from 'vite';
+
+const config: StorybookConfig = {
+ stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
+ addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
+ framework: {
+ name: '@storybook/react-vite',
+ options: {},
+ },
+
+ viteFinal: async (config) =>
+ mergeConfig(config, {
+ plugins: [nxViteTsPaths()],
+ }),
+};
+
+export default config;
+
+// To customize your Vite configuration you can use the viteFinal field.
+// Check https://storybook.js.org/docs/react/builders/vite#configuration
+// and https://nx.dev/recipes/storybook/custom-builder-configs
+"
+`;
+
+exports[`Storybook Configuration it should create a storybook configuration and use react-vite framework with testing framework vitest 1`] = `
+"import type { StorybookConfig } from '@storybook/react-vite';
+
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import { mergeConfig } from 'vite';
+
+const config: StorybookConfig = {
+ stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
+ addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
+ framework: {
+ name: '@storybook/react-vite',
+ options: {},
+ },
+
+ viteFinal: async (config) =>
+ mergeConfig(config, {
+ plugins: [nxViteTsPaths()],
+ }),
+};
+
+export default config;
+
+// To customize your Vite configuration you can use the viteFinal field.
+// Check https://storybook.js.org/docs/react/builders/vite#configuration
+// and https://nx.dev/recipes/storybook/custom-builder-configs
+"
+`;
diff --git a/packages/remix/src/generators/storybook-configuration/files/vite.config.ts__tpl__ b/packages/remix/src/generators/storybook-configuration/files/vite.config.ts__tpl__
new file mode 100644
index 0000000000..5cc91668a0
--- /dev/null
+++ b/packages/remix/src/generators/storybook-configuration/files/vite.config.ts__tpl__
@@ -0,0 +1,15 @@
+///
+import {defineConfig} from 'vite';
+import react from '@vitejs/plugin-react';
+import viteTsConfigPaths from 'vite-tsconfig-paths';
+
+export default defineConfig({
+ cacheDir: '../../../node_modules/.vite/storybook-generator-test',
+
+ plugins: [
+ react(),
+ viteTsConfigPaths({
+ root: '../../../',
+ }),
+ ],
+})
diff --git a/packages/remix/src/generators/storybook-configuration/schema.d.ts b/packages/remix/src/generators/storybook-configuration/schema.d.ts
new file mode 100644
index 0000000000..67855b1ee6
--- /dev/null
+++ b/packages/remix/src/generators/storybook-configuration/schema.d.ts
@@ -0,0 +1,15 @@
+import { Linter } from '@nx/eslint';
+
+export interface StorybookConfigurationSchema {
+ project: string;
+ configureCypress: boolean;
+ generateStories?: boolean;
+ generateCypressSpecs?: boolean;
+ js?: boolean;
+ tsConfiguration?: boolean;
+ linter?: Linter;
+ cypressDirectory?: string;
+ ignorePaths?: string[];
+ configureTestRunner?: boolean;
+ configureStaticServe?: boolean;
+}
diff --git a/packages/remix/src/generators/storybook-configuration/schema.json b/packages/remix/src/generators/storybook-configuration/schema.json
new file mode 100644
index 0000000000..933b6f16ae
--- /dev/null
+++ b/packages/remix/src/generators/storybook-configuration/schema.json
@@ -0,0 +1,89 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "cli": "nx",
+ "$id": "NxRemixStorybookConfigure",
+ "title": "Remix Storybook Configuration",
+ "description": "Set up Storybook for a Remix library.",
+ "type": "object",
+ "properties": {
+ "project": {
+ "type": "string",
+ "aliases": ["name", "projectName"],
+ "description": "Project for which to generate Storybook configuration.",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "x-prompt": "For which project do you want to generate Storybook configuration?",
+ "x-dropdown": "projects",
+ "x-priority": "important"
+ },
+ "configureCypress": {
+ "type": "boolean",
+ "description": "Run the cypress-configure generator.",
+ "x-prompt": "Configure a cypress e2e app to run against the storybook instance?",
+ "default": true,
+ "x-priority": "important"
+ },
+ "generateStories": {
+ "type": "boolean",
+ "description": "Automatically generate `*.stories.ts` files for components declared in this project?",
+ "x-prompt": "Automatically generate *.stories.ts files for components declared in this project?",
+ "default": true,
+ "x-priority": "important"
+ },
+ "generateCypressSpecs": {
+ "type": "boolean",
+ "description": "Automatically generate test files in the Cypress E2E app generated by the `cypress-configure` generator.",
+ "x-prompt": "Automatically generate test files in the Cypress E2E app generated by the cypress-configure generator?",
+ "default": true,
+ "x-priority": "important"
+ },
+ "configureStaticServe": {
+ "type": "boolean",
+ "description": "Specifies whether to configure a static file server target for serving storybook. Helpful for speeding up CI build/test times.",
+ "x-prompt": "Configure a static file server for the storybook instance?",
+ "default": true,
+ "x-priority": "important"
+ },
+ "cypressDirectory": {
+ "type": "string",
+ "description": "A directory where the Cypress project will be placed. Placed at the root by default."
+ },
+ "js": {
+ "type": "boolean",
+ "description": "Generate JavaScript story files rather than TypeScript story files.",
+ "default": false
+ },
+ "tsConfiguration": {
+ "type": "boolean",
+ "description": "Configure your project with TypeScript. Generate main.ts and preview.ts files, instead of main.js and preview.js.",
+ "default": false
+ },
+ "linter": {
+ "description": "The tool to use for running lint checks.",
+ "type": "string",
+ "enum": ["eslint"],
+ "default": "eslint"
+ },
+ "ignorePaths": {
+ "type": "array",
+ "description": "Paths to ignore when looking for components.",
+ "items": {
+ "type": "string",
+ "description": "Path to ignore."
+ },
+ "examples": [
+ "**/**/src/**/not-stories/**",
+ "libs/my-lib/**/*.something.ts",
+ "**/**/src/**/*.other.*",
+ "libs/my-lib/src/not-stories/**,**/**/src/**/*.other.*,apps/my-app/**/*.something.ts"
+ ]
+ },
+ "configureTestRunner": {
+ "type": "boolean",
+ "description": "Add a Storybook Test-Runner target."
+ }
+ },
+ "required": ["name"]
+}
diff --git a/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.spec.ts b/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.spec.ts
new file mode 100644
index 0000000000..e2395527c0
--- /dev/null
+++ b/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.spec.ts
@@ -0,0 +1,33 @@
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import libraryGenerator from '../library/library.impl';
+import storybookConfigurationGenerator from './storybook-configuration.impl';
+
+describe('Storybook Configuration', () => {
+ it.each(['jest', 'vitest', 'none'])(
+ 'it should create a storybook configuration and use react-vite framework with testing framework %s',
+ async (unitTestRunner: 'jest' | 'vitest' | 'none') => {
+ // ARRANGE
+ const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+
+ await libraryGenerator(tree, {
+ name: 'storybook-test',
+ style: 'css',
+ unitTestRunner,
+ });
+
+ // ACT
+ await storybookConfigurationGenerator(tree, {
+ project: 'storybook-test',
+ configureCypress: false,
+ configureStaticServe: false,
+ generateStories: true,
+ });
+
+ // ASSERT
+ expect(tree.exists(`libs/storybook-test/vite.config.ts`));
+ expect(
+ tree.read(`libs/storybook-test/.storybook/main.ts`, 'utf-8')
+ ).toMatchSnapshot();
+ }
+ );
+});
diff --git a/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.ts b/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.ts
new file mode 100644
index 0000000000..e5a27fd4ad
--- /dev/null
+++ b/packages/remix/src/generators/storybook-configuration/storybook-configuration.impl.ts
@@ -0,0 +1,24 @@
+import {
+ generateFiles,
+ joinPathFragments,
+ readProjectConfiguration,
+ type Tree,
+} from '@nx/devkit';
+import { join } from 'path';
+import type { StorybookConfigurationSchema } from './schema';
+import { storybookConfigurationGenerator } from '@nx/react';
+
+export default async function remixStorybookConfiguration(
+ tree: Tree,
+ schema: StorybookConfigurationSchema
+) {
+ const { root } = readProjectConfiguration(tree, schema.project);
+
+ if (!tree.exists(joinPathFragments(root, 'vite.config.ts'))) {
+ generateFiles(tree, join(__dirname, 'files'), root, { tpl: '' });
+ }
+
+ const task = await storybookConfigurationGenerator(tree, schema);
+
+ return task;
+}
diff --git a/packages/remix/src/generators/style/schema.d.ts b/packages/remix/src/generators/style/schema.d.ts
new file mode 100644
index 0000000000..b0bb7d20a8
--- /dev/null
+++ b/packages/remix/src/generators/style/schema.d.ts
@@ -0,0 +1,10 @@
+import { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+
+export interface RemixStyleSchema {
+ path: string;
+ nameAndDirectoryFormat?: NameAndDirectoryFormat;
+ /**
+ * @deprecated Provide the `path` option instead. The project will be determined from the path provided. It will be removed in Nx v18.
+ */
+ project?: string;
+}
diff --git a/packages/remix/src/generators/style/schema.json b/packages/remix/src/generators/style/schema.json
new file mode 100644
index 0000000000..31bd23bf33
--- /dev/null
+++ b/packages/remix/src/generators/style/schema.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "NxRemixRouteStyle",
+ "title": "Add style import to a route",
+ "description": "Generate a style import and file for a given route.",
+ "type": "object",
+ "examples": [
+ {
+ "command": "g style --path='apps/demo/app/routes/path/to/page.tsx'",
+ "description": "Generate route at apps/demo/app/routes/path/to/page.tsx"
+ }
+ ],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Route path",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "x-prompt": "What is the path of the route? (e.g. 'apps/demo/app/routes/foo/bar.tsx')"
+ },
+ "nameAndDirectoryFormat": {
+ "description": "Whether to generate the styles in the path as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "project": {
+ "type": "string",
+ "description": "The name of the project.",
+ "$default": {
+ "$source": "projectName"
+ },
+ "x-prompt": "What project is this route in?",
+ "pattern": "^[a-zA-Z].*$",
+ "x-deprecated": "Provide the `path` option instead and use the `as-provided` format. The project will be determined from the path provided. It will be removed in Nx v18."
+ }
+ },
+ "required": ["path"]
+}
diff --git a/packages/remix/src/generators/style/style.impl.spec.ts b/packages/remix/src/generators/style/style.impl.spec.ts
new file mode 100644
index 0000000000..d2e0a48cf2
--- /dev/null
+++ b/packages/remix/src/generators/style/style.impl.spec.ts
@@ -0,0 +1,163 @@
+import { Tree } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import applicationGenerator from '../application/application.impl';
+import presetGenerator from '../preset/preset.impl';
+import routeGenerator from '../route/route.impl';
+import styleGenerator from './style.impl';
+
+describe('route', () => {
+ let tree: Tree;
+
+ beforeEach(() => {
+ tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+ tree.write('.gitignore', `/node_modules/dist`);
+ });
+
+ it('should add css file to shared styles directory', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ project: 'demo',
+ path: 'path/to/example',
+ style: 'none',
+ loader: false,
+ action: false,
+ meta: false,
+ skipChecks: false,
+ });
+ await styleGenerator(tree, {
+ project: 'demo',
+ path: 'path/to/example',
+ });
+
+ expect(
+ tree.exists('apps/demo/app/styles/path/to/example.css')
+ ).toBeTruthy();
+ });
+
+ it('should handle routes that have a param', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ project: 'demo',
+ path: '/example/$withParam.tsx',
+ style: 'none',
+ loader: false,
+ action: false,
+ meta: false,
+ skipChecks: false,
+ });
+ await styleGenerator(tree, {
+ project: 'demo',
+ path: '/example/$withParam.tsx',
+ });
+
+ expect(
+ tree.exists('apps/demo/app/styles/example/$withParam.css')
+ ).toBeTruthy();
+ });
+
+ it('should place styles correctly when app dir is changed', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+
+ tree.write(
+ 'apps/demo/remix.config.cjs',
+ `
+ /**
+ * @type {import('@remix-run/dev').AppConfig}
+ */
+ module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ appDirectory: "my-custom-dir",
+ };`
+ );
+
+ await routeGenerator(tree, {
+ project: 'demo',
+ path: 'route.tsx',
+ style: 'none',
+ loader: true,
+ action: true,
+ meta: true,
+ skipChecks: false,
+ });
+ await styleGenerator(tree, {
+ project: 'demo',
+ path: '/route.tsx',
+ });
+
+ expect(tree.exists('apps/demo/my-custom-dir/styles/route.css')).toBe(true);
+ });
+
+ it('should import stylesheet with a relative path in an integrated workspace', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ project: 'demo',
+ path: '/example/$withParam.tsx',
+ style: 'none',
+ loader: false,
+ action: false,
+ meta: false,
+ skipChecks: false,
+ });
+ await styleGenerator(tree, {
+ project: 'demo',
+ path: '/example/$withParam.tsx',
+ });
+ const content = tree.read(
+ 'apps/demo/app/routes/example/$withParam.tsx',
+ 'utf-8'
+ );
+
+ expect(content).toMatch(
+ "import stylesUrl from '../../styles/example/$withParam.css';"
+ );
+ });
+
+ it('should import stylesheet using ~ in a standalone project', async () => {
+ await presetGenerator(tree, { name: 'demo' });
+
+ await routeGenerator(tree, {
+ project: 'demo',
+ path: '/example/$withParam.tsx',
+ style: 'none',
+ loader: false,
+ action: false,
+ meta: false,
+ skipChecks: false,
+ });
+
+ await styleGenerator(tree, {
+ project: 'demo',
+ path: '/example/$withParam.tsx',
+ });
+ const content = tree.read('app/routes/example/$withParam.tsx', 'utf-8');
+
+ expect(content).toMatch(
+ "import stylesUrl from '~/styles/example/$withParam.css';"
+ );
+ });
+
+ it('--nameAndDirectoryFormat=as-provided', async () => {
+ await applicationGenerator(tree, { name: 'demo' });
+ await routeGenerator(tree, {
+ path: 'apps/demo/app/routes/example/$withParam.tsx',
+ nameAndDirectoryFormat: 'as-provided',
+ style: 'none',
+ loader: false,
+ action: false,
+ meta: false,
+ skipChecks: false,
+ });
+ await styleGenerator(tree, {
+ path: 'apps/demo/app/routes/example/$withParam.tsx',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ const content = tree.read(
+ 'apps/demo/app/routes/example/$withParam.tsx',
+ 'utf-8'
+ );
+
+ expect(content).toMatch(
+ "import stylesUrl from '../../styles/example/$withParam.css';"
+ );
+ });
+});
diff --git a/packages/remix/src/generators/style/style.impl.ts b/packages/remix/src/generators/style/style.impl.ts
new file mode 100644
index 0000000000..cfd9e13450
--- /dev/null
+++ b/packages/remix/src/generators/style/style.impl.ts
@@ -0,0 +1,91 @@
+import {
+ formatFiles,
+ joinPathFragments,
+ readProjectConfiguration,
+ stripIndents,
+ Tree,
+} from '@nx/devkit';
+import { RemixStyleSchema } from './schema';
+
+import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+import { dirname, relative } from 'path';
+import { insertImport } from '../../utils/insert-import';
+import { insertStatementAfterImports } from '../../utils/insert-statement-after-imports';
+import {
+ normalizeRoutePath,
+ resolveRemixAppDirectory,
+ resolveRemixRouteFile,
+} from '../../utils/remix-route-utils';
+
+export default async function (tree: Tree, options: RemixStyleSchema) {
+ const { project: projectName, artifactName: name } =
+ await determineArtifactNameAndDirectoryOptions(tree, {
+ artifactType: 'style',
+ callingGenerator: '@nx/remix:style',
+ name: options.path,
+ nameAndDirectoryFormat: options.nameAndDirectoryFormat,
+ project: options.project,
+ });
+ const project = readProjectConfiguration(tree, projectName);
+ if (!project) throw new Error(`Project does not exist: ${projectName}`);
+
+ const appDir = await resolveRemixAppDirectory(tree, project.name);
+ const normalizedRoutePath = `${normalizeRoutePath(options.path)
+ .replace(/^\//, '')
+ .replace('.tsx', '')}.css`;
+ const stylesheetPath = joinPathFragments(
+ appDir,
+ 'styles',
+ normalizedRoutePath
+ );
+
+ tree.write(
+ stylesheetPath,
+ stripIndents`
+ :root {
+ --color-foreground: #fff;
+ --color-background: #143157;
+ --color-links: hsl(214, 73%, 69%);
+ --color-border: #275da8;
+ --font-body: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ Liberation Mono, Courier New, monospace;
+ }
+ `
+ );
+
+ const routeFilePath = options.nameAndDirectoryFormat
+ ? options.path
+ : await resolveRemixRouteFile(tree, options.path, options.project, '.tsx');
+
+ insertImport(tree, routeFilePath, 'LinksFunction', '@remix-run/node', {
+ typeOnly: true,
+ });
+
+ if (project.root === '.') {
+ insertStatementAfterImports(
+ tree,
+ routeFilePath,
+ `
+ import stylesUrl from '~/styles/${normalizedRoutePath}'
+
+ export const links: LinksFunction = () => {
+ return [{ rel: 'stylesheet', href: stylesUrl }];
+ };
+ `
+ );
+ } else {
+ insertStatementAfterImports(
+ tree,
+ routeFilePath,
+ `
+ import stylesUrl from '${relative(dirname(routeFilePath), stylesheetPath)}';
+
+ export const links: LinksFunction = () => {
+ return [{ rel: 'stylesheet', href: stylesUrl }];
+ };
+ `
+ );
+ }
+
+ await formatFiles(tree);
+}
diff --git a/packages/remix/src/utils/create-watch-paths.spec.ts b/packages/remix/src/utils/create-watch-paths.spec.ts
new file mode 100644
index 0000000000..fe657316ce
--- /dev/null
+++ b/packages/remix/src/utils/create-watch-paths.spec.ts
@@ -0,0 +1,160 @@
+import { joinPathFragments, workspaceRoot } from '@nx/devkit';
+import {
+ createWatchPaths,
+ getRelativeDependencyPaths,
+} from './create-watch-paths';
+
+describe('createWatchPaths', () => {
+ it('should list root paths of dependencies relative to project root', async () => {
+ const testDir = joinPathFragments(workspaceRoot, 'e2e/remix');
+
+ const paths = await createWatchPaths(testDir);
+ expect(paths).toEqual(['../../packages', '../../graph', '../../e2e/utils']);
+ });
+});
+
+describe('getRelativeDependencyPaths', () => {
+ it('should work for standalone projects', () => {
+ const project = {
+ type: 'app' as const,
+ name: 'test',
+ data: { root: '.', files: [] },
+ };
+ const result = getRelativeDependencyPaths(
+ project,
+ ['lib-1', 'lib-2', 'lib-3'],
+ {
+ nodes: {
+ test: project,
+ 'lib-1': {
+ type: 'lib',
+ name: 'lib-1',
+ data: { root: 'lib-1' },
+ },
+ 'lib-2': {
+ type: 'lib',
+ name: 'lib-2',
+ data: { root: 'lib-2' },
+ },
+ 'lib-3': {
+ type: 'lib',
+ name: 'lib-3',
+ data: { root: 'lib-3' },
+ },
+ },
+ dependencies: {},
+ }
+ );
+
+ expect(result).toEqual(['lib-1', 'lib-2', 'lib-3']);
+ });
+
+ it('should watch the entire libs folder for integrated monorepos', () => {
+ const project = {
+ type: 'app' as const,
+ name: 'test',
+ data: { root: 'apps/test', files: [] },
+ };
+ const result = getRelativeDependencyPaths(
+ project,
+ ['lib-1', 'lib-2', 'lib-3'],
+ {
+ nodes: {
+ test: project,
+ 'lib-1': {
+ type: 'lib',
+ name: 'lib-1',
+ data: { root: 'libs/lib-1' },
+ },
+ 'lib-2': {
+ type: 'lib',
+ name: 'lib-2',
+ data: { root: 'libs/lib-2' },
+ },
+ 'lib-3': {
+ type: 'lib',
+ name: 'lib-3',
+ data: { root: 'libs/lib-3' },
+ },
+ },
+ dependencies: {},
+ }
+ );
+
+ expect(result).toEqual(['../../libs']);
+ });
+
+ it('should watch the entire packages folder for monorepos if apps is not contained in it', () => {
+ const project = {
+ type: 'app' as const,
+ name: 'test',
+ data: { root: 'apps/test', files: [] },
+ };
+ const result = getRelativeDependencyPaths(
+ project,
+ ['lib-1', 'lib-2', 'lib-3'],
+ {
+ nodes: {
+ test: project,
+ 'lib-1': {
+ type: 'lib',
+ name: 'lib-1',
+ data: { root: 'packages/lib-1' },
+ },
+ 'lib-2': {
+ type: 'lib',
+ name: 'lib-2',
+ data: { root: 'packages/lib-2' },
+ },
+ 'lib-3': {
+ type: 'lib',
+ name: 'lib-3',
+ data: { root: 'packages/lib-3' },
+ },
+ },
+ dependencies: {},
+ }
+ );
+
+ expect(result).toEqual(['../../packages']);
+ });
+
+ it('should watch individual dependency folder if app is contained in the same base path', () => {
+ const project = {
+ type: 'app' as const,
+ name: 'test',
+ data: { root: 'packages/test', files: [] },
+ };
+ const result = getRelativeDependencyPaths(
+ project,
+ ['lib-1', 'lib-2', 'lib-3'],
+ {
+ nodes: {
+ test: project,
+ 'lib-1': {
+ type: 'lib',
+ name: 'lib-1',
+ data: { root: 'packages/lib-1' },
+ },
+ 'lib-2': {
+ type: 'lib',
+ name: 'lib-2',
+ data: { root: 'packages/lib-2' },
+ },
+ 'lib-3': {
+ type: 'lib',
+ name: 'lib-3',
+ data: { root: 'packages/lib-3' },
+ },
+ },
+ dependencies: {},
+ }
+ );
+
+ expect(result).toEqual([
+ '../../packages/lib-1',
+ '../../packages/lib-2',
+ '../../packages/lib-3',
+ ]);
+ });
+});
diff --git a/packages/remix/src/utils/create-watch-paths.ts b/packages/remix/src/utils/create-watch-paths.ts
new file mode 100644
index 0000000000..d262d326cb
--- /dev/null
+++ b/packages/remix/src/utils/create-watch-paths.ts
@@ -0,0 +1,59 @@
+import {
+ createProjectGraphAsync,
+ joinPathFragments,
+ offsetFromRoot,
+ workspaceRoot,
+ type ProjectGraph,
+ type ProjectGraphProjectNode,
+} from '@nx/devkit';
+import {
+ createProjectRootMappings,
+ findProjectForPath,
+} from 'nx/src/project-graph/utils/find-project-for-path';
+import { findAllProjectNodeDependencies } from 'nx/src/utils/project-graph-utils';
+import { normalize, relative, sep } from 'path';
+
+/**
+ * Generates an array of paths to watch based on the project dependencies.
+ *
+ * @param {string} dirname The absolute path to the Remix project, typically `__dirname`.
+ */
+export async function createWatchPaths(dirname: string): Promise {
+ const graph = await createProjectGraphAsync();
+ const projectRootMappings = createProjectRootMappings(graph.nodes);
+ const projectName = findProjectForPath(
+ relative(workspaceRoot, dirname),
+ projectRootMappings
+ );
+ const deps = findAllProjectNodeDependencies(projectName, graph);
+
+ return getRelativeDependencyPaths(graph.nodes[projectName], deps, graph);
+}
+
+// Exported for testing
+export function getRelativeDependencyPaths(
+ project: ProjectGraphProjectNode,
+ deps: string[],
+ graph: ProjectGraph
+): string[] {
+ if (!project.data?.root) {
+ throw new Error(
+ `Project ${project.name} has no root set. Check the project configuration.`
+ );
+ }
+
+ const paths = new Set();
+ const offset = offsetFromRoot(project.data.root);
+ const [baseProjectPath] = project.data.root.split('/');
+
+ for (const dep of deps) {
+ const node = graph.nodes[dep];
+ if (!node?.data?.root) continue;
+ const [basePath] = normalize(node.data.root).split(sep);
+ const watchPath = baseProjectPath !== basePath ? basePath : node.data.root;
+ const relativeWatchPath = joinPathFragments(offset, watchPath);
+ paths.add(relativeWatchPath);
+ }
+
+ return Array.from(paths);
+}
diff --git a/packages/remix/src/utils/get-default-export-name.spec.ts b/packages/remix/src/utils/get-default-export-name.spec.ts
new file mode 100644
index 0000000000..c7736e6311
--- /dev/null
+++ b/packages/remix/src/utils/get-default-export-name.spec.ts
@@ -0,0 +1,31 @@
+import { Tree } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import { getDefaultExportName } from './get-default-export-name';
+
+describe('getDefaultExportName', () => {
+ let tree: Tree;
+
+ beforeEach(() => {
+ tree = createTreeWithEmptyWorkspace();
+ tree.write('.gitignore', `/node_modules/dist`);
+ });
+
+ it("should get the default export's name", () => {
+ tree.write(
+ 'component.tsx',
+ `export default function Component() { return (
Hello world!
); };`
+ );
+
+ const defaultExportName = getDefaultExportName(tree, 'component.tsx');
+
+ expect(defaultExportName).toEqual('Component');
+ });
+
+ it("should return 'Unknown' if there is no default export", () => {
+ tree.write('util.ts', `export function util() { return 'hello world'; };`);
+
+ const defaultExportName = getDefaultExportName(tree, 'util.ts');
+
+ expect(defaultExportName).toEqual('Unknown');
+ });
+});
diff --git a/packages/remix/src/utils/get-default-export-name.ts b/packages/remix/src/utils/get-default-export-name.ts
new file mode 100644
index 0000000000..bda9cab522
--- /dev/null
+++ b/packages/remix/src/utils/get-default-export-name.ts
@@ -0,0 +1,6 @@
+import { Tree } from '@nx/devkit';
+import { getDefaultExport } from './get-default-export';
+
+export function getDefaultExportName(tree: Tree, path: string) {
+ return getDefaultExport(tree, path)?.name.text ?? 'Unknown';
+}
diff --git a/packages/remix/src/utils/get-default-export.ts b/packages/remix/src/utils/get-default-export.ts
new file mode 100644
index 0000000000..3adbd7c281
--- /dev/null
+++ b/packages/remix/src/utils/get-default-export.ts
@@ -0,0 +1,29 @@
+import { Tree } from '@nx/devkit';
+import {
+ createSourceFile,
+ isFunctionDeclaration,
+ ScriptTarget,
+ SyntaxKind,
+} from 'typescript';
+
+export function getDefaultExport(tree: Tree, path: string) {
+ const contents = tree.read(path, 'utf-8');
+
+ const sourceFile = createSourceFile(path, contents, ScriptTarget.ESNext);
+
+ const functionDeclarations = sourceFile.statements.filter(
+ isFunctionDeclaration
+ );
+
+ return functionDeclarations.find((functionDeclaration) => {
+ const isDefault = functionDeclaration.modifiers.find(
+ (mod) => mod.kind === SyntaxKind.DefaultKeyword
+ );
+
+ const isExport = functionDeclaration.modifiers.find(
+ (mod) => mod.kind === SyntaxKind.ExportKeyword
+ );
+
+ return isDefault && isExport;
+ });
+}
diff --git a/packages/remix/src/utils/insert-import.spec.ts b/packages/remix/src/utils/insert-import.spec.ts
new file mode 100644
index 0000000000..a0b55ddf40
--- /dev/null
+++ b/packages/remix/src/utils/insert-import.spec.ts
@@ -0,0 +1,93 @@
+import { Tree } from '@nx/devkit';
+import { createTree } from '@nx/devkit/testing';
+import { insertImport } from './insert-import';
+
+describe('insertImport', () => {
+ let tree: Tree;
+
+ beforeEach(() => {
+ tree = createTree();
+ });
+
+ it('should insert a statement after the last import', () => {
+ tree.write('index.ts', `import { a } from 'a-path';`);
+
+ insertImport(tree, 'index.ts', 'b', 'a-path');
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(
+ `"import { a ,b} from 'a-path';"`
+ );
+ });
+
+ it('should insert a statement after the last import with a trailing comma', () => {
+ tree.write('index.ts', `import { a, } from 'a-path';`);
+
+ insertImport(tree, 'index.ts', 'b', 'a-path');
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(
+ `"import { a, b,} from 'a-path';"`
+ );
+ });
+
+ it('should insert a statement at the beginning if there are no imports', () => {
+ tree.write('index.ts', `import { a } from 'a-path';`);
+
+ insertImport(tree, 'index.ts', 'b', 'b-path');
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(`
+ "import { a } from 'a-path';
+ import { b } from 'b-path';"
+ `);
+ });
+
+ it('should insert a type-only statement after the last import', () => {
+ tree.write('index.ts', `import type { a } from 'a-path';`);
+
+ insertImport(tree, 'index.ts', 'b', 'a-path', { typeOnly: true });
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(
+ `"import type { a ,b} from 'a-path';"`
+ );
+ });
+
+ it('should insert a type-only statement after the last import with a trailing comma', () => {
+ tree.write('index.ts', `import type { a, } from 'a-path';`);
+
+ insertImport(tree, 'index.ts', 'b', 'a-path', { typeOnly: true });
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(
+ `"import type { a, b,} from 'a-path';"`
+ );
+ });
+
+ it('should insert a type-only statement at the beginning if there are no imports', () => {
+ tree.write('index.ts', `import { a } from 'a-path';`);
+
+ insertImport(tree, 'index.ts', 'b', 'b-path', { typeOnly: true });
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(`
+ "import { a } from 'a-path';
+ import type { b } from 'b-path';"
+ `);
+ });
+
+ it('should not insert a type-only statement into an existing import', () => {
+ tree.write('index.ts', `import { a } from 'a-path';`);
+
+ insertImport(tree, 'index.ts', 'b', 'a-path', { typeOnly: true });
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(`
+ "import { a } from 'a-path';
+ import type { b } from 'a-path';"
+ `);
+ });
+
+ it('should not add the same import twice', () => {
+ tree.write('index.ts', `import { a } from 'a-path';`);
+ insertImport(tree, 'index.ts', 'a', 'a-path');
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(
+ `"import { a } from 'a-path';"`
+ );
+ });
+});
diff --git a/packages/remix/src/utils/insert-import.ts b/packages/remix/src/utils/insert-import.ts
new file mode 100644
index 0000000000..823ecee3f9
--- /dev/null
+++ b/packages/remix/src/utils/insert-import.ts
@@ -0,0 +1,90 @@
+import { applyChangesToString, ChangeType, Tree } from '@nx/devkit';
+import {
+ createSourceFile,
+ isImportDeclaration,
+ isNamedImports,
+ isStringLiteral,
+ NamedImports,
+ ScriptTarget,
+} from 'typescript';
+import { insertStatementAfterImports } from './insert-statement-after-imports';
+
+export function insertImport(
+ tree: Tree,
+ path: string,
+ name: string,
+ modulePath: string,
+ options: { typeOnly: boolean } = { typeOnly: false }
+) {
+ if (!tree.exists(path))
+ throw Error(
+ `Could not insert import ${name} from ${modulePath} in ${path}: path not found`
+ );
+
+ const contents = tree.read(path, 'utf-8');
+
+ const sourceFile = createSourceFile(path, contents, ScriptTarget.ESNext);
+
+ let importStatements = sourceFile.statements.filter(isImportDeclaration);
+
+ if (options.typeOnly) {
+ importStatements = importStatements.filter(
+ (node) => node.importClause.isTypeOnly
+ );
+ } else {
+ importStatements = importStatements.filter(
+ (node) => !node.importClause.isTypeOnly
+ );
+ }
+
+ const existingImport = importStatements.find(
+ (statement) =>
+ isStringLiteral(statement.moduleSpecifier) &&
+ statement.moduleSpecifier
+ .getText(sourceFile)
+ .replace(/['"`]/g, '')
+ .trim() === modulePath &&
+ statement.importClause.namedBindings &&
+ isNamedImports(statement.importClause.namedBindings)
+ );
+
+ if (!existingImport) {
+ insertStatementAfterImports(
+ tree,
+ path,
+ options.typeOnly
+ ? `import type { ${name} } from '${modulePath}';`
+ : `import { ${name} } from '${modulePath}';`
+ );
+ return;
+ }
+
+ const namedImports = existingImport.importClause
+ .namedBindings as NamedImports;
+
+ const alreadyImported =
+ namedImports.elements.find(
+ (element) => element.name.escapedText === name
+ ) !== undefined;
+
+ if (!alreadyImported) {
+ const index = namedImports.getEnd() - 1;
+
+ let text: string;
+ if (namedImports.elements.hasTrailingComma) {
+ text = `${name},`;
+ } else {
+ text = `,${name}`;
+ }
+
+ const newContents = applyChangesToString(contents, [
+ {
+ type: ChangeType.Insert,
+ index,
+ text,
+ },
+ ]);
+
+ tree.write(path, newContents);
+ }
+}
diff --git a/packages/remix/src/utils/insert-statement-after-imports.spec.ts b/packages/remix/src/utils/insert-statement-after-imports.spec.ts
new file mode 100644
index 0000000000..b515e3fc9b
--- /dev/null
+++ b/packages/remix/src/utils/insert-statement-after-imports.spec.ts
@@ -0,0 +1,33 @@
+import { Tree } from '@nx/devkit';
+import { createTree } from '@nx/devkit/testing';
+import { insertStatementAfterImports } from './insert-statement-after-imports';
+
+describe('insertStatement', () => {
+ let tree: Tree;
+
+ beforeEach(() => {
+ tree = createTree();
+ });
+
+ it('should insert a statement after the last import', () => {
+ tree.write('index.ts', `import { a } from 'a';`);
+
+ insertStatementAfterImports(tree, 'index.ts', 'const b = 0;');
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(`
+ "import { a } from 'a';
+ const b = 0;"
+ `);
+ });
+
+ it('should insert a statement at the beginning if there are no imports', () => {
+ tree.write('index.ts', `const a = 0;`);
+
+ insertStatementAfterImports(tree, 'index.ts', 'const b = 0;\n');
+
+ expect(tree.read('index.ts', 'utf-8')).toMatchInlineSnapshot(`
+ "const b = 0;
+ const a = 0;"
+ `);
+ });
+});
diff --git a/packages/remix/src/utils/insert-statement-after-imports.ts b/packages/remix/src/utils/insert-statement-after-imports.ts
new file mode 100644
index 0000000000..9c6e90d05c
--- /dev/null
+++ b/packages/remix/src/utils/insert-statement-after-imports.ts
@@ -0,0 +1,39 @@
+import { applyChangesToString, ChangeType, Tree } from '@nx/devkit';
+import {
+ createSourceFile,
+ isImportDeclaration,
+ ScriptTarget,
+} from 'typescript';
+
+/**
+ * Insert a statement after the last import statement in a file
+ */
+export function insertStatementAfterImports(
+ tree: Tree,
+ path: string,
+ statement: string
+) {
+ const contents = tree.read(path, 'utf-8');
+
+ const sourceFile = createSourceFile(path, contents, ScriptTarget.ESNext);
+
+ const importStatements = sourceFile.statements.filter(isImportDeclaration);
+ const index =
+ importStatements.length > 0
+ ? importStatements[importStatements.length - 1].getEnd()
+ : 0;
+
+ if (importStatements.length > 0) {
+ statement = `\n${statement}`;
+ }
+
+ const newContents = applyChangesToString(contents, [
+ {
+ type: ChangeType.Insert,
+ index,
+ text: statement,
+ },
+ ]);
+
+ tree.write(path, newContents);
+}
diff --git a/packages/remix/src/utils/insert-statement-in-default-function.spec.ts b/packages/remix/src/utils/insert-statement-in-default-function.spec.ts
new file mode 100644
index 0000000000..16ed6c24d9
--- /dev/null
+++ b/packages/remix/src/utils/insert-statement-in-default-function.spec.ts
@@ -0,0 +1,40 @@
+import { Tree } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
+import { insertStatementInDefaultFunction } from './insert-statement-in-default-function';
+
+describe('insertStatementInDefaultFunction', () => {
+ let tree: Tree;
+
+ beforeEach(() => {
+ tree = createTreeWithEmptyWorkspace();
+ tree.write('.gitignore', `/node_modules/dist`);
+ });
+
+ it('should insert statement in default function', () => {
+ tree.write(
+ 'component.tsx',
+ `export default function Component() { return (