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({