@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.
- package/CHANGELOG.md +3606 -0
- package/README.md +12 -13
- package/agent-manifest.json +11 -5
- package/dist/{AnalyticsProvider-EMM2TKRE.js → AnalyticsProvider-ZMQUV33M.js} +4 -4
- package/dist/{ArticleViewTracker-RA64BGL6.js → ArticleViewTracker-V7NUXYBY.js} +3 -3
- package/dist/{BlocksPopup-D25RFNOV.js → BlocksPopup-52EU7OUY.js} +4 -4
- package/dist/{ChatWidget-RYI7BMJJ.js → ChatWidget-5BHMNR57.js} +5 -5
- package/dist/{EngageWidget-UKFCN33M.js → EngageWidget-PCGLX7SO.js} +4 -4
- package/dist/{FileField-MUHA7LZR.js → FileField-TSFGNAMY.js} +3 -3
- package/dist/{FormSpotlight-TCLPWPLL.js → FormSpotlight-XLBWEOTE.js} +1 -1
- package/dist/{FormStage-CNYLP6I6.js → FormStage-IJQ5Q2X6.js} +1 -1
- package/dist/{ManagedForm-7ZL5SKTO.js → ManagedForm-Z3PKOPIZ.js} +6 -6
- package/dist/{ManagedNewsletterForm-33B4JLX7.js → ManagedNewsletterForm-QAO3POLC.js} +4 -4
- package/dist/{SignalCore-L5FVDHFE.js → SignalCore-RBA3VDBL.js} +3 -3
- package/dist/{SiteDesignReporter-4JOFL4FP.js → SiteDesignReporter-C4LR5X2V.js} +5 -5
- package/dist/SitemapSync-XVMGKCF3.js +8 -0
- package/dist/_client/booking-widget.js +5 -5
- package/dist/affiliates/index.js +3 -3
- package/dist/analytics/index.js +4 -4
- package/dist/articles/index.js +1 -1
- package/dist/articles/server-ui.js +1 -1
- package/dist/chat/index.js +5 -5
- package/dist/{chunk-FYBZ5SNP.js → chunk-3G2SE2J4.js} +1 -1
- package/dist/{chunk-HGCK465A.js → chunk-3J2ERO3I.js} +1 -1
- package/dist/{chunk-KXPBMCFL.js → chunk-3QI26673.js} +3 -1
- package/dist/{chunk-BMO3VGMR.js → chunk-3XPJKZ6D.js} +30 -7
- package/dist/{chunk-QGHSMJKW.js → chunk-4JQQDCMO.js} +1 -1
- package/dist/{chunk-6HDT4G4A.js → chunk-4YTYGG2C.js} +2 -2
- package/dist/{chunk-N2UVOR3X.js → chunk-662ILEZ6.js} +2 -0
- package/dist/chunk-6G43IRWR.js +4 -0
- package/dist/{chunk-LVESVYCE.js → chunk-7MHHWZKC.js} +11 -117
- package/dist/{chunk-MV2MBTC3.js → chunk-7QTMMHUO.js} +1 -1
- package/dist/{chunk-KPAZG65P.js → chunk-CGWUXUYZ.js} +138 -46
- package/dist/{chunk-4IQ52CXL.js → chunk-DUAO4Q75.js} +2 -2
- package/dist/{chunk-4RMVXRBO.js → chunk-EIULXXUJ.js} +3 -3
- package/dist/{chunk-3KUUH2YP.js → chunk-EVFZ7KEW.js} +1 -1
- package/dist/{chunk-OFOAHPUV.js → chunk-F42R35NV.js} +1 -1
- package/dist/{chunk-QANVUXKH.js → chunk-FLR3EMK6.js} +1 -1
- package/dist/{chunk-P4GRY6QP.js → chunk-GIAOPEN6.js} +1 -1
- package/dist/{chunk-P5J7VMQ3.js → chunk-GWUKQ26F.js} +1 -1
- package/dist/{chunk-TT63HHIT.js → chunk-HAG4YIZY.js} +1 -1
- package/dist/{chunk-QZZIKMAT.js → chunk-HVH37YPX.js} +1 -1
- package/dist/{chunk-SSUQKA7L.js → chunk-J4D6ZXRW.js} +1 -1
- package/dist/{chunk-GYESATRY.js → chunk-L2DJD5Y4.js} +1 -1
- package/dist/{chunk-WATH55UY.js → chunk-LFXVE32I.js} +1 -1
- package/dist/chunk-LPH5FANE.js +169 -0
- package/dist/{chunk-V6LSQRTH.js → chunk-PLUP2KN5.js} +1 -1
- package/dist/chunk-RYVDGXC2.js +19 -0
- package/dist/{chunk-EGOD74PP.js → chunk-U35H2JIQ.js} +2 -2
- package/dist/chunk-VCJYLYJV.js +49 -0
- package/dist/{chunk-FL4EPUWA.js → chunk-W2CL2DB3.js} +2 -2
- package/dist/{chunk-UZN4ZYR2.js → chunk-XD3ZQET6.js} +1 -1
- package/dist/{chunk-CVTVNC2U.js → chunk-XNVSCQ2O.js} +2 -2
- package/dist/{chunk-T3MC4HOD.js → chunk-YLSEB32F.js} +1 -1
- package/dist/chunk-ZETJTCMV.js +118 -0
- package/dist/{chunk-5SEM2V4A.js → chunk-ZIMFQWGJ.js} +3 -3
- package/dist/client/index.js +3 -3
- package/dist/cms/CmsPage.d.ts +1 -0
- package/dist/cms/CmsPreview.d.ts +1 -0
- package/dist/cms/CmsSection.d.ts +1 -0
- package/dist/cms/index.d.ts +6 -0
- package/dist/cms/server-api.d.ts +3 -0
- package/dist/commerce/index.js +4 -4
- package/dist/config/index.js +1 -1
- package/dist/contracts/entries.d.ts +1 -1
- package/dist/contracts/site-cache.d.ts +55 -0
- package/dist/contracts/site-edit-param.d.ts +7 -0
- package/dist/contracts/site-edit.d.ts +77 -0
- package/dist/contracts/slot-content.d.ts +111 -0
- package/dist/contracts/slots.d.ts +39 -25
- package/dist/engage/index.js +6 -6
- package/dist/fleet/index.js +4 -4
- package/dist/forms/index.js +8 -8
- package/dist/forms/server.js +2 -2
- package/dist/forms/types.d.ts +3 -1
- package/dist/images/index.js +4 -4
- package/dist/index.js +1 -1
- package/dist/layout/client.js +8 -7
- package/dist/layout/index.js +9 -8
- package/dist/llms/index.js +4 -2
- package/dist/llms/seo-revalidate.d.ts +8 -1
- package/dist/maps/index.js +3 -3
- package/dist/mcp/sonor.js +6 -6
- package/dist/overlay-RXV6U6QC.js +353 -0
- package/dist/proxy/index.js +2 -2
- package/dist/proxy/securityHeaders.d.ts +4 -0
- package/dist/revalidate/index.d.ts +44 -0
- package/dist/revalidate/index.js +27 -0
- package/dist/seo/ManagedContent.d.ts +2 -0
- package/dist/seo/client.js +4 -4
- package/dist/seo/index.js +9 -8
- package/dist/seo/llms.js +4 -2
- package/dist/seo/register-sitemap-cli.js +1 -1
- package/dist/seo/server.js +3 -2
- package/dist/seo/sitemap.js +2 -2
- package/dist/server/index.js +2 -2
- package/dist/{server-api-GJJQZVG7.js → server-api-BVCBLJKL.js} +2 -1
- package/dist/shared/build-entries.d.ts +1 -0
- package/dist/shared/edit-bridge.d.ts +8 -0
- package/dist/shared/version.d.ts +1 -1
- package/dist/signal/index.js +2 -2
- package/dist/sitemap/index.js +2 -2
- package/dist/slots/ManagedLink.d.ts +31 -0
- package/dist/slots/ManagedList.d.ts +30 -0
- package/dist/slots/ManagedRichText.d.ts +31 -0
- package/dist/slots/contract.js +2 -1
- package/dist/slots/edit/locate.d.ts +30 -0
- package/dist/slots/edit/overlay.d.ts +18 -0
- package/dist/slots/index.d.ts +12 -4
- package/dist/slots/index.js +4 -2
- package/dist/slots/revalidate.d.ts +8 -3
- package/dist/slots/rich.d.ts +7 -0
- package/dist/slots/server-api.d.ts +6 -2
- package/dist/sync/index.js +5 -5
- package/dist/website/images.js +4 -4
- package/dist/website/index.js +5 -5
- package/dist/website/popups.js +4 -4
- package/dist/website/slots/contract.js +2 -1
- package/dist/website/slots.js +4 -2
- package/dist/{writeLLMsTxt-UMHKGNRR.js → writeLLMsTxt-QR23OQUE.js} +1 -1
- package/docs/MIGRATING-TO-7.md +146 -0
- package/docs.json +69 -0
- package/package.json +14 -4
- package/src/admin-auth/README.md +88 -0
- package/src/analytics/README.md +264 -0
- package/src/articles/README.md +325 -0
- package/src/commerce/README.md +109 -0
- package/src/cta-bar/README.md +154 -0
- package/src/engage/README.md +241 -0
- package/src/forms/README.md +219 -0
- package/src/images/README.md +74 -0
- package/src/layout/README.md +66 -0
- package/src/llms/README.md +723 -0
- package/src/mcp/README.md +376 -0
- package/src/motion/README.md +372 -0
- package/src/og/README.md +304 -0
- package/src/proxy/README.md +152 -0
- package/src/redirects/README.md +74 -0
- package/src/reputation/README.md +64 -0
- package/src/revalidate/README.md +82 -0
- package/src/seo/README.md +346 -0
- package/src/signal/README.md +115 -0
- package/src/sitemap/README.md +127 -0
- package/src/slots/README.md +168 -0
- package/src/sync/README.md +115 -0
- package/dist/SitemapSync-7WKY4HXI.js +0 -8
- 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.
|