@sonordev/site-kit 7.0.1 → 7.1.0

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 (147) hide show
  1. package/CHANGELOG.md +3606 -0
  2. package/README.md +12 -13
  3. package/agent-manifest.json +11 -5
  4. package/dist/{AnalyticsProvider-EMM2TKRE.js → AnalyticsProvider-ZMQUV33M.js} +4 -4
  5. package/dist/{ArticleViewTracker-RA64BGL6.js → ArticleViewTracker-V7NUXYBY.js} +3 -3
  6. package/dist/{BlocksPopup-D25RFNOV.js → BlocksPopup-52EU7OUY.js} +4 -4
  7. package/dist/{ChatWidget-RYI7BMJJ.js → ChatWidget-5BHMNR57.js} +5 -5
  8. package/dist/{EngageWidget-UKFCN33M.js → EngageWidget-PCGLX7SO.js} +4 -4
  9. package/dist/{FileField-MUHA7LZR.js → FileField-TSFGNAMY.js} +3 -3
  10. package/dist/{FormSpotlight-TCLPWPLL.js → FormSpotlight-XLBWEOTE.js} +1 -1
  11. package/dist/{FormStage-CNYLP6I6.js → FormStage-IJQ5Q2X6.js} +1 -1
  12. package/dist/{ManagedForm-7ZL5SKTO.js → ManagedForm-Z3PKOPIZ.js} +6 -6
  13. package/dist/{ManagedNewsletterForm-33B4JLX7.js → ManagedNewsletterForm-QAO3POLC.js} +4 -4
  14. package/dist/{SignalCore-L5FVDHFE.js → SignalCore-RBA3VDBL.js} +3 -3
  15. package/dist/{SiteDesignReporter-4JOFL4FP.js → SiteDesignReporter-C4LR5X2V.js} +5 -5
  16. package/dist/SitemapSync-XVMGKCF3.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-FYBZ5SNP.js → chunk-3G2SE2J4.js} +1 -1
  24. package/dist/{chunk-HGCK465A.js → chunk-3J2ERO3I.js} +1 -1
  25. package/dist/{chunk-KXPBMCFL.js → chunk-3QI26673.js} +3 -1
  26. package/dist/{chunk-BMO3VGMR.js → chunk-3XPJKZ6D.js} +30 -7
  27. package/dist/{chunk-QGHSMJKW.js → chunk-4JQQDCMO.js} +1 -1
  28. package/dist/{chunk-6HDT4G4A.js → chunk-4YTYGG2C.js} +2 -2
  29. package/dist/{chunk-N2UVOR3X.js → chunk-662ILEZ6.js} +2 -0
  30. package/dist/chunk-6G43IRWR.js +4 -0
  31. package/dist/{chunk-LVESVYCE.js → chunk-7MHHWZKC.js} +11 -117
  32. package/dist/{chunk-MV2MBTC3.js → chunk-7QTMMHUO.js} +1 -1
  33. package/dist/{chunk-KPAZG65P.js → chunk-CGWUXUYZ.js} +138 -46
  34. package/dist/{chunk-4IQ52CXL.js → chunk-DUAO4Q75.js} +2 -2
  35. package/dist/{chunk-4RMVXRBO.js → chunk-EIULXXUJ.js} +3 -3
  36. package/dist/{chunk-3KUUH2YP.js → chunk-EVFZ7KEW.js} +1 -1
  37. package/dist/{chunk-OFOAHPUV.js → chunk-F42R35NV.js} +1 -1
  38. package/dist/{chunk-QANVUXKH.js → chunk-FLR3EMK6.js} +1 -1
  39. package/dist/{chunk-P4GRY6QP.js → chunk-GIAOPEN6.js} +1 -1
  40. package/dist/{chunk-P5J7VMQ3.js → chunk-GWUKQ26F.js} +1 -1
  41. package/dist/{chunk-TT63HHIT.js → chunk-HAG4YIZY.js} +1 -1
  42. package/dist/{chunk-QZZIKMAT.js → chunk-HVH37YPX.js} +1 -1
  43. package/dist/{chunk-SSUQKA7L.js → chunk-J4D6ZXRW.js} +1 -1
  44. package/dist/{chunk-GYESATRY.js → chunk-L2DJD5Y4.js} +1 -1
  45. package/dist/{chunk-WATH55UY.js → chunk-LFXVE32I.js} +1 -1
  46. package/dist/chunk-LPH5FANE.js +169 -0
  47. package/dist/{chunk-V6LSQRTH.js → chunk-PLUP2KN5.js} +1 -1
  48. package/dist/chunk-RYVDGXC2.js +19 -0
  49. package/dist/{chunk-EGOD74PP.js → chunk-U35H2JIQ.js} +2 -2
  50. package/dist/chunk-VCJYLYJV.js +49 -0
  51. package/dist/{chunk-FL4EPUWA.js → chunk-W2CL2DB3.js} +2 -2
  52. package/dist/{chunk-UZN4ZYR2.js → chunk-XD3ZQET6.js} +1 -1
  53. package/dist/{chunk-CVTVNC2U.js → chunk-XNVSCQ2O.js} +2 -2
  54. package/dist/{chunk-T3MC4HOD.js → chunk-YLSEB32F.js} +1 -1
  55. package/dist/chunk-ZETJTCMV.js +118 -0
  56. package/dist/{chunk-5SEM2V4A.js → chunk-ZIMFQWGJ.js} +3 -3
  57. package/dist/client/index.js +3 -3
  58. package/dist/cms/CmsPage.d.ts +1 -0
  59. package/dist/cms/CmsPreview.d.ts +1 -0
  60. package/dist/cms/CmsSection.d.ts +1 -0
  61. package/dist/cms/index.d.ts +6 -0
  62. package/dist/cms/server-api.d.ts +3 -0
  63. package/dist/commerce/index.js +4 -4
  64. package/dist/config/index.js +1 -1
  65. package/dist/contracts/entries.d.ts +1 -1
  66. package/dist/contracts/site-cache.d.ts +55 -0
  67. package/dist/contracts/site-edit-param.d.ts +7 -0
  68. package/dist/contracts/site-edit.d.ts +77 -0
  69. package/dist/contracts/slot-content.d.ts +111 -0
  70. package/dist/contracts/slots.d.ts +39 -25
  71. package/dist/engage/index.js +6 -6
  72. package/dist/fleet/index.js +4 -4
  73. package/dist/forms/index.js +8 -8
  74. package/dist/forms/server.js +2 -2
  75. package/dist/forms/types.d.ts +3 -1
  76. package/dist/images/index.js +4 -4
  77. package/dist/index.js +1 -1
  78. package/dist/layout/client.js +8 -7
  79. package/dist/layout/index.js +9 -8
  80. package/dist/llms/index.js +4 -2
  81. package/dist/llms/seo-revalidate.d.ts +8 -1
  82. package/dist/maps/index.js +3 -3
  83. package/dist/mcp/sonor.js +6 -6
  84. package/dist/overlay-RXV6U6QC.js +353 -0
  85. package/dist/proxy/index.js +2 -2
  86. package/dist/proxy/securityHeaders.d.ts +4 -0
  87. package/dist/revalidate/index.d.ts +44 -0
  88. package/dist/revalidate/index.js +27 -0
  89. package/dist/seo/ManagedContent.d.ts +2 -0
  90. package/dist/seo/client.js +4 -4
  91. package/dist/seo/index.js +9 -8
  92. package/dist/seo/llms.js +4 -2
  93. package/dist/seo/register-sitemap-cli.js +1 -1
  94. package/dist/seo/server.js +3 -2
  95. package/dist/seo/sitemap.js +2 -2
  96. package/dist/server/index.js +2 -2
  97. package/dist/{server-api-GJJQZVG7.js → server-api-BVCBLJKL.js} +2 -1
  98. package/dist/shared/build-entries.d.ts +1 -0
  99. package/dist/shared/edit-bridge.d.ts +8 -0
  100. package/dist/shared/version.d.ts +1 -1
  101. package/dist/signal/index.js +2 -2
  102. package/dist/sitemap/index.js +2 -2
  103. package/dist/slots/ManagedLink.d.ts +31 -0
  104. package/dist/slots/ManagedList.d.ts +30 -0
  105. package/dist/slots/ManagedRichText.d.ts +31 -0
  106. package/dist/slots/contract.js +2 -1
  107. package/dist/slots/edit/locate.d.ts +30 -0
  108. package/dist/slots/edit/overlay.d.ts +18 -0
  109. package/dist/slots/index.d.ts +12 -4
  110. package/dist/slots/index.js +4 -2
  111. package/dist/slots/revalidate.d.ts +8 -3
  112. package/dist/slots/rich.d.ts +7 -0
  113. package/dist/slots/server-api.d.ts +6 -2
  114. package/dist/sync/index.js +5 -5
  115. package/dist/website/images.js +4 -4
  116. package/dist/website/index.js +5 -5
  117. package/dist/website/popups.js +4 -4
  118. package/dist/website/slots/contract.js +2 -1
  119. package/dist/website/slots.js +4 -2
  120. package/dist/{writeLLMsTxt-UMHKGNRR.js → writeLLMsTxt-QR23OQUE.js} +1 -1
  121. package/docs/MIGRATING-TO-7.md +146 -0
  122. package/docs.json +69 -0
  123. package/package.json +14 -4
  124. package/src/admin-auth/README.md +88 -0
  125. package/src/analytics/README.md +264 -0
  126. package/src/articles/README.md +325 -0
  127. package/src/commerce/README.md +109 -0
  128. package/src/cta-bar/README.md +154 -0
  129. package/src/engage/README.md +241 -0
  130. package/src/forms/README.md +219 -0
  131. package/src/images/README.md +74 -0
  132. package/src/layout/README.md +66 -0
  133. package/src/llms/README.md +723 -0
  134. package/src/mcp/README.md +376 -0
  135. package/src/motion/README.md +372 -0
  136. package/src/og/README.md +304 -0
  137. package/src/proxy/README.md +152 -0
  138. package/src/redirects/README.md +74 -0
  139. package/src/reputation/README.md +64 -0
  140. package/src/revalidate/README.md +82 -0
  141. package/src/seo/README.md +346 -0
  142. package/src/signal/README.md +115 -0
  143. package/src/sitemap/README.md +127 -0
  144. package/src/slots/README.md +168 -0
  145. package/src/sync/README.md +115 -0
  146. package/dist/SitemapSync-7WKY4HXI.js +0 -8
  147. package/dist/chunk-SS636UDN.js +0 -35
