Skip to content
26 changes: 26 additions & 0 deletions docs/content/scripts/gravatar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---

title: Gravatar
description: Use Gravatar in your Nuxt app.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/gravatar.ts
size: xs
- label: "<ScriptGravatar>"
icon: i-simple-icons-github
to: https://github.com/nuxt/scripts/blob/main/src/runtime/components/ScriptGravatar.vue
size: xs

---

[Gravatar](https://gravatar.com) provides globally recognized avatars linked to email addresses. Nuxt Scripts provides a privacy-preserving integration that proxies avatar requests through your own server, preventing Gravatar from tracking your users.

::script-stats
::

::script-docs
::

::script-types
::
19 changes: 17 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ export default defineNuxtModule<ModuleOptions>({
googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled
? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge }
: undefined,
}
} as any

// Merge registry config with existing runtimeConfig.public.scripts for proper env var resolution
// Both scripts.registry and runtimeConfig.public.scripts should be supported
Expand Down Expand Up @@ -428,7 +428,7 @@ export default defineNuxtModule<ModuleOptions>({
const partytownConfig = (nuxt.options as any).partytown || {}
const existingForwards = partytownConfig.forward || []
const newForwards = [...new Set([...existingForwards, ...requiredForwards])]
;(nuxt.options as any).partytown = { ...partytownConfig, forward: newForwards }
; (nuxt.options as any).partytown = { ...partytownConfig, forward: newForwards }
logger.info(`[partytown] Auto-configured forwards: ${requiredForwards.join(', ')}`)
}
}
Expand Down Expand Up @@ -703,6 +703,21 @@ export default defineNuxtModule<ModuleOptions>({
})
}

// Add Gravatar proxy handler when registry.gravatar is enabled
if (config.registry?.gravatar) {
const gravatarConfig = typeof config.registry.gravatar === 'object' && !Array.isArray(config.registry.gravatar)
? config.registry.gravatar as Record<string, any>
: {}
nuxt.options.runtimeConfig.public['nuxt-scripts'] = defu(
{ gravatarProxy: { cacheMaxAge: gravatarConfig.cacheMaxAge ?? 3600 } },
nuxt.options.runtimeConfig.public['nuxt-scripts'] as any,
) as any
addServerHandler({
route: '/_scripts/gravatar-proxy',
handler: await resolvePath('./runtime/server/gravatar-proxy'),
})
}

// Add X/Twitter embed proxy handlers
addServerHandler({
route: '/api/_scripts/x-embed',
Expand Down
13 changes: 13 additions & 0 deletions src/proxy-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,19 @@ function buildProxyConfig(collectPrefix: string) {
[`${collectPrefix}/vercel/**`]: { proxy: 'https://va.vercel-scripts.com/**' },
},
},

gravatar: {
// Gravatar: avatar proxy β€” IP anonymized, rest not needed
privacy: { ip: true, userAgent: false, language: false, screen: false, timezone: false, hardware: false },
rewrite: [
{ from: 'secure.gravatar.com', to: `${collectPrefix}/gravatar` },
{ from: 'gravatar.com/avatar', to: `${collectPrefix}/gravatar-avatar` },
],
routes: {
[`${collectPrefix}/gravatar/**`]: { proxy: 'https://secure.gravatar.com/**' },
[`${collectPrefix}/gravatar-avatar/**`]: { proxy: 'https://gravatar.com/avatar/**' },
},
},
} satisfies Record<string, ProxyConfig>
}

