diff --git a/.gitignore b/.gitignore index ec1e1531..873126ff 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ coverage *.iml .idea +# Zed +.zed + # OSX .DS_Store .AppleDouble diff --git a/playground/capacitor.config.ts b/playground/capacitor.config.ts index a95c5588..669063ae 100644 --- a/playground/capacitor.config.ts +++ b/playground/capacitor.config.ts @@ -4,7 +4,6 @@ const config: CapacitorConfig = { appId: 'io.ionic.starter', appName: 'nuxt-ionic-playground', webDir: 'dist', - bundledWebRuntime: false, } export default config diff --git a/src/module.ts b/src/module.ts index 0c859fcb..fe5f1983 100644 --- a/src/module.ts +++ b/src/module.ts @@ -20,6 +20,7 @@ import { useCSSSetup } from './parts/css' import { setupIcons } from './parts/icons' import { setupMeta } from './parts/meta' import { setupRouter } from './parts/router' +import { setupCapacitor } from './parts/capacitor' export interface ModuleOptions { integrations?: { @@ -138,6 +139,17 @@ export default defineNuxtModule({ nuxt.options.typescript.hoist ||= [] nuxt.options.typescript.hoist.push('@ionic/vue') + // add capacitor integration + const { excludeNativeFolders, findCapacitorConfig, parseCapacitorConfig } = setupCapacitor() + + // add the `android` and `ios` folders to the TypeScript config exclude list if capacitor is enabled + // this is to prevent TypeScript from trying to resolve the Capacitor native code + const capacitorConfigPath = await findCapacitorConfig() + if (capacitorConfigPath) { + const { androidPath, iosPath } = await parseCapacitorConfig(capacitorConfigPath) + excludeNativeFolders(androidPath, iosPath) + } + // Add auto-imported components IonicBuiltInComponents.map(name => addComponent({ diff --git a/src/parts/capacitor.ts b/src/parts/capacitor.ts new file mode 100644 index 00000000..fe80345f --- /dev/null +++ b/src/parts/capacitor.ts @@ -0,0 +1,68 @@ +import type { CapacitorConfig } from '@capacitor/cli' +import { findPath, useNuxt } from '@nuxt/kit' +import { join } from 'pathe' + +export const setupCapacitor = () => { + const nuxt = useNuxt() + + /** Find the path to capacitor configuration file (if it exists) */ + const findCapacitorConfig = async () => { + const path = await findPath( + 'capacitor.config', + { + extensions: ['ts', 'json'], + virtual: false, + }, + 'file', + ) + + return path + } + + const parseCapacitorConfig = async (path: string | null): Promise<{ + androidPath: string | null + iosPath: string | null + }> => { + if (!path) { + return { + androidPath: null, + iosPath: null, + } + } + + const capacitorConfig = (await import(path)) as CapacitorConfig + + return { + androidPath: capacitorConfig.android?.path || null, + iosPath: capacitorConfig.ios?.path || null, + } + } + + /** Exclude native folder paths from type checking by excluding them in tsconfig */ + const excludeNativeFolders = (androidPath: string | null, iosPath: string | null) => { + nuxt.hook('prepare:types', (ctx) => { + const paths = [ + join('..', androidPath ?? 'android'), + join('..', iosPath ?? 'ios'), + ] + + for (const key of ['tsConfig', 'nodeTsConfig', 'sharedTsConfig'] as const) { + if (ctx[key]) { + ctx[key].exclude ||= [] + ctx[key].exclude.push(...paths) + } + } + }) + + nuxt.options.ignore.push( + join(androidPath ?? 'android'), + join(iosPath ?? 'ios'), + ) + } + + return { + excludeNativeFolders, + findCapacitorConfig, + parseCapacitorConfig, + } +} diff --git a/test/unit/capacitor.spec.ts b/test/unit/capacitor.spec.ts new file mode 100644 index 00000000..3d2a4324 --- /dev/null +++ b/test/unit/capacitor.spec.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useNuxt, findPath } from '@nuxt/kit' +import { setupCapacitor } from '../../src/parts/capacitor' + +// Mock @nuxt/kit +vi.mock('@nuxt/kit', () => ({ + findPath: vi.fn(), + useNuxt: vi.fn(), +})) + +describe('useCapacitor', () => { + const mockNuxt = { + hook: vi.fn(), + options: { + ignore: [], + }, + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useNuxt).mockReturnValue(mockNuxt as any) + mockNuxt.options.ignore = [] + }) + + describe('findCapacitorConfig', () => { + it('should find capacitor.config.ts', async () => { + const mockPath = '/project/capacitor.config.ts' + vi.mocked(findPath).mockResolvedValue(mockPath) + + const { findCapacitorConfig } = setupCapacitor() + const result = await findCapacitorConfig() + + expect(result).toBe(mockPath) + }) + + it('should return null when no config found', async () => { + vi.mocked(findPath).mockResolvedValue(null) + + const { findCapacitorConfig } = setupCapacitor() + const result = await findCapacitorConfig() + + expect(result).toBeNull() + }) + }) + + describe('parseCapacitorConfig', () => { + it('should return null paths when no config path provided', async () => { + const { parseCapacitorConfig } = setupCapacitor() + const result = await parseCapacitorConfig(null) + + expect(result).toEqual({ + androidPath: null, + iosPath: null, + }) + }) + + it('should parse capacitor config with custom paths', async () => { + const configPath = './capacitor.config.ts' + const mockConfig = { + android: { path: 'custom-android' }, + ios: { path: 'custom-ios' }, + } + + vi.doMock(configPath, () => ({ + default: mockConfig, + ...mockConfig, + })) + + const { parseCapacitorConfig } = setupCapacitor() + const result = await parseCapacitorConfig(configPath) + + expect(result).toEqual({ + androidPath: 'custom-android', + iosPath: 'custom-ios', + }) + }) + + it('should handle config without android/ios paths', async () => { + const configPath = './capacitor.config.ts' + const mockConfig = { + android: undefined, + ios: undefined, + } + + vi.doMock(configPath, () => ({ + default: mockConfig, + ...mockConfig, + })) + + const { parseCapacitorConfig } = setupCapacitor() + const result = await parseCapacitorConfig(configPath) + + expect(result).toEqual({ + androidPath: null, + iosPath: null, + }) + }) + }) + + describe('excludeNativeFolders', () => { + it('should register prepare:types hook and add native folders to ignore', () => { + const { excludeNativeFolders } = setupCapacitor() + excludeNativeFolders('android', 'ios') + + expect(mockNuxt.hook).toHaveBeenCalledWith('prepare:types', expect.any(Function)) + expect(mockNuxt.options.ignore).toContain('android') + expect(mockNuxt.options.ignore).toContain('ios') + }) + + it('should handle null paths with defaults', () => { + const { excludeNativeFolders } = setupCapacitor() + excludeNativeFolders(null, null) + + expect(mockNuxt.hook).toHaveBeenCalledWith('prepare:types', expect.any(Function)) + expect(mockNuxt.options.ignore).toContain('android') + expect(mockNuxt.options.ignore).toContain('ios') + }) + + it('should modify typescript configs in prepare:types hook', () => { + const { excludeNativeFolders } = setupCapacitor() + excludeNativeFolders('custom-android', 'custom-ios') + + // Get the hook callback that was registered + const hookCallback = mockNuxt.hook.mock.calls.find(call => call[0] === 'prepare:types')?.[1] + expect(hookCallback).toBeDefined() + + // Mock typescript context + const mockCtx = { + tsConfig: { exclude: [] }, + nodeTsConfig: { exclude: [] }, + sharedTsConfig: { exclude: [] }, + } + + // Call the hook callback + hookCallback(mockCtx) + + // Verify all configs were updated + expect(mockCtx.tsConfig.exclude).toContain('../custom-android') + expect(mockCtx.tsConfig.exclude).toContain('../custom-ios') + expect(mockCtx.nodeTsConfig.exclude).toContain('../custom-android') + expect(mockCtx.nodeTsConfig.exclude).toContain('../custom-ios') + expect(mockCtx.sharedTsConfig.exclude).toContain('../custom-android') + expect(mockCtx.sharedTsConfig.exclude).toContain('../custom-ios') + }) + + it('should initialize exclude arrays if not present in typescript configs', () => { + const { excludeNativeFolders } = setupCapacitor() + excludeNativeFolders('android', 'ios') + + const hookCallback = mockNuxt.hook.mock.calls.find(call => call[0] === 'prepare:types')?.[1] + + // Mock context without exclude arrays + const mockCtx = { + tsConfig: {} as any, + nodeTsConfig: {} as any, + sharedTsConfig: {} as any, + } + + hookCallback(mockCtx) + + expect(mockCtx.tsConfig.exclude).toEqual(['../android', '../ios']) + expect(mockCtx.nodeTsConfig.exclude).toEqual(['../android', '../ios']) + expect(mockCtx.sharedTsConfig.exclude).toEqual(['../android', '../ios']) + }) + + it('should handle missing typescript configs gracefully', () => { + const { excludeNativeFolders } = setupCapacitor() + excludeNativeFolders('android', 'ios') + + const hookCallback = mockNuxt.hook.mock.calls.find(call => call[0] === 'prepare:types')?.[1] + + // Mock context with only some configs present + const mockCtx = { + tsConfig: { exclude: [] }, + // nodeTsConfig and sharedTsConfig are undefined + } + + expect(() => hookCallback(mockCtx)).not.toThrow() + expect(mockCtx.tsConfig.exclude).toContain('../android') + expect(mockCtx.tsConfig.exclude).toContain('../ios') + }) + }) +})