@sonordev/site-kit 7.0.0 → 7.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +3539 -0
  2. package/README.md +12 -13
  3. package/agent-manifest.json +1 -1
  4. package/dist/{AnalyticsProvider-GJ6JFQO5.js → AnalyticsProvider-XXWTFKJH.js} +4 -4
  5. package/dist/{ArticleViewTracker-FEA5SBKW.js → ArticleViewTracker-V4KZB6QN.js} +3 -3
  6. package/dist/{BlocksPopup-MNK4SDZM.js → BlocksPopup-JHGHB6XW.js} +4 -4
  7. package/dist/{ChatWidget-M2YNLRYW.js → ChatWidget-CG32POI3.js} +5 -5
  8. package/dist/{EngageWidget-RR63JQCA.js → EngageWidget-LQMR4LEX.js} +4 -4
  9. package/dist/{FileField-JFBXXD43.js → FileField-KUG3CKXG.js} +3 -3
  10. package/dist/{FormSpotlight-QE3J3VB5.js → FormSpotlight-FVNPOCU3.js} +1 -1
  11. package/dist/{FormStage-OTPKEWID.js → FormStage-C7VKRURJ.js} +1 -1
  12. package/dist/{ManagedForm-3VRALWUD.js → ManagedForm-VLNJKV65.js} +6 -6
  13. package/dist/{ManagedNewsletterForm-A2FE6L5C.js → ManagedNewsletterForm-KJEU23BV.js} +4 -4
  14. package/dist/{SignalCore-S6DO3VQV.js → SignalCore-K2O46QG7.js} +3 -3
  15. package/dist/{SiteDesignReporter-2WZVWJMA.js → SiteDesignReporter-D7MD66GI.js} +5 -5
  16. package/dist/SitemapSync-NMXGMPCQ.js +8 -0
  17. package/dist/_client/booking-widget.js +5 -5
  18. package/dist/affiliates/index.js +3 -3
  19. package/dist/analytics/index.js +4 -4
  20. package/dist/articles/index.js +1 -1
  21. package/dist/articles/server-ui.js +1 -1
  22. package/dist/chat/index.js +5 -5
  23. package/dist/{chunk-XZMVNORA.js → chunk-42OXY4JV.js} +1 -1
  24. package/dist/{chunk-5YSI5KFD.js → chunk-56JNI463.js} +1 -1
  25. package/dist/{chunk-M4ZY2FVB.js → chunk-5FBY2ZIH.js} +1 -1
  26. package/dist/{chunk-3XHK5DFX.js → chunk-7JIKGKWD.js} +7 -7
  27. package/dist/{chunk-7ZHJIRM3.js → chunk-B6RZ2NRH.js} +1 -1
  28. package/dist/{chunk-UINSEWQ3.js → chunk-BEL7YFMC.js} +1 -1
  29. package/dist/{chunk-R3TOKUDJ.js → chunk-BS7FWUOY.js} +1 -1
  30. package/dist/{chunk-MZFF5F7R.js → chunk-DKTSGYLM.js} +2 -2
  31. package/dist/{chunk-LT5ITURG.js → chunk-GGD4P7UW.js} +1 -1
  32. package/dist/{chunk-OO2GZ272.js → chunk-GYY6ETGB.js} +1 -1
  33. package/dist/{chunk-I6WUCBKI.js → chunk-K5WZX776.js} +2 -2
  34. package/dist/{chunk-B52EXDWI.js → chunk-LJZ3SUET.js} +2 -2
  35. package/dist/{chunk-OSQHWIM5.js → chunk-O52CH273.js} +1 -1
  36. package/dist/{chunk-HPUXKMNR.js → chunk-OIETJKIL.js} +1 -1
  37. package/dist/{chunk-5UZN5V52.js → chunk-P2GIIQH5.js} +1 -1
  38. package/dist/{chunk-5C4WVOVO.js → chunk-P72ZJRSX.js} +3 -3
  39. package/dist/{chunk-4WI3FU5L.js → chunk-RU2RMTGT.js} +2 -2
  40. package/dist/{chunk-GYBMMWGF.js → chunk-SAUTJMK6.js} +1 -1
  41. package/dist/{chunk-JMRESWQT.js → chunk-SWP36NCB.js} +1 -1
  42. package/dist/{chunk-WFHI6HXP.js → chunk-T4SY3FMN.js} +3 -3
  43. package/dist/{chunk-6ZB3XNAT.js → chunk-ZRE4ZYEG.js} +1 -1
  44. package/dist/{chunk-6CUFRMMF.js → chunk-ZSLRAMCK.js} +1 -1
  45. package/dist/client/index.js +3 -3
  46. package/dist/commerce/index.js +4 -4
  47. package/dist/engage/index.js +6 -6
  48. package/dist/fleet/index.js +4 -4
  49. package/dist/forms/index.js +7 -7
  50. package/dist/forms/server.js +2 -2
  51. package/dist/forms/types.d.ts +3 -1
  52. package/dist/images/index.js +4 -4
  53. package/dist/index.js +1 -1
  54. package/dist/layout/client.js +7 -7
  55. package/dist/layout/index.js +8 -8
  56. package/dist/maps/index.js +3 -3
  57. package/dist/mcp/sonor.js +9 -7
  58. package/dist/seo/client.js +4 -4
  59. package/dist/seo/index.js +4 -4
  60. package/dist/server/index.js +2 -2
  61. package/dist/shared/version.d.ts +1 -1
  62. package/dist/signal/index.js +2 -2
  63. package/dist/sync/index.js +5 -5
  64. package/dist/website/images.js +4 -4
  65. package/dist/website/index.js +5 -5
  66. package/dist/website/popups.js +4 -4
  67. package/docs/MIGRATING-TO-7.md +146 -0
  68. package/docs.json +67 -0
  69. package/package.json +9 -4
  70. package/src/admin-auth/README.md +88 -0
  71. package/src/analytics/README.md +264 -0
  72. package/src/articles/README.md +325 -0
  73. package/src/commerce/README.md +109 -0
  74. package/src/cta-bar/README.md +154 -0
  75. package/src/engage/README.md +241 -0
  76. package/src/forms/README.md +219 -0
  77. package/src/images/README.md +74 -0
  78. package/src/layout/README.md +66 -0
  79. package/src/llms/README.md +723 -0
  80. package/src/mcp/README.md +376 -0
  81. package/src/motion/README.md +372 -0
  82. package/src/og/README.md +304 -0
  83. package/src/proxy/README.md +152 -0
  84. package/src/redirects/README.md +74 -0
  85. package/src/reputation/README.md +64 -0
  86. package/src/seo/README.md +359 -0
  87. package/src/signal/README.md +115 -0
  88. package/src/sitemap/README.md +127 -0
  89. package/src/sync/README.md +115 -0
  90. package/dist/SitemapSync-IKNKPT2G.js +0 -8
