@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,346 @@
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>` (deprecated)
223
+
224
+ Deprecated in 7.1 and removed in 8.0. Page copy is [managed copy](../slots/README.md) now: wrap the text in `<ManagedSlot>` or `<ManagedRichText>` and edit it in Sonor under Website → Content, with drafts, history and Edit on page. Sonor no longer creates content blocks, so `ManagedContent` renders its `fallback`.
225
+
226
+ ## Multi-site projects
227
+
228
+ One Sonor project can serve many domains (example.com plus its city microsites). Managed FAQs and internal links can be project-wide (every host) or tagged with one host (that host only). `ManagedFAQ` and `ManagedInternalLinks` send the site host with every read, so each microsite gets its own rows plus the project-wide ones, never a sibling's.
229
+
230
+ The host resolves from `NEXT_PUBLIC_SITE_URL`, which every microsite already sets, so most sites change nothing. To pin one, pass `site`:
231
+
232
+ ```tsx
233
+ <ManagedFAQ path="/contact" site="charlotte.example.com" />
234
+ await getFAQData('/contact', 'charlotte.example.com')
235
+ await getInternalLinks('/contact', { position: 'bottom', site: 'charlotte.example.com' })
236
+ await getContentBlock('/contact', 'hero', 'charlotte.example.com')
237
+ ```
238
+
239
+ 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.
240
+
241
+ ## Redirects, robots and sitemaps
242
+
243
+ These have dedicated modules, and that's where to start:
244
+
245
+ - **Redirects:** `createProxy()` from `@sonordev/site-kit/proxy` applies Sonor-managed redirects by default. See the [redirects README](../redirects/README.md) for the standalone helpers.
246
+ - **Sitemap:** `createSitemap()` from `@sonordev/site-kit/sitemap` in `app/sitemap.ts`. See the [sitemap README](../sitemap/README.md).
247
+
248
+ The SEO module keeps a few lower-level helpers:
249
+
250
+ | Function | Returns |
251
+ |----------|---------|
252
+ | `getRedirect({ path })` | `{ destination, statusCode, isExternal }`, or `null`. Expired rules are skipped. |
253
+ | `getRobotsDirective({ path })` | `{ index, follow, noarchive?, nosnippet?, ... }` parsed from the page's managed robots value. `{ index: true, follow: true }` when there isn't one. |
254
+ | `isIndexable(projectId, path)` | `boolean`. This is a legacy signature and the first argument is ignored. `(await getRobotsDirective({ path })).index` says the same thing. |
255
+ | `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`. |
256
+
257
+ ### Registering pages with Sonor
258
+
259
+ `createSitemap` already syncs your page list to Sonor during `next build`. A site without an `app/sitemap` route can use the postbuild CLI instead:
260
+
261
+ ```json
262
+ {
263
+ "scripts": {
264
+ "postbuild": "sonor-register-sitemap --auto-discover"
265
+ }
266
+ }
267
+ ```
268
+
269
+ It skips itself when an `app/sitemap` route exists, and it only adds or updates pages unless you pass `--full-replace`.
270
+
271
+ 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.
272
+
273
+ From code, `registerLocalSitemap({ entries?, autoDiscover?, mode?, site? })` on `@sonordev/site-kit/seo/server` does the same and is additive by default.
274
+
275
+ `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.
276
+
277
+ All of these and createSitemap's own sync build the request in one place (`seo/register-sitemap-request.ts`).
278
+
279
+ ## Data fetchers
280
+
281
+ 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()`.
282
+
283
+ | Function | Returns |
284
+ |----------|---------|
285
+ | `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`. |
286
+ | `getSchemaMarkups(path, { includeTypes?, excludeTypes? })` | Schema rows (`schema_type`, `schema_json`, ...). `excludeTypes` also prunes matching nodes inside each row. |
287
+ | `getFAQData(path, site?)` | The FAQ (`title`, `description`, `items`, `include_schema`), or `null` |
288
+ | `getInternalLinks(path, { position?, limit?, site? })` | Link rows |
289
+ | `getContentBlock(path, section, site?)` | The content block, or `null` |
290
+ | `getABTest(path, field)` | The running test for that field, or `null` |
291
+ | `recordABImpression(testId, variant, sessionId?)` | `void` |
292
+ | `getRedirectData(path)` | The raw redirect row, or `null` |
293
+ | `getRobotsData(path)` | The page's managed robots string, or `null` |
294
+ | `getSitemapEntries({ publishedOnly? })` | Raw sitemap rows |
295
+ | `getManagedScripts(position, path?)` | Always `[]` (retired, see below) |
296
+
297
+ `getSEOPageData` and `getSchemaMarkups` share one request per path, so using both in a render (metadata plus `ManagedSchema`) costs a single round trip.
298
+
299
+ ## Entity graph and AI visibility
300
+
301
+ `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` has no effect. Don't build on them until that changes.
302
+
303
+ ## Caching
304
+
305
+ - **Within a request:** React `cache()` collapses identical calls into one.
306
+ - **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.
307
+
308
+ 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:
309
+
310
+ ```ts
311
+ // app/api/revalidate/route.ts
312
+ import { revalidatePath } from 'next/cache'
313
+
314
+ export async function POST(request: Request) {
315
+ if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATION_SECRET) {
316
+ return new Response('Unauthorized', { status: 401 })
317
+ }
318
+ const { path } = await request.json()
319
+ revalidatePath(path)
320
+ return Response.json({ revalidated: true })
321
+ }
322
+ ```
323
+
324
+ ## Retired and deprecated
325
+
326
+ - **`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.
327
+ - **`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.
328
+ - **`projectId`** on any option or prop is ignored. The project comes from the key.
329
+
330
+ ## Upgrading older code
331
+
332
+ - Drop `projectId` from every call and prop. The fetchers take just the path: `getSEOPageData('/about')`.
333
+ - `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.
334
+ - Import from `@sonordev/site-kit/seo` or `@sonordev/site-kit/seo/server`. There's no `/seo/api` entry.
335
+
336
+ ## Troubleshooting
337
+
338
+ **`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.
339
+
340
+ **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`.
341
+
342
+ **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.
343
+
344
+ **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.
345
+
346
+ **The brand is in the title twice.** See [Title templates](#getmanagedmetadataoptions).
@@ -0,0 +1,115 @@
1
+ # Signal — `@sonordev/site-kit/signal`
2
+
3
+ Real-time A/B experiments, behavior tracking, and dynamic configuration from Signal AI. Requires `full_signal` plan.
4
+
5
+ ## Usage
6
+
7
+ Auto-included by `SiteKitLayout` when `signal` prop is enabled:
8
+
9
+ ```tsx
10
+ <SiteKitLayout signal>{children}</SiteKitLayout>
11
+ ```
12
+
13
+ For standalone use:
14
+
15
+ ```tsx
16
+ 'use client'
17
+ import { SignalBridge } from '@sonordev/site-kit/signal'
18
+
19
+ export default function Providers({ children }) {
20
+ return <SignalBridge>{children}</SignalBridge>
21
+ }
22
+ ```
23
+
24
+ ## A/B Experiments
25
+
26
+ ### Declarative
27
+
28
+ ```tsx
29
+ import { SignalExperiment } from '@sonordev/site-kit/signal'
30
+
31
+ <SignalExperiment
32
+ experimentId="hero-cta"
33
+ variants={{
34
+ control: <Button>Get Started</Button>,
35
+ variant_a: <Button>Start Free Trial</Button>,
36
+ }}
37
+ trackImpression
38
+ fallback={<Button>Default</Button>}
39
+ />
40
+ ```
41
+
42
+ ### Hook-Based
43
+
44
+ ```tsx
45
+ import { useSignalExperiment } from '@sonordev/site-kit/signal'
46
+
47
+ function HeroCTA() {
48
+ const { variant, isControl } = useSignalExperiment('hero-cta')
49
+ return isControl ? <Button>Get Started</Button> : <Button>Start Free Trial</Button>
50
+ }
51
+ ```
52
+
53
+ ### Conversion Tracking
54
+
55
+ ```tsx
56
+ import { ExperimentConversion } from '@sonordev/site-kit/signal'
57
+
58
+ <ExperimentConversion experimentId="hero-cta" conversionType="click">
59
+ <Button>Sign Up</Button>
60
+ </ExperimentConversion>
61
+ ```
62
+
63
+ ## Hooks
64
+
65
+ ```ts
66
+ useSignal() // Full context: config, loading, trackEvent, trackOutcome
67
+ useSignalConfig() // Just the config object
68
+ useSignalEvent() // Returns trackEvent function
69
+ useSignalOutcome() // Returns trackOutcome function
70
+ useSignalExperiment(id) // Returns { assignment, variant, isControl }
71
+ ```
72
+
73
+ ## SignalBridge Props
74
+
75
+ ```ts
76
+ interface SignalBridgeProps {
77
+ enabled?: boolean // Default: true
78
+ realtime?: boolean // SSE real-time updates (default: true)
79
+ experiments?: boolean // Participate in A/B tests (default: true)
80
+ behaviorTracking?: boolean // Scroll, clicks, time-on-page (default: true)
81
+ children: React.ReactNode
82
+ }
83
+ ```
84
+
85
+ ## What It Does
86
+
87
+ 1. Fetches config from `GET /api/public/signal/config`
88
+ 2. Opens SSE stream for real-time `config_update` and `experiment_update` events
89
+ 3. Assigns experiment variants per visitor (cached)
90
+ 4. Batches behavioral events (scroll depth, click count, time-on-page) and flushes on debounce
91
+ 5. Tracks outcomes/conversions via POST
92
+
93
+ ## Key Types
94
+
95
+ ```ts
96
+ interface ExperimentConfig {
97
+ id: string; name: string;
98
+ status: 'draft' | 'running' | 'paused' | 'completed';
99
+ variants: ExperimentVariant[];
100
+ traffic_allocation: number; // 0-1
101
+ goal: string;
102
+ winner?: string;
103
+ }
104
+
105
+ interface ExperimentVariant {
106
+ key: string; name: string; weight: number; description?: string;
107
+ }
108
+
109
+ interface SignalEvent {
110
+ event_type: string; event_name: string; event_data?: object;
111
+ page_url: string; page_title: string;
112
+ engagement: { time_on_page: number; scroll_depth: number; click_count: number };
113
+ experiments: Array<{ id: string; variant: string }>;
114
+ }
115
+ ```
@@ -0,0 +1,127 @@
1
+ # Sitemap — `@sonordev/site-kit/sitemap`
2
+
3
+ Auto-generates `sitemap.xml` from your Next.js app directory structure. Discovers pages, resolves dynamic routes, syncs to Sonor, and optionally writes build-time `llms.txt`.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ // app/sitemap.ts
9
+ import { createSitemap } from '@sonordev/site-kit/sitemap'
10
+
11
+ export default createSitemap({
12
+ baseUrl: 'https://example.com',
13
+ })
14
+ ```
15
+
16
+ ## Config
17
+
18
+ ```ts
19
+ interface SitemapConfig {
20
+ baseUrl?: string // Resolved from Sonor API if not set
21
+ trailingSlash?: boolean // Default: next.config's trailingSlash (see below)
22
+ exclude?: string[] // Glob patterns: ['/admin/*', '/api/*']
23
+ defaultPriority?: number // Default: 0.5
24
+ defaultChangeFrequency?: 'weekly' | 'monthly' | 'yearly' | 'never'
25
+
26
+ // Dynamic route resolution
27
+ dynamicRoutes?: Record<string, string[]> // Manual: { '[slug]': ['seo', 'analytics'] }
28
+ resolveGenerateStaticParams?: boolean // Auto-import generateStaticParams (default: true)
29
+
30
+ // Priority overrides by path pattern
31
+ priorities?: Record<string, number> // { '/services/*': 0.8, '/article/*': 0.6 }
32
+
33
+ // Additional paths not in app directory
34
+ additionalPaths?: () => Promise<{ path: string; priority?: number }[]>
35
+
36
+ // Sonor sync
37
+ apiUrl?: string // Sonor API URL
38
+ apiKey?: string // Sonor API key
39
+ disableSync?: boolean // Skip Sonor sync (default: false)
40
+ awaitMetaOptimization?: boolean // Wait for Signal meta optimization
41
+
42
+ // GEO / llms.txt integration
43
+ optimizedLLMsTxt?: boolean // Write AI-optimized llms.txt at build (default: true)
44
+ optimizedLLMsFullTxt?: boolean // Also write llms-full.txt
45
+ includeLlmsTxtInSitemap?: boolean // Add /llms.txt to sitemap (default: false; leave it off)
46
+ includeLlmsFullTxtInSitemap?: boolean // Add /llms-full.txt (default: false; leave it off)
47
+
48
+ // Intelligent priority (requires Signal)
49
+ intelligentPriority?: boolean // Use visibility scores + depth heuristics
50
+
51
+ // Local data for llms.txt fallback
52
+ getLocalData?: () => Promise<LLMsDataResponse | null>
53
+ llmsPublicSummaryOnly?: boolean
54
+ }
55
+ ```
56
+
57
+ ## How Page Discovery Works
58
+
59
+ 1. Scans `app/` directory recursively for `page.tsx`/`page.jsx` files
60
+ 2. Detects dynamic segments (`[slug]`, `[...catchAll]`) and resolves them via:
61
+ - `dynamicRoutes` config (highest priority)
62
+ - Auto-import of `generateStaticParams()` from the page file (5s timeout)
63
+ 3. Fetches portfolio paths via `getPortfolioPaths()` if portfolio module is used
64
+ 4. Deduplicates and filters exclusion patterns
65
+ 5. Infers `pageType` for each page (homepage, service, article, faq, etc.)
66
+
67
+ ## Trailing Slashes
68
+
69
+ Every `<loc>` is the URL the site actually serves. With `trailingSlash: true` in
70
+ next.config, Next 308-redirects `/about` to `/about/`, so createSitemap emits
71
+ `/about/`. The exceptions follow Next's own redirects: `/` itself, file-like
72
+ paths (a `.` in the last segment, like `/llms.txt` or `/feed.xml`) and
73
+ `/.well-known/*`.
74
+
75
+ You don't need to set anything. The option defaults to the site's next.config
76
+ value, which Next inlines into the bundle. Set `trailingSlash` only if the kit
77
+ is loaded outside Next's bundler (`serverExternalPackages`).
78
+
79
+ Only the emitted URL changes. Dedupe, `exclude`, `priorities` and the Sonor
80
+ sync all use the unslashed path, which is how `seo_pages` stores it. The
81
+ build-time llms.txt links follow the same setting. `npx sonor-setup doctor`
82
+ warns (`sitemap.trailing-slash`) when a built sitemap's URLs don't match
83
+ next.config.
84
+
85
+ ## Portfolio Paths
86
+
87
+ ```ts
88
+ import { getPortfolioPaths } from '@sonordev/site-kit/sitemap'
89
+
90
+ createSitemap({
91
+ additionalPaths: () => getPortfolioPaths({ basePath: '/work', priority: 0.7 }),
92
+ })
93
+ ```
94
+
95
+ ## Sonor Sync
96
+
97
+ During `next build` only (not ISR), the sitemap entries are POSTed to Sonor via `POST /api/public/seo/register-sitemap` with `full-replace` mode. This keeps `seo_pages` in sync with the actual site structure.
98
+
99
+ ## Build-Time llms.txt
100
+
101
+ When `optimizedLLMsTxt` is true (default), the sitemap build also writes `public/llms.txt` (and optionally `public/llms-full.txt`) using `writeLLMsTxtToPublic()`. This is served as a static file by the llms route handler.
102
+
103
+ If the Sonor API is unreachable at build time and local data can't produce real content, the write is **skipped** (with a warning) rather than replaced with an "Information not available." stub — any existing `public/llms.txt` / `public/llms-full.txt` from a previous successful build keeps serving.
104
+
105
+ ### It often won't fit in the route — write it from your postbuild
106
+
107
+ Sonor generates llms.txt with an LLM call that can take a minute, and Next
108
+ gives a prerendered route 60s before it retries and fails the build. So the
109
+ in-route write gets only what's left of a 50s share of that budget: it refreshes
110
+ the file when Sonor is quick, and otherwise leaves the existing file serving
111
+ (the warning says so). It never fails the build.
112
+
113
+ For a refresh on every build, move the write to your postbuild, where nothing
114
+ imposes a ceiling and the sync it reads has already run:
115
+
116
+ ```jsonc
117
+ // package.json
118
+ "scripts": { "postbuild": "sonor-register-sitemap --write-llms" }
119
+ ```
120
+
121
+ ```ts
122
+ // app/sitemap.ts — one writer owns the file
123
+ export default createSitemap({ baseUrl, optimizedLLMsTxt: false })
124
+ ```
125
+
126
+ Add `--write-llms-full` for `public/llms-full.txt`. The flag works even when the
127
+ CLI skips its own sync because a sitemap route owns it.