From c9ee94ab0142a0f940c9acdea02fbeeb14524a46 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 20 Jan 2026 17:00:42 +1100 Subject: [PATCH 01/10] feat: add SSR social media embeds for X and Instagram Add privacy-first social media embed components that fetch data server-side and proxy all assets through your domain. Zero client-side API calls to third-party services. - ScriptXEmbed: Headless component with scoped slots for X/Twitter - ScriptInstagramEmbed: Renders proxied Instagram embed HTML - Server routes for data fetching and image/asset proxying - 10-minute server-side caching - Full documentation and e2e tests Resolves #335 Co-Authored-By: Claude Opus 4.5 --- docs/app/pages/index.vue | 70 ++++++++ .../scripts/content/instagram-embed.md | 133 ++++++++++++++ docs/content/scripts/content/x-embed.md | 169 ++++++++++++++++++ .../third-parties/instagram-embed/default.vue | 37 ++++ .../pages/third-parties/x-embed/default.vue | 93 ++++++++++ src/module.ts | 24 +++ .../components/ScriptInstagramEmbed.vue | 59 ++++++ src/runtime/components/ScriptXEmbed.vue | 101 +++++++++++ src/runtime/registry/instagram-embed.ts | 30 ++++ src/runtime/registry/x-embed.ts | 97 ++++++++++ src/runtime/server/instagram-embed-asset.ts | 43 +++++ src/runtime/server/instagram-embed-image.ts | 41 +++++ src/runtime/server/instagram-embed.ts | 60 +++++++ src/runtime/server/x-embed-image.ts | 47 +++++ src/runtime/server/x-embed.ts | 82 +++++++++ test/e2e/basic.test.ts | 84 +++++++++ test/fixtures/basic/pages/instagram-embed.vue | 32 ++++ test/fixtures/basic/pages/x-embed.vue | 46 +++++ 18 files changed, 1248 insertions(+) create mode 100644 docs/content/scripts/content/instagram-embed.md create mode 100644 docs/content/scripts/content/x-embed.md create mode 100644 playground/pages/third-parties/instagram-embed/default.vue create mode 100644 playground/pages/third-parties/x-embed/default.vue create mode 100644 src/runtime/components/ScriptInstagramEmbed.vue create mode 100644 src/runtime/components/ScriptXEmbed.vue create mode 100644 src/runtime/registry/instagram-embed.ts create mode 100644 src/runtime/registry/x-embed.ts create mode 100644 src/runtime/server/instagram-embed-asset.ts create mode 100644 src/runtime/server/instagram-embed-image.ts create mode 100644 src/runtime/server/instagram-embed.ts create mode 100644 src/runtime/server/x-embed-image.ts create mode 100644 src/runtime/server/x-embed.ts create mode 100644 test/fixtures/basic/pages/instagram-embed.vue create mode 100644 test/fixtures/basic/pages/x-embed.vue diff --git a/docs/app/pages/index.vue b/docs/app/pages/index.vue index 2eeb5652..b4fad73b 100644 --- a/docs/app/pages/index.vue +++ b/docs/app/pages/index.vue @@ -445,6 +445,76 @@ const contributors = useRuntimeConfig().public.contributors + +
+
+ +

+ Privacy-first Social Embeds +

+

+ Embed X (Twitter) and Instagram posts without loading third-party scripts. All content is fetched server-side and proxied through your domain. +

+

+ Zero client-side API calls, no cookies, no tracking. Your users' privacy is protected while still displaying rich social content. +