Expand Down
51 changes: 39 additions & 12 deletions src/registry-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,13 @@
"code": "export const GoogleTagManagerOptions = object({\n /**\n * GTM container ID (format: GTM-XXXXXX)\n * @see https://developers.google.com/tag-platform/tag-manager/web#install-the-container\n */\n id: string(),\n\n /**\n * Optional dataLayer variable name\n * @default 'dataLayer'\n * @see https://developers.google.com/tag-platform/tag-manager/web/datalayer#rename_the_data_layer\n */\n l: optional(string()),\n\n /**\n * Authentication token for environment-specific container versions\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n auth: optional(string()),\n\n /**\n * Preview environment name\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n preview: optional(string()),\n\n /** Forces GTM cookies to take precedence when true */\n cookiesWin: optional(union([boolean(), literal('x')])),\n\n /**\n * Enables debug mode when true\n * @see https://support.google.com/tagmanager/answer/6107056\n */\n debug: optional(union([boolean(), literal('x')])),\n\n /**\n * No Personal Advertising - disables advertising features when true\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n npa: optional(union([boolean(), literal('1')])),\n\n /** Custom dataLayer name (alternative to \"l\" property) */\n dataLayer: optional(string()),\n\n /**\n * Environment name for environment-specific container\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n envName: optional(string()),\n\n /** Referrer policy for analytics requests */\n authReferrerPolicy: optional(string()),\n\n /**\n * Default consent settings for GTM\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n defaultConsent: optional(record(string(), union([string(), number()]))),\n})"
}
],
"gravatar": [
{
"name": "GravatarApi",
"kind": "interface",
"code": "export interface GravatarApi {\n /**\n * Get a proxied avatar URL for a given SHA256 email hash.\n * When firstParty mode is enabled, this routes through your server.\n */\n getAvatarUrl: (hash: string, options?: { size?: number, default?: string, rating?: string }) => string\n /**\n * Get a proxied avatar URL using the server-side hashing endpoint.\n * The email is sent to YOUR server (not Gravatar) for hashing.\n * Only available when the gravatar proxy is enabled.\n */\n getAvatarUrlFromEmail: (email: string, options?: { size?: number, default?: string, rating?: string }) => string\n}"
}
],
"hotjar": [
{
"name": "HotjarOptions",
Expand Down Expand Up @@ -690,6 +697,38 @@
"code": "export interface UmamiAnalyticsApi {\n track: ((payload?: Record<string, any>) => void) & ((event_name: string, event_data: Record<string, any>) => void)\n identify: (session_data?: Record<string, any> | string) => void\n}"
}
],
"vercel-analytics": [
{
"name": "VercelAnalyticsOptions",
"kind": "const",
"code": "export const VercelAnalyticsOptions = object({\n /**\n * The DSN of the project to send events to.\n * Only required when self-hosting or deploying outside of Vercel.\n */\n dsn: optional(string()),\n /**\n * Whether to disable automatic page view tracking on route changes.\n * Set to true if you want to manually call pageview().\n */\n disableAutoTrack: optional(boolean()),\n /**\n * The mode to use for the analytics script.\n * - `auto` - Automatically detect the environment (default)\n * - `production` - Always use production script\n * - `development` - Always use development script (logs to console)\n */\n mode: optional(union([literal('auto'), literal('development'), literal('production')])),\n /**\n * Whether to enable debug logging.\n * Automatically enabled in development/test environments.\n */\n debug: optional(boolean()),\n /**\n * Custom endpoint for data collection.\n * Useful for self-hosted or proxied setups.\n */\n endpoint: optional(string()),\n})"
},
{
"name": "AllowedPropertyValues",
"kind": "type",
"code": "export type AllowedPropertyValues = string | number | boolean | null"
},
{
"name": "VercelAnalyticsMode",
"kind": "type",
"code": "export type VercelAnalyticsMode = 'auto' | 'development' | 'production'"
},
{
"name": "BeforeSendEvent",
"kind": "interface",
"code": "export interface BeforeSendEvent {\n type: 'pageview' | 'event'\n url: string\n}"
},
{
"name": "BeforeSend",
"kind": "type",
"code": "export type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null"
},
{
"name": "VercelAnalyticsApi",
"kind": "interface",
"code": "export interface VercelAnalyticsApi {\n va: (event: string, properties?: unknown) => void\n track: (name: string, properties?: Record<string, AllowedPropertyValues>) => void\n pageview: (options?: { route?: string | null, path?: string }) => void\n}"
}
],
"vimeo-player": [
{
"name": "Constructor",
Expand Down Expand Up @@ -778,18 +817,6 @@
"code": "const ScriptYouTubePlayerDefaults = {\n \"cookies\": \"false\",\n \"trigger\": \"'mousedown'\",\n \"thumbnailSize\": \"'hq720'\",\n \"webp\": \"true\",\n \"playerVars\": \"{ autoplay: 0, playsinline: 1 }\",\n \"width\": \"640\",\n \"height\": \"360\",\n \"ratio\": \"'16/9'\",\n \"placeholderObjectFit\": \"'cover'\"\n}"
}
],
"vercel-analytics": [
{
"name": "VercelAnalyticsOptions",
"kind": "const",
"code": "export const VercelAnalyticsOptions = object({\n /**\n * The DSN of the project to send events to.\n * Only required when self-hosting or deploying outside of Vercel.\n */\n dsn: optional(string()),\n /**\n * Whether to disable automatic page view tracking on route changes.\n * Set to true if you want to manually call pageview().\n */\n disableAutoTrack: optional(boolean()),\n /**\n * The mode to use for the analytics script.\n * - `auto` - Automatically detect the environment (default)\n * - `production` - Always use production script\n * - `development` - Always use development script (logs to console)\n */\n mode: optional(union([literal('auto'), literal('development'), literal('production')])),\n /**\n * Whether to enable debug logging.\n * Automatically enabled in development/test environments.\n */\n debug: optional(boolean()),\n /**\n * Custom endpoint for data collection.\n * Useful for self-hosted or proxied setups.\n */\n endpoint: optional(string()),\n})"
},
{
"name": "VercelAnalyticsApi",
"kind": "interface",
"code": "export interface VercelAnalyticsApi {\n va: (event: string, properties?: unknown) => void\n track: (name: string, properties?: Record<string, AllowedPropertyValues>) => void\n pageview: (options?: { route?: string | null, path?: string }) => void\n}"
}
],
"carbon-ads": [
{
"name": "ScriptCarbonAdsProps",
Expand Down
11 changes: 11 additions & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,5 +442,16 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption
from: await resolve('./runtime/registry/umami-analytics'),
},
},
{
label: 'Gravatar',
proxy: 'gravatar',
src: 'https://secure.gravatar.com/js/gprofiles.js',
category: 'utility',
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256"><circle cx="128" cy="128" r="128" fill="#1d4fc4"/><path d="M128 28c-55.2 0-100 44.8-100 100s44.8 100 100 100 100-44.8 100-100S183.2 28 128 28zm0 180c-44.1 0-80-35.9-80-80s35.9-80 80-80 80 35.9 80 80-35.9 80-80 80z" fill="#fff"/></svg>`,
import: {
name: 'useScriptGravatar',
from: await resolve('./runtime/registry/gravatar'),
},
},
]
}
63 changes: 63 additions & 0 deletions src/runtime/components/ScriptGravatar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, onMounted, ref, useAttrs } from 'vue'
import { useScriptGravatar } from '../registry/gravatar'

