From 85c19a4de8a590ac47ca244e031064d7badb8b36 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 20 Mar 2026 23:57:50 +1100 Subject: [PATCH 1/2] =?UTF-8?q?feat!:=20redesign=20registry=20config=20?= =?UTF-8?q?=E2=80=94=20presence=20=3D=20available,=20trigger=20=3D=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Registry config no longer auto-loads scripts globally. Adding a script to `scripts.registry` now only sets up infrastructure (proxy routes, types, bundling, composable auto-imports). Scripts only auto-load globally when `trigger` is explicitly set. - `true` removed: use `{}` (infra only) or `{ trigger: 'onNuxtReady' }` (global load) - `'proxy-only'` removed: infra-only is now the default behavior - `trigger` hoisted to top-level config alongside script input options - Tuple form `[input, scriptOptions]` still supported internally Migration: - `googleAnalytics: true` → `googleAnalytics: {}` or `googleAnalytics: { trigger: 'onNuxtReady' }` - `googleAnalytics: { id: 'G-xxx' }` → unchanged (but no longer auto-loads; add `trigger` to auto-load) - `googleAnalytics: 'proxy-only'` → `googleAnalytics: {}` --- playground/nuxt.config.ts | 10 ++--- src/module.ts | 3 +- src/normalize.ts | 38 ++++++++++++++----- src/runtime/types.ts | 4 +- src/templates.ts | 44 +++++++++++----------- test/fixtures/basic/nuxt.config.ts | 8 ++-- test/fixtures/first-party/nuxt.config.ts | 2 +- test/unit/auto-inject.test.ts | 30 +++++++-------- test/unit/templates.test.ts | 47 +++++++++++++++++++++--- 9 files changed, 122 insertions(+), 64 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 64bfab68..5e6d3c54 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -46,11 +46,11 @@ export default defineNuxtConfig({ scripts: { debug: true, registry: { - googleSignIn: true, - blueskyEmbed: true, - xEmbed: true, - instagramEmbed: true, - googleMaps: true, + googleSignIn: {}, + blueskyEmbed: {}, + xEmbed: {}, + instagramEmbed: {}, + googleMaps: {}, }, }, }) diff --git a/src/module.ts b/src/module.ts index 41bfaa27..3f52c227 100644 --- a/src/module.ts +++ b/src/module.ts @@ -185,7 +185,8 @@ export interface ModuleOptions { */ firstParty?: boolean | FirstPartyOptions /** - * The registry of supported third-party scripts. Loads the scripts in globally using the default script options. + * The registry of supported third-party scripts. Presence enables infrastructure (proxy routes, types, bundling, composable auto-imports). + * Scripts only auto-load globally when `trigger` is explicitly set in the config object. */ registry?: NuxtConfigScriptRegistry /** diff --git a/src/normalize.ts b/src/normalize.ts index bd3e8d73..6c395730 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -1,13 +1,17 @@ /** * Normalize all registry config entries in-place to [input, scriptOptions?] tuple form. - * Eliminates the 4-shape polymorphism (true | 'mock' | object | [object, options]) - * so all downstream consumers handle a single shape. * - * - `true` → `[{}]` + * User-facing config shapes: + * - `false` → deleted * - `'mock'` → `[{}, { trigger: 'manual', skipValidation: true }]` - * - `{ id: '...' }` → `[{ id: '...' }]` - * - `[{ id: '...' }, opts]` → unchanged - * - falsy / empty array → deleted + * - `{}` → `[{}]` (infrastructure only, no auto-load) + * - `{ id: '...', trigger: 'onNuxtReady' }` → `[{ id: '...' }, { trigger: 'onNuxtReady' }]` + * - `{ id: '...', trigger: 'onNuxtReady', scriptOptions: { warmupStrategy: 'preconnect' } }` → `[{ id: '...' }, { trigger: 'onNuxtReady', warmupStrategy: 'preconnect' }]` + * - `[input, scriptOptions]` → unchanged (internal/backwards compat) + * + * Removed: + * - `true` → build error with migration message + * - `'proxy-only'` → build error with migration message */ export function normalizeRegistryConfig(registry: Record): void { for (const key of Object.keys(registry)) { @@ -17,9 +21,18 @@ export function normalizeRegistryConfig(registry: Record): void { continue } if (entry === true) { - registry[key] = [{}] + throw new Error( + `[nuxt-scripts] registry.${key}: boolean \`true\` is no longer supported. ` + + `Use \`{}\` for infrastructure only (composable/component driven) or \`{ trigger: 'onNuxtReady' }\` for global auto-loading.`, + ) + } + if (entry === 'proxy-only') { + throw new Error( + `[nuxt-scripts] registry.${key}: \`'proxy-only'\` is no longer supported. ` + + `Use \`{}\` instead (infrastructure only is now the default behavior).`, + ) } - else if (entry === 'mock') { + if (entry === 'mock') { registry[key] = [{}, { trigger: 'manual', skipValidation: true }] } else if (Array.isArray(entry)) { @@ -31,7 +44,14 @@ export function normalizeRegistryConfig(registry: Record): void { entry[0] = {} } else if (typeof entry === 'object') { - registry[key] = [entry] + const { trigger, scriptOptions, ...input } = entry + const mergedScriptOptions = { + ...(trigger !== undefined ? { trigger } : {}), + ...scriptOptions, + } + registry[key] = Object.keys(mergedScriptOptions).length > 0 + ? [input, mergedScriptOptions] + : [input] } else { delete registry[key] diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 5c3b967f..1f7d4c94 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -167,7 +167,7 @@ export interface NuxtDevToolsScriptInstance { export interface ScriptRegistry { bingUet?: BingUetInput blueskyEmbed?: BlueskyEmbedInput - carbonAds?: true + carbonAds?: Record crisp?: CrispInput clarity?: ClarityInput cloudflareWebAnalytics?: CloudflareWebAnalyticsInput @@ -226,7 +226,7 @@ export type BuiltInRegistryScriptKey */ export type RegistryScriptKey = Exclude -export type NuxtConfigScriptRegistryEntry = true | false | 'mock' | T | [T, NuxtUseScriptOptionsSerializable] +export type NuxtConfigScriptRegistryEntry = false | 'mock' | (T & { trigger?: NuxtUseScriptOptionsSerializable['trigger'], scriptOptions?: Omit }) | [T, NuxtUseScriptOptionsSerializable] export type NuxtConfigScriptRegistry = Partial<{ [key in T]: NuxtConfigScriptRegistryEntry }> & Record> diff --git a/src/templates.ts b/src/templates.ts index 44870db9..98cca4a5 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -117,36 +117,36 @@ export function templatePlugin(config: Partial, registry: Require let needsServiceWorkerImport = false - // Registry entries are pre-normalized to [input, scriptOptions?] tuple form + // Registry entries are pre-normalized to [input, scriptOptions?] tuple form. + // Only generate a global composable call when scriptOptions.trigger is present; + // entries without a trigger are infrastructure only (proxy routes, types, bundling). for (const [k, c] of Object.entries(config.registry || {})) { if (c === false) continue + const [, scriptOptions] = c as [Record, any?] + if (!scriptOptions?.trigger) + continue const importDefinition = registry.find(i => i.import.name.toLowerCase() === `usescript${k.toLowerCase()}`) if (importDefinition) { resolvedRegistryKeys.push(k) imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`) - const [input, scriptOptions] = c as [Record, any?] - if (scriptOptions) { - const opts = { ...scriptOptions } - const triggerResolved = resolveTriggerForTemplate(opts.trigger) - if (triggerResolved) { - opts.trigger = '__TRIGGER_PLACEHOLDER__' as any - if (triggerResolved.includes('useScriptTriggerIdleTimeout')) - needsIdleTimeoutImport = true - if (triggerResolved.includes('useScriptTriggerInteraction')) - needsInteractionImport = true - if (triggerResolved.includes('useScriptTriggerServiceWorker')) - needsServiceWorkerImport = true - } - const args = { ...input, scriptOptions: opts } - const argsJson = triggerResolved - ? JSON.stringify(args).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved) - : JSON.stringify(args) - inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`) - } - else { - inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(input)})`) + const [input] = c as [Record, any?] + const opts = { ...scriptOptions } + const triggerResolved = resolveTriggerForTemplate(opts.trigger) + if (triggerResolved) { + opts.trigger = '__TRIGGER_PLACEHOLDER__' as any + if (triggerResolved.includes('useScriptTriggerIdleTimeout')) + needsIdleTimeoutImport = true + if (triggerResolved.includes('useScriptTriggerInteraction')) + needsInteractionImport = true + if (triggerResolved.includes('useScriptTriggerServiceWorker')) + needsServiceWorkerImport = true } + const args = { ...input, scriptOptions: opts } + const argsJson = triggerResolved + ? JSON.stringify(args).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved) + : JSON.stringify(args) + inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`) } } for (const [k, c] of Object.entries(config.globals || {})) { diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index d2e610ea..8c2419ba 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -6,10 +6,10 @@ export default defineNuxtConfig({ ], scripts: { registry: { - xEmbed: true, - instagramEmbed: true, - blueskyEmbed: true, - gravatar: true, + xEmbed: {}, + instagramEmbed: {}, + blueskyEmbed: {}, + gravatar: {}, }, }, devtools: { diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 49419ec9..4d018195 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -73,7 +73,7 @@ export default defineNuxtConfig({ posthog: [{ apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, manual], intercom: [{ app_id: 'akg5rmxb' }, manual], crisp: [{ id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, manual], - vercelAnalytics: [true, manual], + vercelAnalytics: [{}, manual], }, }, }) diff --git a/test/unit/auto-inject.test.ts b/test/unit/auto-inject.test.ts index de2208d4..dad05acb 100644 --- a/test/unit/auto-inject.test.ts +++ b/test/unit/auto-inject.test.ts @@ -85,20 +85,20 @@ describe('autoInject via proxy configs', () => { }) }) - describe('boolean entries', () => { - it('injects into runtimeConfig for posthog: true', () => { - const registry: any = { posthog: true } + describe('empty object entries (env var driven)', () => { + it('injects into runtimeConfig for posthog: {}', () => { + const registry: any = { posthog: {} } const rt = makeRuntimeConfig({ posthog: { apiKey: '' } }) autoInjectAll(registry, rt, '/_proxy') - // After normalization, true becomes [{}] — both input and runtimeConfig get the value + // After normalization, {} becomes [{}] — both input and runtimeConfig get the value expect(registry.posthog[0].apiHost).toBe('/_proxy/ph') expect(rt.public.scripts.posthog.apiHost).toBe('/_proxy/ph') }) - it('uses EU prefix for posthog: true when runtime region is eu', () => { - const registry: any = { posthog: true } + it('uses EU prefix for posthog: {} when runtime region is eu', () => { + const registry: any = { posthog: {} } const rt = makeRuntimeConfig({ posthog: { apiKey: '', region: 'eu' } }) autoInjectAll(registry, rt, '/_proxy') @@ -106,8 +106,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.posthog.apiHost).toBe('/_proxy/ph-eu') }) - it('injects into runtimeConfig for plausibleAnalytics: true', () => { - const registry: any = { plausibleAnalytics: true } + it('injects into runtimeConfig for plausibleAnalytics: {}', () => { + const registry: any = { plausibleAnalytics: {} } const rt = makeRuntimeConfig({ plausibleAnalytics: { domain: '' } }) autoInjectAll(registry, rt, '/_proxy') @@ -115,8 +115,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.plausibleAnalytics.endpoint).toBe('/_proxy/plausible/api/event') }) - it('injects into runtimeConfig for umamiAnalytics: true', () => { - const registry: any = { umamiAnalytics: true } + it('injects into runtimeConfig for umamiAnalytics: {}', () => { + const registry: any = { umamiAnalytics: {} } const rt = makeRuntimeConfig({ umamiAnalytics: { websiteId: '' } }) autoInjectAll(registry, rt, '/_proxy') @@ -124,8 +124,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.umamiAnalytics.hostUrl).toBe('/_proxy/umami') }) - it('injects into runtimeConfig for rybbitAnalytics: true', () => { - const registry: any = { rybbitAnalytics: true } + it('injects into runtimeConfig for rybbitAnalytics: {}', () => { + const registry: any = { rybbitAnalytics: {} } const rt = makeRuntimeConfig({ rybbitAnalytics: { siteId: '' } }) autoInjectAll(registry, rt, '/_proxy') @@ -133,8 +133,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.rybbitAnalytics.analyticsHost).toBe('/_proxy/rybbit/api') }) - it('injects into runtimeConfig for databuddyAnalytics: true', () => { - const registry: any = { databuddyAnalytics: true } + it('injects into runtimeConfig for databuddyAnalytics: {}', () => { + const registry: any = { databuddyAnalytics: {} } const rt = makeRuntimeConfig({ databuddyAnalytics: { clientId: '' } }) autoInjectAll(registry, rt, '/_proxy') @@ -185,7 +185,7 @@ describe('autoInject via proxy configs', () => { describe('custom proxyPrefix', () => { it('uses custom prefix in computed values', () => { - const registry: any = { posthog: true } + const registry: any = { posthog: {} } const rt = makeRuntimeConfig({ posthog: { apiKey: '' } }) autoInjectAll(registry, rt, '/_analytics') diff --git a/test/unit/templates.test.ts b/test/unit/templates.test.ts index 8eb275d7..88107571 100644 --- a/test/unit/templates.test.ts +++ b/test/unit/templates.test.ts @@ -110,7 +110,7 @@ describe('template plugin file', () => { `) }) // registry - it('registry object', async () => { + it('registry object without trigger (infrastructure only, no composable call)', async () => { const res = templatePluginNormalized({ globals: {}, registry: { @@ -125,9 +125,27 @@ describe('template plugin file', () => { }, }, ]) - expect(res).toContain('useScriptStripe({"id":"test"})') + expect(res).not.toContain('useScriptStripe') }) - it('registry array', async () => { + it('registry object with trigger (auto-loads globally)', async () => { + const res = templatePluginNormalized({ + globals: {}, + registry: { + stripe: { + id: 'test', + trigger: 'onNuxtReady', + }, + }, + }, [ + { + import: { + name: 'useScriptStripe', + }, + }, + ]) + expect(res).toContain('useScriptStripe({"id":"test","scriptOptions":{"trigger":"onNuxtReady"}})') + }) + it('registry array with trigger', async () => { const res = templatePluginNormalized({ globals: {}, registry: { @@ -150,7 +168,7 @@ describe('template plugin file', () => { expect(res).toContain('useScriptStripe({"id":"test","scriptOptions":{"trigger":"onNuxtReady"}})') }) - it('registry with partytown option', async () => { + it('registry with partytown but no trigger (no composable call)', async () => { const res = templatePluginNormalized({ globals: {}, registry: { @@ -166,7 +184,26 @@ describe('template plugin file', () => { }, }, ]) - expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","scriptOptions":{"partytown":true}})') + expect(res).not.toContain('useScriptGoogleAnalytics') + }) + + it('registry with partytown and trigger', async () => { + const res = templatePluginNormalized({ + globals: {}, + registry: { + googleAnalytics: [ + { id: 'G-XXXXX' }, + { partytown: true, trigger: 'onNuxtReady' }, + ], + }, + }, [ + { + import: { + name: 'useScriptGoogleAnalytics', + }, + }, + ]) + expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","scriptOptions":{"partytown":true,"trigger":"onNuxtReady"}})') }) // Test idleTimeout trigger in globals From 74c2afa51ff39f4c36fe8077209208cbc729e9c0 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 21 Mar 2026 00:32:55 +1100 Subject: [PATCH 2/2] fix: guard event.path for stricter h3 types --- src/assets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets.ts b/src/assets.ts index 0385a3f4..b4938d50 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -53,7 +53,7 @@ export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {}) route: assetsBaseURL, handler: lazyEventHandler(async () => { return eventHandler(async (event) => { - const cleanPath = event.path.split('?')[0].slice(1) + const cleanPath = event.path!.split('?')[0].slice(1) const filename = cleanPath const scriptDescriptor = renderedScript.get(join(assetsBaseURL, cleanPath))