package/docs.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "$comment": "What sonor.dev renders for this package, in nav order. sonor.dev reads this file from the published tarball (latest on npm), so a release IS the docs deploy: add a page here and ship it in package.json `files`. scripts/verify-docs.cjs fails the publish if a listed file is missing from the pack.",
3
+ "schemaVersion": 1,
4
+ "title": "site-kit",
5
+ "summary": "One package, one env var, every Sonor module on a Next.js site: SEO, analytics, forms, articles, commerce, chat, AI visibility, agent tools and motion.",
6
+ "groups": [
7
+ {
8
+ "title": "Start here",
9
+ "pages": [
10
+ { "slug": "", "title": "Overview", "file": "README.md" },
11
+ { "slug": "migrating-to-7", "title": "Migrating to 7", "file": "docs/MIGRATING-TO-7.md" },
12
+ { "slug": "agents", "title": "For coding agents", "file": "AGENTS.md" }
13
+ ]
14
+ },
15
+ {
16
+ "title": "Core",
17
+ "pages": [
18
+ { "slug": "layout", "title": "Layout", "file": "src/layout/README.md" },
19
+ { "slug": "seo", "title": "SEO", "file": "src/seo/README.md" },
20
+ { "slug": "analytics", "title": "Analytics", "file": "src/analytics/README.md" },
21
+ { "slug": "sitemap", "title": "Sitemap", "file": "src/sitemap/README.md" },
22
+ { "slug": "proxy", "title": "Proxy", "file": "src/proxy/README.md" },
23
+ { "slug": "redirects", "title": "Redirects", "file": "src/redirects/README.md" },
24
+ { "slug": "og", "title": "OG cards", "file": "src/og/README.md" }
25
+ ]
26
+ },
27
+ {
28
+ "title": "Content",
29
+ "pages": [
30
+ { "slug": "articles", "title": "Articles", "file": "src/articles/README.md" },
31
+ { "slug": "images", "title": "Images", "file": "src/images/README.md" },
32
+ { "slug": "reputation", "title": "Reputation", "file": "src/reputation/README.md" }
33
+ ]
34
+ },
35
+ {
36
+ "title": "Engagement",
37
+ "pages": [
38
+ { "slug": "forms", "title": "Forms", "file": "src/forms/README.md" },
39
+ { "slug": "chat", "title": "Website chat", "file": "src/engage/README.md" },
40
+ { "slug": "cta-bar", "title": "CTA bar", "file": "src/cta-bar/README.md" },
41
+ { "slug": "signal", "title": "Signal (A/B)", "file": "src/signal/README.md" }
42
+ ]
43
+ },
44
+ {
45
+ "title": "Commerce",
46
+ "pages": [
47
+ { "slug": "commerce", "title": "Commerce", "file": "src/commerce/README.md" },
48
+ { "slug": "booking", "title": "Booking", "file": "src/sync/README.md" }
49
+ ]
50
+ },
51
+ {
52
+ "title": "AI visibility",
53
+ "pages": [
54
+ { "slug": "llms", "title": "llms.txt and AEO", "file": "src/llms/README.md" },
55
+ { "slug": "mcp", "title": "MCP and agent tools", "file": "src/mcp/README.md" }
56
+ ]
57
+ },
58
+ {
59
+ "title": "More",
60
+ "pages": [
61
+ { "slug": "motion", "title": "Motion", "file": "src/motion/README.md" },
62
+ { "slug": "admin-auth", "title": "Sign in with Sonor", "file": "src/admin-auth/README.md" }
63
+ ]
64
+ }
65
+ ],
66
+ "changelog": { "file": "CHANGELOG.md", "since": "7.0.0" }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonordev/site-kit",
3
- "version": "7.0.0",
3
+ "version": "7.0.2",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@11.5.3",
6
6
  "description": "Complete client-side integration kit for Sonor - SEO, Analytics, Engage, Forms, Blog",