const props = withDefaults(defineProps<{
/** Email address β€” sent to your server proxy for hashing, not sent to Gravatar */
email?: string
/** Pre-computed SHA256 hash of the email */
hash?: string
/** Avatar size in pixels */
size?: number
/** Default avatar style when no Gravatar exists */
default?: string
/** Content rating filter */
rating?: string
/** Enable hovercards on hover */
hovercards?: boolean
}>(), {
size: 80,
default: 'mp',
rating: 'g',
hovercards: false,
})

const attrs = useAttrs()
const imgSrc = ref('')

const { onLoaded } = useScriptGravatar()

const queryOverrides = computed(() => ({
size: props.size,
default: props.default,
rating: props.rating,
}))

onMounted(() => {
onLoaded((api) => {
if (props.email) {
imgSrc.value = api.getAvatarUrlFromEmail(props.email, queryOverrides.value)
}
else if (props.hash) {
imgSrc.value = api.getAvatarUrl(props.hash, queryOverrides.value)
}
})
})
</script>

<template>
<img
v-if="imgSrc"
:src="imgSrc"
:width="size"
:height="size"
:class="{ hovercard: hovercards }"
v-bind="attrs"
:alt="attrs.alt as string || 'Gravatar avatar'"
loading="lazy"
>
<span
v-else
:style="{ display: 'inline-block', width: `${size}px`, height: `${size}px`, borderRadius: '50%', background: '#e0e0e0' }"
/>
</template>
52 changes: 52 additions & 0 deletions src/runtime/registry/gravatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { RegistryScriptInput } from '#nuxt-scripts/types'
import { useRegistryScript } from '#nuxt-scripts/utils'
import { GravatarOptions } from './schemas'

export type GravatarInput = RegistryScriptInput<typeof GravatarOptions>

export interface GravatarApi {
/**
* Get a proxied avatar URL for a given SHA256 email hash.
* When firstParty mode is enabled, this routes through your server.
*/
getAvatarUrl: (hash: string, options?: { size?: number, default?: string, rating?: string }) => string
/**
* Get a proxied avatar URL using the server-side hashing endpoint.
* The email is sent to YOUR server (not Gravatar) for hashing.
* Only available when the gravatar proxy is enabled.
*/
getAvatarUrlFromEmail: (email: string, options?: { size?: number, default?: string, rating?: string }) => string
}