+
+ + X Embed Docs + + + Instagram Embed Docs + +
+
+
+ + + + +
+
+
+ + + +``` + +```vue [With Custom Loading/Error States] + +``` + +```vue [Custom Rendering] + +``` + +:: + +### Props + +The `ScriptInstagramEmbed` component accepts the following props: + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `postUrl` | `string` | Required | The Instagram post URL (e.g., `https://www.instagram.com/p/ABC123/`) | +| `captions` | `boolean` | `true` | Whether to include captions in the embed | +| `apiEndpoint` | `string` | `/api/_scripts/instagram-embed` | Custom API endpoint for fetching embed HTML | +| `rootAttrs` | `HTMLAttributes` | `{}` | Root element attributes | + +### Slot Props + +The default slot receives: + +```ts +interface SlotProps { + html: string // The processed embed HTML + shortcode: string // The post shortcode (e.g., "C3Sk6d2MTjI") + postUrl: string // The original post URL +} +``` + +### Named Slots + +| Slot | Description | +|------|-------------| +| `default` | Main content, receives `{ html, shortcode, postUrl }`. By default renders the HTML. | +| `loading` | Shown while fetching embed HTML | +| `error` | Shown if embed fetch fails, receives `{ error }` | + +## Supported URL Formats + +- Posts: `https://www.instagram.com/p/ABC123/` +- Reels: `https://www.instagram.com/reel/ABC123/` +- TV: `https://www.instagram.com/tv/ABC123/` + +## How It Works + +1. **Server-side fetch**: The Instagram embed HTML is fetched from `{postUrl}/embed/` +2. **Asset proxying**: All images from `scontent.cdninstagram.com` and assets from `static.cdninstagram.com` are rewritten to proxy through your server +3. **Script removal**: Instagram's `embed.js` is removed (not needed for static rendering) +4. **Caching**: Responses are cached for 10 minutes at the server level + +This approach is inspired by [Cloudflare Zaraz's embed implementation](https://blog.cloudflare.com/zaraz-supports-server-side-rendering-of-embeds/). + +## Privacy Benefits + +- No third-party JavaScript loaded +- No cookies set by Instagram/Meta +- No direct browser-to-Instagram communication +- User IP addresses not shared with Instagram +- All content served from your domain + +## Limitations + +- Only supports single-image posts (galleries show first image only) +- Videos display as static poster images +- Some interactive features are not available (likes, comments) diff --git a/docs/content/scripts/content/x-embed.md b/docs/content/scripts/content/x-embed.md new file mode 100644 index 00000000..c0dc54b7 --- /dev/null +++ b/docs/content/scripts/content/x-embed.md @@ -0,0 +1,169 @@ +--- +title: X Embed +description: Server-side rendered X (Twitter) embeds with zero client-side API calls. +links: + - label: ScriptXEmbed + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/components/ScriptXEmbed.vue + size: xs +--- + +[X (formerly Twitter)](https://x.com) is a social media platform for sharing posts. + +Nuxt Scripts provides a `ScriptXEmbed` component that fetches tweet data server-side and exposes it via slots for complete styling control. All data is proxied through your server - no client-side API calls to X. + +## ScriptXEmbed + +The `ScriptXEmbed` component is a headless component that: +- Fetches tweet data server-side via the X syndication API +- Proxies all images through your server for privacy +- Exposes tweet data via scoped slots for custom rendering +- Caches responses for 10 minutes + +### Demo + +::code-group + +```vue [Basic Usage] + +``` + +```vue [Styled Tweet Card] + +``` + +:: + +### Props + +The `ScriptXEmbed` component accepts the following props: + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `tweetId` | `string` | Required | The ID of the tweet to embed | +| `apiEndpoint` | `string` | `/api/_scripts/x-embed` | Custom API endpoint for fetching tweet data | +| `imageProxyEndpoint` | `string` | `/api/_scripts/x-embed-image` | Custom endpoint for proxying images | +| `rootAttrs` | `HTMLAttributes` | `{}` | Root element attributes | + +### Slot Props + +The default slot receives the following props: + +```ts +interface SlotProps { + // Raw data + tweet: XEmbedTweetData + // User info + userName: string + userHandle: string + userAvatar: string // Proxied URL + userAvatarOriginal: string // Original X URL + isVerified: boolean + // Tweet content + text: string + // Formatted values + datetime: string // "12:47 PM · Feb 5, 2024" + createdAt: Date + likes: number + likesFormatted: string // "1.2K" + replies: number + repliesFormatted: string // "234" + // Media + photos?: Array<{ + url: string + proxiedUrl: string + width: number + height: number + }> + video?: { + poster: string + posterProxied: string + variants: Array<{ type: string; src: string }> + } + // Links + tweetUrl: string + userUrl: string + // Quote tweet + quotedTweet?: XEmbedTweetData + // Reply context + isReply: boolean + replyToUser?: string + // Helpers + proxyImage: (url: string) => string +} +``` + +### Named Slots + +| Slot | Description | +|------|-------------| +| `default` | Main content with slot props | +| `loading` | Shown while fetching tweet data | +| `error` | Shown if tweet fetch fails, receives `{ error }` | + +## How It Works + +1. **Server-side fetch**: Tweet data is fetched from `cdn.syndication.twimg.com` during SSR +2. **Image proxying**: All images are rewritten to proxy through `/api/_scripts/x-embed-image` +3. **Caching**: Responses are cached for 10 minutes at the server level +4. **No client-side API calls**: The user's browser never contacts X directly + +This approach is inspired by [Cloudflare Zaraz's embed implementation](https://blog.cloudflare.com/zaraz-supports-server-side-rendering-of-embeds/). + +## Privacy Benefits + +- No third-party JavaScript loaded +- No cookies set by X +- No direct browser-to-X communication +- User IP addresses not shared with X +- All content served from your domain diff --git a/playground/pages/third-parties/instagram-embed/default.vue b/playground/pages/third-parties/instagram-embed/default.vue new file mode 100644 index 00000000..8bfcc360 --- /dev/null +++ b/playground/pages/third-parties/instagram-embed/default.vue @@ -0,0 +1,37 @@ + + + diff --git a/playground/pages/third-parties/x-embed/default.vue b/playground/pages/third-parties/x-embed/default.vue new file mode 100644 index 00000000..12c8a86b --- /dev/null +++ b/playground/pages/third-parties/x-embed/default.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/module.ts b/src/module.ts index a40618ab..ff78053c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -288,6 +288,30 @@ export default defineNuxtModule({ }) } + // Add X/Twitter embed proxy handlers + addServerHandler({ + route: '/api/_scripts/x-embed', + handler: await resolvePath('./runtime/server/x-embed'), + }) + addServerHandler({ + route: '/api/_scripts/x-embed-image', + handler: await resolvePath('./runtime/server/x-embed-image'), + }) + + // Add Instagram embed proxy handlers + addServerHandler({ + route: '/api/_scripts/instagram-embed', + handler: await resolvePath('./runtime/server/instagram-embed'), + }) + addServerHandler({ + route: '/api/_scripts/instagram-embed-image', + handler: await resolvePath('./runtime/server/instagram-embed-image'), + }) + addServerHandler({ + route: '/api/_scripts/instagram-embed-asset', + handler: await resolvePath('./runtime/server/instagram-embed-asset'), + }) + if (nuxt.options.dev) setupDevToolsUI(config, resolvePath) }, diff --git a/src/runtime/components/ScriptInstagramEmbed.vue b/src/runtime/components/ScriptInstagramEmbed.vue new file mode 100644 index 00000000..0d25caa8 --- /dev/null +++ b/src/runtime/components/ScriptInstagramEmbed.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/runtime/components/ScriptXEmbed.vue b/src/runtime/components/ScriptXEmbed.vue new file mode 100644 index 00000000..809564f1 --- /dev/null +++ b/src/runtime/components/ScriptXEmbed.vue @@ -0,0 +1,101 @@ + + + diff --git a/src/runtime/registry/instagram-embed.ts b/src/runtime/registry/instagram-embed.ts new file mode 100644 index 00000000..0e92d08e --- /dev/null +++ b/src/runtime/registry/instagram-embed.ts @@ -0,0 +1,30 @@ +import { boolean, object, optional, string } from '#nuxt-scripts-validator' +import type { RegistryScriptInput } from '#nuxt-scripts/types' + +export const InstagramEmbedOptions = object({ + /** + * The Instagram post URL to embed + * e.g., https://www.instagram.com/p/ABC123/ + */ + postUrl: string(), + /** + * Whether to include captions in the embed + * @default true + */ + captions: optional(boolean()), + /** + * Custom API endpoint for fetching embed HTML + * @default '/api/_scripts/instagram-embed' + */ + apiEndpoint: optional(string()), +}) + +export type InstagramEmbedInput = RegistryScriptInput + +/** + * Extract the post shortcode from an Instagram URL + */ +export function extractInstagramShortcode(url: string): string | undefined { + const match = url.match(/instagram\.com\/(?:p|reel|tv)\/([^/?]+)/) + return match?.[1] +} diff --git a/src/runtime/registry/x-embed.ts b/src/runtime/registry/x-embed.ts new file mode 100644 index 00000000..87cde4bd --- /dev/null +++ b/src/runtime/registry/x-embed.ts @@ -0,0 +1,97 @@ +import { object, optional, string } from '#nuxt-scripts-validator' +import type { RegistryScriptInput } from '#nuxt-scripts/types' + +export interface XEmbedTweetData { + id_str: string + text: string + created_at: string + favorite_count: number + conversation_count: number + user: { + name: string + screen_name: string + profile_image_url_https: string + verified?: boolean + is_blue_verified?: boolean + } + entities?: { + media?: Array<{ + media_url_https: string + type: string + sizes: Record + }> + urls?: Array<{ + url: string + expanded_url: string + display_url: string + }> + } + photos?: Array<{ + url: string + width: number + height: number + }> + video?: { + poster: string + variants: Array<{ type: string, src: string }> + } + quoted_tweet?: XEmbedTweetData + parent?: { + user: { + screen_name: string + } + } +} + +export const XEmbedOptions = object({ + /** + * The tweet ID to embed + */ + tweetId: string(), + /** + * Optional: Custom API endpoint for fetching tweet data + * @default '/api/_scripts/x-embed' + */ + apiEndpoint: optional(string()), + /** + * Optional: Custom image proxy endpoint + * @default '/api/_scripts/x-embed-image' + */ + imageProxyEndpoint: optional(string()), +}) + +export type XEmbedInput = RegistryScriptInput + +/** + * Proxy an X/Twitter image URL through the server + */ +export function proxyXImageUrl(url: string, proxyEndpoint = '/api/_scripts/x-embed-image'): string { + return `${proxyEndpoint}?url=${encodeURIComponent(url)}` +} + +/** + * Format a tweet date for display + */ +export function formatTweetDate(dateString: string): string { + const date = new Date(dateString) + const time = date.toLocaleString('en-US', { + hour: 'numeric', + minute: 'numeric', + hour12: true, + }) + const day = date.toLocaleString('en-US', { month: 'short' }) + return `${time} · ${day} ${date.getDate()}, ${date.getFullYear()}` +} + +/** + * Format a number for display (e.g., 1234 -> 1.2K) + */ +export function formatCount(count: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M` + } + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}K` + } + return count.toString() +} diff --git a/src/runtime/server/instagram-embed-asset.ts b/src/runtime/server/instagram-embed-asset.ts new file mode 100644 index 00000000..a0f1ef51 --- /dev/null +++ b/src/runtime/server/instagram-embed-asset.ts @@ -0,0 +1,43 @@ +import { createError, defineEventHandler, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const url = query.url as string + + if (!url) { + throw createError({ + statusCode: 400, + statusMessage: 'Asset URL is required', + }) + } + + // Only allow Instagram static CDN + const parsedUrl = new URL(url) + if (parsedUrl.hostname !== 'static.cdninstagram.com') { + throw createError({ + statusCode: 403, + statusMessage: 'Domain not allowed', + }) + } + + const response = await $fetch.raw(url, { + headers: { + 'Accept': '*/*', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + }, + }).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to fetch asset', + }) + }) + + const contentType = response.headers.get('content-type') || 'application/octet-stream' + + // Cache assets for 1 day (they're versioned) + setHeader(event, 'Content-Type', contentType) + setHeader(event, 'Cache-Control', 'public, max-age=86400, s-maxage=86400') + + return response._data +}) diff --git a/src/runtime/server/instagram-embed-image.ts b/src/runtime/server/instagram-embed-image.ts new file mode 100644 index 00000000..e381e1f8 --- /dev/null +++ b/src/runtime/server/instagram-embed-image.ts @@ -0,0 +1,41 @@ +import { createError, defineEventHandler, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const url = query.url as string + + if (!url) { + throw createError({ + statusCode: 400, + statusMessage: 'Image URL is required', + }) + } + + // Only allow Instagram CDN domains (any scontent*.cdninstagram.com subdomain) + const parsedUrl = new URL(url) + if (!parsedUrl.hostname.endsWith('.cdninstagram.com') && parsedUrl.hostname !== 'scontent.cdninstagram.com') { + throw createError({ + statusCode: 403, + statusMessage: 'Domain not allowed', + }) + } + + const response = await $fetch.raw(url, { + headers: { + 'Accept': 'image/webp,image/jpeg,image/png,image/*,*/*;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + }, + }).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to fetch image', + }) + }) + + // Cache images for 1 hour + setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/jpeg') + setHeader(event, 'Cache-Control', 'public, max-age=3600, s-maxage=3600') + + return response._data +}) diff --git a/src/runtime/server/instagram-embed.ts b/src/runtime/server/instagram-embed.ts new file mode 100644 index 00000000..5f060e37 --- /dev/null +++ b/src/runtime/server/instagram-embed.ts @@ -0,0 +1,60 @@ +import { createError, defineEventHandler, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const postUrl = query.url as string + const captions = query.captions === 'true' + + if (!postUrl) { + throw createError({ + statusCode: 400, + statusMessage: 'Post URL is required', + }) + } + + // Validate Instagram URL + const parsedUrl = new URL(postUrl) + if (!['instagram.com', 'www.instagram.com'].includes(parsedUrl.hostname)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid Instagram URL', + }) + } + + const cleanUrl = parsedUrl.origin + parsedUrl.pathname + const embedUrl = cleanUrl + 'embed/' + (captions ? 'captioned/' : '') + + const html = await $fetch(embedUrl, { + headers: { + 'Accept': 'text/html', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + }, + }).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to fetch Instagram embed', + }) + }) + + // Rewrite image URLs to proxy through our endpoint + const rewrittenHtml = html + // Rewrite scontent CDN images + .replace( + /https:\/\/scontent\.cdninstagram\.com([^"'\s)]+)/g, + '/api/_scripts/instagram-embed-image?url=' + encodeURIComponent('https://scontent.cdninstagram.com') + '$1', + ) + // Rewrite static CDN CSS/assets + .replace( + /https:\/\/static\.cdninstagram\.com([^"'\s)]+)/g, + '/api/_scripts/instagram-embed-asset?url=' + encodeURIComponent('https://static.cdninstagram.com') + '$1', + ) + // Remove Instagram's embed.js script (we don't need it) + .replace(/]*src="[^"]*embed\.js"[^>]*><\/script>/gi, '') + + // Cache for 10 minutes + setHeader(event, 'Content-Type', 'text/html') + setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=600') + + return rewrittenHtml +}) diff --git a/src/runtime/server/x-embed-image.ts b/src/runtime/server/x-embed-image.ts new file mode 100644 index 00000000..dce3b688 --- /dev/null +++ b/src/runtime/server/x-embed-image.ts @@ -0,0 +1,47 @@ +import { createError, defineEventHandler, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const url = query.url as string + + if (!url) { + throw createError({ + statusCode: 400, + statusMessage: 'Image URL is required', + }) + } + + // Only allow Twitter/X image domains + const allowedDomains = [ + 'pbs.twimg.com', + 'abs.twimg.com', + 'video.twimg.com', + ] + + const parsedUrl = new URL(url) + if (!allowedDomains.includes(parsedUrl.hostname)) { + throw createError({ + statusCode: 403, + statusMessage: 'Domain not allowed', + }) + } + + const response = await $fetch.raw(url, { + headers: { + 'Accept': 'image/webp,image/jpeg,image/png,image/*,*/*;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + }, + }).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to fetch image', + }) + }) + + // Cache images for 1 hour + setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/jpeg') + setHeader(event, 'Cache-Control', 'public, max-age=3600, s-maxage=3600') + + return response._data +}) diff --git a/src/runtime/server/x-embed.ts b/src/runtime/server/x-embed.ts new file mode 100644 index 00000000..13f423e7 --- /dev/null +++ b/src/runtime/server/x-embed.ts @@ -0,0 +1,82 @@ +import { createError, defineEventHandler, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' + +interface TweetData { + id_str: string + text: string + created_at: string + favorite_count: number + conversation_count: number + user: { + name: string + screen_name: string + profile_image_url_https: string + verified?: boolean + is_blue_verified?: boolean + } + entities?: { + media?: Array<{ + media_url_https: string + type: string + sizes: Record + }> + urls?: Array<{ + url: string + expanded_url: string + display_url: string + }> + } + photos?: Array<{ + url: string + width: number + height: number + }> + video?: { + poster: string + variants: Array<{ type: string, src: string }> + } + quoted_tweet?: TweetData + parent?: { + user: { + screen_name: string + } + } +} + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const tweetId = query.id as string + + if (!tweetId) { + throw createError({ + statusCode: 400, + statusMessage: 'Tweet ID is required', + }) + } + + // Generate random token like Zaraz does + const randomToken = [...Array(11)] + .map(() => (Math.random() * 36).toString(36)[2]) + .join('') + + const tweetData = await $fetch( + `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&token=${randomToken}`, + { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + }, + }, + ).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to fetch tweet', + }) + }) + + // Cache for 10 minutes + setHeader(event, 'Content-Type', 'application/json') + setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=600') + + return tweetData +}) diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index 871a34ee..1c360f8a 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -351,3 +351,87 @@ describe('third-party-capital', () => { expect(hasGoogleApi).toBe(true) }) }) + +describe('social-embeds', () => { + it('X embed fetches tweet data server-side and renders', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/x-embed') + + // Wait for content to load (SSR should have it immediately, but useAsyncData may need hydration) + await page.waitForSelector('#tweet-content', { timeout: 10000 }) + + // Verify tweet data was fetched and rendered + const userName = await page.$eval('#user-name', el => el.textContent?.trim()) + const userHandle = await page.$eval('#user-handle', el => el.textContent?.trim()) + const text = await page.$eval('#text', el => el.textContent?.trim()) + const tweetUrl = await page.$eval('#tweet-url', el => el.getAttribute('href')) + + expect(userName).toBeTruthy() + expect(userHandle).toBeTruthy() + expect(text).toBeTruthy() + expect(tweetUrl).toContain('x.com') + expect(tweetUrl).toContain('/status/') + }) + + it('X embed proxies images through server', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/x-embed') + + await page.waitForSelector('#tweet-content', { timeout: 10000 }) + + // Check if there are any images and they use the proxy endpoint + const hasProxiedImages = await page.evaluate(() => { + const photos = document.querySelector('#photos') + if (!photos) + return true // No photos is OK, some tweets don't have them + const imgs = photos.querySelectorAll('img') + return Array.from(imgs).every(img => img.src.includes('/api/_scripts/x-embed-image')) + }) + expect(hasProxiedImages).toBe(true) + }) + + it('Instagram embed fetches HTML server-side and renders', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/instagram-embed') + + // Wait for content to load + await page.waitForSelector('#instagram-content', { timeout: 10000 }) + + // Verify shortcode was extracted + const shortcode = await page.$eval('#shortcode', el => el.textContent?.trim()) + expect(shortcode).toBe('C3Sk6d2MTjI') + + // Verify HTML was rendered (should contain Instagram embed markup) + const hasEmbedHtml = await page.evaluate(() => { + const embedDiv = document.querySelector('#embed-html') + return embedDiv && embedDiv.innerHTML.length > 100 + }) + expect(hasEmbedHtml).toBe(true) + }) + + it('Instagram embed proxies images through server', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/instagram-embed') + + await page.waitForSelector('#instagram-content', { timeout: 10000 }) + + // Check that images in the embed use the proxy endpoint + const hasProxiedImages = await page.evaluate(() => { + const embedDiv = document.querySelector('#embed-html') + if (!embedDiv) + return false + const imgs = embedDiv.querySelectorAll('img') + if (imgs.length === 0) + return true // No images yet is OK (might be lazy loaded) + return Array.from(imgs).every(img => + img.src.includes('/api/_scripts/instagram-embed-image') + || img.src.includes('/api/_scripts/instagram-embed-asset'), + ) + }) + expect(hasProxiedImages).toBe(true) + }) +}) diff --git a/test/fixtures/basic/pages/instagram-embed.vue b/test/fixtures/basic/pages/instagram-embed.vue new file mode 100644 index 00000000..a705f41f --- /dev/null +++ b/test/fixtures/basic/pages/instagram-embed.vue @@ -0,0 +1,32 @@ + + + diff --git a/test/fixtures/basic/pages/x-embed.vue b/test/fixtures/basic/pages/x-embed.vue new file mode 100644 index 00000000..05fd6570 --- /dev/null +++ b/test/fixtures/basic/pages/x-embed.vue @@ -0,0 +1,46 @@ + + + From c546293ed052a30f1035c992fc95a9878fa5d80f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 20 Jan 2026 17:36:56 +1100 Subject: [PATCH 02/10] doc: cors guide --- docs/content/docs/1.guides/6.cors.md | 167 +++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/content/docs/1.guides/6.cors.md diff --git a/docs/content/docs/1.guides/6.cors.md b/docs/content/docs/1.guides/6.cors.md new file mode 100644 index 00000000..206ea3c5 --- /dev/null +++ b/docs/content/docs/1.guides/6.cors.md @@ -0,0 +1,167 @@ +--- +title: CORS and Security Attributes +description: Understanding how Nuxt Scripts handles cross-origin security. +--- + +## Background + +When loading scripts from external domains, browsers enforce Cross-Origin Resource Sharing (CORS) policies. CORS controls how resources on one domain can be requested by scripts running on another domain. For third-party scripts, this affects: + +- Whether cookies are sent with requests +- Access to error details for debugging +- Subresource Integrity (SRI) validation + +## Default Behavior + +Nuxt Scripts applies privacy-focused defaults to all scripts: + +```html + +``` + +These defaults: +- **`crossorigin="anonymous"`** - Prevents the script from sending cookies to third-party servers +- **`referrerpolicy="no-referrer"`** - Prevents sharing the page URL with third-party servers + +This improves user privacy but may break scripts that require cookies or referrer information. + +## Common CORS Errors + +### Script Fails to Load + +``` +Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource +``` + +This occurs when a server doesn't return proper CORS headers but `crossorigin="anonymous"` is set. Some third-party scripts don't support CORS. + +### Script Loads but Functions Fail + +The script loads but functionality is broken because it expected cookies or session data. + +### Error Details Hidden + +```js +window.onerror = (msg) => console.log(msg) +// Shows: "Script error." instead of actual error +``` + +Without `crossorigin`, browsers hide error details from external scripts for security. + +## Configuring CORS Attributes + +### Per-Script Configuration + +Disable CORS attributes for scripts that don't support them: + +```ts +useScript({ + src: 'https://example.com/script.js', + crossorigin: false, // Remove crossorigin attribute + referrerpolicy: false, // Remove referrerpolicy attribute +}) +``` + +Or use a different `crossorigin` value: + +```ts +useScript({ + src: 'https://example.com/script.js', + crossorigin: 'use-credentials', // Send cookies with request +}) +``` + +### Global Configuration + +Change defaults for all scripts: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + defaultScriptOptions: { + crossorigin: false, + referrerpolicy: false, + } + } +}) +``` + +## Crossorigin Values + +| Value | Cookies Sent | Error Details | Use Case | +|-------|-------------|---------------|----------| +| `anonymous` | No | Yes (if server supports) | Privacy-focused default | +| `use-credentials` | Yes | Yes | Scripts requiring auth | +| `false` | Yes | No | Scripts without CORS support | + +## Registry Scripts + +Many registry scripts already disable CORS attributes because the third-party doesn't support them: + +```ts +// From useScriptStripe +scriptInput: { + src: 'https://js.stripe.com/basil/stripe.js', + crossorigin: false, + referrerpolicy: false, +} +``` + +Scripts with `crossorigin: false` include: +- Stripe +- YouTube Player +- Google Sign-In +- Google reCAPTCHA +- Meta Pixel +- TikTok Pixel +- X (Twitter) Pixel +- Snapchat Pixel +- Cloudflare Web Analytics +- Lemon Squeezy +- Matomo Analytics + +If a registry script fails, check if CORS configuration needs adjustment. + +## Subresource Integrity + +When using [bundled scripts with SRI](/docs/guides/bundling#subresource-integrity-sri), `crossorigin="anonymous"` is required and automatically added: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + assets: { + integrity: true, // Automatically sets crossorigin="anonymous" + } + } +}) +``` + +## Troubleshooting + +### Script Won't Load + +1. Check browser console for CORS errors +2. Set `crossorigin: false` to disable CORS mode +3. Verify the third-party server supports CORS headers + +### Script Loads but Broken + +1. The script may require cookies - try `crossorigin: 'use-credentials'` +2. The script may need the referrer - set `referrerpolicy: false` +3. Check if the script expects to be loaded without CORS attributes + +### Debugging External Script Errors + +To see full error messages from external scripts: + +1. Ensure the script has `crossorigin="anonymous"` +2. Verify the server returns `Access-Control-Allow-Origin` header +3. If the server doesn't support CORS, you won't get detailed errors + +### Bundling as Alternative + +If CORS issues persist, consider [bundling the script](/docs/guides/bundling) to serve it from your own domain, eliminating CORS entirely. From dd545fd7b9d605564d8d384972f691d84f4d168a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 20 Jan 2026 17:53:03 +1100 Subject: [PATCH 03/10] doc: gtm / ga imporovements --- .../scripts/analytics/google-analytics.md | 89 +++++++++++++++++++ .../scripts/tracking/google-tag-manager.md | 62 ++++++++++--- 2 files changed, 137 insertions(+), 14 deletions(-) diff --git a/docs/content/scripts/analytics/google-analytics.md b/docs/content/scripts/analytics/google-analytics.md index ce8d0084..6544e4a6 100644 --- a/docs/content/scripts/analytics/google-analytics.md +++ b/docs/content/scripts/analytics/google-analytics.md @@ -213,3 +213,92 @@ function sendConversion() { ``` :: + +## Custom Dimensions and User Properties + +```ts +const { proxy } = useScriptGoogleAnalytics() + +// User properties (persist across sessions) +proxy.gtag('set', 'user_properties', { + user_tier: 'premium', + account_type: 'business' +}) + +// Event with custom dimensions (register in GA4 Admin > Custom Definitions) +proxy.gtag('event', 'purchase', { + transaction_id: 'T12345', + value: 99.99, + payment_method: 'credit_card', // custom dimension + discount_code: 'SAVE10' // custom dimension +}) + +// Default params for all future events +proxy.gtag('set', { country: 'US', currency: 'USD' }) +``` + +## Manual Page View Tracking (SPAs) + +GA4 auto-tracks page views. To disable and track manually: + +```ts +const { proxy } = useScriptGoogleAnalytics() + +// Disable automatic page views +proxy.gtag('config', 'G-XXXXXXXX', { send_page_view: false }) + +// Track on route change +const router = useRouter() +router.afterEach((to) => { + proxy.gtag('event', 'page_view', { page_path: to.fullPath }) +}) +``` + +## Proxy Queuing + +The proxy queues all `gtag` calls until the script loads. Calls are SSR-safe, adblocker-resilient, and order-preserved. + +```ts +const { proxy, onLoaded } = useScriptGoogleAnalytics() + +// Fire-and-forget (queued until GA loads) +proxy.gtag('event', 'cta_click', { button_id: 'hero-signup' }) + +// Need return value? Wait for load +onLoaded(({ gtag }) => { + gtag('get', 'G-XXXXXXXX', 'client_id', (id) => console.log(id)) +}) +``` + +## Common Event Patterns + +```ts +const { proxy } = useScriptGoogleAnalytics() + +// E-commerce +proxy.gtag('event', 'purchase', { + transaction_id: 'T_12345', + value: 59.98, + currency: 'USD', + items: [{ item_id: 'SKU_12345', item_name: 'Widget', price: 29.99, quantity: 2 }] +}) + +// Engagement +proxy.gtag('event', 'login', { method: 'Google' }) +proxy.gtag('event', 'search', { search_term: 'nuxt scripts' }) + +// Custom +proxy.gtag('event', 'feature_used', { feature_name: 'dark_mode' }) +``` + +## Debugging + +Enable debug mode via config or URL param `?debug_mode=true`: + +```ts +proxy.gtag('config', 'G-XXXXXXXX', { debug_mode: true }) +``` + +View events in GA4: **Admin > DebugView**. Install [GA Debugger extension](https://chrome.google.com/webstore/detail/google-analytics-debugger/jnkmfdileelhofjcijamephohjechhna) for console logging. + +For consent mode setup, see the [Consent Guide](/docs/guides/consent). diff --git a/docs/content/scripts/tracking/google-tag-manager.md b/docs/content/scripts/tracking/google-tag-manager.md index fc4db272..1e9489d9 100644 --- a/docs/content/scripts/tracking/google-tag-manager.md +++ b/docs/content/scripts/tracking/google-tag-manager.md @@ -190,7 +190,13 @@ type GoogleTagManagerInput = typeof GoogleTagManagerOptions & { onBeforeGtmStart ### Server-Side GTM Setup -We can add custom GTM script source for server-side implementation. You can override the script src, this will merge in any of the computed query params. +Server-side GTM moves tag execution to your server for better privacy, performance (~500ms faster), and ad-blocker bypass. + +**Prerequisites:** [Server-side GTM container](https://tagmanager.google.com), hosting ([Cloud Run](https://developers.google.com/tag-platform/tag-manager/server-side/cloud-run-setup-guide) / [Docker](https://developers.google.com/tag-platform/tag-manager/server-side/manual-setup-guide)), and a custom domain. + +#### Configuration + +Override the script source with your custom domain: ```ts // nuxt.config.ts @@ -200,7 +206,7 @@ export default defineNuxtConfig({ googleTagManager: { id: 'GTM-XXXXXX', scriptInput: { - src: 'https://your-domain.com/gtm.js' + src: 'https://gtm.example.com/gtm.js' } } } @@ -208,17 +214,19 @@ export default defineNuxtConfig({ }) ``` -```vue - - -``` +For environment tokens (`auth`, `preview`), find them in GTM: Admin > Environments > Get Snippet. + +#### Troubleshooting + +| Issue | Cause | Solution | +|-------|-------|----------| +| Script blocked by ad blocker | Custom domain detected as tracker | Use a non-obvious subdomain name (avoid `gtm`, `analytics`, `tracking`) | +| Cookies expire after 7 days in Safari | ITP treats subdomain as third-party | Use same-origin setup or implement cookie keeper | +| Preview mode not working | Missing or incorrect auth/preview tokens | Copy tokens from GTM: Admin > Environments > Get Snippet | +| CORS errors | Server container misconfigured | Ensure your server container allows requests from your domain | +| `gtm.js` returns 404 | Incorrect path mapping | Verify your CDN/proxy routes `/gtm.js` to the container | + +For infrastructure setup, see [Cloud Run](https://developers.google.com/tag-platform/tag-manager/server-side/cloud-run-setup-guide) or [Docker](https://developers.google.com/tag-platform/tag-manager/server-side/manual-setup-guide) guides. ### Basic Usage @@ -269,9 +277,35 @@ If you're calling `useScriptGoogleTagManager` with the ID directly in a componen :: ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"} -Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent) on StackBlitz for a complete Consent Mode v2 implementation. +Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent) or [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent) on StackBlitz. :: +#### Consent Mode v2 Signals + +| Signal | Purpose | +|--------|---------| +| `ad_storage` | Cookies for advertising | +| `ad_user_data` | Send user data to Google for ads | +| `ad_personalization` | Personalized ads (remarketing) | +| `analytics_storage` | Cookies for analytics | + +#### Updating Consent + +When the user accepts, call `gtag('consent', 'update', ...)`: + +```ts +function acceptCookies() { + window.gtag?.('consent', 'update', { + ad_storage: 'granted', + ad_user_data: 'granted', + ad_personalization: 'granted', + analytics_storage: 'granted', + }) +} +``` + +To block GTM entirely until consent, combine with [useScriptTriggerConsent](/docs/guides/consent). + ```vue @@ -28,7 +28,7 @@ const tweetIds = [ @{{ userHandle }} - + diff --git a/src/runtime/components/ScriptInstagramEmbed.vue b/src/runtime/components/ScriptInstagramEmbed.vue index 0d25caa8..e0f38a1d 100644 --- a/src/runtime/components/ScriptInstagramEmbed.vue +++ b/src/runtime/components/ScriptInstagramEmbed.vue @@ -1,5 +1,6 @@