@@ -367,7 +367,11 @@
367
367
  "README.md",
368
368
  "AGENTS.md",
369
369
  "agent-manifest.json",
370
- "skills"
370
+ "skills",
371
+ "docs.json",
372
+ "CHANGELOG.md",
373
+ "docs/MIGRATING-TO-7.md",
374
+ "src/*/README.md"
371
375
  ],
372
376
  "scripts": {
373
377
  "build": "NODE_OPTIONS=--max-old-space-size=8192 tsup && pnpm build:types && node scripts/gen-agent-manifest.cjs",
@@ -387,8 +391,9 @@
387
391
  "typecheck": "tsc --noEmit",
388
392
  "typecheck:setup": "tsc --noEmit -p packages/sonor-setup/tsconfig.json",
389
393
  "version": "node scripts/sync-version.cjs && node scripts/gen-agent-manifest.cjs && git add src/shared/version.ts agent-manifest.json",
390
- "prepublishOnly": "rm -rf dist && NODE_OPTIONS=--max-old-space-size=8192 tsup && pnpm build:types && node scripts/gen-agent-manifest.cjs && node scripts/verify-dts.cjs && node scripts/prepublish-integration.cjs && node scripts/audit-as-consumer.cjs",
391
- "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --noEmit false && node scripts/alias-dts.cjs"
394
+ "prepublishOnly": "rm -rf dist && NODE_OPTIONS=--max-old-space-size=8192 tsup && pnpm build:types && node scripts/gen-agent-manifest.cjs && node scripts/verify-dts.cjs && node scripts/prepublish-integration.cjs && node scripts/audit-as-consumer.cjs && node scripts/verify-docs.cjs",
395
+ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --noEmit false && node scripts/alias-dts.cjs",
396
+ "verify:docs": "node scripts/verify-docs.cjs"
392
397
  },
