diff --git a/packages/knip/fixtures/plugins/expo/app.config.ts b/packages/knip/fixtures/plugins/expo/app.config.ts new file mode 100644 index 000000000..5af23da9e --- /dev/null +++ b/packages/knip/fixtures/plugins/expo/app.config.ts @@ -0,0 +1,20 @@ +const config = { + name: 'Knip', + updates: { + enabled: true, + }, + notification: { + color: '#ffffff', + }, + userInterfaceStyle: 'automatic', + ios: { + backgroundColor: '#ffffff', + }, + plugins: [ + ['@config-plugins/detox', { subdomains: '*' }], + '@sentry/react-native/expo', + ['expo-splash-screen', { backgroundColor: '#ffffff' }], + ], +}; + +export default config; diff --git a/packages/knip/fixtures/plugins/expo/node_modules/expo/package.json b/packages/knip/fixtures/plugins/expo/node_modules/expo/package.json new file mode 100644 index 000000000..e60cb292e --- /dev/null +++ b/packages/knip/fixtures/plugins/expo/node_modules/expo/package.json @@ -0,0 +1,8 @@ +{ + "name": "expo", + "bin": "bin/cli", + "peerDependencies": { + "react": "*", + "react-native": "*" + } +} \ No newline at end of file diff --git a/packages/knip/fixtures/plugins/expo/package.json b/packages/knip/fixtures/plugins/expo/package.json new file mode 100644 index 000000000..70074b77d --- /dev/null +++ b/packages/knip/fixtures/plugins/expo/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixtures/expo", + "version": "*", + "scripts": { + "start": "expo start" + }, + "dependencies": { + "@config-plugins/detox": "*", + "@sentry/react-native": "*", + "expo": "*", + "expo-atlas": "*", + "expo-dev-client": "*", + "expo-notifications": "*", + "expo-router": "*", + "expo-splash-screen": "*", + "expo-system-ui": "*", + "expo-updates": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/packages/knip/fixtures/plugins/expo/src/app/index.ts b/packages/knip/fixtures/plugins/expo/src/app/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/knip/fixtures/plugins/expo2/app.config.js b/packages/knip/fixtures/plugins/expo2/app.config.js new file mode 100644 index 000000000..0511de685 --- /dev/null +++ b/packages/knip/fixtures/plugins/expo2/app.config.js @@ -0,0 +1,21 @@ +const config = { + name: 'Knip', + platforms: ['android'], + androidNavigationBar: { + visible: true, + }, + android: { + userInterfaceStyle: 'dark', + }, + plugins: [ + 'expo-camera', + [ + 'expo-router', + { + root: 'src/routes', + }, + ], + ], +}; + +export default config; diff --git a/packages/knip/fixtures/plugins/expo2/node_modules/expo/package.json b/packages/knip/fixtures/plugins/expo2/node_modules/expo/package.json new file mode 100644 index 000000000..e60cb292e --- /dev/null +++ b/packages/knip/fixtures/plugins/expo2/node_modules/expo/package.json @@ -0,0 +1,8 @@ +{ + "name": "expo", + "bin": "bin/cli", + "peerDependencies": { + "react": "*", + "react-native": "*" + } +} \ No newline at end of file diff --git a/packages/knip/fixtures/plugins/expo2/package.json b/packages/knip/fixtures/plugins/expo2/package.json new file mode 100644 index 000000000..219399ebe --- /dev/null +++ b/packages/knip/fixtures/plugins/expo2/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fixtures/expo2", + "version": "*", + "main": "expo-router/entry", + "scripts": { + "start": "expo start" + }, + "dependencies": { + "expo": "*", + "expo-dev-client": "*", + "expo-insights": "*", + "expo-navigation-bar": "*", + "expo-router": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/packages/knip/fixtures/plugins/expo2/src/routes/index.js b/packages/knip/fixtures/plugins/expo2/src/routes/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/packages/knip/fixtures/plugins/expo3/app.json b/packages/knip/fixtures/plugins/expo3/app.json new file mode 100644 index 000000000..12f54f6a9 --- /dev/null +++ b/packages/knip/fixtures/plugins/expo3/app.json @@ -0,0 +1,11 @@ +{ + "expo": { + "name": "Knip", + "slug": "knip", + "platforms": ["ios", "web"], + "updates": { + "enabled": false + }, + "plugins": ["react-native-ble-plx"] + } +} diff --git a/packages/knip/fixtures/plugins/expo3/app/_layout.ts b/packages/knip/fixtures/plugins/expo3/app/_layout.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/knip/fixtures/plugins/expo3/node_modules/expo/package.json b/packages/knip/fixtures/plugins/expo3/node_modules/expo/package.json new file mode 100644 index 000000000..e60cb292e --- /dev/null +++ b/packages/knip/fixtures/plugins/expo3/node_modules/expo/package.json @@ -0,0 +1,8 @@ +{ + "name": "expo", + "bin": "bin/cli", + "peerDependencies": { + "react": "*", + "react-native": "*" + } +} \ No newline at end of file diff --git a/packages/knip/fixtures/plugins/expo3/package.json b/packages/knip/fixtures/plugins/expo3/package.json new file mode 100644 index 000000000..92fa41196 --- /dev/null +++ b/packages/knip/fixtures/plugins/expo3/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fixtures/expo3", + "version": "*", + "main": "expo-router/entry", + "scripts": { + "start": "expo start" + }, + "dependencies": { + "@expo/metro-runtime": "*", + "expo": "*", + "expo-system-ui": "*", + "expo-updates": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-web": "*" + } +} diff --git a/packages/knip/schema.json b/packages/knip/schema.json index 572336274..763a62b43 100644 --- a/packages/knip/schema.json +++ b/packages/knip/schema.json @@ -344,6 +344,10 @@ "title": "ESLint plugin configuration (https://knip.dev/reference/plugins/eslint)", "$ref": "#/definitions/plugin" }, + "expo": { + "title": "Expo plugin configuration (https://knip.dev/reference/plugins/expo)", + "$ref": "#/definitions/plugin" + }, "gatsby": { "title": "Gatsby plugin configuration (https://knip.dev/reference/plugins/gatsby)", "$ref": "#/definitions/plugin" diff --git a/packages/knip/src/plugins/expo/helpers.ts b/packages/knip/src/plugins/expo/helpers.ts new file mode 100644 index 000000000..4a4a55584 --- /dev/null +++ b/packages/knip/src/plugins/expo/helpers.ts @@ -0,0 +1,78 @@ +import type { PluginOptions } from '../../types/config.js'; +import { type Input, toDependency, toProductionDependency } from '../../util/input.js'; +import { getPackageNameFromModuleSpecifier } from '../../util/modules.js'; +import type { ExpoConfig } from './types.js'; + +// https://docs.expo.dev/versions/latest/config/app + +export const getDependencies = async (expoConfig: ExpoConfig, { manifest }: PluginOptions) => { + const config = 'expo' in expoConfig ? expoConfig.expo : expoConfig; + + const platforms = config.platforms ?? ['ios', 'android']; + + const pluginPackages = + (config.plugins + ?.map(plugin => { + const pluginName = Array.isArray(plugin) ? plugin[0] : plugin; + return getPackageNameFromModuleSpecifier(pluginName); + }) + .filter(Boolean) as string[]) ?? []; + + const inputs = new Set(pluginPackages.map(toDependency)); + + const allowedPackages = ['expo-atlas', 'expo-dev-client']; + const allowedProductionPackages = ['expo-insights']; + + const manifestDependencies = Object.keys(manifest.dependencies ?? {}); + + for (const pkg of allowedPackages) { + if (manifestDependencies.includes(pkg)) { + inputs.add(toDependency(pkg)); + } + } + + for (const pkg of allowedProductionPackages) { + if (manifestDependencies.includes(pkg)) { + inputs.add(toProductionDependency(pkg)); + } + } + + if (config.updates?.enabled !== false) { + inputs.add(toProductionDependency('expo-updates')); + } + + if (config.notification) { + inputs.add(toProductionDependency('expo-notifications')); + } + + const isExpoRouter = manifest.main === 'expo-router/entry'; + + // https://docs.expo.dev/router/installation/#setup-entry-point + if (isExpoRouter) { + inputs.add(toProductionDependency('expo-router')); + } + + // https://docs.expo.dev/workflow/web/#install-web-dependencies + if (platforms.includes('web')) { + inputs.add(toProductionDependency('react-native-web')); + inputs.add(toProductionDependency('react-dom')); + + // https://github.com/expo/expo/tree/main/packages/@expo/metro-runtime + if (!isExpoRouter) { + inputs.add(toDependency('@expo/metro-runtime')); + } + } + + if ( + (platforms.includes('android') && (config.userInterfaceStyle || config.android?.userInterfaceStyle)) || + (platforms.includes('ios') && (config.backgroundColor || config.ios?.backgroundColor)) + ) { + inputs.add(toProductionDependency('expo-system-ui')); + } + + if (platforms.includes('android') && config.androidNavigationBar) { + inputs.add(toProductionDependency('expo-navigation-bar')); + } + + return [...inputs]; +}; diff --git a/packages/knip/src/plugins/expo/index.ts b/packages/knip/src/plugins/expo/index.ts new file mode 100644 index 000000000..d2673832a --- /dev/null +++ b/packages/knip/src/plugins/expo/index.ts @@ -0,0 +1,52 @@ +import type { IsPluginEnabled, Plugin, ResolveConfig, ResolveEntryPaths } from '../../types/config.js'; +import { toProductionEntry } from '../../util/input.js'; +import { join } from '../../util/path.js'; +import { hasDependency } from '../../util/plugin.js'; +import { getDependencies } from './helpers.js'; +import type { ExpoConfig } from './types.js'; + +// https://docs.expo.dev/ + +const title = 'Expo'; + +const enablers = ['expo']; + +const isEnabled: IsPluginEnabled = ({ dependencies }) => hasDependency(dependencies, enablers); + +const config: string[] = ['app.json', 'app.config.{ts,js}']; + +const resolveEntryPaths: ResolveEntryPaths = async (expoConfig, { manifest }) => { + const config = 'expo' in expoConfig ? expoConfig.expo : expoConfig; + + let production: string[] = []; + + // https://docs.expo.dev/router/installation/#setup-entry-point + if (manifest.main === 'expo-router/entry') { + production = ['app/**/*.{js,jsx,ts,tsx}', 'src/app/**/*.{js,jsx,ts,tsx}']; + + const normalizedPlugins = + config.plugins?.map(plugin => (Array.isArray(plugin) ? plugin : ([plugin] as const))) ?? []; + const expoRouterPlugin = normalizedPlugins.find(([plugin]) => plugin === 'expo-router'); + + if (expoRouterPlugin) { + const [, options] = expoRouterPlugin; + + if (typeof options?.root === 'string') { + production = [join(options.root, '**/*.{js,jsx,ts,tsx}')]; + } + } + } + + return production.map(entry => toProductionEntry(entry)); +}; + +const resolveConfig: ResolveConfig = async (expoConfig, options) => getDependencies(expoConfig, options); + +export default { + title, + enablers, + isEnabled, + config, + resolveEntryPaths, + resolveConfig, +} satisfies Plugin; diff --git a/packages/knip/src/plugins/expo/types.ts b/packages/knip/src/plugins/expo/types.ts new file mode 100644 index 000000000..3f5eb6dfb --- /dev/null +++ b/packages/knip/src/plugins/expo/types.ts @@ -0,0 +1,21 @@ +// https://github.com/expo/expo/blob/main/packages/%40expo/config-types/src/ExpoConfig.ts + +type AppConfig = { + platforms?: ('ios' | 'android' | 'web')[]; + notification?: Record; + updates?: { + enabled?: boolean; + }; + backgroundColor?: string; + userInterfaceStyle?: 'automatic' | 'light' | 'dark'; + ios?: { + backgroundColor?: string; + }; + android?: { + userInterfaceStyle?: 'automatic' | 'light' | 'dark'; + }; + androidNavigationBar?: Record; + plugins?: (string | [string, Record])[]; +}; + +export type ExpoConfig = AppConfig | { expo: AppConfig }; diff --git a/packages/knip/src/plugins/index.ts b/packages/knip/src/plugins/index.ts index c7b7286fd..98469fc3f 100644 --- a/packages/knip/src/plugins/index.ts +++ b/packages/knip/src/plugins/index.ts @@ -15,6 +15,7 @@ import { default as dotenv } from './dotenv/index.js'; import { default as drizzle } from './drizzle/index.js'; import { default as eleventy } from './eleventy/index.js'; import { default as eslint } from './eslint/index.js'; +import { default as expo } from './expo/index.js'; import { default as gatsby } from './gatsby/index.js'; import { default as githubActions } from './github-actions/index.js'; import { default as glob } from './glob/index.js'; @@ -105,6 +106,7 @@ export const Plugins = { drizzle, eleventy, eslint, + expo, gatsby, 'github-actions': githubActions, glob, diff --git a/packages/knip/src/schema/plugins.ts b/packages/knip/src/schema/plugins.ts index d4046edaf..c573c293e 100644 --- a/packages/knip/src/schema/plugins.ts +++ b/packages/knip/src/schema/plugins.ts @@ -29,6 +29,7 @@ export const pluginsSchema = z.object({ drizzle: pluginSchema, eleventy: pluginSchema, eslint: pluginSchema, + expo: pluginSchema, gatsby: pluginSchema, 'github-actions': pluginSchema, glob: pluginSchema, diff --git a/packages/knip/src/types/PluginNames.ts b/packages/knip/src/types/PluginNames.ts index 1f3a078e3..7d8ec9f3c 100644 --- a/packages/knip/src/types/PluginNames.ts +++ b/packages/knip/src/types/PluginNames.ts @@ -16,6 +16,7 @@ export type PluginName = | 'drizzle' | 'eleventy' | 'eslint' + | 'expo' | 'gatsby' | 'github-actions' | 'glob' @@ -106,6 +107,7 @@ export const pluginNames = [ 'drizzle', 'eleventy', 'eslint', + 'expo', 'gatsby', 'github-actions', 'glob', diff --git a/packages/knip/test/plugins/expo.test.ts b/packages/knip/test/plugins/expo.test.ts new file mode 100644 index 000000000..d7084e50a --- /dev/null +++ b/packages/knip/test/plugins/expo.test.ts @@ -0,0 +1,27 @@ +import { test } from 'bun:test'; +import assert from 'node:assert/strict'; +import { main } from '../../src/index.js'; +import { join, resolve } from '../../src/util/path.js'; +import baseArguments from '../helpers/baseArguments.js'; +import baseCounters from '../helpers/baseCounters.js'; + +const cwd = resolve('fixtures/plugins/expo'); + +test('Find dependencies with the Expo plugin (1)', async () => { + const { issues, counters } = await main({ + ...baseArguments, + cwd, + }); + + assert(issues.files.has(join(cwd, 'src/app/index.ts'))); + + assert(issues.dependencies['package.json']['expo-router']); + + assert.deepEqual(counters, { + ...baseCounters, + processed: 2, + total: 2, + files: 1, + dependencies: 1, + }); +}); diff --git a/packages/knip/test/plugins/expo2.test.ts b/packages/knip/test/plugins/expo2.test.ts new file mode 100644 index 000000000..0e626fd96 --- /dev/null +++ b/packages/knip/test/plugins/expo2.test.ts @@ -0,0 +1,26 @@ +import { test } from 'bun:test'; +import assert from 'node:assert/strict'; +import { main } from '../../src/index.js'; +import { resolve } from '../../src/util/path.js'; +import baseArguments from '../helpers/baseArguments.js'; +import baseCounters from '../helpers/baseCounters.js'; + +const cwd = resolve('fixtures/plugins/expo2'); + +test('Find dependencies with the Expo plugin (2)', async () => { + const { issues, counters } = await main({ + ...baseArguments, + cwd, + }); + + assert(issues.unlisted['app.config.js']['expo-camera']); + assert(issues.unlisted['app.config.js']['expo-system-ui']); + assert(issues.unlisted['app.config.js']['expo-updates']); + + assert.deepEqual(counters, { + ...baseCounters, + processed: 2, + total: 2, + unlisted: 3, + }); +}); diff --git a/packages/knip/test/plugins/expo3.test.ts b/packages/knip/test/plugins/expo3.test.ts new file mode 100644 index 000000000..0bef61960 --- /dev/null +++ b/packages/knip/test/plugins/expo3.test.ts @@ -0,0 +1,30 @@ +import { test } from 'bun:test'; +import assert from 'node:assert/strict'; +import { main } from '../../src/index.js'; +import { resolve } from '../../src/util/path.js'; +import baseArguments from '../helpers/baseArguments.js'; +import baseCounters from '../helpers/baseCounters.js'; + +const cwd = resolve('fixtures/plugins/expo3'); + +test('Find dependencies with the Expo plugin (3)', async () => { + const { issues, counters } = await main({ + ...baseArguments, + cwd, + }); + + assert(issues.unlisted['app.json']['expo-router']); + assert(issues.unlisted['app.json']['react-native-ble-plx']); + + assert(issues.dependencies['package.json']['@expo/metro-runtime']); + assert(issues.dependencies['package.json']['expo-system-ui']); + assert(issues.dependencies['package.json']['expo-updates']); + + assert.deepEqual(counters, { + ...baseCounters, + processed: 1, + total: 1, + unlisted: 2, + dependencies: 3, + }); +});