Skip to content
Open
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
170 changes: 170 additions & 0 deletions docs/content/scripts/utility/gravatar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
---
title: Gravatar
description: Add Gravatar avatars and hovercards to your Nuxt app with privacy-preserving server-side proxying.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/gravatar.ts
size: xs
- label: Gravatar Developer Docs
icon: i-simple-icons-gravatar
to: https://docs.gravatar.com/
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.

## Privacy Benefits

When using the Gravatar proxy:

- **User IPs are hidden** from Gravatar's servers
- **Email hashes stay server-side** — the `?email=` parameter is SHA256-hashed on YOUR server, so hashes never appear in client HTML
- **Hovercards JS is bundled** through your domain via firstParty mode
- **Configurable caching** reduces requests to Gravatar

## Nuxt Config Setup

Enable the Gravatar proxy in your `nuxt.config.ts`:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
scripts: {
registry: {
gravatar: true
},
gravatarProxy: {
enabled: true,
cacheMaxAge: 3600 // 1 hour (default)
}
}
})
```

## useScriptGravatar

The `useScriptGravatar` composable loads the Gravatar hovercards script and provides avatar URL helpers.

```ts
const { proxy } = useScriptGravatar()

// Get avatar URL from a pre-computed SHA256 hash
const url = proxy.getAvatarUrl('sha256hash', { size: 200 })

// Get avatar URL from email (hashed server-side)
const url = proxy.getAvatarUrlFromEmail('user@example.com', { size: 200 })
```
Comment on lines +48 to +56
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Duplicate const url declaration makes the example invalid JS/TS.

Both getAvatarUrl and getAvatarUrlFromEmail assign to the same const url inside the same code block. Readers who copy this snippet will hit Cannot redeclare block-scoped variable 'url'.

📝 Proposed fix
-// Get avatar URL from a pre-computed SHA256 hash
-const url = proxy.getAvatarUrl('sha256hash', { size: 200 })
-
-// Get avatar URL from email (hashed server-side)
-const url = proxy.getAvatarUrlFromEmail('user@example.com', { size: 200 })
+// Get avatar URL from a pre-computed SHA256 hash
+const avatarUrl = proxy.getAvatarUrl('sha256hash', { size: 200 })
+
+// Get avatar URL from email (hashed server-side)
+const emailAvatarUrl = proxy.getAvatarUrlFromEmail('user@example.com', { size: 200 })
📝 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
```ts
const { proxy } = useScriptGravatar()
// Get avatar URL from a pre-computed SHA256 hash
const url = proxy.getAvatarUrl('sha256hash', { size: 200 })
// Get avatar URL from email (hashed server-side)
const url = proxy.getAvatarUrlFromEmail('user@example.com', { size: 200 })
```
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/scripts/utility/gravatar.md` around lines 48 - 56, The example
declares the same const variable twice causing a redeclaration error; update the
snippet to use distinct variable names (e.g., urlFromHash and urlFromEmail) or
convert one to let/var so both calls to proxy.getAvatarUrl and
proxy.getAvatarUrlFromEmail can coexist; change the second declaration that uses
getAvatarUrlFromEmail to a different identifier and ensure references in the
example match.


Please follow the [Registry Scripts](/docs/guides/registry-scripts) guide to learn more about advanced usage.

### GravatarApi

```ts
export interface GravatarApi {
getAvatarUrl: (hash: string, options?: {
size?: number
default?: string
rating?: string
}) => string
getAvatarUrlFromEmail: (email: string, options?: {
size?: number
default?: string
rating?: string
}) => string
}
```

### Config Schema

```ts
export const GravatarOptions = object({
cacheMaxAge: optional(number()),
default: optional(string()), // 'mp', '404', 'robohash', etc.
size: optional(number()), // 1-2048
rating: optional(string()), // 'g', 'pg', 'r', 'x'
})
```

## ScriptGravatar Component

The `<ScriptGravatar>` component provides a simple way to render Gravatar avatars:

```vue
<template>
<!-- By email (server-side hashed) -->
<ScriptGravatar email="user@example.com" :size="80" />

<!-- By pre-computed hash -->
<ScriptGravatar hash="sha256hash" :size="80" />

<!-- With hovercards enabled -->
<ScriptGravatar email="user@example.com" hovercards />
</template>
```

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `email` | `string` | — | Email address (hashed server-side, never exposed in HTML) |
| `hash` | `string` | — | Pre-computed SHA256 email hash |
| `size` | `number` | `80` | Avatar size in pixels |
| `default` | `string` | `'mp'` | Default image when no Gravatar exists |
| `rating` | `string` | `'g'` | Content rating filter |
| `hovercards` | `boolean` | `false` | Add hovercards class for profile pop-ups |

## Example

### Basic Avatar with Proxy

```vue
<script setup lang="ts">
const { proxy } = useScriptGravatar()

const avatarUrl = computed(() =>
proxy.getAvatarUrlFromEmail('user@example.com', { size: 200 })
)
</script>

<template>
<img :src="avatarUrl" alt="User avatar" />
</template>
```

### With Hovercards

Load the Gravatar hovercards script to show profile pop-ups on hover:

```vue
<script setup lang="ts">
const { status } = useScriptGravatar()
</script>

<template>
<div>
<ScriptGravatar
email="user@example.com"
:size="80"
hovercards
/>
<p>Hovercards script: {{ status }}</p>
</div>
</template>
```

## Gravatar Proxy Server Handler

The proxy handler at `/_scripts/gravatar-proxy` accepts:

