diff --git a/content/docs/cli.mdx b/content/docs/cli.mdx
index 1f48dfaa..3d8bf1a2 100644
--- a/content/docs/cli.mdx
+++ b/content/docs/cli.mdx
@@ -519,12 +519,14 @@ reports local edits, while `init --scaffold --force` explicitly replaces those m
- Nothing is overwritten. A project that already has its own `cn`, `Media`, or Pages
- collection keeps it, and re-running the command creates nothing. If your Payload config
- has no readable `collections: [` array, the command prints the two lines to add rather
- than rewriting a `buildConfig` call it could not parse. If `shadcn init` finishes without
- writing a `components.json`, the command stops there rather than scaffolding on top of a
- project that still cannot install anything.
+ Your own code is never overwritten. A project that already has its own `cn`, `Media`, or
+ Pages collection keeps it as an unowned file the scaffold will not touch. A rerun can still
+ create any other missing canonical managed files. For files the scaffold already owns, it
+ updates pristine copies and reports edited copies rather than replacing them unless you pass
+ `--force`. If your Payload config has no readable `collections: [` array, the command prints
+ the two lines to add rather than rewriting a `buildConfig` call it could not parse. If
+ `shadcn init` finishes without writing a `components.json`, the command stops there rather
+ than scaffolding on top of a project that still cannot install anything.
diff --git a/package.json b/package.json
index 35770f6d..bccc0ad0 100644
--- a/package.json
+++ b/package.json
@@ -138,7 +138,7 @@
],
"overrides": {
"esbuild": "0.28.1",
- "fast-uri": "3.1.5",
+ "fast-uri": "3.1.7",
"postcss": "8.5.23",
"brace-expansion@<1.1.18": "1.1.18",
"brace-expansion@>=4.0.0 <5.0.9": "5.0.9",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 35721250..eaf19157 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,7 +6,7 @@ settings:
overrides:
esbuild: 0.28.1
- fast-uri: 3.1.5
+ fast-uri: 3.1.7
postcss: 8.5.23
brace-expansion@<1.1.18: 1.1.18
brace-expansion@>=4.0.0 <5.0.9: 5.0.9
@@ -3261,8 +3261,8 @@ packages:
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
- fast-uri@3.1.5:
- resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
+ fast-uri@3.1.7:
+ resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
@@ -7770,7 +7770,7 @@ snapshots:
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
- fast-uri: 3.1.5
+ fast-uri: 3.1.7
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -8845,7 +8845,7 @@ snapshots:
fast-string-truncated-width: 3.0.3
optional: true
- fast-uri@3.1.5: {}
+ fast-uri@3.1.7: {}
fast-wrap-ansi@0.2.2:
dependencies:
diff --git a/src/components/site/AnalyticsShell.tsx b/src/components/site/AnalyticsShell.tsx
index 88093c90..61e920de 100644
--- a/src/components/site/AnalyticsShell.tsx
+++ b/src/components/site/AnalyticsShell.tsx
@@ -3,6 +3,9 @@ import { Analytics } from '@vercel/analytics/next'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { usePathname } from 'next/navigation'
import Script from 'next/script'
+
+import { isChromeFreePreviewPath } from '@/i18n/config'
+
import { AnalyticsPageview } from './AnalyticsPageview'
import { useConsent } from './useConsent'
@@ -12,8 +15,7 @@ export function AnalyticsShell() {
// Chrome-free iframe targets: embedding pages already carry analytics, so
// mounting the general stream here would double-count every embedded view.
// Template previews emit their own explicit template_* events instead.
- if (pathname.startsWith('/components/preview/')) return null
- if (/^\/templates\/[^/]+\/preview(\/|$)/.test(pathname)) return null
+ if (isChromeFreePreviewPath(pathname)) return null
/* Two tiers, split by what each provider writes to the visitor's device.
*
diff --git a/src/components/site/ConsentBanner.tsx b/src/components/site/ConsentBanner.tsx
index e27a9ea3..addee8ce 100644
--- a/src/components/site/ConsentBanner.tsx
+++ b/src/components/site/ConsentBanner.tsx
@@ -4,7 +4,7 @@ import Link from '@/i18n/Link'
import { useLocale, useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation'
-import { localizeHref, normalizeSiteLocale, splitLocalePathname } from '@/i18n/config'
+import { isChromeFreePreviewPath, localizeHref, normalizeSiteLocale } from '@/i18n/config'
import { setConsent } from '@/lib/consent'
import { useConsent } from './useConsent'
@@ -18,12 +18,10 @@ export function ConsentBanner() {
const locale = normalizeSiteLocale(useLocale())
const t = useTranslations('Consent')
const consent = useConsent()
- const unlocalizedPathname = splitLocalePathname(pathname).pathname
// Chrome-free iframe targets never carry site chrome; a banner inside an
// embedded preview would be both wrong and unreachable.
- if (unlocalizedPathname.startsWith('/components/preview/')) return null
- if (/^\/templates\/[^/]+\/preview(\/|$)/.test(unlocalizedPathname)) return null
+ if (isChromeFreePreviewPath(pathname)) return null
// `undefined` is the pre-hydration state — render nothing so server and first
// client render agree; the effect in useConsent supplies the real value.
if (consent !== null) return null
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
index 75d288d5..6e78683c 100644
--- a/src/i18n/config.ts
+++ b/src/i18n/config.ts
@@ -249,6 +249,23 @@ export function isLocaleNeutralPath(pathname: string) {
)
}
+/* Chrome-free iframe targets. The embedding page already carries the site
+ * chrome and the general analytics stream, so anything mounted per-page here is
+ * either unreachable (a consent banner inside an iframe) or a double count.
+ *
+ * A localized preview is the same resource as its unprefixed form, and the
+ * localized template detail deliberately localizes its iframe URL. Classifying
+ * on the raw pathname therefore exempts only English — hence the split here
+ * rather than at each call site, so no caller can forget it. */
+export function isChromeFreePreviewPath(pathname: string): boolean {
+ const unlocalized = splitLocalePathname(pathname).pathname
+
+ return (
+ unlocalized.startsWith('/components/preview/') ||
+ /^\/templates\/[^/]+\/preview(\/|$)/.test(unlocalized)
+ )
+}
+
/** Add or replace the public locale prefix without touching external URLs. */
export function localizeHref(href: string, locale: SiteLocale): string {
if (!href.startsWith('/') || href.startsWith('//')) return href
diff --git a/tests/e2e/templates.e2e.spec.ts b/tests/e2e/templates.e2e.spec.ts
index 5655384d..881cc4c5 100644
--- a/tests/e2e/templates.e2e.spec.ts
+++ b/tests/e2e/templates.e2e.spec.ts
@@ -559,6 +559,19 @@ test.describe('Template full previews (/templates//preview/)', () =>
await expect(page.locator('script[src*="googletagmanager"]')).toHaveCount(0)
await expect(page.locator('script#google-tag')).toHaveCount(0)
+ /* Localized previews are the same chrome-free resource, and the localized
+ * detail page loads a localized iframe URL — so a locale prefix must not
+ * exempt the route. It did: /zh previews mounted the tag that / suppressed. */
+ for (const localizedPreview of [
+ `/zh${templatePreviewHref(template.slug)}`,
+ '/zh/components/preview/hero-basic',
+ ]) {
+ await page.goto(`${baseURL}${localizedPreview}`)
+ await page.waitForLoadState('load')
+ await expect(page.locator('script[src*="googletagmanager"]')).toHaveCount(0)
+ await expect(page.locator('script#google-tag')).toHaveCount(0)
+ }
+
// ...while the indexable gallery keeps the one Google tag.
await page.goto(`${baseURL}/templates`)
await expect(
diff --git a/tests/int/site-i18n.int.spec.ts b/tests/int/site-i18n.int.spec.ts
index a4d81fc9..b8bbc414 100644
--- a/tests/int/site-i18n.int.spec.ts
+++ b/tests/int/site-i18n.int.spec.ts
@@ -8,6 +8,7 @@ import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server'
import { describe, expect, it } from 'vitest'
import {
+ isChromeFreePreviewPath,
isLocaleNeutralPath,
localeAlternates,
localeDetails,
@@ -91,6 +92,44 @@ describe('site internationalization', () => {
)
})
+ it('treats chrome-free previews as chrome-free in every locale', async () => {
+ /* The guard used to compare usePathname() with an unprefixed literal, so a
+ * locale prefix exempted the route: /zh previews mounted the GA tag that
+ * their English equivalents suppress, double-counting every embedded view
+ * the localized template detail loads in its own localized iframe. */
+ for (const prefix of ['', '/en', '/zh', '/ar']) {
+ expect(isChromeFreePreviewPath(`${prefix}/components/preview/hero-basic`)).toBe(true)
+ expect(isChromeFreePreviewPath(`${prefix}/templates/saas-launch/preview`)).toBe(true)
+ expect(isChromeFreePreviewPath(`${prefix}/templates/saas-launch/preview/pricing`)).toBe(true)
+ }
+
+ // Chrome-carrying routes keep both the banner and the general stream.
+ for (const chromed of [
+ '/',
+ '/zh',
+ '/components',
+ '/zh/components',
+ '/components/previewer',
+ '/templates/saas-launch',
+ '/zh/templates/saas-launch',
+ ]) {
+ expect(isChromeFreePreviewPath(chromed)).toBe(false)
+ }
+
+ /* Both call sites must delegate rather than re-derive: the bug was one of
+ * the two forgetting to strip the prefix the other already stripped. */
+ const [analyticsShell, consentBanner] = await Promise.all([
+ readFile(path.join(repoRoot, 'src/components/site/AnalyticsShell.tsx'), 'utf8'),
+ readFile(path.join(repoRoot, 'src/components/site/ConsentBanner.tsx'), 'utf8'),
+ ])
+
+ for (const source of [analyticsShell, consentBanner]) {
+ expect(source).toContain('isChromeFreePreviewPath(pathname)')
+ expect(source).not.toContain("'/components/preview/'")
+ expect(source).not.toContain('/preview(')
+ }
+ })
+
it('runs locale middleware for routes that begin with r without matching registry assets', () => {
expect(
unstable_doesMiddlewareMatch({