@@ -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.
@@ -0,0 +1,325 @@
1
+ # Articles — `@sonordev/site-kit/articles`
2
+
3
+ ## Publication routes and artwork (5.4.0)
4
+
5
+ Use one routing object for the stock components and their SEO helpers. Existing
6
+ sites keep `/article/slug` unless they opt in. `basePath` remains supported as a
7
+ metadata alias; `basePath` takes precedence when both are provided.
8
+
9
+ ```tsx
10
+ import { Article, ArticleList } from '@sonordev/site-kit/articles/server-ui'
11
+ import {
12
+ createPublicationRoutes, generateArticleMetadata, generateArticleSchema,
13
+ generateArticleSitemap, generateArticleStaticParams, generateRssFeed,
14
+ getArticle,
15
+ } from '@sonordev/site-kit/articles/server'
16
+ import type { PublicationRoutingOptions } from '@sonordev/site-kit/articles/server'
17
+
18
+ const siteUrl = 'https://example.com'
19
+ const routing = {
20
+ basePath: '/theforge',
21
+ includeCategoryInPath: true,
22
+ categoryPath: (slug: string) => `/theforge?category=${encodeURIComponent(slug)}`,
23
+ } satisfies PublicationRoutingOptions
24
+
25
+ // Cards, related stories and cluster links now use /theforge/category/slug.
26
+ const article = <Article slug="build-first" routing={routing} />
27
+ const archive = <ArticleList routing={routing} />
28
+
29
+ // Use these helpers from their corresponding Next.js route exports.
30
+ const metadata = await generateArticleMetadata('build-first', { siteUrl, ...routing })
31
+ const post = await getArticle('build-first')
32
+ const schema = post ? generateArticleSchema(post, { siteUrl, ...routing }) : null
33
+ // In app/theforge/[category]/[slug]/page.tsx, wrap it to pass the routing:
34
+ // export function generateStaticParams() { return generateArticleStaticParams(routing) }
35
+ const params = await generateArticleStaticParams(routing) // { category, slug }[]
36
+ const sitemap = await generateArticleSitemap(siteUrl, {
37
+ ...routing,
38
+ includeCategories: false, // category filters don't need separate sitemap entries
39
+ includeClusters: false, // enable when your site implements cluster routes
40
+ })
41
+ const rss = await generateRssFeed({ siteUrl, siteName: 'The Forge', ...routing })
42
+ const href = createPublicationRoutes(routing).post({ slug: 'build-first', category: 'guides' })
43
+ ```
44
+
45
+ `routing` also works on `PublicationLayout`, `PublicationSidebar`, `RelatedPosts`, `AuthorPage`,
46
+ `ClusterLandingPage`, and `ClusterNavigation`. To support another article shape,
47
+ provide `postPath: post => '/articles/' + encodeURIComponent(post.slug)`.
48
+ Callbacks return local paths; generated metadata, feeds, and sitemaps separately
49
+ honor a valid `canonical_url`. The same routes apply to Atom and generated
50
+ breadcrumb/cluster schemas. Supplied `schema`/`schema_json` objects stay intact.
51
+
52
+ For custom layouts, `resolveArticleArtwork(post, 'article')` prefers
53
+ `editorial_image`, preserving an empty decorative `editorial_image_alt`.
54
+ `resolveArticleArtwork(post, 'card')` keeps the complete `featured_image`. Older API
55
+ responses fall back to the featured image for both. The stock UI keeps generated
56
+ Sonor cards fully visible, while manually selected photos keep their existing
57
+ layout. Social metadata and RSS enclosures use the featured share card.
58
+
59
+ The stock article renders accessible table scroll regions automatically. Custom
60
+ renderers can call `wrapArticleTables(html)` and include `articleTableCss` within their
61
+ `.sk-article-content` scope. Both exports are available from `article/server` and
62
+ `article/server-ui`. The wrapper preserves the original table and is safe to apply
63
+ twice. It's a layout transform, not a sanitizer; keep your existing content trust
64
+ policy. Scrollbars and keyboard focus use `--sk-*` tokens.
65
+
66
+ ## Import server components from `article/server-ui`
67
+
68
+ `Article`, `ArticleList`, `PublicationLayout`, `PublicationSidebar` and `RelatedPosts` are async
69
+ server components. The `@sonordev/site-kit/articles` barrel is stamped `'use client'`
70
+ at build time, and an async component inside a client module is not something Next
71
+ can render — the route returns a 500.
72
+
73
+ ```tsx
74
+ import { Article, ArticleList } from '@sonordev/site-kit/articles/server-ui' // ✅
75
+ import { Article } from '@sonordev/site-kit/articles' // ❌ 500s
76
+ ```
77
+
78
+ The barrel still re-exports them for backwards compatibility and they will move out
79
+ in the next major. Reach for `@sonordev/site-kit/articles` only for genuine client
80
+ components: `TableOfContents`, `ArticleFAQ`, `AuthorCard`, `ServiceCallout`,
81
+ `NewsletterWidget`.
82
+
83
+ `NewsletterWidget` needs either an `onSubmit` callback or a `formSlug`
84
+ pointing at a managed Sonor form (newsletter routing) — with neither it
85
+ renders nothing rather than a form that discards emails. `Article` mounts a
86
+ childless `ArticleViewTracker` client island that counts real readers (one POST
87
+ per post per session, deferred to idle); it needs `SiteKitLayout`'s globals
88
+ and silently no-ops without them.
89
+
90
+ Data helpers live in `@sonordev/site-kit/articles/server`, which is `server-only` — a
91
+ client import there is a build error rather than a runtime one.
92
+
93
+ Sonor-managed article with SSG, topic clusters, E-E-A-T author profiles, and full SEO integration. Create posts in the Sonor dashboard — they appear on your site automatically.
94
+
95
+ ## Quick Start
96
+
97
+ ### Articles Index
98
+
99
+ ```tsx
100
+ // app/article/page.tsx
101
+ import { ArticleList, PublicationLayout } from '@sonordev/site-kit/articles/server-ui'
102
+ import { generatePublicationMetadata } from '@sonordev/site-kit/articles/server'
103
+
104
+ export async function generateMetadata() {
105
+ return generatePublicationMetadata({ siteName: 'My Site', siteUrl: 'https://example.com' })
106
+ }
107
+
108
+ export default function PublicationPage() {
109
+ return (
110
+ <PublicationLayout hero={{ title: 'The Forge', subtitle: 'Latest articles' }}>
111
+ <ArticleList showCategoryFilter showPagination />
112
+ </PublicationLayout>
113
+ )
114
+ }
115
+ ```
116
+
117
+ ### Single Post
118
+
119
+ ```tsx
120
+ // app/article/[slug]/page.tsx
121
+ import { Article } from '@sonordev/site-kit/articles/server-ui'
122
+ import {
123
+ generateArticleStaticParams,
124
+ generateArticleMetadata,
125
+ requireArticle,
126
+ } from '@sonordev/site-kit/articles/server'
127
+
128
+ export const generateStaticParams = generateArticleStaticParams
129
+
130
+ type Props = { params: Promise<{ slug: string }> }
131
+
132
+ export async function generateMetadata({ params }: Props) {
133
+ const { slug } = await params
134
+ await requireArticle(slug) // 404 for an unknown slug, before anything streams
135
+ return generateArticleMetadata(slug, { siteName: 'My Site', siteUrl: 'https://example.com' })
136
+ }
137
+
138
+ export default async function Post({ params }: Props) {
139
+ const { slug } = await params
140
+ return <Article slug={slug} showRelated showToc showAuthor />
141
+ }
142
+ ```
143
+
144
+ **A missing post is a 404.** `requireArticle`, `generateArticleMetadata` and
145
+ `Article` all call Next's `notFound()` when the post doesn't exist, so a
146
+ mistyped or deleted post URL answers 404 with `noindex`. They used to render a
147
+ "Post Not Found" page on a 200 with an indexable title, which is a soft 404.
148
+ Calling `requireArticle` from `generateMetadata` is what guarantees the status:
149
+ metadata resolves before the page streams. To render your own missing-post
150
+ state instead, pass `notFound: false` to `generateArticleMetadata` (its
151
+ placeholder is marked `noindex`) and `notFound={false}` to `Article`.
152
+
153
+ **A post with its own social card.** If the post route has an
154
+ `opengraph-image.tsx` beside the page (see `@sonordev/site-kit/og/route`), pass
155
+ `images: false`:
156
+
157
+ ```ts
158
+ return generateArticleMetadata(slug, { siteName: 'My Site', images: false })
159
+ ```
160
+
161
+ Otherwise the featured image is declared as `openGraph.images`, and Next lets
162
+ declared images beat the file convention: the card never ships. `images: false`
163
+ leaves the keys out entirely (Next checks `hasOwnProperty('images')`, so even
164
+ `images: undefined` would hide the card). `sonor-setup doctor` flags a post
165
+ route that has a card but still declares the image.
166
+
167
+ ### Topic Cluster Landing
168
+
169
+ ```tsx
170
+ // app/article/topics/[slug]/page.tsx
171
+ // From `article/server-ui`, not `article` — it's an async server component that fetches
172
+ // the cluster, so it must stay out of the client-side `article` barrel.
173
+ import { ClusterLandingPage } from '@sonordev/site-kit/articles/server-ui'
174
+
175
+ export default function ClusterPage({ params }: { params: { slug: string } }) {
176
+ return <ClusterLandingPage slug={params.slug} basePath="/article" />
177
+ }
178
+ ```
179
+
180
+ ## Components
181
+
182
+ | Component | Import | Purpose |
183
+ |-----------|--------|---------|
184
+ | `Article` | `article/server-ui` | Single post with content, TOC, author, related |
185
+ | `ArticleList` | `article/server-ui` | Post grid with pagination and category filter |
186
+ | `PublicationLayout` | `article/server-ui` | Full layout with optional sidebar |
187
+ | `PublicationSidebar` | `article/server-ui` | Categories, recent posts, tags |
188
+ | `RelatedPosts` | `article/server-ui` | Related articles widget |
189
+ | `PublicationPage` | `article/server-ui` | Drop-in publication index page (layout + list) |
190
+ | `ArticlePage` | `article/server-ui` | Drop-in single-post page |
191
+ | `CategoryPage` | `article/server-ui` | Drop-in category archive page |
192
+ | `ClusterLandingPage` | `article/server-ui` | Topic cluster overview with pillar + support articles |
193
+ | `ClusterNavigation` | `article` | Breadcrumb-style cluster nav |
194
+ | `AuthorCard` | `article` | Author profile with E-E-A-T fields |
195
+ | `TableOfContents` | `article` | Auto-generated from H2-H4 headings |
196
+ | `ArticleFAQ` | `article` | FAQ section with schema |
197
+ | `ServiceCallout` | `article` | CTA callout for related services |
198
+ | `NewsletterWidget` | `article` | Email capture; needs `onSubmit` or `formSlug` |
199
+
200
+ ## Server Functions (`article/server`)
201
+
202
+ ```ts
203
+ // Data fetching
204
+ getArticle(slug) // Single post with full data, or null
205
+ requireArticle(slug) // Same, but calls notFound() when there's no post
206
+ getAllArticleSlugs() // All published slugs (for generateStaticParams)
207
+ getArticleCategories() // Categories with post counts
208
+ getTopicCluster(slug) // Cluster with pillar + support articles
209
+ getTopicClusters() // All clusters
210
+
211
+ // Next.js integration
212
+ generateArticleStaticParams(routing?) // [{ slug }], or [{ category, slug }] with includeCategoryInPath
213
+ generateCategoryStaticParams() // Returns [{ category }]
214
+ generateAuthorStaticParams() // Returns [{ slug }]
215
+ // With no routing options, all three can be exported directly as
216
+ // `generateStaticParams`; the props Next passes are ignored.
217
+ generateArticleMetadata(slug, opts) // Next.js Metadata object; notFound() for a missing post.
218
+ // opts.images: false when the route has its own opengraph-image card
219
+ generatePublicationMetadata(opts) // Index page metadata
220
+ generateArticleCategoryMetadata(name, opts)
221
+
222
+ // Schema & SEO
223
+ generateArticleSchema(post, opts) // JSON-LD Article with FAQ
224
+ generateArticleListSchema() // JSON-LD for publication index
225
+ generateFaqSchema(items) // FAQ Page schema
226
+ generateArticleSitemap(siteUrl) // Sitemap entries for article
227
+
228
+ // Validation
229
+ validateArticleSeo(post) // Returns field-by-field SEO audit
230
+ validateSeoTitle(title, keyphrase?) // Title length + keyword checks
231
+ validateMetaDescription(desc) // Description length check
232
+ ```
233
+
234
+ ## Multi-site projects
235
+
236
+ One Sonor project can serve many domains (example.com plus its city
237
+ microsites), each with its own article. A post with no site is project-wide and
238
+ shows on every host; a post tagged `charlotte.example.com` shows only there.
239
+
240
+ Every article read sends the site host automatically, as `?site=` on GETs and a
241
+ `site` field on the related-posts and view-count POSTs. The host resolves from
242
+ `NEXT_PUBLIC_SITE_URL`, which every microsite already sets, so most sites
243
+ change nothing. To pin a host explicitly, pass `site`:
244
+
245
+ ```tsx
246
+ <ArticleList site="charlotte.example.com" /> // also Article, PublicationSidebar, PublicationLayout, RelatedPosts, ClusterLandingPage
247
+ await getArticle(slug, { site: 'charlotte.example.com' })
248
+ await getAllArticles({ site: 'charlotte.example.com' })
249
+ ```
250
+
251
+ `getAllArticleSlugs()` and `getAllAuthorSlugs()` take no arguments, so they can
252
+ still be exported as `generateStaticParams`. They always use
253
+ `NEXT_PUBLIC_SITE_URL`. Single-site projects and older API servers ignore
254
+ `site`.
255
+
256
+ ## Article Props
257
+
258
+ ```ts
259
+ interface ArticleProps {
260
+ slug: string
261
+ showRelated?: boolean // Related posts section
262
+ showToc?: boolean // Table of contents
263
+ showAuthor?: boolean // Author card
264
+ unstyled?: boolean // Skip default styles
265
+ notFound?: boolean // Default true: notFound() when the post is missing. false renders a message.
266
+ className?: string
267
+ children?: (props: { post, toc, related }) => ReactNode // Render prop
268
+ }
269
+ ```
270
+
271
+ ## ArticleList Props
272
+
273
+ ```ts
274
+ interface ArticleListProps {
275
+ category?: string // Filter by category slug
276
+ tag?: string // Filter by tag
277
+ author?: string // Filter by author
278
+ featured?: boolean // Featured posts only
279
+ search?: string // Search posts
280
+ page?: number // Default: 1
281
+ perPage?: number // Default: 12
282
+ orderBy?: 'published_at' | 'title' | 'view_count'
283
+ order?: 'asc' | 'desc'
284
+ showCategoryFilter?: boolean
285
+ showPagination?: boolean
286
+ unstyled?: boolean
287
+ className?: string
288
+ children?: (props: { posts, pagination, categories }) => ReactNode
289
+ }
290
+ ```
291
+
292
+ ## Key Types
293
+
294
+ ```ts
295
+ interface Article {
296
+ slug: string; title: string; excerpt?: string; content: string;
297
+ featured_image?: string; author?: ArticleAuthor; category?: ArticleCategory;
298
+ tags?: string[]; meta_title?: string; meta_description?: string;
299
+ faq_items?: { question: string; answer: string }[];
300
+ article_type?: 'pillar' | 'support' | 'comparison' | 'faq' | 'glossary' | 'checklist';
301
+ cluster_slug?: string; reading_time?: number; // the API column ('X min read')
302
+ published_at?: string; status: 'draft' | 'published' | 'scheduled' | 'archived';
303
+ }
304
+
305
+ interface ArticleAuthor {
306
+ name: string; slug: string; bio?: string; avatar_url?: string;
307
+ title?: string; credentials?: string[]; expertise_areas?: string[];
308
+ years_experience?: number; is_subject_matter_expert?: boolean;
309
+ }
310
+
311
+ interface TopicCluster {
312
+ cluster_name: string; cluster_slug: string; core_topic: string;
313
+ geo_target?: string; target_service_page?: string; article_count: number;
314
+ pillar: Article | null; supports: Article[];
315
+ }
316
+ ```
317
+
318
+ ## Styling
319
+
320
+ Components use `.sk-article-*` and `.sk-article-list-*` classes. Import default styles:
321
+
322
+ ```tsx
323
+ ```
324
+
325
+ Or use `unstyled` prop + `children` render prop for complete control.