| Parameter | Description |
|-----------|-------------|
| `hash` | SHA256 email hash |
| `email` | Raw email (hashed server-side) |
| `s` | Size in pixels (default: 80) |
| `d` | Default image (default: mp) |
| `r` | Rating filter (default: g) |

```
/_scripts/gravatar-proxy?email=user@example.com&s=200&d=mp&r=g
/_scripts/gravatar-proxy?hash=abc123...&s=80
```
Comment on lines +167 to +170
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add a language identifier to the fenced code block to fix the markdownlint MD040 error.

📝 Proposed fix
-```
+```text
 /_scripts/gravatar-proxy?email=user@example.com&s=200&d=mp&r=g
 /_scripts/gravatar-proxy?hash=abc123...&s=80
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 167-167: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/scripts/utility/gravatar.md` around lines 167 - 170, The fenced
code block in gravatar.md currently uses triple backticks with no language,
causing markdownlint MD040; update that fenced block (the snippet showing
"/_scripts/gravatar-proxy?email=..." and "/_scripts/gravatar-proxy?hash=...") to
include a language identifier such as "text" (i.e., change the opening "```" to
"```text") so the block is explicitly labeled and MD040 is resolved.

36 changes: 34 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,23 @@ export interface ModuleOptions {
*/
cacheMaxAge?: number
}
/**
* Gravatar proxy configuration.
* Proxies avatar images through your server for privacy (hides email hashes and IPs from Gravatar).
* Supports server-side email hashing so the hash never appears in client HTML.
*/
gravatarProxy?: {
/**
* Enable proxying Gravatar avatars through your own origin.
* @default false
*/
enabled?: boolean
/**
* Cache duration for avatar images in seconds.
* @default 3600 (1 hour)
*/
cacheMaxAge?: number
}
/**
* Whether the module is enabled.
*
Expand Down Expand Up @@ -234,6 +251,10 @@ export default defineNuxtModule<ModuleOptions>({
enabled: false,
cacheMaxAge: 3600,
},
gravatarProxy: {
enabled: false,
cacheMaxAge: 3600,
},
enabled: true,
debug: false,
},
Expand Down Expand Up @@ -271,7 +292,10 @@ export default defineNuxtModule<ModuleOptions>({
googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled
? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge }
: undefined,
}
gravatarProxy: config.gravatarProxy?.enabled
? { enabled: true, cacheMaxAge: config.gravatarProxy.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 @@ -348,7 +372,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 @@ -664,6 +688,14 @@ export default defineNuxtPlugin({
})
}

// Add Gravatar proxy handler if enabled
if (config.gravatarProxy?.enabled) {
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 @@ -172,6 +172,19 @@ function buildProxyConfig(collectPrefix: string) {
[`${collectPrefix}/hotjar-insights/**`]: { proxy: 'https://insights.hotjar.com/**' },
},
},

gravatar: {
rewrite: [
// Hovercards JS and related scripts
{ from: 'secure.gravatar.com', to: `${collectPrefix}/gravatar` },
// Avatar images (used by hovercards internally)
{ 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
11 changes: 11 additions & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,5 +405,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, ref, onMounted, useAttrs } from 'vue'
import { useScriptGravatar } from '../registry/gravatar'

const props = withDefaults(defineProps<{
/** Email address — hashed server-side, never exposed in client HTML */
email?: string
Comment on lines +6 to +7
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

Email is exposed in client HTML despite JSDoc claiming otherwise.

The email prop's JSDoc states "hashed server-side, never exposed in client HTML," but getAvatarUrlFromEmail generates a URL like /_scripts/gravatar-proxy?email=user@example.com&…, which becomes the <img src> attribute — fully visible in the DOM.

The server-side hashing prevents the email from reaching Gravatar, but it does appear in client-rendered HTML. Either:

  1. Update the JSDoc to accurately describe the privacy model ("email is sent to your server for hashing, not to Gravatar"), or
  2. Hash the email client-side before building the URL (using crypto.subtle.digest in the browser), which would truly keep the email out of HTML while still routing through the proxy.

Option 2 would also require adjusting the proxy to accept hash instead of email in that flow.

Also applies to: 36-44

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/ScriptGravatar.vue` around lines 6 - 7, The JSDoc for
the email prop in ScriptGravatar.vue is inaccurate because getAvatarUrlFromEmail
currently embeds the raw email into the client-side <img src> URL
(/_scripts/gravatar-proxy?email=…), exposing it in HTML; either (A) update the
JSDoc for the email prop to accurately state that the email is sent to the
server for hashing (e.g., "email is sent to server for hashing, not exposed to
Gravatar") and adjust comments around getAvatarUrlFromEmail, or (B) implement
client-side hashing: use crypto.subtle.digest to compute the MD5 (or agreed
hash) of the email in the browser, change getAvatarUrlFromEmail to send hash
instead of email (e.g., ?hash=...), and update the server proxy (gravatar-proxy)
to accept and use hash rather than email; pick one approach and make the
corresponding changes to ScriptGravatar.vue, getAvatarUrlFromEmail, and the
proxy handling.

/** 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 { $script } = useScriptGravatar()

Check failure on line 28 in src/runtime/components/ScriptGravatar.vue

View workflow job for this annotation

GitHub Actions / ci

Property '$script' does not exist on type 'VueScriptInstance<GravatarApi> & { reload: () => Promise<GravatarApi>; }'.

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

onMounted(() => {
$script.then((api) => {

Check failure on line 37 in src/runtime/components/ScriptGravatar.vue

View workflow job for this annotation

GitHub Actions / ci

Parameter 'api' implicitly has an 'any' type.
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>
Loading
Loading