export function useScriptGravatar<T extends GravatarApi>(_options?: GravatarInput) {
return useRegistryScript<T, typeof GravatarOptions>(_options?.key || 'gravatar', (options) => {
const size = options?.size ?? 80
const defaultImg = options?.default ?? 'mp'
const rating = options?.rating ?? 'g'

const buildQuery = (overrides?: { size?: number, default?: string, rating?: string }) => {
const params = new URLSearchParams()
params.set('s', String(overrides?.size ?? size))
params.set('d', overrides?.default ?? defaultImg)
params.set('r', overrides?.rating ?? rating)
return params.toString()
}

return {
scriptInput: {
src: 'https://secure.gravatar.com/js/gprofiles.js',
},
schema: import.meta.dev ? GravatarOptions : undefined,
scriptOptions: {
use: () => ({
getAvatarUrl: (hash: string, overrides?: { size?: number, default?: string, rating?: string }) => {
return `/_scripts/gravatar-proxy?hash=${encodeURIComponent(hash)}&${buildQuery(overrides)}`
},
getAvatarUrlFromEmail: (email: string, overrides?: { size?: number, default?: string, rating?: string }) => {
return `/_scripts/gravatar-proxy?email=${encodeURIComponent(email)}&${buildQuery(overrides)}`
},
Comment on lines +41 to +47
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check for gravatarProxy references and any guards
rg "gravatarProxy" --type ts --type vue -C 2

Repository: nuxt/scripts

Length of output: 83


🏁 Script executed:

# Read the gravatar.ts file to confirm implementation details
wc -l src/runtime/registry/gravatar.ts

Repository: nuxt/scripts

Length of output: 91


🏁 Script executed:

# Look for the actual file to understand structure
fd "gravatar" --type f

Repository: nuxt/scripts

Length of output: 284


🏁 Script executed:

# Retry the gravatarProxy search with correct file types
rg "gravatarProxy" --type ts -C 2

Repository: nuxt/scripts

Length of output: 1459


🏁 Script executed:

# Read the gravatar.ts file to verify the implementation
cat -n src/runtime/registry/gravatar.ts

Repository: nuxt/scripts

Length of output: 3355


🏁 Script executed:

# Check the gravatar-proxy.ts to see if there are any guards
cat -n src/runtime/server/gravatar-proxy.ts

Repository: nuxt/scripts

Length of output: 3036


🏁 Script executed:

# Check the module.ts to see full proxy configuration and registration
rg -A 5 "Add Gravatar proxy handler" src/module.ts

Repository: nuxt/scripts

Length of output: 289


🏁 Script executed:

# Check the ScriptGravatar component to see if it has any guards
cat -n src/runtime/components/ScriptGravatar.vue

Repository: nuxt/scripts

Length of output: 2007


🏁 Script executed:

# Check the documentation to see if this is documented
cat -n docs/content/scripts/utility/gravatar.md

Repository: nuxt/scripts

Length of output: 5678


Avatar URLs hard-code the proxy path with no fallback when disabled.

getAvatarUrl and getAvatarUrlFromEmail always return /_scripts/gravatar-proxy?… URLs. If the proxy isn't enabled in nuxt.config.ts, requests to this endpoint will return a 404 with message "Gravatar proxy is not enabled".

While the documentation mentions the proxy must be enabled, there is no runtime warning in the API itself or fallback to direct Gravatar URLs. If developers forget to enable the proxy, avatars will silently fail to load without clear feedback.

Consider either:

  1. Adding a runtime check/warning when the proxy isn't enabled, or
  2. Providing a fallback to direct Gravatar URLs (at least for the hash-based method).
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/registry/gravatar.ts` around lines 65 - 71, Update getAvatarUrl
and getAvatarUrlFromEmail to respect a runtime flag (e.g., gravatarProxyEnabled
from module/runtime config) instead of always returning
"/_scripts/gravatar-proxy"; in both functions check gravatarProxyEnabled: if
true, return the existing proxy URL (`/_scripts/gravatar-proxy?...` using
buildQuery), otherwise for getAvatarUrl return a direct Gravatar URL
("https://www.gravatar.com/avatar/{hash}?{buildQuery(overrides)}") as a safe
fallback, and for getAvatarUrlFromEmail emit a concise runtime warning
(console.warn or runtime logger) that the proxy is disabled and either require
the caller to provide a hash or enable the proxy (or implement MD5 conversion
later) before returning a best-effort value. Ensure you reference and use the
existing symbols getAvatarUrl, getAvatarUrlFromEmail, buildQuery and the
gravatar proxy path when making the change.

}),
},
}
}, _options)
}
24 changes: 24 additions & 0 deletions src/runtime/registry/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,3 +921,27 @@ export const XPixelOptions = object({
*/
version: optional(string()),
})

export const GravatarOptions = object({
/**
* Cache duration for proxied avatar images in seconds.
* @default 3600
*/
cacheMaxAge: optional(number()),
/**
Comment on lines +925 to +931
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

cacheMaxAge is exposed here but currently has no effect in Gravatar registry usage.

At Line 930, cacheMaxAge is part of GravatarOptions, but useScriptGravatar only consumes size/default/rating when building avatar URLs (src/runtime/registry/gravatar.ts:23-31). This makes cacheMaxAge a silent no-op for this API surface and can mislead users.

Proposed fix (remove no-op option from this schema surface)
 export const GravatarOptions = object({
-  /**
-   * Cache duration for proxied avatar images in seconds.
-   * `@default` 3600
-   */
-  cacheMaxAge: optional(number()),
   /**
    * Default image to show when no Gravatar exists.
    * `@see` https://docs.gravatar.com/general/images/#default-image
    * `@default` 'mp'
    */
   default: optional(string()),
πŸ“ 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
export const GravatarOptions = object({
/**
* Cache duration for proxied avatar images in seconds.
* @default 3600
*/
cacheMaxAge: optional(number()),
/**
export const GravatarOptions = object({
/**
* Default image to show when no Gravatar exists.
* `@see` https://docs.gravatar.com/general/images/#default-image
* `@default` 'mp'
*/
default: optional(string()),
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/registry/schemas.ts` around lines 925 - 931, GravatarOptions
exposes cacheMaxAge but it’s unused by useScriptGravatar, creating a no-op
option; remove the cacheMaxAge field from the GravatarOptions schema (and any
related comments/docs) so the public API surface only includes supported fields
(size, default, rating), or alternatively implement support for cacheMaxAge
inside useScriptGravatar if intended β€” locate the object schema named
GravatarOptions and the function useScriptGravatar to apply the fix
consistently.

* Default image to show when no Gravatar exists.
* @see https://docs.gravatar.com/general/images/#default-image
* @default 'mp'
*/
default: optional(string()),
/**
* Avatar size in pixels (1-2048).
* @default 80
*/
size: optional(number()),
/**
* Content rating filter.
* @default 'g'
*/
rating: optional(string()),
})
77 changes: 77 additions & 0 deletions src/runtime/server/gravatar-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useRuntimeConfig } from '#imports'
import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3'
import { $fetch } from 'ofetch'
import { withQuery } from 'ufo'

export default defineEventHandler(async (event) => {
const runtimeConfig = useRuntimeConfig()
const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.gravatarProxy

// Validate referer to prevent external abuse
const referer = getHeader(event, 'referer')
const host = getHeader(event, 'host')
if (referer && host) {
let refererHost: string | undefined
try {
refererHost = new URL(referer).host
}
catch {}
if (refererHost && refererHost !== host) {
throw createError({
statusCode: 403,
statusMessage: 'Invalid referer',
})
}
}

const query = getQuery(event)
let hash = query.hash as string | undefined
const email = query.email as string | undefined

// Server-side hashing: email never leaves your server
if (!hash && email) {
const encoder = new TextEncoder()
const data = encoder.encode(email.trim().toLowerCase())
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}

if (!hash) {
throw createError({
statusCode: 400,
statusMessage: 'Either hash or email parameter is required',
})
}

// Build Gravatar URL with query params
const size = query.s as string || '80'
const defaultImg = query.d as string || 'mp'
const rating = query.r as string || 'g'

const gravatarUrl = withQuery(`https://www.gravatar.com/avatar/${hash}`, {
s: size,
d: defaultImg,
r: rating,
})

const response = await $fetch.raw(gravatarUrl, {
responseType: 'arrayBuffer',
headers: {
'User-Agent': 'Nuxt Scripts Gravatar Proxy',
},
}).catch((error: any) => {
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || 'Failed to fetch Gravatar avatar',
})
})

const cacheMaxAge = proxyConfig?.cacheMaxAge ?? 3600
setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/jpeg')
setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`)
setHeader(event, 'Vary', 'Accept-Encoding')

return response._data
})
Loading
Loading