diff --git a/packages/build-info/src/frameworks/framework.ts b/packages/build-info/src/frameworks/framework.ts index c31311b900..82567b31ed 100644 --- a/packages/build-info/src/frameworks/framework.ts +++ b/packages/build-info/src/frameworks/framework.ts @@ -45,8 +45,17 @@ export interface Framework { id: string name: string category: Category + /** + * If this is set, at least ONE of these must exist, anywhere in the project + */ configFiles: string[] + /** + * If this is set, at least ONE of these must be present in the `package.json` `dependencies|devDependencies` + */ npmDependencies: string[] + /** + * if this is set, NONE of these must be present in the `package.json` `dependencies|devDependencies` + */ excludedNpmDependencies?: string[] version?: SemVer /** Information on how it was detected and how accurate the detection is */ @@ -255,9 +264,9 @@ export abstract class BaseFramework implements Framework { } /** detect if the framework config file is located somewhere up the tree */ - private async detectConfigFile(): Promise { - if (this.configFiles?.length) { - const config = await this.project.fs.findUp(this.configFiles, { + protected async detectConfigFile(configFiles: string[]): Promise { + if (configFiles.length) { + const config = await this.project.fs.findUp(configFiles, { cwd: this.path || this.project.baseDirectory, stopAt: this.project.root, }) @@ -275,7 +284,7 @@ export abstract class BaseFramework implements Framework { /** * Checks if the project is using a specific framework: - * - if `npmDependencies` is set, one of them must be present in then `package.json` `dependencies|devDependencies` + * - if `npmDependencies` is set, one of them must be present in the `package.json` `dependencies|devDependencies` * - if `excludedNpmDependencies` is set, none of them must be present in the `package.json` `dependencies|devDependencies` * - if `configFiles` is set, one of the files must exist */ @@ -286,7 +295,7 @@ export abstract class BaseFramework implements Framework { } const npm = await this.detectNpmDependency() - const config = await this.detectConfigFile() + const config = await this.detectConfigFile(this.configFiles ?? []) this.detected = mergeDetections([npm, config]) if (this.detected) { diff --git a/packages/build-info/src/frameworks/hydrogen.test.ts b/packages/build-info/src/frameworks/hydrogen.test.ts new file mode 100644 index 0000000000..fdf848f010 --- /dev/null +++ b/packages/build-info/src/frameworks/hydrogen.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, expect, test } from 'vitest' + +import { mockFileSystem } from '../../tests/mock-file-system.js' +import { NodeFS } from '../node/file-system.js' +import { Project } from '../project.js' + +beforeEach((ctx) => { + ctx.fs = new NodeFS() +}) + +test('detects a Hydrogen v2 site using the Remix Classic Compiler', async ({ fs }) => { + const cwd = mockFileSystem({ + 'remix.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix build', + dev: 'remix dev --manual -c "netlify dev"', + preview: 'netlify serve', + }, + dependencies: { + '@netlify/edge-functions': '^2.2.0', + '@netlify/remix-edge-adapter': '^3.1.0', + '@netlify/remix-runtime': '^2.1.0', + '@remix-run/react': '^2.2.0', + '@shopify/cli': '3.50.0', + '@shopify/cli-hydrogen': '^6.0.0', + '@shopify/hydrogen': '^2023.10.0', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + devDependencies: { + '@remix-run/dev': '^2.2.0', + }, + }), + }) + const detected = await new Project(fs, cwd).detectFrameworks() + + const detectedFrameworks = (detected ?? []).map((framework) => framework.id) + expect(detectedFrameworks).not.toContain('remix') + expect(detectedFrameworks).not.toContain('vite') + + expect(detected?.[0]?.id).toBe('hydrogen') + expect(detected?.[0]?.build?.command).toBe('remix build') + expect(detected?.[0]?.build?.directory).toBe('public') + expect(detected?.[0]?.dev?.command).toBe('remix dev --manual -c "netlify dev"') + expect(detected?.[0]?.dev?.port).toBeUndefined() +}) + +test('detects a Hydrogen v2 site using Remix Vite', async ({ fs }) => { + const cwd = mockFileSystem({ + 'vite.config.ts': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix vite:build', + dev: 'shopify hydrogen dev --codegen', + preview: 'netlify serve', + }, + dependencies: { + '@netlify/edge-functions': '^2.10.0', + '@netlify/remix-edge-adapter': '^3.4.0', + '@netlify/remix-runtime': '^2.3.0', + '@remix-run/react': '^2.11.2', + '@shopify/hydrogen': '^2024.7.4', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + devDependencies: { + '@remix-run/dev': '^2.11.2', + '@shopify/cli': '^3.66.1', + '@shopify/hydrogen-codegen': '^0.3.1', + vite: '^5.4.3', + }, + }), + }) + const detected = await new Project(fs, cwd).detectFrameworks() + + const detectedFrameworks = (detected ?? []).map((framework) => framework.id) + expect(detectedFrameworks).not.toContain('remix') + expect(detectedFrameworks).not.toContain('vite') + + expect(detected?.[0]?.id).toBe('hydrogen') + expect(detected?.[0]?.build?.command).toBe('remix vite:build') + expect(detected?.[0]?.build?.directory).toBe('dist/client') + expect(detected?.[0]?.dev?.command).toBe('shopify hydrogen dev') + expect(detected?.[0]?.dev?.port).toBe(5173) +}) diff --git a/packages/build-info/src/frameworks/hydrogen.ts b/packages/build-info/src/frameworks/hydrogen.ts index d4135a53d0..24e31a550b 100644 --- a/packages/build-info/src/frameworks/hydrogen.ts +++ b/packages/build-info/src/frameworks/hydrogen.ts @@ -1,4 +1,32 @@ -import { BaseFramework, Category, Framework } from './framework.js' +import { BaseFramework, Category, DetectedFramework, Framework } from './framework.js' + +const CLASSIC_COMPILER_CONFIG_FILES = ['remix.config.js'] +const CLASSIC_COMPILER_DEV = { + command: 'remix dev --manual -c "netlify dev"', +} +const CLASSIC_COMPILER_BUILD = { + command: 'remix build', + directory: 'public', +} + +const VITE_CONFIG_FILES = [ + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.cjs', + 'vite.config.ts', + 'vite.config.mts', + 'vite.config.cts', +] +const VITE_DEV = { + command: 'shopify hydrogen dev', + port: 5173, +} +const VITE_BUILD = { + // This should be `shopify hydrogen build` but we use this as a workaround for + // https://github.com/Shopify/hydrogen/issues/2496 and https://github.com/Shopify/hydrogen/issues/2497. + command: 'remix vite:build', + directory: 'dist/client', +} export class Hydrogen extends BaseFramework implements Framework { readonly id = 'hydrogen' @@ -6,20 +34,32 @@ export class Hydrogen extends BaseFramework implements Framework { npmDependencies = ['@shopify/hydrogen'] category = Category.SSG - dev = { - command: 'vite', - port: 3000, - pollingStrategies: [{ name: 'TCP' }], - } - - build = { - command: 'npm run build', - directory: 'dist/client', - } - logo = { default: '/logos/hydrogen/default.svg', light: '/logos/hydrogen/default.svg', dark: '/logos/hydrogen/default.svg', } + + async detect(): Promise { + await super.detect() + + if (this.detected) { + const viteDetection = await this.detectConfigFile(VITE_CONFIG_FILES) + if (viteDetection) { + this.detected = viteDetection + this.dev = VITE_DEV + this.build = VITE_BUILD + return this as DetectedFramework + } + const classicCompilerDetection = await this.detectConfigFile(CLASSIC_COMPILER_CONFIG_FILES) + if (classicCompilerDetection) { + this.detected = classicCompilerDetection + this.dev = CLASSIC_COMPILER_DEV + this.build = CLASSIC_COMPILER_BUILD + return this as DetectedFramework + } + // If neither config file exists, it can't be a valid Hydrogen site for Netlify anyway. + return + } + } } diff --git a/packages/build-info/src/frameworks/remix.test.ts b/packages/build-info/src/frameworks/remix.test.ts new file mode 100644 index 0000000000..4ee96c15bf --- /dev/null +++ b/packages/build-info/src/frameworks/remix.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, expect, test } from 'vitest' + +import { mockFileSystem } from '../../tests/mock-file-system.js' +import { NodeFS } from '../node/file-system.js' +import { Project } from '../project.js' + +beforeEach((ctx) => { + ctx.fs = new NodeFS() +}) +;[ + [ + 'with origin SSR', + { + 'vite.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix vite:build', + dev: 'remix vite:dev', + start: 'remix-serve ./build/server/index.js', + }, + dependencies: { + '@remix-run/node': '^2.9.2', + '@remix-run/react': '^2.9.2', + '@remix-run/serve': '^2.9.2', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + devDependencies: { + '@netlify/remix-adapter': '^2.4.0', + '@remix-run/dev': '^2.9.2', + '@types/react': '^18.2.20', + '@types/react-dom': '^18.2.7', + vite: '^5.0.0', + 'vite-tsconfig-paths': '^4.2.1', + }, + }), + }, + ] as const, + [ + 'with edge SSR', + { + 'vite.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix vite:build', + dev: 'remix vite:dev', + start: 'remix-serve ./build/server/index.js', + }, + dependencies: { + '@netlify/remix-runtime': '^2.3.0', + '@remix-run/node': '^2.9.2', + '@remix-run/react': '^2.9.2', + '@remix-run/serve': '^2.9.2', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + devDependencies: { + '@netlify/remix-edge-adapter': '^3.3.0', + '@remix-run/dev': '^2.9.2', + '@types/react': '^18.2.20', + '@types/react-dom': '^18.2.7', + vite: '^5.0.0', + 'vite-tsconfig-paths': '^4.2.1', + }, + }), + }, + ] as const, +].forEach(([description, files]) => + test(`detects a Remix Vite site ${description}`, async ({ fs }) => { + const cwd = mockFileSystem(files) + const detected = await new Project(fs, cwd).detectFrameworks() + + const detectedFrameworks = (detected ?? []).map((framework) => framework.id) + expect(detectedFrameworks).not.toContain('vite') + + expect(detected?.[0]?.id).toBe('remix') + expect(detected?.[0]?.build?.command).toBe('remix vite:build') + expect(detected?.[0]?.build?.directory).toBe('build/client') + expect(detected?.[0]?.dev?.command).toBe('remix vite:dev') + expect(detected?.[0]?.dev?.port).toBe(5173) + }), +) +;[ + [ + 'with origin SSR', + { + 'remix.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix build', + dev: 'remix dev', + start: 'netlify serve', + typecheck: 'tsc', + }, + dependencies: { + '@netlify/functions': '^2.8.1', + '@remix-run/css-bundle': '^2.9.2', + '@remix-run/node': '^2.9.2', + '@remix-run/react': '^2.9.2', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + devDependencies: { + '@netlify/remix-adapter': '^2.4.0', + '@remix-run/dev': '^2.9.2', + '@remix-run/serve': '^2.9.2', + }, + }), + }, + ] as const, + [ + 'with edge SSR', + { + 'remix.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix build', + dev: 'remix dev', + start: 'netlify serve', + typecheck: 'tsc', + }, + dependencies: { + '@netlify/functions': '^2.8.1', + '@netlify/remix-runtime': '^2.3.0', + '@remix-run/css-bundle': '^2.9.2', + '@remix-run/node': '^2.9.2', + '@remix-run/react': '^2.9.2', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + devDependencies: { + '@netlify/remix-edge-adapter': '^3.3.0', + '@remix-run/dev': '^2.9.2', + '@remix-run/serve': '^2.9.2', + }, + }), + }, + ] as const, + [ + 'with origin SSR via our legacy packages', + { + 'remix.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix build', + dev: 'remix dev', + start: 'netlify serve', + typecheck: 'tsc', + }, + dependencies: { + '@remix-run/netlify': '1.7.4', + '@remix-run/node': '1.7.4', + '@remix-run/react': '1.7.4', + react: '18.2.0', + 'react-dom': '18.2.0', + }, + devDependencies: { + '@netlify/functions': '^1.0.0', + '@remix-run/dev': '1.7.4', + '@remix-run/serve': '1.7.4', + }, + }), + }, + ] as const, + [ + 'with edge SSR via our legacy packages', + { + 'remix.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix build', + dev: 'remix dev', + start: 'netlify serve', + typecheck: 'tsc', + }, + dependencies: { + '@remix-run/netlify-edge': '1.7.4', + '@remix-run/node': '1.7.4', + '@remix-run/react': '1.7.4', + react: '18.2.0', + 'react-dom': '18.2.0', + }, + devDependencies: { + '@netlify/functions': '^1.0.0', + '@remix-run/dev': '1.7.4', + '@remix-run/serve': '1.7.4', + }, + }), + }, + ] as const, + [ + 'with origin SSR and the legacy remix package', + { + 'remix.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix build', + dev: 'remix dev', + start: 'netlify serve', + typecheck: 'tsc', + }, + dependencies: { + '@remix-run/netlify': '1.7.4', + remix: '1.7.4', + react: '18.2.0', + 'react-dom': '18.2.0', + }, + devDependencies: { + '@netlify/functions': '^1.0.0', + }, + }), + }, + ] as const, + [ + 'with edge SSR and the legacy remix package', + { + 'remix.config.js': '', + 'package.json': JSON.stringify({ + scripts: { + build: 'remix build', + dev: 'remix dev', + start: 'netlify serve', + typecheck: 'tsc', + }, + dependencies: { + '@remix-run/netlify-edge': '1.7.4', + remix: '1.7.4', + react: '18.2.0', + 'react-dom': '18.2.0', + }, + devDependencies: { + '@netlify/functions': '^1.0.0', + }, + }), + }, + ] as const, +].forEach(([description, files]) => + test(`detects a Remix Classic Compiler site ${description}`, async ({ fs }) => { + const cwd = mockFileSystem(files) + const detected = await new Project(fs, cwd).detectFrameworks() + + const detectedFrameworks = (detected ?? []).map((framework) => framework.id) + expect(detectedFrameworks).not.toContain('vite') + + expect(detected?.[0]?.id).toBe('remix') + expect(detected?.[0]?.build?.command).toBe('remix build') + expect(detected?.[0]?.build?.directory).toBe('public') + expect(detected?.[0]?.dev?.command).toBe('remix watch') + expect(detected?.[0]?.dev?.port).toBeUndefined() + }), +) + +test('does not detect an invalid Remix site with no config file', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ + scripts: { + build: 'remix vite:build', + dev: 'remix vite:dev', + start: 'remix-serve ./build/server/index.js', + }, + dependencies: { + '@netlify/remix-runtime': '^2.3.0', + '@remix-run/node': '^2.9.2', + '@remix-run/react': '^2.9.2', + '@remix-run/serve': '^2.9.2', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + devDependencies: { + '@netlify/remix-edge-adapter': '^3.3.0', + '@remix-run/dev': '^2.9.2', + '@types/react': '^18.2.20', + '@types/react-dom': '^18.2.7', + vite: '^5.0.0', + 'vite-tsconfig-paths': '^4.2.1', + }, + }), + }) + const detected = await new Project(fs, cwd).detectFrameworks() + + const detectedFrameworks = (detected ?? []).map((framework) => framework.id) + expect(detectedFrameworks).not.toContain('remix') +}) diff --git a/packages/build-info/src/frameworks/remix.ts b/packages/build-info/src/frameworks/remix.ts index 9ad2ceeb6a..0cdcea9157 100644 --- a/packages/build-info/src/frameworks/remix.ts +++ b/packages/build-info/src/frameworks/remix.ts @@ -1,24 +1,76 @@ -import { BaseFramework, Category, Framework } from './framework.js' +import { BaseFramework, Category, DetectedFramework, Framework } from './framework.js' + +const CLASSIC_COMPILER_CONFIG_FILES = ['remix.config.js'] +const CLASSIC_COMPILER_DEV = { + command: 'remix watch', +} +const CLASSIC_COMPILER_BUILD = { + command: 'remix build', + directory: 'public', +} + +const VITE_CONFIG_FILES = [ + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.cjs', + 'vite.config.ts', + 'vite.config.mts', + 'vite.config.cts', +] +const VITE_DEV = { + command: 'remix vite:dev', + port: 5173, +} +const VITE_BUILD = { + command: 'remix vite:build', + directory: 'build/client', +} export class Remix extends BaseFramework implements Framework { readonly id = 'remix' name = 'Remix' - npmDependencies = ['remix', '@remix-run/netlify', '@remix-run/netlify-edge'] - configFiles = ['remix.config.js'] + npmDependencies = [ + '@remix-run/react', + '@remix-run/dev', + '@remix-run/server-runtime', + '@netlify/remix-adapter', + '@netlify/remix-edge-adapter', + '@netlify/remix-runtime', + // Deprecated package name (deprecated in 1.6, removed in 2.0) + 'remix', + // Deprecated Netlify packages + '@remix-run/netlify', + '@remix-run/netlify-edge', + ] + excludedNpmDependencies = ['@shopify/hydrogen'] category = Category.SSG - dev = { - command: 'remix watch', - } - - build = { - command: 'remix build', - directory: 'public', - } - logo = { default: '/logos/remix/default.svg', light: '/logos/remix/light.svg', dark: '/logos/remix/dark.svg', } + + async detect(): Promise { + await super.detect() + + if (this.detected) { + const viteDetection = await this.detectConfigFile(VITE_CONFIG_FILES) + if (viteDetection) { + this.detected = viteDetection + this.dev = VITE_DEV + this.build = VITE_BUILD + return this as DetectedFramework + } + const classicCompilerDetection = await this.detectConfigFile(CLASSIC_COMPILER_CONFIG_FILES) + if (classicCompilerDetection) { + this.detected = classicCompilerDetection + this.dev = CLASSIC_COMPILER_DEV + this.build = CLASSIC_COMPILER_BUILD + return this as DetectedFramework + } + // If neither config file exists, it can't be a valid Remix site for Netlify anyway. + return + } + } } diff --git a/packages/build-info/src/frameworks/vite.ts b/packages/build-info/src/frameworks/vite.ts index 2ee26ef80f..52f2a258c1 100644 --- a/packages/build-info/src/frameworks/vite.ts +++ b/packages/build-info/src/frameworks/vite.ts @@ -5,6 +5,9 @@ export class Vite extends BaseFramework implements Framework { name = 'Vite' npmDependencies = ['vite'] excludedNpmDependencies = [ + '@remix-run/react', + '@remix-run/dev', + '@remix-run/server-runtime', '@shopify/hydrogen', '@builder.io/qwik', 'solid-start',