@sonordev/site-kit 7.0.1 → 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.
- package/CHANGELOG.md +3539 -0
- package/README.md +12 -13
- package/agent-manifest.json +1 -1
- package/dist/{AnalyticsProvider-EMM2TKRE.js → AnalyticsProvider-XXWTFKJH.js} +4 -4
- package/dist/{ArticleViewTracker-RA64BGL6.js → ArticleViewTracker-V4KZB6QN.js} +3 -3
- package/dist/{BlocksPopup-D25RFNOV.js → BlocksPopup-JHGHB6XW.js} +4 -4
- package/dist/{ChatWidget-RYI7BMJJ.js → ChatWidget-CG32POI3.js} +5 -5
- package/dist/{EngageWidget-UKFCN33M.js → EngageWidget-LQMR4LEX.js} +4 -4
- package/dist/{FileField-MUHA7LZR.js → FileField-KUG3CKXG.js} +3 -3
- package/dist/{FormSpotlight-TCLPWPLL.js → FormSpotlight-FVNPOCU3.js} +1 -1
- package/dist/{FormStage-CNYLP6I6.js → FormStage-C7VKRURJ.js} +1 -1
- package/dist/{ManagedForm-7ZL5SKTO.js → ManagedForm-VLNJKV65.js} +6 -6
- package/dist/{ManagedNewsletterForm-33B4JLX7.js → ManagedNewsletterForm-KJEU23BV.js} +4 -4
- package/dist/{SignalCore-L5FVDHFE.js → SignalCore-K2O46QG7.js} +3 -3
- package/dist/{SiteDesignReporter-4JOFL4FP.js → SiteDesignReporter-D7MD66GI.js} +5 -5
- package/dist/SitemapSync-NMXGMPCQ.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-QANVUXKH.js → chunk-42OXY4JV.js} +1 -1
- package/dist/{chunk-GYESATRY.js → chunk-56JNI463.js} +1 -1
- package/dist/{chunk-MV2MBTC3.js → chunk-5FBY2ZIH.js} +1 -1
- package/dist/{chunk-BMO3VGMR.js → chunk-7JIKGKWD.js} +7 -7
- package/dist/{chunk-QGHSMJKW.js → chunk-B6RZ2NRH.js} +1 -1
- package/dist/{chunk-OFOAHPUV.js → chunk-BEL7YFMC.js} +1 -1
- package/dist/{chunk-WATH55UY.js → chunk-BS7FWUOY.js} +1 -1
- package/dist/{chunk-FL4EPUWA.js → chunk-DKTSGYLM.js} +2 -2
- package/dist/{chunk-HGCK465A.js → chunk-GGD4P7UW.js} +1 -1
- package/dist/{chunk-FYBZ5SNP.js → chunk-GYY6ETGB.js} +1 -1
- package/dist/{chunk-CVTVNC2U.js → chunk-K5WZX776.js} +2 -2
- package/dist/{chunk-4IQ52CXL.js → chunk-LJZ3SUET.js} +2 -2
- package/dist/{chunk-P5J7VMQ3.js → chunk-O52CH273.js} +1 -1
- package/dist/{chunk-3KUUH2YP.js → chunk-OIETJKIL.js} +1 -1
- package/dist/{chunk-V6LSQRTH.js → chunk-P2GIIQH5.js} +1 -1
- package/dist/{chunk-4RMVXRBO.js → chunk-P72ZJRSX.js} +3 -3
- package/dist/{chunk-EGOD74PP.js → chunk-RU2RMTGT.js} +2 -2
- package/dist/{chunk-QZZIKMAT.js → chunk-SAUTJMK6.js} +1 -1
- package/dist/{chunk-T3MC4HOD.js → chunk-SWP36NCB.js} +1 -1
- package/dist/{chunk-5SEM2V4A.js → chunk-T4SY3FMN.js} +3 -3
- package/dist/{chunk-P4GRY6QP.js → chunk-ZRE4ZYEG.js} +1 -1
- package/dist/{chunk-UZN4ZYR2.js → chunk-ZSLRAMCK.js} +1 -1
- package/dist/client/index.js +3 -3
- package/dist/commerce/index.js +4 -4
- package/dist/engage/index.js +6 -6
- package/dist/fleet/index.js +4 -4
- package/dist/forms/index.js +7 -7
- 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 +7 -7
- package/dist/layout/index.js +8 -8
- package/dist/maps/index.js +3 -3
- package/dist/mcp/sonor.js +6 -6
- package/dist/seo/client.js +4 -4
- package/dist/seo/index.js +4 -4
- package/dist/server/index.js +2 -2
- package/dist/shared/version.d.ts +1 -1
- package/dist/signal/index.js +2 -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/docs/MIGRATING-TO-7.md +146 -0
- package/docs.json +67 -0
- package/package.json +9 -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/seo/README.md +359 -0
- package/src/signal/README.md +115 -0
- package/src/sitemap/README.md +127 -0
- package/src/sync/README.md +115 -0
- package/dist/SitemapSync-7WKY4HXI.js +0 -8
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Proxy — `@sonordev/site-kit/proxy`
|
|
2
|
+
|
|
3
|
+
Composable Next.js Proxy factory. Zero-config redirects + security headers. Opt-in AI discovery headers.
|
|
4
|
+
|
|
5
|
+
Next 16 renamed the `middleware` file convention to `proxy`. Through 7.x this
|
|
6
|
+
module is also served on `@sonordev/site-kit/middleware`, with `createMiddleware`
|
|
7
|
+
as an alias of `createProxy`; both go in 8.0. `npx sonor-setup codemod --write`
|
|
8
|
+
moves a site over (the file too).
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// proxy.ts — at the project root, or in src/
|
|
14
|
+
import { createProxy } from '@sonordev/site-kit/proxy'
|
|
15
|
+
|
|
16
|
+
export default createProxy()
|
|
17
|
+
|
|
18
|
+
// Inlined on purpose. See "The matcher must be inlined" below — importing
|
|
19
|
+
// siteKitMatcher here is a build error.
|
|
20
|
+
export const config = {
|
|
21
|
+
matcher: [
|
|
22
|
+
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:ico|png|jpg|jpeg|gif|webp|svg|woff2?)$).*)',
|
|
23
|
+
],
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
With options:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
export default createProxy({
|
|
31
|
+
securityHeaders: { frameAncestors: ["'self'", 'https://partner.example.com'] },
|
|
32
|
+
llmsDiscovery: { siteUrl: 'https://example.com' },
|
|
33
|
+
before: (req) => {
|
|
34
|
+
// Custom auth check, geolocation, etc.
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## The matcher must be inlined
|
|
40
|
+
|
|
41
|
+
**`export const config = siteKitMatcher` is a build error.** Next statically
|
|
42
|
+
parses `config.matcher` at build time and Turbopack rejects any imported value,
|
|
43
|
+
so the array has to be a literal in your own file. Copy it from the example
|
|
44
|
+
above, or read it off `siteKitMatcher` — but paste the contents, don't export
|
|
45
|
+
the binding.
|
|
46
|
+
|
|
47
|
+
`siteKitMatcher` exists so the canonical pattern lives in one place that the
|
|
48
|
+
scaffold, the docs, and the tests all read. It is a **reference value**.
|
|
49
|
+
|
|
50
|
+
## Migrating from `middleware.ts`
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx sonor-setup next16
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Runs the codemod's `next-16-proxy` transform (deterministic and offline: it
|
|
57
|
+
moves the file, renames a named `middleware` export to `proxy`, and never
|
|
58
|
+
moves onto an existing `proxy.ts`) and re-runs the doctor check afterwards, so
|
|
59
|
+
the result is verified rather than assumed. Safe to run twice. A file that
|
|
60
|
+
sets `runtime` is flagged rather than moved (see below).
|
|
61
|
+
|
|
62
|
+
Two Next 16 traps to know:
|
|
63
|
+
|
|
64
|
+
- **No `runtime` export.** Proxy defaults to the Node.js runtime and the
|
|
65
|
+
`runtime` config option is unavailable in proxy files — setting it throws at
|
|
66
|
+
build time. (`middleware.ts` still accepts `runtime: 'nodejs'`, and it does
|
|
67
|
+
run on Netlify; remove the export before moving the file.)
|
|
68
|
+
- **The matcher**, as above.
|
|
69
|
+
|
|
70
|
+
## Config
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
interface SiteKitProxyConfig {
|
|
74
|
+
redirects?: boolean | RedirectConfig // Sonor-managed redirects (default: true)
|
|
75
|
+
securityHeaders?: boolean | SecurityHeadersConfig // Security headers (default: true)
|
|
76
|
+
llmsDiscovery?: LlmsDiscoveryConfig | false // AI crawler discovery header
|
|
77
|
+
identity?: boolean | IdentityConfig // Opt-in edge identity pass
|
|
78
|
+
|
|
79
|
+
before?: (req: NextRequest) => NextResponse | undefined | Promise<NextResponse | undefined>
|
|
80
|
+
after?: (req: NextRequest, res: NextResponse) => NextResponse | Promise<NextResponse>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface LlmsDiscoveryConfig {
|
|
84
|
+
siteUrl: string // e.g. 'https://example.com'
|
|
85
|
+
llmsPath?: string // Default: '/llms.txt'
|
|
86
|
+
mcpServerCard?: boolean | string // Also link the MCP server card (rel="service-desc"); true = '/.well-known/mcp-server-card'
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`redirects: true` (the default) checks managed rules before each page render.
|
|
91
|
+
The rule list is cached in memory for five minutes per instance, and a fetch is
|
|
92
|
+
bounded at 300ms with a 30s cooldown after a failure, so page loads rarely wait
|
|
93
|
+
on it. Set `redirects: false` only on a site that uses no Sonor-managed
|
|
94
|
+
redirects.
|
|
95
|
+
|
|
96
|
+
Don't resolve redirects in `app/not-found.tsx` with `resolveManagedRedirect()`.
|
|
97
|
+
Next renders the root not-found boundary inside every page, so its `headers()`
|
|
98
|
+
call makes every route dynamic: on a Next 16 build every static route turned
|
|
99
|
+
into a dynamic one. The resolver is deprecated.
|
|
100
|
+
|
|
101
|
+
## What It Handles (in order)
|
|
102
|
+
|
|
103
|
+
1. **Before hook** — optional custom logic (return `NextResponse` to short-circuit)
|
|
104
|
+
2. **Sonor-managed redirects** — 301/302/307/308 with query param preservation
|
|
105
|
+
3. **Security headers** — see the table below
|
|
106
|
+
4. **AI discovery header** — `Link: <url>; rel="describedby"; type="text/markdown"` on every request that
|
|
107
|
+
could be for a page: GET or HEAD, an Accept header that allows HTML (or no Accept header at all, which is
|
|
108
|
+
how curl and many crawlers ask), not a file-like path (`/llms.txt`, `/sitemap.xml`), not `/api/`, and not
|
|
109
|
+
a Next RSC navigation request. See `wantsLlmsDiscoveryLink` in `@sonordev/site-kit/llms`.
|
|
110
|
+
5. **After hook** — mutate response headers
|
|
111
|
+
|
|
112
|
+
## Security Headers (defaults)
|
|
113
|
+
|
|
114
|
+
| Header | Value |
|
|
115
|
+
|--------|-------|
|
|
116
|
+
| X-DNS-Prefetch-Control | `on` |
|
|
117
|
+
| Content-Security-Policy | `frame-ancestors 'self' https://upforge.io https://*.upforge.io` |
|
|
118
|
+
| X-Content-Type-Options | `nosniff` |
|
|
119
|
+
| X-XSS-Protection | `1; mode=block` |
|
|
120
|
+
| Referrer-Policy | `strict-origin-when-cross-origin` |
|
|
121
|
+
| Permissions-Policy | `camera=(), microphone=(), geolocation=()` |
|
|
122
|
+
|
|
123
|
+
Note there is no `X-Frame-Options` row — that is deliberate, see below.
|
|
124
|
+
|
|
125
|
+
### Framing / `frame-ancestors`
|
|
126
|
+
|
|
127
|
+
Upforge showcases live client work in iframes on its portfolio and area
|
|
128
|
+
pages, so managed sites ship a CSP `frame-ancestors` allowlist instead of
|
|
129
|
+
`X-Frame-Options`. XFO has no allowlist form (`ALLOW-FROM` is dead), and
|
|
130
|
+
emitting both would let a stray `DENY` silently re-block the embed — so
|
|
131
|
+
`X-Frame-Options` is **omitted** whenever `frameAncestors` is active.
|
|
132
|
+
Every origin not on the list is still blocked.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
createProxy({
|
|
136
|
+
// extend the allowlist
|
|
137
|
+
securityHeaders: {
|
|
138
|
+
frameAncestors: [
|
|
139
|
+
"'self'",
|
|
140
|
+
'https://upforge.io',
|
|
141
|
+
'https://*.upforge.io',
|
|
142
|
+
'https://partner.example.com',
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// opt out entirely → falls back to X-Frame-Options: DENY
|
|
148
|
+
createProxy({ securityHeaders: { frameAncestors: false } })
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`DEFAULT_FRAME_ANCESTORS` is the single source of truth — add origins there
|
|
152
|
+
rather than re-forking the list into individual sites.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Redirects — `@sonordev/site-kit/redirects`
|
|
2
|
+
|
|
3
|
+
Sonor-managed 301/302/307/308 redirect rules. Used by `createProxy()` or standalone.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
Automatically handled when using `createProxy()`:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// proxy.ts
|
|
11
|
+
import { createProxy } from '@sonordev/site-kit/proxy'
|
|
12
|
+
|
|
13
|
+
export default createProxy() // redirects: true by default
|
|
14
|
+
|
|
15
|
+
// Inlined: an imported `config.matcher` is a build error. See the proxy README.
|
|
16
|
+
export const config = {
|
|
17
|
+
matcher: [
|
|
18
|
+
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:ico|png|jpg|jpeg|gif|webp|svg|woff2?)$).*)',
|
|
19
|
+
],
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For standalone use:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { handleManagedRedirects } from '@sonordev/site-kit/redirects'
|
|
27
|
+
|
|
28
|
+
export async function middleware(request: NextRequest) {
|
|
29
|
+
const redirect = await handleManagedRedirects(request, {})
|
|
30
|
+
if (redirect) return redirect
|
|
31
|
+
return NextResponse.next()
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
handleManagedRedirects(request: NextRequest, config: RedirectConfig): Promise<NextResponse | undefined>
|
|
39
|
+
fetchRedirectRules(config: RedirectConfig): Promise<RedirectRule[]>
|
|
40
|
+
generateNextRedirects(config: RedirectConfig): Promise<Redirect[]> // For next.config.js
|
|
41
|
+
clearRedirectCache(): void // Dev helper
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Config
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
interface RedirectConfig {
|
|
48
|
+
domain?: string // Resolved from Sonor when using apiKey
|
|
49
|
+
apiKey?: string // Project API key
|
|
50
|
+
site?: string // Multi-site host, sent as ?site= and cached per host (default: NEXT_PUBLIC_SITE_URL host)
|
|
51
|
+
portalApiUrl?: string // Default: https://api.sonor.io
|
|
52
|
+
cacheSeconds?: number // Default: 300 (5 minutes)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Behavior
|
|
57
|
+
|
|
58
|
+
1. Checks in-memory cache first
|
|
59
|
+
2. Fetches rules from Sonor API if expired
|
|
60
|
+
3. Matches pathname (exact or trailing-slash variant)
|
|
61
|
+
4. Preserves query parameters on redirect
|
|
62
|
+
5. Tracks redirect hit (fire-and-forget)
|
|
63
|
+
6. Skips static assets and API routes
|
|
64
|
+
|
|
65
|
+
## Types
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
interface RedirectRule {
|
|
69
|
+
from_path: string
|
|
70
|
+
to_path: string
|
|
71
|
+
redirect_type: '301' | '302' | '307' | '308'
|
|
72
|
+
is_enabled: boolean
|
|
73
|
+
}
|
|
74
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Reputation — `@sonordev/site-kit/reputation`
|
|
2
|
+
|
|
3
|
+
Display client reviews, testimonials, and rating statistics from Sonor.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { TestimonialSection } from '@sonordev/site-kit/reputation'
|
|
9
|
+
|
|
10
|
+
export default function ReviewsPage() {
|
|
11
|
+
return (
|
|
12
|
+
<TestimonialSection
|
|
13
|
+
title="What Our Clients Say"
|
|
14
|
+
showRating
|
|
15
|
+
maxReviews={6}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## TestimonialSection Props
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
interface TestimonialSectionProps {
|
|
25
|
+
title?: string
|
|
26
|
+
subtitle?: string
|
|
27
|
+
autoplay?: boolean // Auto-rotate reviews
|
|
28
|
+
autoplayInterval?: number // ms between rotations
|
|
29
|
+
showRating?: boolean // Display star ratings
|
|
30
|
+
maxReviews?: number // Limit displayed count
|
|
31
|
+
featuredOnly?: boolean // Show featured reviews only
|
|
32
|
+
service?: string // Filter by service tag
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## API Functions
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { fetchReviews, fetchReviewStats } from '@sonordev/site-kit/reputation'
|
|
41
|
+
|
|
42
|
+
const reviews = await fetchReviews({ service: 'divorce', limit: 10, featured: true })
|
|
43
|
+
const stats = await fetchReviewStats() // { total_reviews, average_rating, distribution }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Types
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
interface Review {
|
|
50
|
+
id: string; quote: string; name: string; role?: string;
|
|
51
|
+
rating: number; image?: string; date?: string;
|
|
52
|
+
platform?: string; isFeatured?: boolean; serviceTags?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ReviewStats {
|
|
56
|
+
total_reviews: number
|
|
57
|
+
average_rating: number
|
|
58
|
+
distribution: { 1: number; 2: number; 3: number; 4: number; 5: number }
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Caching
|
|
63
|
+
|
|
64
|
+
Uses in-memory cache (60s TTL) with retry-on-429 backoff to prevent rate limiting during dev hot reload.
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# SEO: `@sonordev/site-kit/seo`
|
|
2
|
+
|
|
3
|
+
Server Components and server helpers that render what you manage in the SEO module at [app.sonor.io](https://app.sonor.io): page metadata, JSON-LD, FAQs, internal links, content blocks, redirects and robots directives.
|
|
4
|
+
|
|
5
|
+
The project comes from `SONOR_API_KEY`. Nothing in this module takes a project ID. A few option types and props still carry an optional `projectId` from older versions; it's ignored, so leave it out.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# .env.local
|
|
11
|
+
SONOR_API_KEY=sonor_xxxxxxxx_xxxxx
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That's the only variable you need. Keep it server-side, with no `NEXT_PUBLIC_` prefix. `SONOR_API_URL` is optional and defaults to `https://api.sonor.io`.
|
|
15
|
+
|
|
16
|
+
If the key is missing, the server helpers throw:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
@sonordev/seo: SONOR_API_KEY environment variable is required for server-side SEO functions
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Entry points
|
|
23
|
+
|
|
24
|
+
| Import | Runs on | Contains |
|
|
25
|
+
|--------|---------|----------|
|
|
26
|
+
| `@sonordev/site-kit/seo` | Server only | Everything on this page except `registerLocalSitemap` |
|
|
27
|
+
| `@sonordev/site-kit/seo/server` | Server only | The [data fetchers](#data-fetchers), `getManagedMetadata`, `getManagedMetadataWithAB`, `generateSitemap`, `registerLocalSitemap`, and the types |
|
|
28
|
+
| `@sonordev/site-kit/seo/client` | Client | `SitemapSync` only |
|
|
29
|
+
|
|
30
|
+
Both server entries import `server-only`, so importing either one from a Client Component fails the build. That's deliberate: it keeps the key out of the browser bundle.
|
|
31
|
+
|
|
32
|
+
## Page metadata
|
|
33
|
+
|
|
34
|
+
### `getManagedMetadata(options)`
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
// app/services/[slug]/page.tsx
|
|
38
|
+
import { getManagedMetadata } from '@sonordev/site-kit/seo'
|
|
39
|
+
|
|
40
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
|
41
|
+
const { slug } = await params
|
|
42
|
+
return getManagedMetadata({
|
|
43
|
+
path: `/services/${slug}`,
|
|
44
|
+
fallback: {
|
|
45
|
+
title: 'Our Services',
|
|
46
|
+
description: 'What we do and where we do it.',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Option | Type | Notes |
|
|
53
|
+
|--------|------|-------|
|
|
54
|
+
| `path` | `string` | Required. The page path as Sonor has it. |
|
|
55
|
+
| `fallback` | `Metadata` | Fills any field Sonor has no managed value for. Used in full when the page isn't in Sonor at all. |
|
|
56
|
+
| `overrides` | `Partial<Metadata>` | Applied last, so it wins over managed values. |
|
|
57
|
+
| `favicon` | `'metadata' \| 'component'` | `'metadata'` (default) adds `icons` from the project logo. Pass `'component'` when your layout already renders the favicon, which `SiteKitLayout` does by default, so icons aren't emitted twice. |
|
|
58
|
+
|
|
59
|
+
It returns a Next.js `Metadata` object with two extra flags, `_managed` and `_source`. Managed fields map like this:
|
|
60
|
+
|
|
61
|
+
| Sonor field | Metadata field |
|
|
62
|
+
|-------------|----------------|
|
|
63
|
+
| `managed_title` | `title` |
|
|
64
|
+
| `managed_meta_description` | `description` |
|
|
65
|
+
| `managed_keywords` | `keywords` |
|
|
66
|
+
| `managed_robots` | `robots` |
|
|
67
|
+
| `managed_canonical` | `alternates.canonical` |
|
|
68
|
+
| `language_alternates` | `alternates.languages` |
|
|
69
|
+
| `managed_og_title`, `managed_og_description`, `managed_og_image` | `openGraph` and `twitter` (`summary_large_image`), falling back to the title and description |
|
|
70
|
+
|
|
71
|
+
When the page exists in Sonor but has neither a title nor a description, the call asks Signal to write them in the background and returns your fallback for now. The generated copy shows up once the cached response refreshes (see [Caching](#caching)).
|
|
72
|
+
|
|
73
|
+
**Title templates.** The managed title comes back as a plain string, so a root-layout `title.template` still applies to it. If your managed titles already include the brand, you'll get it twice. Mark the title absolute:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const metadata = await getManagedMetadata({ path: '/about' })
|
|
77
|
+
return typeof metadata.title === 'string'
|
|
78
|
+
? { ...metadata, title: { absolute: metadata.title } }
|
|
79
|
+
: metadata
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `withManagedMetadata(path, pageMetadata?)`
|
|
83
|
+
|
|
84
|
+
Builds the `generateMetadata` function for you. Pick one of these forms:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { withManagedMetadata } from '@sonordev/site-kit/seo'
|
|
88
|
+
|
|
89
|
+
// A fixed path
|
|
90
|
+
export const generateMetadata = withManagedMetadata('/about')
|
|
91
|
+
|
|
92
|
+
// A path built from params
|
|
93
|
+
export const generateMetadata = withManagedMetadata(
|
|
94
|
+
async ({ params }) => `/services/${(await params).slug}`,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// Page-level values on top of Sonor's
|
|
98
|
+
export const generateMetadata = withManagedMetadata('/about', async () => ({
|
|
99
|
+
title: 'About Us',
|
|
100
|
+
}))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Whatever `pageMetadata` returns wins over Sonor's values. `openGraph` and `twitter` merge one level deep. It calls `getManagedMetadata` with the default `favicon: 'metadata'`.
|
|
104
|
+
|
|
105
|
+
### A/B-tested titles and descriptions
|
|
106
|
+
|
|
107
|
+
`getManagedMetadataWithAB` works like `getManagedMetadata`, then swaps in the assigned variant of any running title or description test for that path. `getABVariant({ path, field, sessionId? })` does the same for one field (`'title' | 'description' | 'content'`) and returns `{ testId, variant, value }`, or `null` when nothing's running.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { cookies } from 'next/headers'
|
|
111
|
+
import { getManagedMetadataWithAB } from '@sonordev/site-kit/seo'
|
|
112
|
+
|
|
113
|
+
export async function generateMetadata() {
|
|
114
|
+
const sessionId = (await cookies()).get('visitor_id')?.value
|
|
115
|
+
return getManagedMetadataWithAB({ path: '/pricing', sessionId })
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Pass a stable visitor ID your site already keeps. The kit doesn't set a cookie for this, and without one every request gets a random variant. Reading cookies makes the route dynamic, and each variant lookup records an impression.
|
|
120
|
+
|
|
121
|
+
## JSON-LD
|
|
122
|
+
|
|
123
|
+
### `<ManagedSchema>`
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { ManagedSchema } from '@sonordev/site-kit/seo'
|
|
127
|
+
|
|
128
|
+
export default function Page() {
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
<ManagedSchema path="/services/plumbing" />
|
|
132
|
+
<main>{/* ... */}</main>
|
|
133
|
+
</>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
It renders one `application/ld+json` script (an `@graph` when there's more than one node) that combines:
|
|
139
|
+
|
|
140
|
+
- the schema Sonor has for the path, filtered by `includeTypes` / `excludeTypes`
|
|
141
|
+
- the page's Signal-generated `managed_schema`
|
|
142
|
+
- anything you pass in `additionalSchemas`
|
|
143
|
+
- a `BreadcrumbList` built from the path, when there isn't one already and the project has a site URL (skipped on `/`)
|
|
144
|
+
- a speakable `WebPage` or `Article` node, when `speakable`, `pageName` and `pageUrl` are all set
|
|
145
|
+
|
|
146
|
+
| Prop | Default | Notes |
|
|
147
|
+
|------|---------|-------|
|
|
148
|
+
| `path` | | Required. |
|
|
149
|
+
| `includeTypes` / `excludeTypes` | | Type allow and deny lists. `includeTypes` keeps Sonor schema rows by `schema_type`. `excludeTypes` drops a node of that `@type` wherever it sits in Sonor's schema: a whole row, an `@graph` member, or a nested value like `mainEntity`, and in `managed_schema` too. A row left empty is dropped. Your `additionalSchemas` are never filtered. |
|
|
150
|
+
| `additionalSchemas` | `[]` | Extra nodes to merge in. |
|
|
151
|
+
| `speakable` | | `true` for the default selectors (`h1`, `[data-speakable="true"]`, `.page-summary`, `.key-points`, `.aeo-block[data-speakable="true"]`), or `{ cssSelector }` / `{ xpath }`. |
|
|
152
|
+
| `pageType` | `'WebPage'` | `'WebPage'` or `'Article'`, for the speakable node. |
|
|
153
|
+
| `pageName`, `pageUrl` | | Required for the speakable node. |
|
|
154
|
+
| `includeEntityGraph` | `true` | Meant to add nodes from Signal's entity graph. It adds nothing today; see [Entity graph](#entity-graph-and-ai-visibility). |
|
|
155
|
+
|
|
156
|
+
It's wrapped in `Suspense`, so the fetch never holds up the rest of the page. The script streams in when it's ready.
|
|
157
|
+
|
|
158
|
+
### `<LLMSchema path>`
|
|
159
|
+
|
|
160
|
+
Renders the page's `managed_llm_schema` as a `WebPage` JSON-LD script marked `data-llm-optimized="true"`, linked to the site's `WebSite` node when the project has a site URL. It renders nothing when the page has no LLM schema.
|
|
161
|
+
|
|
162
|
+
### Schema helpers
|
|
163
|
+
|
|
164
|
+
- `createSchema(type, data)` returns `{ '@context': 'https://schema.org', '@type': type, ...data }`.
|
|
165
|
+
- `createBreadcrumbSchema(baseUrl, path, labels?)` builds a `BreadcrumbList`. `labels` maps a path segment to its display name.
|
|
166
|
+
- `createWebSiteOrganizationStub({ name, url, sameAs?, knowsAbout? })` returns a minimal `Organization` and `WebSite` pair with stable `@id`s. Only reach for it when Sonor isn't already emitting those nodes.
|
|
167
|
+
|
|
168
|
+
Hand the result to `ManagedSchema` through `additionalSchemas`, so it's serialized and escaped in the same script as everything else.
|
|
169
|
+
|
|
170
|
+
## FAQs: `<ManagedFAQ>`
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import { ManagedFAQ } from '@sonordev/site-kit/seo'
|
|
174
|
+
|
|
175
|
+
<ManagedFAQ path="/services/plumbing" />
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
| Prop | Default | Notes |
|
|
179
|
+
|------|---------|-------|
|
|
180
|
+
| `path` | | Required. |
|
|
181
|
+
| `showTitle` | `true` | Renders the FAQ's title as an `<h2>`. |
|
|
182
|
+
| `includeSchema` | `true` | Emits `FAQPage` JSON-LD, but only when the FAQ is also set to include schema in Sonor. |
|
|
183
|
+
| `renderItem` | | `(item, index) => ReactNode`, for your own markup. |
|
|
184
|
+
| `className` | `'sk-faq'` | Wrapper class. |
|
|
185
|
+
| `site` | | Sub-site host on a multi-site project. See [Multi-site projects](#multi-site-projects). |
|
|
186
|
+
|
|
187
|
+
The default markup is native `<details>` / `<summary>` with its own small `<style>` block (`sk-faq-*` classes), so there's no CSS to import. Only visible items render, in their saved order. Answers are HTML, so render them as HTML in a custom item:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
<ManagedFAQ
|
|
191
|
+
path="/faq"
|
|
192
|
+
renderItem={(faq) => (
|
|
193
|
+
<details key={faq.id}>
|
|
194
|
+
<summary>{faq.question}</summary>
|
|
195
|
+
<div dangerouslySetInnerHTML={{ __html: faq.answer }} />
|
|
196
|
+
</details>
|
|
197
|
+
)}
|
|
198
|
+
/>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Don't also hand-write `FAQPage` JSON-LD for a page that renders `ManagedFAQ`. You'd ship it twice.
|
|
202
|
+
|
|
203
|
+
## Internal links: `<ManagedInternalLinks>`
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
import { ManagedInternalLinks } from '@sonordev/site-kit/seo'
|
|
207
|
+
|
|
208
|
+
<ManagedInternalLinks path="/article/my-post" position="related" limit={5} />
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Renders the internal links Sonor has for `path` at that position, or nothing when there aren't any.
|
|
212
|
+
|
|
213
|
+
| `position` | Markup |
|
|
214
|
+
|------------|--------|
|
|
215
|
+
| `'bottom'` (default) | "Related Articles" list |
|
|
216
|
+
| `'sidebar'` | `<aside>` with a "Related Pages" list |
|
|
217
|
+
| `'related'` | `<nav>` grid titled "You May Also Like", with each link's context |
|
|
218
|
+
| `'inline'` | Bare links in a `<span>`, for dropping into copy |
|
|
219
|
+
|
|
220
|
+
`limit` defaults to 5. `renderLink(link)` replaces the default `<a>`, and `className` replaces the default `sk-internal-links sk-internal-links--{position}`. `site` pins the sub-site host (see [Multi-site projects](#multi-site-projects)).
|
|
221
|
+
|
|
222
|
+
## Content blocks: `<ManagedContent>`
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
import { ManagedContent } from '@sonordev/site-kit/seo'
|
|
226
|
+
|
|
227
|
+
<ManagedContent path="/services/plumbing" section="hero" fallback={<DefaultHero />} />
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Renders the block Sonor has for that path and section, by its content type:
|
|
231
|
+
|
|
232
|
+
- `html`: injected as-is
|
|
233
|
+
- `markdown`: a basic conversion (headings, bold, italic, links, paragraphs). For anything richer, fetch the raw block with `getManagedContentData(path, section)` and use your own parser.
|
|
234
|
+
- `json`: renders `title`, `subtitle`, `content` (as HTML) and `items`
|
|
235
|
+
- `react`: `{ component, props }`, looked up by name in the `components` prop
|
|
236
|
+
|
|
237
|
+
It renders `fallback` (or nothing) when there's no block. `injectEntityAnnotations` depends on the entity graph and changes nothing today. `site` pins the sub-site host, and `getManagedContentData(path, section, site?)` takes it too.
|
|
238
|
+
|
|
239
|
+
## Multi-site projects
|
|
240
|
+
|
|
241
|
+
One Sonor project can serve many domains (example.com plus its city microsites). Managed FAQs, internal links and content blocks can be project-wide (every host) or tagged with one host (that host only). `ManagedFAQ`, `ManagedInternalLinks` and `ManagedContent` send the site host with every read, so each microsite gets its own rows plus the project-wide ones, never a sibling's.
|
|
242
|
+
|
|
243
|
+
The host resolves from `NEXT_PUBLIC_SITE_URL`, which every microsite already sets, so most sites change nothing. To pin one, pass `site`:
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
<ManagedFAQ path="/contact" site="charlotte.example.com" />
|
|
247
|
+
await getFAQData('/contact', 'charlotte.example.com')
|
|
248
|
+
await getInternalLinks('/contact', { position: 'bottom', site: 'charlotte.example.com' })
|
|
249
|
+
await getContentBlock('/contact', 'hero', 'charlotte.example.com')
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
When no host resolves, `site` is left off and the API answers for the project's primary domain. Single-site projects and older API servers ignore it.
|
|
253
|
+
|
|
254
|
+
## Redirects, robots and sitemaps
|
|
255
|
+
|
|
256
|
+
These have dedicated modules, and that's where to start:
|
|
257
|
+
|
|
258
|
+
- **Redirects:** `createProxy()` from `@sonordev/site-kit/proxy` applies Sonor-managed redirects by default. See the [redirects README](../redirects/README.md) for the standalone helpers.
|
|
259
|
+
- **Sitemap:** `createSitemap()` from `@sonordev/site-kit/sitemap` in `app/sitemap.ts`. See the [sitemap README](../sitemap/README.md).
|
|
260
|
+
|
|
261
|
+
The SEO module keeps a few lower-level helpers:
|
|
262
|
+
|
|
263
|
+
| Function | Returns |
|
|
264
|
+
|----------|---------|
|
|
265
|
+
| `getRedirect({ path })` | `{ destination, statusCode, isExternal }`, or `null`. Expired rules are skipped. |
|
|
266
|
+
| `getRobotsDirective({ path })` | `{ index, follow, noarchive?, nosnippet?, ... }` parsed from the page's managed robots value. `{ index: true, follow: true }` when there isn't one. |
|
|
267
|
+
| `isIndexable(projectId, path)` | `boolean`. This is a legacy signature and the first argument is ignored. `(await getRobotsDirective({ path })).index` says the same thing. |
|
|
268
|
+
| `generateSitemap({ baseUrl, publishedOnly? })` | Sonor's page list as `{ path, url, lastmod, changefreq, priority }`. `publishedOnly` defaults to `true`. Those keys aren't Next's `MetadataRoute.Sitemap` shape (`lastModified`, `changeFrequency`), so map them before returning them from `app/sitemap.ts`. |
|
|
269
|
+
|
|
270
|
+
### Registering pages with Sonor
|
|
271
|
+
|
|
272
|
+
`createSitemap` already syncs your page list to Sonor during `next build`. A site without an `app/sitemap` route can use the postbuild CLI instead:
|
|
273
|
+
|
|
274
|
+
```json
|
|
275
|
+
{
|
|
276
|
+
"scripts": {
|
|
277
|
+
"postbuild": "sonor-register-sitemap --auto-discover"
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
It skips itself when an `app/sitemap` route exists, and it only adds or updates pages unless you pass `--full-replace`.
|
|
283
|
+
|
|
284
|
+
On a multi-site project, each page is tagged with the host it belongs to. The CLI takes it from `NEXT_PUBLIC_SITE_URL` (it loads `.env` and `.env.local`), or from `--site ohiopowerstudies.com`. It used to send no host at all, so a microsite's pages synced as unattributed.
|
|
285
|
+
|
|
286
|
+
From code, `registerLocalSitemap({ entries?, autoDiscover?, mode?, site? })` on `@sonordev/site-kit/seo/server` does the same and is additive by default.
|
|
287
|
+
|
|
288
|
+
`registerSitemap(entries, { mode?, site? })` is the raw call, and it **defaults to `'full-replace'`**, which prunes every page that isn't in `entries`. Pass `mode: 'additive'` unless `entries` really is the whole site. It sends the site host the same way.
|
|
289
|
+
|
|
290
|
+
All of these and createSitemap's own sync build the request in one place (`seo/register-sitemap-request.ts`).
|
|
291
|
+
|
|
292
|
+
## Data fetchers
|
|
293
|
+
|
|
294
|
+
Every component above is built on these. They're server-only, take paths rather than project IDs, and are deduplicated per request with React `cache()`.
|
|
295
|
+
|
|
296
|
+
| Function | Returns |
|
|
297
|
+
|----------|---------|
|
|
298
|
+
| `getSEOPageData(path)` | `{ page, project }`. `page` is the page's Sonor row (the `managed_*` fields) or `null`; `project` is `{ id, title, domain, logo_url, site_url }` or `null`. |
|
|
299
|
+
| `getSchemaMarkups(path, { includeTypes?, excludeTypes? })` | Schema rows (`schema_type`, `schema_json`, ...). `excludeTypes` also prunes matching nodes inside each row. |
|
|
300
|
+
| `getFAQData(path, site?)` | The FAQ (`title`, `description`, `items`, `include_schema`), or `null` |
|
|
301
|
+
| `getInternalLinks(path, { position?, limit?, site? })` | Link rows |
|
|
302
|
+
| `getContentBlock(path, section, site?)` | The content block, or `null` |
|
|
303
|
+
| `getABTest(path, field)` | The running test for that field, or `null` |
|
|
304
|
+
| `recordABImpression(testId, variant, sessionId?)` | `void` |
|
|
305
|
+
| `getRedirectData(path)` | The raw redirect row, or `null` |
|
|
306
|
+
| `getRobotsData(path)` | The page's managed robots string, or `null` |
|
|
307
|
+
| `getSitemapEntries({ publishedOnly? })` | Raw sitemap rows |
|
|
308
|
+
| `getManagedScripts(position, path?)` | Always `[]` (retired, see below) |
|
|
309
|
+
|
|
310
|
+
`getSEOPageData` and `getSchemaMarkups` share one request per path, so using both in a render (metadata plus `ManagedSchema`) costs a single round trip.
|
|
311
|
+
|
|
312
|
+
## Entity graph and AI visibility
|
|
313
|
+
|
|
314
|
+
`getEntities`, `getPrimaryEntity`, `getEntityEnhancedSchema`, `getVisibilityScore` and `getVisibilitySummary` are exported, but called from a site they currently return empty results (`[]` or `null`): the Signal endpoints behind them don't accept a site key yet. That's also why `ManagedSchema`'s `includeEntityGraph` and `ManagedContent`'s `injectEntityAnnotations` have no effect. Don't build on them until that changes.
|
|
315
|
+
|
|
316
|
+
## Caching
|
|
317
|
+
|
|
318
|
+
- **Within a request:** React `cache()` collapses identical calls into one.
|
|
319
|
+
- **Across requests:** Sonor API responses sit in Next's data cache for 24 hours (entity-graph calls, 5 minutes). Transient `429`, `502` and `503` responses are retried with backoff, inside a 30-second budget per call.
|
|
320
|
+
|
|
321
|
+
So a change in the dashboard can take up to a day to reach the site. To push one sooner, revalidate the path from a route you control:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
// app/api/revalidate/route.ts
|
|
325
|
+
import { revalidatePath } from 'next/cache'
|
|
326
|
+
|
|
327
|
+
export async function POST(request: Request) {
|
|
328
|
+
if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATION_SECRET) {
|
|
329
|
+
return new Response('Unauthorized', { status: 401 })
|
|
330
|
+
}
|
|
331
|
+
const { path } = await request.json()
|
|
332
|
+
revalidatePath(path)
|
|
333
|
+
return Response.json({ revalidated: true })
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Retired and deprecated
|
|
338
|
+
|
|
339
|
+
- **`ManagedScripts` / `ManagedNoScripts`** were retired in June 2026. They render nothing and make no request. Load third-party scripts with `next/script` in your own code.
|
|
340
|
+
- **`LocationPageContent` / `getLocationSection`** were removed in 7.0. They were the one place that still sent a `projectId` in the request body instead of authenticating with the key, and no site used them.
|
|
341
|
+
- **`projectId`** on any option or prop is ignored. The project comes from the key.
|
|
342
|
+
|
|
343
|
+
## Upgrading older code
|
|
344
|
+
|
|
345
|
+
- Drop `projectId` from every call and prop. The fetchers take just the path: `getSEOPageData('/about')`.
|
|
346
|
+
- `SONOR_API_KEY` is the only variable. `UPTRADE_API_KEY` and `NEXT_PUBLIC_UPTRADE_*` aren't read, `uptrade_` keys aren't accepted, and `SONOR_PROJECT_ID` isn't needed. `npx sonor-setup codemod --only uptrade-to-sonor --write` moves a pre-rebrand site over.
|
|
347
|
+
- Import from `@sonordev/site-kit/seo` or `@sonordev/site-kit/seo/server`. There's no `/seo/api` entry.
|
|
348
|
+
|
|
349
|
+
## Troubleshooting
|
|
350
|
+
|
|
351
|
+
**`SONOR_API_KEY environment variable is required`.** The key isn't in the server environment. Check `.env.local` locally and the host's environment settings in production, then redeploy.
|
|
352
|
+
|
|
353
|
+
**A build error that mentions `server-only`.** A Client Component imports `@sonordev/site-kit/seo` or `/seo/server`. Move that code into a Server Component, or import `SitemapSync` from `/seo/client`.
|
|
354
|
+
|
|
355
|
+
**The metadata is always the fallback.** The path isn't in Sonor yet, or its managed fields are empty. Make sure the page is registered (`createSitemap` or `sonor-register-sitemap`) and that `path` matches the path Sonor has.
|
|
356
|
+
|
|
357
|
+
**The schema isn't in the page.** `ManagedSchema` has to render inside a Server Component. It streams in after the first bytes, so check the complete HTML (`curl` the page) rather than an early paint.
|
|
358
|
+
|
|
359
|
+
**The brand is in the title twice.** See [Title templates](#getmanagedmetadataoptions).
|