Skip to content
Closed
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
14 changes: 8 additions & 6 deletions content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -519,12 +519,14 @@ reports local edits, while `init --scaffold --force` explicitly replaces those m
</Callout>

<Callout type="info">
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.
</Callout>

<Callout type="warn">
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions src/components/site/AnalyticsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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.
*
Expand Down
6 changes: 2 additions & 4 deletions src/components/site/ConsentBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/e2e/templates.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,19 @@ test.describe('Template full previews (/templates/<slug>/preview/<page>)', () =>
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(
Expand Down
39 changes: 39 additions & 0 deletions tests/int/site-i18n.int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server'
import { describe, expect, it } from 'vitest'

import {
isChromeFreePreviewPath,
isLocaleNeutralPath,
localeAlternates,
localeDetails,
Expand Down Expand Up @@ -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({
Expand Down