From 906ad46768607f636d982ae42bfb03a286c1b5a0 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 16 Jan 2026 01:58:48 +1100 Subject: [PATCH 1/2] Squash commits from main Combined commits: - fix(plausible): use consistent window reference in clientInit stub --- .claude/plans/543-plausible-broken.md | 61 +++++++++++++++++++++ src/runtime/registry/plausible-analytics.ts | 14 +++-- 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 .claude/plans/543-plausible-broken.md diff --git a/.claude/plans/543-plausible-broken.md b/.claude/plans/543-plausible-broken.md new file mode 100644 index 00000000..ea85c8ed --- /dev/null +++ b/.claude/plans/543-plausible-broken.md @@ -0,0 +1,61 @@ +## DONE + +# Fix: Plausible Analytics new script not working (#543) + +## Problem +Users report that the new Plausible script format (with `scriptId`) loads correctly in DOM but no events are sent. + +## Analysis + +### Root Cause +The `clientInit` stub uses bare `plausible` identifier inconsistently with `window.plausible`: + +```js +// Current code (plausible-analytics.ts:187) +window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) }, plausible.init = plausible.init || function (i) { plausible.o = i || {} } +``` + +Issues: +1. **Inconsistent window reference**: First part uses `window.plausible`, second part uses bare `plausible` +2. **Module scope**: In ES modules (strict mode), bare identifier resolution differs from non-module scripts +3. **Compare to GA**: Google Analytics uses `w` (window) consistently throughout its clientInit + +### How Plausible's new script works +The `pa-{scriptId}.js` script: +1. Checks `plausible.o && S(plausible.o)` on load to pick up pre-init options +2. The stub's `plausible.init()` stores options in `plausible.o` +3. Script has domain hardcoded, doesn't need `data-domain` attribute + +### Verification +Plausible script expected stub format: +```js +window.plausible = window.plausible || {} +plausible.o && S(plausible.o) // If .o exists, initialize with those options +``` + +Our stub needs to set `plausible.o` before script loads, which it does via: +```js +plausible.init = function(i) { plausible.o = i || {} } +window.plausible.init(initOptions) +``` + +## Fix + +Update `clientInit` to use `window.plausible` consistently (like GA does): + +```ts +clientInit() { + const w = window as any + w.plausible = w.plausible || function () { (w.plausible.q = w.plausible.q || []).push(arguments) } + w.plausible.init = w.plausible.init || function (i: PlausibleInitOptions) { w.plausible.o = i || {} } + w.plausible.init(initOptions) +} +``` + +## Files to modify +- `src/runtime/registry/plausible-analytics.ts`: Fix clientInit stub pattern + +## Test plan +1. Run existing tests +2. Test playground with plausible-analytics-v2.vue +3. Verify script loads and init options are picked up diff --git a/src/runtime/registry/plausible-analytics.ts b/src/runtime/registry/plausible-analytics.ts index 9a955032..17ac9c95 100644 --- a/src/runtime/registry/plausible-analytics.ts +++ b/src/runtime/registry/plausible-analytics.ts @@ -181,12 +181,14 @@ export function useScriptPlausibleAnalytics(_op use() { return { plausible: window.plausible } }, - clientInit() { - // @ts-expect-error untyped - // eslint-disable-next-line @typescript-eslint/no-unused-expressions,@stylistic/max-statements-per-line,prefer-rest-params - window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) }, plausible.init = plausible.init || function (i) { plausible.o = i || {} } - window.plausible.init(initOptions) - }, + clientInit: import.meta.server + ? undefined + : () => { + const w = window as any + w.plausible = w.plausible || function () { (w.plausible.q = w.plausible.q || []).push(arguments) } + w.plausible.init = w.plausible.init || function (i: PlausibleInitOptions) { w.plausible.o = i || {} } + w.plausible.init(initOptions) + }, }, } }, _options) From b8cac434872d7c3bdf18d293fc58fded97215b90 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 18 Jan 2026 14:51:38 +1100 Subject: [PATCH 2/2] fix(google-maps): static maps proxy, color mode, and bug fixes - Add googleStaticMapsProxy config for CORS fixes and caching (#380, #83) - API key stored server-side only (not exposed to client) - Referer validation to prevent external abuse - Add mapIds prop for light/dark color mode support (#539) - Fix MarkerClusterer optional peer dep with inline types (#540) - Add PinElement cleanup on unmount - Fix importLibrary cache to retry on failure Co-Authored-By: Claude Opus 4.5 --- src/module.ts | 41 +++++++++++- .../GoogleMaps/ScriptGoogleMaps.vue | 59 +++++++++++++--- .../ScriptGoogleMapsMarkerClusterer.vue | 29 ++++++-- .../GoogleMaps/ScriptGoogleMapsPinElement.vue | 10 ++- .../server/google-static-maps-proxy.ts | 67 +++++++++++++++++++ 5 files changed, 189 insertions(+), 17 deletions(-) create mode 100644 src/runtime/server/google-static-maps-proxy.ts diff --git a/src/module.ts b/src/module.ts index 73f5668f..b075f491 100644 --- a/src/module.ts +++ b/src/module.ts @@ -3,6 +3,7 @@ import { addComponentsDir, addImports, addPluginTemplate, + addServerHandler, addTemplate, createResolver, defineNuxtModule, @@ -78,6 +79,22 @@ export interface ModuleOptions { */ integrity?: boolean | 'sha256' | 'sha384' | 'sha512' } + /** + * Google Static Maps proxy configuration. + * Proxies static map images through your server to fix CORS issues and enable caching. + */ + googleStaticMapsProxy?: { + /** + * Enable proxying Google Static Maps through your own origin. + * @default false + */ + enabled?: boolean + /** + * Cache duration for static map images in seconds. + * @default 3600 (1 hour) + */ + cacheMaxAge?: number + } /** * Whether the module is enabled. * @@ -115,6 +132,10 @@ export default defineNuxtModule({ timeout: 15_000, // Configures the maximum time (in milliseconds) allowed for each fetch attempt. }, }, + googleStaticMapsProxy: { + enabled: false, + cacheMaxAge: 3600, + }, enabled: true, debug: false, }, @@ -136,11 +157,21 @@ export default defineNuxtModule({ if (unheadVersion?.startsWith('1')) { logger.error(`Nuxt Scripts requires Unhead >= 2, you are using v${unheadVersion}. Please run \`nuxi upgrade --clean\` to upgrade...`) } - nuxt.options.runtimeConfig['nuxt-scripts'] = { version } + nuxt.options.runtimeConfig['nuxt-scripts'] = { + version, + // Private proxy config with API key (server-side only) + googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled + ? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey } + : undefined, + } nuxt.options.runtimeConfig.public['nuxt-scripts'] = { // expose for devtools version: nuxt.options.dev ? version : undefined, defaultScriptOptions: config.defaultScriptOptions, + // Only expose enabled and cacheMaxAge to client, not apiKey + googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled + ? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge } + : undefined, } // Merge registry config with existing runtimeConfig.public.scripts for proper env var resolution @@ -250,6 +281,14 @@ export default defineNuxtModule({ }) }) + // Add Google Static Maps proxy handler if enabled + if (config.googleStaticMapsProxy?.enabled) { + addServerHandler({ + route: '/_scripts/google-static-maps-proxy', + handler: await resolvePath('./runtime/server/google-static-maps-proxy'), + }) + } + if (nuxt.options.dev) setupDevToolsUI(config, resolvePath) }, diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 3630b314..9c0838bf 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -6,7 +6,7 @@ import { withQuery } from 'ufo' import type { QueryObject } from 'ufo' import { defu } from 'defu' import { hash } from 'ohash' -import { useHead } from 'nuxt/app' +import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app' import type { ElementScriptTrigger } from '#nuxt-scripts/types' import { scriptRuntimeConfig } from '#nuxt-scripts/utils' import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTriggerElement' @@ -104,6 +104,17 @@ const props = withDefaults(defineProps<{ * Extra Markers to add to the map. */ markers?: (`${string},${string}` | google.maps.marker.AdvancedMarkerElementOptions)[] + /** + * Map IDs for light and dark color modes. + * When provided, the map will automatically switch styles based on color mode. + * Requires @nuxtjs/color-mode or manual colorMode prop. + */ + mapIds?: { light?: string, dark?: string } + /** + * Manual color mode control. When provided, overrides auto-detection from @nuxtjs/color-mode. + * Accepts 'light', 'dark', or a reactive ref. + */ + colorMode?: 'light' | 'dark' }>(), { // @ts-expect-error untyped trigger: ['mouseenter', 'mouseover', 'mousedown'], @@ -119,6 +130,26 @@ const emits = defineEmits<{ }>() const apiKey = props.apiKey || scriptRuntimeConfig('googleMaps')?.apiKey +const runtimeConfig = useRuntimeConfig() +const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy + +// Color mode support - try to auto-detect from @nuxtjs/color-mode +const nuxtApp = tryUseNuxtApp() +const nuxtColorMode = nuxtApp?.$colorMode as { value: string } | undefined + +const currentColorMode = computed(() => { + if (props.colorMode) + return props.colorMode + if (nuxtColorMode?.value) + return nuxtColorMode.value === 'dark' ? 'dark' : 'light' + return 'light' +}) + +const currentMapId = computed(() => { + if (!props.mapIds) + return props.mapOptions?.mapId + return props.mapIds[currentColorMode.value] || props.mapIds.light || props.mapOptions?.mapId +}) const mapsApi = ref() @@ -144,10 +175,10 @@ const { load, status, onLoaded } = useScriptGoogleMaps({ }) const options = computed(() => { - return defu({ center: centerOverride.value }, props.mapOptions, { + const mapId = props.mapOptions?.styles ? undefined : (currentMapId.value || 'map') + return defu({ center: centerOverride.value, mapId }, props.mapOptions, { center: props.center, zoom: 15, - mapId: props.mapOptions?.styles ? undefined : 'map', }) }) const ready = ref(false) @@ -266,8 +297,13 @@ function importLibrary(key: string): Promise { } }, { immediate: true }) }) - libraries.set(key, p) - return p as any as Promise + // Clear cache on failure to allow retry + const cached = Promise.resolve(p).catch((err) => { + libraries.delete(key) + throw err + }) + libraries.set(key, cached) + return cached as Promise } const googleMaps = { @@ -380,7 +416,7 @@ onMounted(() => { }) }) -if (import.meta.server) { +if (import.meta.server && !proxyConfig?.enabled) { useHead({ link: [ { @@ -419,9 +455,11 @@ const placeholder = computed(() => { center, }, { size: `${props.width}x${props.height}`, - key: apiKey, + // Only include API key if not using proxy (proxy injects it server-side) + key: proxyConfig?.enabled ? undefined : apiKey, scale: 2, // we assume a high DPI to avoid hydration issues style: props.mapOptions?.styles ? transformMapStyles(props.mapOptions.styles) : undefined, + map_id: currentMapId.value, markers: [ ...(props.markers || []), props.centerMarker && center, @@ -438,7 +476,12 @@ const placeholder = computed(() => { }) .join('|'), }) - return withQuery('https://maps.googleapis.com/maps/api/staticmap', placeholderOptions as QueryObject) + + const baseUrl = proxyConfig?.enabled + ? '/_scripts/google-static-maps-proxy' + : 'https://maps.googleapis.com/maps/api/staticmap' + + return withQuery(baseUrl, placeholderOptions as QueryObject) }) const placeholderAttrs = computed(() => { diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue index d4725bb0..c21b1167 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue @@ -3,13 +3,28 @@ @@ -26,19 +41,19 @@ const markerClustererEvents = [ ] as const const emit = defineEmits<{ - (event: typeof markerClustererEvents[number], payload: MarkerClusterer): void + (event: typeof markerClustererEvents[number], payload: MarkerClustererInstance): void }>() const mapContext = inject(MAP_INJECTION_KEY, undefined) -const markerClusterer = shallowRef(undefined) +const markerClusterer = shallowRef(undefined) whenever(() => mapContext?.map.value, async (map) => { const { MarkerClusterer } = await import('@googlemaps/markerclusterer') markerClusterer.value = new MarkerClusterer({ map, ...props.options, - }) + } as any) as MarkerClustererInstance setupMarkerClustererEventListeners(markerClusterer.value) }, { @@ -76,9 +91,9 @@ provide( }, ) -function setupMarkerClustererEventListeners(markerClusterer: MarkerClusterer) { +function setupMarkerClustererEventListeners(clusterer: MarkerClustererInstance) { markerClustererEvents.forEach((event) => { - markerClusterer.addListener(event, () => emit(event, markerClusterer)) + clusterer.addListener(event, () => emit(event, clusterer)) }) } diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue index 0ef56248..be1216cf 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue @@ -2,7 +2,7 @@ diff --git a/src/runtime/server/google-static-maps-proxy.ts b/src/runtime/server/google-static-maps-proxy.ts new file mode 100644 index 00000000..9995f8f4 --- /dev/null +++ b/src/runtime/server/google-static-maps-proxy.ts @@ -0,0 +1,67 @@ +import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' +import { withQuery } from 'ufo' +import { useRuntimeConfig } from '#imports' + +export default defineEventHandler(async (event) => { + const runtimeConfig = useRuntimeConfig() + const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy + const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleStaticMapsProxy + + if (!publicConfig?.enabled) { + throw createError({ + statusCode: 404, + statusMessage: 'Google Static Maps proxy is not enabled', + }) + } + + // Get API key from private config (server-side only, not exposed to client) + const apiKey = privateConfig?.apiKey + if (!apiKey) { + throw createError({ + statusCode: 500, + statusMessage: 'Google Maps API key not configured for proxy', + }) + } + + // Validate referer to prevent external abuse + const referer = getHeader(event, 'referer') + const host = getHeader(event, 'host') + if (referer && host) { + const refererUrl = new URL(referer).host + if (refererUrl !== host) { + throw createError({ + statusCode: 403, + statusMessage: 'Invalid referer', + }) + } + } + + const query = getQuery(event) + + // Remove any client-provided key and use server-side key + const { key: _clientKey, ...safeQuery } = query + + const googleMapsUrl = withQuery('https://maps.googleapis.com/maps/api/staticmap', { + ...safeQuery, + key: apiKey, + }) + + const response = await $fetch.raw(googleMapsUrl, { + headers: { + 'User-Agent': 'Nuxt Scripts Google Static Maps Proxy', + }, + }).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to fetch static map', + }) + }) + + const cacheMaxAge = publicConfig.cacheMaxAge || 3600 + setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/png') + setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) + setHeader(event, 'Vary', 'Accept-Encoding') + + return response._data +})