From 1715e26b5041f9db03bca5d886ad4c2b49b9212d Mon Sep 17 00:00:00 2001 From: Daniel Slepov Date: Fri, 26 Jun 2026 15:00:24 +0500 Subject: [PATCH] feat: support external `nuxt-ionic.config` file --- playground/nuxt-ionic.config.ts | 6 ++ src/module.ts | 62 +++----------- src/parts/config.ts | 51 +++++++++++ src/runtime/config.ts | 11 +++ test/unit/config.spec.ts | 144 ++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 51 deletions(-) create mode 100644 playground/nuxt-ionic.config.ts create mode 100644 src/parts/config.ts create mode 100644 src/runtime/config.ts create mode 100644 test/unit/config.spec.ts diff --git a/playground/nuxt-ionic.config.ts b/playground/nuxt-ionic.config.ts new file mode 100644 index 00000000..8e93c515 --- /dev/null +++ b/playground/nuxt-ionic.config.ts @@ -0,0 +1,6 @@ +export default defineNuxtIonicConfig(() => { + const backButtonText = isPlatform('ios') ? 'Go Back' : undefined + return { + backButtonText, + } +}) diff --git a/src/module.ts b/src/module.ts index fe5f1983..44974a30 100644 --- a/src/module.ts +++ b/src/module.ts @@ -4,14 +4,13 @@ import { defineNuxtModule, addComponent, addPlugin, - addTemplate, addImportsSources, } from '@nuxt/kit' import { join, resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' import { defineUnimportPreset } from 'unimport' -import type { AnimationBuilder, SpinnerTypes, PlatformConfig } from '@ionic/vue' +import type { IonicConfig } from './runtime/config' import { runtimeDir } from './utils' import { IonicBuiltInComponents, IonicHooks } from './imports' @@ -21,6 +20,7 @@ import { setupIcons } from './parts/icons' import { setupMeta } from './parts/meta' import { setupRouter } from './parts/router' import { setupCapacitor } from './parts/capacitor' +import { setupVueConfigTemplate } from './parts/config' export interface ModuleOptions { integrations?: { @@ -33,52 +33,11 @@ export interface ModuleOptions { basic?: boolean utilities?: boolean } - config?: { - actionSheetEnter?: AnimationBuilder - actionSheetLeave?: AnimationBuilder - alertEnter?: AnimationBuilder - alertLeave?: AnimationBuilder - animated?: boolean - backButtonDefaultHref?: string - backButtonIcon?: string - backButtonText?: string - innerHTMLTemplatesEnabled?: boolean - hardwareBackButton?: boolean - infiniteLoadingSpinner?: SpinnerTypes - loadingEnter?: AnimationBuilder - loadingLeave?: AnimationBuilder - loadingSpinner?: SpinnerTypes - menuIcon?: string - menuType?: string - modalEnter?: AnimationBuilder - modalLeave?: AnimationBuilder - mode?: 'ios' | 'md' - navAnimation?: AnimationBuilder - pickerEnter?: AnimationBuilder - pickerLeave?: AnimationBuilder - platform?: PlatformConfig - popoverEnter?: AnimationBuilder - popoverLeave?: AnimationBuilder - refreshingIcon?: string - refreshingSpinner?: SpinnerTypes - sanitizerEnabled?: boolean - spinner?: SpinnerTypes - statusTap?: boolean - swipeBackEnabled?: boolean - tabButtonLayout?: - | 'icon-top' - | 'icon-start' - | 'icon-end' - | 'icon-bottom' - | 'icon-hide' - | 'label-hide' - toastDuration?: number - toastEnter?: AnimationBuilder - toastLeave?: AnimationBuilder - toggleOnOffLabels?: boolean - } + config?: IonicConfig } +export type { IonicConfig } from './runtime/config' + export default defineNuxtModule({ meta: { name: '@nuxtjs/ionic', @@ -104,11 +63,8 @@ export default defineNuxtModule({ nuxt.options.build.transpile.push(runtimeDir) nuxt.options.build.transpile.push(/@ionic/, /@stencil/) - // Inject options for the Ionic Vue plugin as a virtual module - addTemplate({ - filename: 'ionic/vue-config.mjs', - getContents: () => `export default ${JSON.stringify(options.config)}`, - }) + // Inject Ionic Vue plugin config + await setupVueConfigTemplate(options.config) // Create an Ionic config file if it doesn't exist yet const ionicConfigPath = join(nuxt.options.rootDir, 'ionic.config.json') @@ -170,6 +126,10 @@ export default defineNuxtModule({ imports: ['useHead'], priority: 2, }), + defineUnimportPreset({ + from: resolve(runtimeDir, 'config'), + imports: ['defineNuxtIonicConfig'], + }), ]) // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/parts/config.ts b/src/parts/config.ts new file mode 100644 index 00000000..17c2836f --- /dev/null +++ b/src/parts/config.ts @@ -0,0 +1,51 @@ +import { addTemplate, findPath, useNuxt } from '@nuxt/kit' +import { resolve } from 'pathe' +import type { IonicConfig } from '../runtime/config' + +export const findIonicConfigFile = async (rootDir: string) => { + return await findPath( + resolve(rootDir, 'nuxt-ionic.config'), + { + extensions: ['ts', 'js', 'mjs'], + virtual: false, + }, + 'file', + ) +} + +export const hasInlineConfig = (config: IonicConfig | undefined) => { + return !!config && Object.keys(config).length > 0 +} + +export const setupVueConfigTemplate = async (config?: IonicConfig) => { + const nuxt = useNuxt() + + // Resolve an external `nuxt-ionic.config.{ts,js,mjs}` file + const ionicConfigFile = await findIonicConfigFile(nuxt.options.rootDir) + if (!ionicConfigFile) { + addTemplate({ + filename: 'ionic/vue-config.mjs', + getContents: () => `export default ${JSON.stringify(config ?? {})}`, + }) + return + } + + nuxt.options.watch ||= [] + nuxt.options.watch.push(ionicConfigFile) + + if (hasInlineConfig(config)) { + console.warn( + `[@nuxtjs/ionic] A \`nuxt-ionic.config\` file was found, so the inline \`ionic.config\` \`config\` option is ignored. Move it into ${ionicConfigFile}.`, + ) + } + + const contents = [ + `import config from ${JSON.stringify(ionicConfigFile)}`, + 'export default typeof config === "function" ? config() : config', + ].join('\n') + + addTemplate({ + filename: 'ionic/vue-config.mjs', + getContents: () => contents, + }) +} diff --git a/src/runtime/config.ts b/src/runtime/config.ts new file mode 100644 index 00000000..4dd2fd10 --- /dev/null +++ b/src/runtime/config.ts @@ -0,0 +1,11 @@ +import type { IonicConfig } from '@ionic/core' + +export type { IonicConfig } + +export type NuxtIonicConfigInput = IonicConfig | (() => IonicConfig) + +export function defineNuxtIonicConfig( + config: NuxtIonicConfigInput, +): NuxtIonicConfigInput { + return config +} diff --git a/test/unit/config.spec.ts b/test/unit/config.spec.ts new file mode 100644 index 00000000..93ef4a1b --- /dev/null +++ b/test/unit/config.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { addTemplate, findPath, useNuxt } from '@nuxt/kit' +import { + findIonicConfigFile, + setupVueConfigTemplate, +} from '../../src/parts/config' +import { defineNuxtIonicConfig } from '../../src/runtime/config' + +// Mock @nuxt/kit +vi.mock('@nuxt/kit', () => ({ + findPath: vi.fn(), + addTemplate: vi.fn(), + useNuxt: vi.fn(), +})) + +describe('config:findIonicConfigFile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('looks up `nuxt-ionic.config` in the root dir', async () => { + const mockPath = '/project/nuxt-ionic.config.ts' + vi.mocked(findPath).mockResolvedValue(mockPath) + + const result = await findIonicConfigFile('/project') + + expect(result).toBe(mockPath) + }) + + it('returns null when no config file is found', async () => { + vi.mocked(findPath).mockResolvedValue(null) + + expect(await findIonicConfigFile('/project')).toBeNull() + }) +}) + +describe('config:setupVueConfigTemplate', () => { + const mockNuxt = { + options: { + rootDir: '/project', + watch: [] as string[], + }, + } + + // Read back the contents the module registered for the virtual config module + const addedTemplateContents = () => { + const call = vi.mocked(addTemplate).mock.calls.at(-1)?.[0] + expect(call).toMatchObject({ filename: 'ionic/vue-config.mjs' }) + return (call as { getContents: () => string }).getContents() + } + + beforeEach(() => { + vi.clearAllMocks() + mockNuxt.options.watch = [] + vi.mocked(useNuxt).mockReturnValue(mockNuxt as never) + }) + + describe('without an external config file', () => { + beforeEach(() => { + vi.mocked(findPath).mockResolvedValue(null) + }) + + it('serialises the inline config', async () => { + await setupVueConfigTemplate({ mode: 'ios' }) + + expect(addedTemplateContents()).toBe('export default {"mode":"ios"}') + }) + + it('falls back to an empty object for an undefined inline config', async () => { + await setupVueConfigTemplate(undefined) + + expect(addedTemplateContents()).toBe('export default {}') + }) + + it('does not register a watcher', async () => { + await setupVueConfigTemplate({ mode: 'ios' }) + + expect(mockNuxt.options.watch).toEqual([]) + }) + }) + + describe('with an external config file', () => { + const mockPath = '/project/nuxt-ionic.config.ts' + + beforeEach(() => { + vi.mocked(findPath).mockResolvedValue(mockPath) + }) + + it('re-exports the file and resolves its factory form', async () => { + await setupVueConfigTemplate(undefined) + + const contents = addedTemplateContents() + expect(contents).toContain(`import config from "${mockPath}"`) + expect(contents).toContain( + 'export default typeof config === "function" ? config() : config', + ) + }) + + it('ignores the inline config', async () => { + await setupVueConfigTemplate({ mode: 'ios' }) + + const contents = addedTemplateContents() + expect(contents).not.toContain('mode') + expect(contents).not.toContain('ios') + }) + + it('registers a watcher on the config file', async () => { + await setupVueConfigTemplate(undefined) + + expect(mockNuxt.options.watch).toContain(mockPath) + }) + + it('warns when an inline config is also set', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await setupVueConfigTemplate({ mode: 'ios' }) + + expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls[0]?.[0]).toContain(mockPath) + warn.mockRestore() + }) + + it('does not warn when there is no inline config', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await setupVueConfigTemplate({}) + + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + }) +}) + +describe('config:defineNuxtIonicConfig', () => { + it('returns an object config unchanged', () => { + const config = { backButtonText: 'Go back' } + expect(defineNuxtIonicConfig(config)).toBe(config) + }) + + it('returns a factory config unchanged', () => { + const factory = () => ({ mode: 'ios' as const }) + expect(defineNuxtIonicConfig(factory)).toBe(factory) + }) +})