Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions docs/content/docs/1.guides/1.registry-scripts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -68,8 +68,7 @@ NUXT_PUBLIC_SCRIPTS_CLOUDFLARE_WEB_ANALYTICS_TOKEN=YOUR_TOKEN
export default defineNuxtConfig({
scripts: {
registry: {
// loads the script
cloudflareWebAnalytics: true,
cloudflareWebAnalytics: { trigger: 'onNuxtReady' },
},
},
runtimeConfig: {
Expand DownExpand Up@@ -100,7 +99,7 @@ You can do this by providing a `mock` value to the registry script.
export default defineNuxtConfig({
scripts: {
registry: {
googleTagManager: true,
googleTagManager: { trigger: 'onNuxtReady' },
},
},
$development: {
Expand Down
56 changes: 49 additions & 7 deletions docs/content/docs/4.migration-guide/1.v0-to-v1.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@

Third-party scripts expose data that enables fingerprinting users across sites. Every request shares the user's IP address, and scripts can set third-party cookies for cross-site tracking.

First-party mode acts as a reverse proxy: scripts are bundled at build time and served from your domain, while runtime collection requests are forwarded through Nitro server routes with automatic anonymisation. It is **auto-enabled** for scripts that support it, zero-config:

Check warning on line 14 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions/ lint

Passive voice: "are bundled". Consider rewriting in active voice

Check warning on line 14 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions/ lint

Passive voice: "are bundled". Consider rewriting in active voice

Check warning on line 14 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions/ lint

Passive voice: "are bundled". Consider rewriting in active voice

```ts
export default defineNuxtConfig({
Expand DownExpand Up@@ -63,9 +63,9 @@
export default defineNuxtConfig({
scripts: {
registry: {
xEmbed: true,
instagramEmbed: true,
blueskyEmbed: true,
xEmbed: { trigger: 'onNuxtReady' },
instagramEmbed: { trigger: 'onNuxtReady' },
blueskyEmbed: { trigger: 'onNuxtReady' },
},
},
})
Expand DownExpand Up@@ -192,14 +192,56 @@

v1 redesigns how registry entries work. The key change: **presence enables infrastructure, `trigger` enables auto-loading**.

### Scripts No Longer Auto-Load Without a Trigger

In v0, any configured script auto-loaded globally. In v1, scripts require an explicit `trigger` to auto-load. Without one, the script registers infrastructure (proxy routes, types, bundling) but does **not** load on the page.

If you had a config like this in v0:

```ts [v0 nuxt.config.ts]
export default defineNuxtConfig({
scripts: {
registry: {
googleAnalytics: { id: 'G-XXXXXX' },
}
}
})
```

You need to add a trigger in v1:

```ts [v1 nuxt.config.ts]
export default defineNuxtConfig({
scripts: {
registry: {
googleAnalytics: { id: 'G-XXXXXX', trigger: 'onNuxtReady' },
}
}
})
```

If you only need infrastructure without loading the script on the page, set `trigger: false` explicitly. This registers proxy routes, TypeScript types, and bundling config, but no `<script>`{lang="html"} tag is injected. Useful when you load the script yourself via a component or composable.

Check warning on line 223 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions/ lint

Passive voice: "is injected". Consider rewriting in active voice

Check warning on line 223 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions/ lint

Passive voice: "is injected". Consider rewriting in active voice

Check warning on line 223 in docs/content/docs/4.migration-guide/1.v0-to-v1.md

View workflow job for this annotation

GitHub Actions/ lint

Passive voice: "is injected". Consider rewriting in active voice

```ts [nuxt.config.ts]
export default defineNuxtConfig({
scripts: {
registry: {
googleAnalytics: { id: 'G-XXXXXX', trigger: false },
}
}
})
```

::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
A build warning will appear if you provide config values without a `trigger`. Set `trigger: 'onNuxtReady'` to auto-load, or `trigger: false` for infrastructure only.
::

### Config Migration

| v0 | v1 | What changed |
|----|-----|--------------|
| `googleAnalytics: true` | `googleAnalytics: true` | Still works; alias for `{ trigger: 'onNuxtReady' }` (auto-load). Use `{}` for proxy/bundling infrastructure without auto-loading |
| `[{ id: '...' }, { bundle: true }]` | `{ id: '...' }` | Bundling is auto-enabled via capabilities; no need to opt in |
| `[{ id: '...' }, { trigger: 'onNuxtReady' }]` | `{ id: '...', trigger: 'onNuxtReady' }` | Array tuple syntax still works, but flat config is preferred |
| `googleAnalytics: 'mock'` | `googleAnalytics: 'mock'` | Unchanged; creates a stub for testing |
| `{ id: '...' }` | `{ id: '...', trigger: 'onNuxtReady' }` | Add an explicit `trigger` to auto-load. `true` shorthand, `bundle` option, and array tuple syntax are also replaced by flat config with `trigger`. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Clarify bundle wording to avoid implying it was replaced by trigger.

Line 243 currently reads as if bundle was replaced by trigger, but bundle is still a supported flat top-level option (as shown later in this doc). Please split this into two points: (1) tuple/boolean shapes were replaced by flat config, and (2) auto-loading now requires trigger.

Suggested wording
-| `{ id: '...' }` | `{ id: '...', trigger: 'onNuxtReady' }` | Add an explicit `trigger` to auto-load. `true` shorthand, `bundle` option, and array tuple syntax are also replaced by flat config with `trigger`. |+| `{ id: '...' }` | `{ id: '...', trigger: 'onNuxtReady' }` | Add an explicit `trigger` to auto-load. `true` shorthand and array tuple syntax are replaced by flat config. `bundle` remains supported as a flat top-level option when needed. |
πŸ“ Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
|`{ id: '...' }`|`{ id: '...', trigger: 'onNuxtReady' }`| Add an explicit `trigger` to auto-load. `true` shorthand, `bundle` option, and array tuple syntax are also replaced by flat config with `trigger`. |
|`{ id: '...' }`|`{ id: '...', trigger: 'onNuxtReady' }`| Add an explicit `trigger` to auto-load. `true` shorthandand array tuple syntax are replaced by flat config. `bundle` remains supported as a flat top-level option when needed. |
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/content/docs/4.migration-guide/1.v0-to-v1.md` at line 243, Update the
migration note that currently implies `bundle` was replaced by `trigger`: split
the single sentence into two clear points β€” first state that tuple/boolean
shapes (e.g. `true` shorthand and array tuple syntax) were replaced by a flat
config shape (referencing the `{ id: '...' }` β†’ `{ id: '...', trigger:
'onNuxtReady' }` example), and second state that auto-loading now requires an
explicit `trigger` option; explicitly mention that `bundle` remains a supported
top-level flat option and is not replaced by `trigger` so readers are not
misled.

| `'mock'` | `'mock'` | Unchanged; creates a stub for testing |

### Flat Config Syntax

Expand Down
2 changes: 1 addition & 1 deletion docs/content/scripts/crisp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,7 @@ If you prefer to configure your id using environment variables.
export default defineNuxtConfig({
scripts: {
registry: {
crisp: true,
crisp: { trigger: 'onNuxtReady' },
}
},
// you need to provide a runtime config to access the environment variables
Expand Down
2 changes: 1 addition & 1 deletion docs/content/scripts/google-maps/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,7 @@ Enable Google Maps in your `nuxt.config` and provide your API key via environmen
export default defineNuxtConfig({
scripts: {
registry: {
googleMaps: true,
googleMaps: { trigger: 'onNuxtReady' },
},
},
runtimeConfig: {
Expand Down
2 changes: 1 addition & 1 deletion docs/content/scripts/intercom.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -87,7 +87,7 @@ If you prefer to configure your app ID using environment variables.
export default defineNuxtConfig({
scripts: {
registry: {
intercom: true,
intercom: { trigger: 'onNuxtReady' },
}
},
// you need to provide a runtime config to access the environment variables
Expand Down
2 changes: 1 addition & 1 deletion docs/content/scripts/paypal.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,7 +106,7 @@ If you prefer to configure your client ID using environment variables.
export default defineNuxtConfig({
scripts: {
registry: {
paypal: true,
paypal: { trigger: 'onNuxtReady' },
}
},
// you need to provide a runtime config to access the environment variables
Expand Down
4 changes: 2 additions & 2 deletions docs/content/scripts/stripe.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ Stripe recommends loading their script globally on your app to improve fraud det
export default defineNuxtConfig({
scripts: {
registry: {
stripe: true,
stripe: { trigger: 'onNuxtReady' },
}
}
})
Expand All@@ -54,7 +54,7 @@ export default defineNuxtConfig({
$production: {
scripts: {
registry: {
stripe: true,
stripe: { trigger: 'onNuxtReady' },
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/content/scripts/vercel-analytics.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ First-party mode is auto-enabled for Vercel Analytics. Nuxt bundles the analytic
export default defineNuxtConfig({
scripts: {
registry: {
vercelAnalytics: true,
vercelAnalytics: { trigger: 'onNuxtReady' },
}
}
})
Expand Down
18 changes: 16 additions & 2 deletions packages/script/src/module.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@ import { setupPublicAssetStrategy } from './assets'
import { buildDevtoolsData, buildDevtoolsEntry, setupDevtools } from './devtools'
import { installNuxtModule } from './kit'
import { logger } from './logger'
import { extractRequiredFields, normalizeRegistryConfig } from './normalize'
import { extractRequiredFields, migrateDeprecatedRegistryKeys, normalizeRegistryConfig } from './normalize'
import { NuxtScriptsCheckScripts } from './plugins/check-scripts'
import { generateInterceptPluginContents } from './plugins/intercept'
import { NuxtScriptBundleTransformer } from './plugins/transform'
Expand DownExpand Up@@ -332,7 +332,8 @@ export default defineNuxtModule<ModuleOptions>({
// Normalize registry entries to [input, scriptOptions?] tuple form
// Eliminates 4-shape polymorphism (true | 'mock' | object | array) for all downstream consumers
if (config.registry) {
normalizeRegistryConfig(config.registry as Record<string, any>)
migrateDeprecatedRegistryKeys(config.registry as Record<string, any>, msg => logger.warn(msg))
normalizeRegistryConfig(config.registry as Record<string, any>, msg => logger.warn(msg))
nuxt.options.runtimeConfig.public = nuxt.options.runtimeConfig.public || {}

// Auto-populate env var defaults for enabled registry scripts so that
Expand DownExpand Up@@ -459,6 +460,19 @@ export default defineNuxtModule<ModuleOptions>({
if (missing.length) {
logger.warn(`[nuxt-scripts] registry.${key}: missing required field${missing.length > 1 ? 's' : ''} ${missing.map(f => `'${f}'`).join(', ')}. The script infrastructure is registered but will not function without ${missing.length > 1 ? 'them' : 'it'}.`)
}
// Warn when a user provides input config but no explicit trigger.
// In v0 all configured scripts auto-loaded; in v1 a trigger is required.
// trigger: false is valid (explicit infrastructure-only).
// Only warn for user-provided fields (skip env-var-only defaults).
const envDefaultKeys = new Set(Object.keys(script.envDefaults || {}))
const userProvidedFields = Object.keys(input).filter(f => !envDefaultKeys.has(f))
if (userProvidedFields.length > 0 && (!scriptOptions || !('trigger' in scriptOptions))) {
logger.warn(
`[nuxt-scripts] registry.${key}: config provided without a \`trigger\`. `
+ `The script will not auto-load. Add \`trigger: 'onNuxtReady'\` to auto-load, or \`trigger: false\` for infrastructure only. `
+ `See https://scripts.nuxt.com/docs/migration-guide/v0-to-v1`,
)
}
}
}

Expand Down
53 changes: 50 additions & 3 deletions packages/script/src/normalize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import type { NuxtUseScriptOptionsSerializable, RegistryScript } from './runtime
export type NormalizedRegistryEntry = [input: Record<string, unknown>, scriptOptions?: NormalizedScriptOptions]

export type NormalizedScriptOptions = Partial<Omit<NuxtUseScriptOptionsSerializable, 'trigger'>> & {
trigger?: NuxtUseScriptOptionsSerializable['trigger'] | 'manual'
trigger?: NuxtUseScriptOptionsSerializable['trigger'] | 'manual' | false
skipValidation?: boolean
}

Expand All@@ -23,6 +23,49 @@ export function extractRequiredFields(schema: RegistryScript['schema']): string[
.map(([key]) => key)
}

/**
* Rewrite deprecated config keys in-place before normalization.
* Currently handles: `reverseProxyIntercept` β†’ `proxy`.
*/
export function migrateDeprecatedRegistryKeys(
registry: Record<string, unknown>,
warn: (msg: string) => void,
): void {
for (const key of Object.keys(registry)) {
const entry = registry[key]
if (!entry || typeof entry !== 'object')
continue

if (Array.isArray(entry)) {
// Array tuple: check scriptOptions (second element)
const opts = entry[1]
if (opts && typeof opts === 'object' && 'reverseProxyIntercept' in opts) {
warn(`[nuxt-scripts] registry.${key}: \`reverseProxyIntercept\` has been renamed to \`proxy\`. Please update your config. Auto-migrating for now.`)
const o = opts as Record<string, unknown>
o.proxy ??= o.reverseProxyIntercept
delete o.reverseProxyIntercept
}
}
else {
const obj = entry as Record<string, unknown>
// Top-level key
if ('reverseProxyIntercept' in obj) {
warn(`[nuxt-scripts] registry.${key}: \`reverseProxyIntercept\` has been renamed to \`proxy\`. Please update your config. Auto-migrating for now.`)
obj.proxy ??= obj.reverseProxyIntercept
delete obj.reverseProxyIntercept
}
// Nested scriptOptions
const so = obj.scriptOptions
if (so && typeof so === 'object' && 'reverseProxyIntercept' in so) {
warn(`[nuxt-scripts] registry.${key}: \`scriptOptions.reverseProxyIntercept\` has been renamed to \`proxy\`. Please update your config. Auto-migrating for now.`)
const s = so as Record<string, unknown>
s.proxy ??= s.reverseProxyIntercept
delete s.reverseProxyIntercept
}
}
}
}

/**
* Normalize all registry config entries in-place to [input, scriptOptions?] tuple form.
*
Expand All@@ -35,17 +78,21 @@ export function extractRequiredFields(schema: RegistryScript['schema']): string[
* - `[input, scriptOptions]` β†’ unchanged (internal/backwards compat)
*
* Aliases:
* - `true` β†’ `[{}, { trigger: 'onNuxtReady' }]` (auto-load globally)
* - `true` β†’ `[{}, { trigger: 'onNuxtReady' }]` (deprecated, use `{ trigger: 'onNuxtReady' }`)
* - `'proxy-only'` β†’ build error with migration message
*/
export function normalizeRegistryConfig(registry: Record<string, unknown>): void {
export function normalizeRegistryConfig(
registry: Record<string, unknown>,
warn?: (msg: string) => void,
): void {
for (const key of Object.keys(registry)) {
const entry = registry[key]
if (!entry) {
delete registry[key]
continue
}
if (entry === true) {
warn?.(`[nuxt-scripts] registry.${key}: \`true\` shorthand is deprecated. Use \`{ trigger: 'onNuxtReady' }\` instead.`)
registry[key] = [{}, { trigger: 'onNuxtReady' }] satisfies NormalizedRegistryEntry
continue
}
Expand Down
2 changes: 1 addition & 1 deletion packages/script/src/runtime/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,7 +251,7 @@ export type RegistryScriptKey = Exclude<keyof ScriptRegistry, `${string}-npm`>

type RegistryConfigInput<T> = [T] extends [true] ? Record<string, never> : T

export type NuxtConfigScriptRegistryEntry<T> = true | false | 'mock' | (RegistryConfigInput<T> & { trigger?: NuxtUseScriptOptionsSerializable['trigger'], proxy?: boolean, bundle?: boolean, partytown?: boolean, privacy?: ProxyPrivacyInput })
export type NuxtConfigScriptRegistryEntry<T> = true | false | 'mock' | (RegistryConfigInput<T> & { trigger?: NuxtUseScriptOptionsSerializable['trigger'] | false, proxy?: boolean, bundle?: boolean, partytown?: boolean, privacy?: ProxyPrivacyInput })
export type NuxtConfigScriptRegistry<T extends keyof ScriptRegistry = keyof ScriptRegistry> = Partial<{
[key in T]: NuxtConfigScriptRegistryEntry<ScriptRegistry[key]>
}> & Record<string & {}, NuxtConfigScriptRegistryEntry<any>>
Expand Down
69 changes: 67 additions & 2 deletions test/unit/normalize.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { normalizeRegistryConfig } from '../../packages/script/src/normalize'
import { describe, expect, it, vi } from 'vitest'
import { migrateDeprecatedRegistryKeys, normalizeRegistryConfig } from '../../packages/script/src/normalize'

describe('normalizeRegistryConfig', () => {
it('normalizes true to [{}, { trigger: "onNuxtReady" }]', () => {
Expand All@@ -8,6 +8,15 @@ describe('normalizeRegistryConfig', () => {
expect(registry.plausible).toEqual([{}, { trigger: 'onNuxtReady' }])
})

it('warns when true shorthand is used', () => {
const warn = vi.fn()
const registry: Record<string, any> = { plausible: true }
normalizeRegistryConfig(registry, warn)
expect(warn).toHaveBeenCalledOnce()
expect(warn.mock.calls[0][0]).toContain('true')
expect(warn.mock.calls[0][0]).toContain('deprecated')
})

it('throws on "proxy-only" with migration message', () => {
const registry: Record<string, any> = { ga: 'proxy-only' }
expect(() => normalizeRegistryConfig(registry)).toThrowError(/proxy-only.*no longer supported/)
Expand DownExpand Up@@ -37,6 +46,12 @@ describe('normalizeRegistryConfig', () => {
expect(registry.ga).toEqual([{ id: 'G-xxx' }, { trigger: 'onNuxtReady' }])
})

it('hoists trigger: false to scriptOptions', () => {
const registry: Record<string, any> = { ga: { id: 'G-xxx', trigger: false } }
normalizeRegistryConfig(registry)
expect(registry.ga).toEqual([{ id: 'G-xxx' }, { trigger: false }])
})

it('hoists proxy to scriptOptions', () => {
const registry: Record<string, any> = { plausible: { domain: 'mysite.com', proxy: false } }
normalizeRegistryConfig(registry)
Expand DownExpand Up@@ -108,3 +123,53 @@ describe('normalizeRegistryConfig', () => {
expect(registry.stripe).toBeUndefined()
})
})

describe('migrateDeprecatedRegistryKeys', () => {
it('rewrites reverseProxyIntercept in flat object to proxy', () => {
const warn = vi.fn()
const registry: Record<string, any> = { ga: { id: 'G-xxx', reverseProxyIntercept: false } }
migrateDeprecatedRegistryKeys(registry, warn)
expect(registry.ga).toEqual({ id: 'G-xxx', proxy: false })
expect(warn).toHaveBeenCalledOnce()
expect(warn.mock.calls[0][0]).toContain('reverseProxyIntercept')
})

it('rewrites reverseProxyIntercept in nested scriptOptions', () => {
const warn = vi.fn()
const registry: Record<string, any> = { ga: { id: 'G-xxx', scriptOptions: { reverseProxyIntercept: false } } }
migrateDeprecatedRegistryKeys(registry, warn)
expect(registry.ga.scriptOptions).toEqual({ proxy: false })
expect(warn).toHaveBeenCalledOnce()
})

it('rewrites reverseProxyIntercept in array tuple scriptOptions', () => {
const warn = vi.fn()
const registry: Record<string, any> = { ga: [{ id: 'G-xxx' }, { reverseProxyIntercept: false }] }
migrateDeprecatedRegistryKeys(registry, warn)
expect(registry.ga[1]).toEqual({ proxy: false })
expect(warn).toHaveBeenCalledOnce()
})

it('does not clobber existing proxy when both are present', () => {
const warn = vi.fn()
const registry: Record<string, any> = { ga: { id: 'G-xxx', proxy: true, reverseProxyIntercept: false } }
migrateDeprecatedRegistryKeys(registry, warn)
expect(registry.ga).toEqual({ id: 'G-xxx', proxy: true })
expect(warn).toHaveBeenCalledOnce()
})

it('does not warn when reverseProxyIntercept is absent', () => {
const warn = vi.fn()
const registry: Record<string, any> = { ga: { id: 'G-xxx', proxy: false } }
migrateDeprecatedRegistryKeys(registry, warn)
expect(registry.ga).toEqual({ id: 'G-xxx', proxy: false })
expect(warn).not.toHaveBeenCalled()
})

it('skips non-object entries', () => {
const warn = vi.fn()
const registry: Record<string, any> = { ga: true, posthog: 'mock', stripe: false }
migrateDeprecatedRegistryKeys(registry, warn)
expect(warn).not.toHaveBeenCalled()
})
})
Loading