393
398
  "peerDependencies": {
394
399
  "@portabletext/react": "^8.0.0",
@@ -0,0 +1,88 @@
1
+ # `@sonordev/site-kit/admin-auth` — Sign in with Sonor
2
+
3
+ Put a site's own admin area behind a Sonor login. Whoever can open the
4
+ project in Sonor (its owners and members, the managing agency, platform
5
+ admins) can sign in; nobody else can. There are no passwords on the site.
6
+
7
+ ## Wiring
8
+
9
+ ```ts
10
+ // lib/sonor-sso.ts
11
+ import { createSonorSso } from '@sonordev/site-kit/admin-auth'
12
+
13
+ export const sso = createSonorSso({
14
+ paths: '/admin', // or ['/bid', '/route'] for several areas
15
+ // loginPath: '/admin/login' (default: `${paths[0]}/login`)
16
+ // adminEmails: process.env.ADMIN_EMAILS,
17
+ })
18
+ ```
19
+
20
+ ```ts
21
+ // proxy.ts
22
+ import { createProxy } from '@sonordev/site-kit/proxy'
23
+ import { sso } from '@/lib/sonor-sso'
24
+
25
+ export default createProxy({ before: sso.gate, redirects: true, securityHeaders: true })
26
+ ```
27
+
28
+ ```ts
29
+ // app/api/auth/callback/route.ts
30
+ import { sso } from '@/lib/sonor-sso'
31
+ export const GET = sso.handleCallback
32
+ ```
33
+
34
+ ```ts
35
+ // app/api/auth/logout/route.ts
36
+ import { sso } from '@/lib/sonor-sso'
37
+ export const GET = sso.handleLogout
38
+ export const POST = sso.handleLogout
39
+ ```
40
+
41
+ ```tsx
42
+ // app/admin/login/page.tsx (server component)
43
+ import { SSO_ERROR_MESSAGES } from '@sonordev/site-kit/admin-auth'
44
+ import { sso } from '@/lib/sonor-sso'
45
+
46
+ export default async function Login({ searchParams }) {
47
+ const { error, return_to } = await searchParams
48
+ let href: string | null = null
49
+ try { href = await sso.getLoginUrl({ returnTo: return_to }) } catch {}
50
+ return (
51
+ <>
52
+ {error && <p>{SSO_ERROR_MESSAGES[error] ?? error}</p>}
53
+ {href ? <a href={href}>Sign in with Sonor</a> : <p>Sign-in is unavailable.</p>}
54
+ </>
55
+ )
56
+ }
57
+ ```
58
+
59
+ ```ts
60
+ // Every API route and server action that touches admin data
61
+ const session = await sso.getSession()
62
+ if (!session) return new Response('Unauthorized', { status: 401 })
63
+ ```
64
+
65
+ Designer APIs or other JSON endpoints can be guarded in the middleware too:
66
+ pass `apiPaths: ['/api/studio']` and include them in the matcher. They get a
67
+ 401 JSON response instead of a redirect.
68
+
69
+ ## Env
70
+
71
+ | Var | |
72
+ |---|---|
73
+ | `SONOR_API_KEY` | Already on every site. Identifies the project and verifies tokens. |
74
+ | `SONOR_SESSION_SECRET` | 32+ random chars (`openssl rand -hex 32`). Rotate to sign everyone out. |
75
+ | `NEXT_PUBLIC_SITE_URL` | The site's public origin, for the callback URL. |
76
+
77
+ The callback route has to be reachable at `callbackPath` (default
78
+ `/api/auth/callback`) on `NEXT_PUBLIC_SITE_URL`.
79
+
80
+ ## Rules
81
+
82
+ - **The gate doesn't cover `/api/*`.** The standard middleware matcher
83
+ excludes it. Check `getSession()` in every API route.
84
+ - **Don't trust identity from headers, query strings or bodies.**
85
+ `getSession()` reads the httpOnly cookie and verifies its signature on
86
+ every call; that's the only source.
87
+ - **Moving a site onto this module:** pass its existing `cookieName` and
88
+ `secretEnv` so current sessions stay valid.
@@ -0,0 +1,264 @@
1
+ # Analytics — `@sonordev/site-kit/analytics`
2
+
3
+ Automatic page view tracking, custom events, conversions, scroll depth, heatmap clicks, and Core Web Vitals. All data flows through the Sonor API.
4
+
5
+ ## Usage
6
+
7
+ `SiteKitLayout` mounts analytics for you. It renders `{children}` first and
8
+ mounts `AnalyticsProvider` after them as a childless sibling, deferred until
9
+ window load + idle (or the first interaction). Analytics never sits in the
10
+ page's hydration path and can't push a route to client rendering. Configure it
11
+ on the layout:
12
+
13
+ ```tsx
14
+ // app/layout.tsx (server component)
15
+ import { SiteKitLayout } from '@sonordev/site-kit/layout'
16
+ import { ContactTracking } from '@sonordev/site-kit/analytics'
17
+
18
+ export default function RootLayout({ children }) {
19
+ return (
20
+ <html lang="en">
21
+ <body>
22
+ <SiteKitLayout analytics={{ excludePaths: ['/admin'] }}>{children}</SiteKitLayout>
23
+ {/* Optional: tel:/mailto: clicks as conversions. Childless, renders null. */}
24
+ <ContactTracking />
25
+ </body>
26
+ </html>
27
+ )
28
+ }
29
+ ```
30
+
31
+ Don't wrap `{children}` in `AnalyticsProvider` yourself. It puts analytics in
32
+ the hydration path (the page-view code ships in the page's initial scripts),
33
+ and if it's lazy-loaded with `next/dynamic({ ssr: false })` the whole route
34
+ de-opts to client rendering.
35
+
36
+ ### Without `SiteKitLayout`
37
+
38
+ A layout that doesn't use core `SiteKitLayout` mounts analytics itself, and
39
+ still as a deferred, childless island. Publish the credential server-side
40
+ first with `SiteKitCredential` (see `@sonordev/site-kit/client`). Never pass
41
+ `SONOR_API_KEY` to a client component.
42
+
43
+ ```tsx
44
+ // app/analytics-shell.tsx
45
+ 'use client'
46
+ import { AnalyticsProvider, ContactTracking } from '@sonordev/site-kit/analytics'
47
+
48
+ export default function AnalyticsShell() {
49
+ return (
50
+ <AnalyticsProvider trackPageViews trackWebVitals>
51
+ <ContactTracking />
52
+ </AnalyticsProvider>
53
+ )
54
+ }
55
+ ```
56
+
57
+ ```tsx
58
+ // app/deferred-analytics.tsx
59
+ 'use client'
60
+ import { lazy, Suspense } from 'react'
61
+ import { useDeferredActivation } from '@sonordev/site-kit/client'
62
+
63
+ const AnalyticsShell = lazy(() => import('./analytics-shell'))
64
+
65
+ export function DeferredAnalytics() {
66
+ const ready = useDeferredActivation(true)
67
+ if (!ready) return null
68
+ return (
69
+ <Suspense fallback={null}>
70
+ <AnalyticsShell />
71
+ </Suspense>
72
+ )
73
+ }
74
+ ```
75
+
76
+ ```tsx
77
+ // app/layout.tsx (server component): {children} is a SIBLING of the island, never inside it
78
+ import { resolveClientCredential } from '@sonordev/site-kit/server'
79
+ import { SiteKitCredential } from '@sonordev/site-kit/client'
80
+ import { DeferredAnalytics } from './deferred-analytics'
81
+
82
+ export default async function RootLayout({ children }) {
83
+ const credential = await resolveClientCredential(process.env.SONOR_API_KEY ?? '', 'https://api.sonor.io')
84
+ return (
85
+ <html lang="en">
86
+ <body>
87
+ <SiteKitCredential apiKey={credential} />
88
+ {children}
89
+ <DeferredAnalytics />
90
+ </body>
91
+ </html>
92
+ )
93
+ }
94
+ ```
95
+
96
+ `AnalyticsProvider` already mounts `WebVitals` when `trackWebVitals` is on.
97
+ Don't add a second `<WebVitals />`, or every metric reports twice.
98
+
99
+ ## Tracking custom events
100
+
101
+ Under `SiteKitLayout` the provider is childless, so no page component sits
102
+ inside it. Use the standalone functions. They need no provider and queue until
103
+ the deferred provider mounts:
104
+
105
+ ```tsx
106
+ 'use client'
107
+ import { trackEvent, trackConversion } from '@sonordev/site-kit/analytics'
108
+
109
+ trackEvent({ name: 'button_click', category: 'engagement', properties: { buttonId: 'cta' } })
110
+ trackConversion({ type: 'purchase', value: 99.99, currency: 'USD' })
111
+ ```
112
+
113
+ ## Hooks
114
+
115
+ `useAnalytics()` and `useTrackEvent()` read the provider's context, so they
116
+ only work in components rendered inside an `AnalyticsProvider` (children of
117
+ your own analytics island, for example). Anywhere else they throw. Reach for
118
+ the standalone functions above instead of wrapping `{children}` to make a hook
119
+ work. `useAnalyticsOptional()` returns `null` instead of throwing.
120
+
121
+ ### useAnalytics()
122
+
123
+ ```tsx
124
+ const { trackEvent, trackConversion, sessionId, visitorId } = useAnalytics()
125
+ ```
126
+
127
+ ### useTrackEvent()
128
+
129
+ ```tsx
130
+ const { trackEvent, trackConversion } = useTrackEvent()
131
+ ```
132
+
133
+ ### useContactTracking()
134
+
135
+ Auto-tracks `tel:` and `mailto:` link clicks as conversions. Works anywhere: it
136
+ uses the standalone dispatch, not the provider's context.
137
+
138
+ ```tsx
139
+ const { trackPhoneClick, trackEmailClick } = useContactTracking()
140
+ ```
141
+
142
+ ## Components
143
+
144
+ | Component | Purpose |
145
+ |-----------|---------|
146
+ | `AnalyticsProvider` | The tracker. `SiteKitLayout` mounts it childless; never wrap `{children}` in it |
147
+ | `WebVitals` | Auto-reports LCP, CLS, TTFB, INP, FCP. `AnalyticsProvider` mounts it when `trackWebVitals` is on |
148
+ | `ContactTracking` | Auto-tracks phone/email link clicks. Childless, renders null |
149
+
150
+ ## AnalyticsProvider Props
151
+
152
+ ```ts
153
+ interface AnalyticsConfig {
154
+ projectId?: string // Auto-resolved from API key
155
+ trackPageViews?: boolean // Default: true
156
+ trackWebVitals?: boolean // Default: true
157
+ trackScrollDepth?: boolean // Default: true
158
+ sessionTimeout?: number // Minutes (default: 30)
159
+ excludePaths?: string[] // Don't track these paths
160
+ allowInFrame?: boolean // Default: false — see below
161
+ allowLocalhost?: boolean // Default: false — local builds report nothing
162
+ debug?: boolean // Log events to console
163
+ }
164
+ ```
165
+
166
+ ## What Gets Tracked Automatically
167
+
168
+ - **Page views** — on every route change (path, URL, title, referrer, UTM params, device/browser/OS)
169
+ - **Web Vitals** — LCP, CLS, TTFB, INP, FCP with good/needs-improvement/poor ratings
170
+ - **Contact clicks** — `tel:` and `mailto:` links tracked as conversions, once `<ContactTracking />` is mounted (it isn't by default)
171
+ - **DOM metadata** — full snapshot per page view (meta tags, H1, word count, links, content, FAQs)
172
+
173
+ ## Embedded pages report nothing (`analytics.allowInFrame`)
174
+
175
+ When this site is loaded inside a **cross-origin iframe**, nothing is sent —
176
+ no page views, journey/session rows, scroll depth, heatmap clicks, web vitals,
177
+ events or conversions. The visitor is on whoever framed the page, not on this
178
+ site, so every metric the frame produces is phantom traffic in the analytics
179
+ its owner reads.
180
+
181
+ This is not hypothetical. Upforge case studies embed each client's whole
182
+ production site in three eager iframes, and `securityHeaders`'
183
+ `DEFAULT_FRAME_ANCESTORS` permits that. Before the guard landed, 23 client
184
+ sites carried the signature in `analytics_page_views`: three views of `/`
185
+ sharing one session and visitor id, 21-80ms apart, referrer
186
+ `https://upforge.io/` — the desktop, tablet and mobile frames of one case
187
+ study. The screenshot overlay in those frames hides the pixels, not the
188
+ JavaScript.
189
+
190
+ **Same-origin frames still report.** A site embedding itself (a preview pane,
191
+ a print view, an on-domain booking frame) has a real visitor really on that
192
+ site and no other tenant to pollute.
193
+
194
+ Opt back in only when the frame IS the product — a widget or partner-hosted
195
+ page deliberately distributed as an embed:
196
+
197
+ ```tsx
198
+ <SiteKitLayout analytics={{ allowInFrame: true }}>…</SiteKitLayout>
199
+ ```
200
+
201
+ There is deliberately no env var or window global for this. A silent switch
202
+ that turns cross-tenant tracking back on is the failure mode, not the feature.
203
+
204
+ The decision lives in one place — `shared/reporting-gate.ts`, over the frame
205
+ primitive in `shared/frame.ts`. Every send in the module routes through it, and
206
+ `send-gate.test.ts` fails the build if a new one does not. `isCrossOriginFrame()`
207
+ is exported from `@sonordev/site-kit/analytics` if a site needs the same answer
208
+ for its own third-party pixels.
209
+
210
+ ## Local builds report nothing (`analytics.allowLocalhost`)
211
+
212
+ The actual browser hostname controls this gate, even in a production build
213
+ with `analytics.site` or `NEXT_PUBLIC_SITE_URL` set to a production domain.
214
+ `localhost`, its subdomains, `127.0.0.1`, `[::1]`, and `0.0.0.0` report nothing
215
+ by default: page views, events, conversions, sessions, vitals, scroll and
216
+ heatmap data, fleet heartbeats, and client-side sitemap registrations all use
217
+ `resolveAnalyticsTarget`. Engage doesn't mount its widgets or chat transport.
218
+ Public hosts and same-origin frames on public hosts still report normally.
219
+
220
+ For intentional local reporting:
221
+
222
+ ```tsx
223
+ <SiteKitLayout analytics={{ allowLocalhost: true }}>…</SiteKitLayout>
224
+ ```
225
+
226
+ The layout passes this option to AnalyticsProvider, WebVitals, FleetHeartbeat,
227
+ SitemapSync and EngageWidget. Standalone components and `sendFleetHeartbeat`
228
+ accept it too; standalone `trackEvent`/`trackConversion` use their mounted
229
+ AnalyticsProvider's options. A local cross-origin frame needs **both** opt-ins.
230
+ Neither option bypasses authentication. No environment variable or global
231
+ silently enables local reporting.
232
+
233
+ This is a browser gate: build-time `createSitemap` and Node fleet reporting
234
+ still run. It doesn't disable forms, commerce, or Signal. When verifying those
235
+ modules locally, point them at a test API as well.
236
+
237
+ ## Environment
238
+
239
+ Uses `SONOR_API_KEY` (injected by `SiteKitLayout`) or `window.__SITE_KIT_API_KEY__`.
240
+
241
+ ## Multi-site projects (`analytics.site`)
242
+
243
+ A single Sonor project can host many sub-sites (e.g. one project hosting
244
+ example.com + 16 state microsites). Every analytics event is
245
+ tagged with the sub-site host so the dashboard can roll up + filter per
246
+ site without forcing each microsite into its own Sonor project.
247
+
248
+ `SiteKitLayout` resolves the host in this precedence order:
249
+
250
+ 1. Explicit `analytics.site` config
251
+ 2. `NEXT_PUBLIC_SITE_URL` host
252
+ 3. `window.location.host`
253
+
254
+ ```tsx
255
+ <SiteKitLayout
256
+ analytics={{ site: 'ohio.example.com' }}
257
+ >
258
+ …
259
+ </SiteKitLayout>
260
+ ```
261
+
262
+ Most projects leave it implicit — `NEXT_PUBLIC_SITE_URL` is already set per
263
+ microsite, so the dimension fills in automatically. The dashboard shows a
264
+ "Site" picker + a "Sites" tab as soon as ≥2 distinct hosts are detected.