@sonordev/site-kit 7.1.1 → 7.2.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 (282) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +68 -3426
  3. package/README.md +9 -8
  4. package/agent-manifest.json +12 -4
  5. package/dist/{AnalyticsProvider-3MI5RCTL.js → AnalyticsProvider-SLFFBRPE.js} +4 -4
  6. package/dist/{ArticleViewTracker-CMBSJR3N.js → ArticleViewTracker-TM5AMGN2.js} +3 -3
  7. package/dist/{BlocksPopup-X3OAAK2L.js → BlocksPopup-YJNJIVPO.js} +5 -5
  8. package/dist/ChatWidget-XKOSH66S.js +17 -0
  9. package/dist/{FileField-QRSEGO6D.js → FileField-CI4UL5SH.js} +3 -3
  10. package/dist/{FormSpotlight-XKV7VTN4.js → FormSpotlight-F2AAEGBN.js} +20 -6
  11. package/dist/{FormStage-C3YGYAUZ.js → FormStage-BI5R5CNC.js} +27 -7
  12. package/dist/ManagedForm-G6COGHXL.js +16 -0
  13. package/dist/{ManagedNewsletterForm-LPIKGJF4.js → ManagedNewsletterForm-BO3WCGXX.js} +7 -5
  14. package/dist/{SignalCore-ICXXZBYI.js → SignalCore-ZA6WHKBR.js} +3 -3
  15. package/dist/SiteChat-YJADDTKG.js +5 -0
  16. package/dist/{SiteDesignReporter-A7MS2CIP.js → SiteDesignReporter-5YAW36AQ.js} +5 -5
  17. package/dist/SitePopups-ST5PMKZE.js +10 -0
  18. package/dist/SitemapSync-RJTEBINX.js +8 -0
  19. package/dist/SitemapSync.d.ts +1 -1
  20. package/dist/_client/booking-widget.js +5 -5
  21. package/dist/_client/testimonial-section.d.ts +1 -1
  22. package/dist/affiliates/api.d.ts +1 -1
  23. package/dist/affiliates/index.js +3 -3
  24. package/dist/analytics/AnalyticsProvider.d.ts +1 -1
  25. package/dist/analytics/index.js +4 -4
  26. package/dist/analytics/send-gate.d.ts +13 -26
  27. package/dist/articles/Article.d.ts +1 -1
  28. package/dist/articles/ArticleList.d.ts +1 -1
  29. package/dist/articles/ClusterLandingPage.d.ts +2 -2
  30. package/dist/articles/PublicationLayout.d.ts +1 -1
  31. package/dist/articles/PublicationSidebar.d.ts +1 -1
  32. package/dist/articles/RelatedPosts.d.ts +1 -1
  33. package/dist/articles/author-schema.d.ts +1 -1
  34. package/dist/articles/excerpt.d.ts +3 -4
  35. package/dist/articles/index.d.ts +2 -2
  36. package/dist/articles/index.js +2 -2
  37. package/dist/articles/news-sitemap.d.ts +1 -1
  38. package/dist/articles/processArticleHtml.d.ts +1 -1
  39. package/dist/articles/server-core.d.ts +4 -4
  40. package/dist/articles/server-ui.js +3 -2
  41. package/dist/articles/server.d.ts +4 -4
  42. package/dist/articles/server.js +2 -1
  43. package/dist/articles/types.d.ts +1 -1
  44. package/dist/{engage → chat}/ChatWidget.d.ts +8 -7
  45. package/dist/{engage → chat}/EchoUiActions.d.ts +4 -4
  46. package/dist/chat/SiteChat.d.ts +25 -0
  47. package/dist/{engage → chat}/brand-color.d.ts +2 -2
  48. package/dist/{engage → chat}/chat-messages.d.ts +1 -1
  49. package/dist/{engage → chat}/echo-config.d.ts +1 -1
  50. package/dist/chat/index.d.ts +10 -6
  51. package/dist/chat/index.js +14 -8
  52. package/dist/{engage → chat}/launcher-placement.d.ts +1 -7
  53. package/dist/{engage → chat}/socket-loader.d.ts +2 -2
  54. package/dist/chat/types.d.ts +136 -0
  55. package/dist/chunk-27FISYK4.js +4 -0
  56. package/dist/{chunk-QPEEKFCA.js → chunk-4NTBQNHA.js} +249 -80
  57. package/dist/{chunk-RK7GF7PQ.js → chunk-5V2V5C3Y.js} +2 -4
  58. package/dist/{chunk-ICFRH2ED.js → chunk-5YCEMV2L.js} +1 -1
  59. package/dist/{chunk-V4HUSHZO.js → chunk-64DKGVEI.js} +41 -7
  60. package/dist/{chunk-GLHQ3LRO.js → chunk-6U3VLV2C.js} +1 -1
  61. package/dist/{chunk-ZETJTCMV.js → chunk-BC4Y3S3D.js} +1 -1
  62. package/dist/{chunk-JSZN6LDZ.js → chunk-BF7TZYC3.js} +38 -31
  63. package/dist/{chunk-EFPS56JA.js → chunk-BKY2ZNM7.js} +1 -1
  64. package/dist/{chunk-AQSNPZY4.js → chunk-C2FZBUSS.js} +80 -1
  65. package/dist/{chunk-H42PZKVC.js → chunk-CF5QNFYU.js} +2 -2
  66. package/dist/{chunk-JC7KXNNM.js → chunk-D5DCBD4Y.js} +2 -2
  67. package/dist/{chunk-PXZ7LAOY.js → chunk-D6ZC7XKZ.js} +2 -2
  68. package/dist/chunk-EJVQJCG5.js +370 -0
  69. package/dist/{chunk-6OL23QYD.js → chunk-F24FNCTK.js} +1 -1
  70. package/dist/{chunk-NEKEH4CK.js → chunk-FR6BYFPC.js} +53 -21
  71. package/dist/{chunk-KOPVJQ2P.js → chunk-GEXT7BTE.js} +1 -1
  72. package/dist/{chunk-ZAWANWUS.js → chunk-GGORNBSC.js} +18 -6
  73. package/dist/{chunk-DUQMSNF2.js → chunk-HGFAEDG3.js} +1 -1
  74. package/dist/chunk-HODO5BX5.js +28 -0
  75. package/dist/chunk-HW7B43E5.js +14 -0
  76. package/dist/{chunk-EQBZ24VH.js → chunk-IAM3D3RL.js} +43 -24
  77. package/dist/{chunk-ODADQM6Z.js → chunk-JJ3DELUL.js} +2 -2
  78. package/dist/{chunk-542MIHNP.js → chunk-JMLK6MH7.js} +1 -1
  79. package/dist/{chunk-7FWA3I6A.js → chunk-JWTQS5F5.js} +1 -1
  80. package/dist/{chunk-OUNRFXDC.js → chunk-K4ZHAURD.js} +1 -1
  81. package/dist/chunk-NV72HZ4W.js +45 -0
  82. package/dist/{chunk-4Y5FWDPM.js → chunk-NZ4RXN3G.js} +55 -7
  83. package/dist/chunk-OWZ7ATLZ.js +187 -0
  84. package/dist/{chunk-TXBAOEKG.js → chunk-PKGN32AU.js} +5 -4
  85. package/dist/chunk-Q6E4OPGG.js +40 -0
  86. package/dist/{chunk-J4D6ZXRW.js → chunk-QL6XNPSD.js} +3 -3
  87. package/dist/{chunk-HAG4YIZY.js → chunk-R46IQIGK.js} +6 -6
  88. package/dist/{chunk-R3VKF43H.js → chunk-RPZ7OQGD.js} +1 -1
  89. package/dist/{chunk-Q3S26EQA.js → chunk-S54ECQGY.js} +1 -1
  90. package/dist/{chunk-JIL5VY2R.js → chunk-VHKEF5ZB.js} +1 -1
  91. package/dist/{chunk-ILAW3XKD.js → chunk-VO3JYRT5.js} +43 -29
  92. package/dist/chunk-VWWITFWM.js +300 -0
  93. package/dist/{chunk-OPRBF7VE.js → chunk-ZTLMPUO6.js} +3 -2
  94. package/dist/client/index.js +3 -3
  95. package/dist/cms/CmsPreview.d.ts +2 -2
  96. package/dist/cms/index.d.ts +3 -3
  97. package/dist/cms/server-api.d.ts +1 -1
  98. package/dist/cms/types.d.ts +1 -1
  99. package/dist/commerce/EventCheckout.d.ts +1 -1
  100. package/dist/commerce/EventsAgenda.d.ts +3 -3
  101. package/dist/commerce/api.d.ts +1 -1
  102. package/dist/commerce/index.js +4 -4
  103. package/dist/commerce/server.d.ts +3 -4
  104. package/dist/contracts/color.d.ts +1 -1
  105. package/dist/contracts/entries.d.ts +1 -1
  106. package/dist/contracts/error-page.d.ts +183 -0
  107. package/dist/contracts/fleet.d.ts +9 -9
  108. package/dist/contracts/forms.d.ts +4 -10
  109. package/dist/contracts/llms.d.ts +2 -1
  110. package/dist/contracts/portfolio.d.ts +11 -15
  111. package/dist/contracts/proposal-sitemap.d.ts +130 -0
  112. package/dist/contracts/schema-placeholders.d.ts +87 -0
  113. package/dist/contracts/seo-meta.d.ts +11 -12
  114. package/dist/contracts/seo-pages.d.ts +12 -18
  115. package/dist/contracts/site-cache.d.ts +4 -4
  116. package/dist/contracts/sites-normalize.d.ts +2 -4
  117. package/dist/contracts/sites.d.ts +3 -5
  118. package/dist/contracts/slot-content.d.ts +2 -2
  119. package/dist/contracts/slots.d.ts +6 -6
  120. package/dist/contracts/voice.d.ts +56 -0
  121. package/dist/contracts/website.d.ts +2 -2
  122. package/dist/engage/EngageWidget.d.ts +13 -12
  123. package/dist/engage/index.d.ts +10 -9
  124. package/dist/engage/index.js +31 -35
  125. package/dist/engage/types.d.ts +19 -244
  126. package/dist/fleet/FleetHeartbeat.d.ts +2 -2
  127. package/dist/fleet/index.js +4 -4
  128. package/dist/forms/FormEnhancer.d.ts +25 -12
  129. package/dist/forms/FormSpotlight.d.ts +2 -2
  130. package/dist/forms/ManagedForm.d.ts +1 -1
  131. package/dist/forms/ServerForm.d.ts +11 -10
  132. package/dist/forms/StaticForm.d.ts +4 -2
  133. package/dist/forms/field-autocomplete.d.ts +28 -0
  134. package/dist/forms/field-interactions.d.ts +2 -2
  135. package/dist/forms/form-dom-values.d.ts +42 -0
  136. package/dist/forms/form-loaded-at.d.ts +2 -2
  137. package/dist/forms/formsApi.d.ts +2 -2
  138. package/dist/forms/index.d.ts +1 -1
  139. package/dist/forms/index.js +11 -9
  140. package/dist/forms/server.d.ts +1 -1
  141. package/dist/forms/server.js +6 -4
  142. package/dist/forms/static.js +3 -2
  143. package/dist/forms/submitForm.d.ts +9 -2
  144. package/dist/forms/types.d.ts +44 -5
  145. package/dist/forms/useForm.d.ts +21 -4
  146. package/dist/forms/webmcp.d.ts +38 -0
  147. package/dist/images/ManagedFavicon.d.ts +1 -1
  148. package/dist/images/ManagedImage.d.ts +4 -4
  149. package/dist/images/api.d.ts +1 -1
  150. package/dist/images/index.d.ts +2 -2
  151. package/dist/images/index.js +4 -4
  152. package/dist/index.d.ts +4 -1
  153. package/dist/index.js +1 -1
  154. package/dist/landing/contract.d.ts +3 -4
  155. package/dist/layout/SiteKitClientProviders.d.ts +4 -4
  156. package/dist/layout/SiteKitLayout.d.ts +11 -11
  157. package/dist/layout/client.d.ts +4 -4
  158. package/dist/layout/client.js +7 -7
  159. package/dist/layout/index.js +8 -8
  160. package/dist/layout/types.d.ts +7 -6
  161. package/dist/llms/SpeakableSchema.d.ts +2 -2
  162. package/dist/llms/agent-access.d.ts +2 -2
  163. package/dist/llms/aiRobots.d.ts +4 -4
  164. package/dist/llms/api.d.ts +2 -2
  165. package/dist/llms/contract.js +1 -1
  166. package/dist/llms/generateLLMsTxt.d.ts +1 -1
  167. package/dist/llms/index.js +6 -6
  168. package/dist/llms/links.d.ts +1 -1
  169. package/dist/llms/revalidate.d.ts +1 -1
  170. package/dist/llms/seo-revalidate.d.ts +2 -3
  171. package/dist/llms/types.d.ts +6 -6
  172. package/dist/llms/writeLLMsTxt.d.ts +10 -10
  173. package/dist/manifest/index.d.ts +8 -8
  174. package/dist/maps/index.js +3 -3
  175. package/dist/mcp/WebMcpTools.d.ts +1 -1
  176. package/dist/mcp/index.js +2 -14
  177. package/dist/mcp/report.d.ts +2 -2
  178. package/dist/mcp/serverCard.d.ts +1 -1
  179. package/dist/mcp/sonor.d.ts +5 -6
  180. package/dist/mcp/sonor.js +14 -11
  181. package/dist/mcp/transport.d.ts +1 -2
  182. package/dist/mcp/types.d.ts +4 -5
  183. package/dist/motion/engine.d.ts +2 -3
  184. package/dist/motion/failsafe.d.ts +1 -2
  185. package/dist/motion/gsap.d.ts +2 -2
  186. package/dist/motion/three.d.ts +1 -1
  187. package/dist/og/config.d.ts +4 -4
  188. package/dist/og/contrast.d.ts +2 -2
  189. package/dist/og/pages.d.ts +5 -5
  190. package/dist/og/route-fit.d.ts +2 -2
  191. package/dist/og/route.d.ts +1 -2
  192. package/dist/og/template.d.ts +1 -1
  193. package/dist/proxy/index.d.ts +2 -2
  194. package/dist/proxy/securityHeaders.d.ts +10 -12
  195. package/dist/redirects/index.d.ts +7 -7
  196. package/dist/reputation/TestimonialSection.d.ts +1 -1
  197. package/dist/reputation/api.d.ts +1 -1
  198. package/dist/revalidate/index.js +2 -2
  199. package/dist/robots/index.d.ts +5 -5
  200. package/dist/robots/indexnow.d.ts +2 -3
  201. package/dist/seo/ManagedScripts.d.ts +2 -2
  202. package/dist/seo/client.js +4 -4
  203. package/dist/seo/getManagedMetadata.d.ts +1 -1
  204. package/dist/seo/groundingSchema.d.ts +2 -2
  205. package/dist/seo/index.js +15 -8
  206. package/dist/seo/llms/contract.js +1 -1
  207. package/dist/seo/llms.js +6 -6
  208. package/dist/seo/register-sitemap-cli-impl.d.ts +1 -1
  209. package/dist/seo/register-sitemap-cli.d.ts +1 -1
  210. package/dist/seo/register-sitemap-cli.js +2 -2
  211. package/dist/seo/schema-filter.d.ts +5 -6
  212. package/dist/seo/server-api.d.ts +1 -1
  213. package/dist/seo/sitemap.js +4 -4
  214. package/dist/seo/types.d.ts +4 -4
  215. package/dist/seo/withManagedMetadata.d.ts +4 -4
  216. package/dist/server/index.js +2 -2
  217. package/dist/server/mint-site-token.d.ts +1 -2
  218. package/dist/server/server-fetch.d.ts +5 -5
  219. package/dist/shared/build-entries.d.ts +4 -4
  220. package/dist/shared/dialog.d.ts +27 -0
  221. package/dist/shared/frame.d.ts +8 -9
  222. package/dist/shared/fresh-fetch.d.ts +1 -2
  223. package/dist/shared/identity.d.ts +1 -1
  224. package/dist/shared/layers.d.ts +7 -0
  225. package/dist/shared/mid-form.d.ts +53 -0
  226. package/dist/shared/next-files.d.ts +1 -1
  227. package/dist/shared/publishCredential.d.ts +1 -1
  228. package/dist/shared/reporting-gate.d.ts +1 -1
  229. package/dist/shared/uuid.d.ts +1 -2
  230. package/dist/shared/version.d.ts +1 -1
  231. package/dist/shared/visual-viewport-gap.d.ts +3 -3
  232. package/dist/signal/index.js +2 -2
  233. package/dist/signal/types.d.ts +1 -1
  234. package/dist/site-config/index.d.ts +2 -2
  235. package/dist/sitemap/index.d.ts +13 -13
  236. package/dist/sitemap/index.js +4 -4
  237. package/dist/sitemap/shared.d.ts +3 -3
  238. package/dist/sites/site-param.d.ts +2 -2
  239. package/dist/slots/ManagedRichText.d.ts +1 -1
  240. package/dist/slots/ManagedSlot.d.ts +1 -1
  241. package/dist/slots/index.d.ts +1 -3
  242. package/dist/{socket-loader-R24ZSRSQ.js → socket-loader-CGIPEG74.js} +1 -1
  243. package/dist/sync/index.js +5 -5
  244. package/dist/types.d.ts +4 -2
  245. package/dist/website/BlocksPopup.d.ts +1 -1
  246. package/dist/website/PopupBlocks.d.ts +8 -6
  247. package/dist/website/SitePopups.d.ts +25 -0
  248. package/dist/website/images.js +4 -4
  249. package/dist/website/index.js +6 -6
  250. package/dist/website/popup-rules.d.ts +46 -0
  251. package/dist/website/popup-types.d.ts +113 -0
  252. package/dist/website/popups.d.ts +2 -6
  253. package/dist/website/popups.js +6 -14
  254. package/dist/{writeLLMsTxt-QR23OQUE.js → writeLLMsTxt-OV24LQVL.js} +3 -3
  255. package/docs/MIGRATING-TO-7.md +50 -22
  256. package/docs.json +2 -1
  257. package/package.json +2 -2
  258. package/src/admin-auth/README.md +3 -3
  259. package/src/analytics/README.md +9 -11
  260. package/src/articles/README.md +10 -7
  261. package/src/{engage → chat}/README.md +63 -62
  262. package/src/cta-bar/README.md +11 -11
  263. package/src/forms/README.md +60 -12
  264. package/src/layout/README.md +8 -5
  265. package/src/llms/README.md +3 -3
  266. package/src/mcp/README.md +7 -7
  267. package/src/motion/README.md +1 -1
  268. package/src/og/README.md +26 -27
  269. package/src/proxy/README.md +14 -14
  270. package/src/seo/README.md +4 -2
  271. package/src/slots/README.md +1 -1
  272. package/src/sync/README.md +10 -0
  273. package/src/website/README.md +133 -0
  274. package/dist/ChatWidget-JMFBXJ75.js +0 -15
  275. package/dist/EngageWidget-A3QKZ3SU.js +0 -11
  276. package/dist/ManagedForm-2GSSDJPP.js +0 -14
  277. package/dist/SitemapSync-4U654SEA.js +0 -8
  278. package/dist/chunk-B73QPZPH.js +0 -837
  279. package/dist/chunk-NDF4A5JM.js +0 -37
  280. package/dist/chunk-RTMMHTEQ.js +0 -100
  281. package/dist/engage/DesignRenderer.d.ts +0 -57
  282. package/dist/engage/element-rules.d.ts +0 -38
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # @sonordev/site-kit Changelog
2
2
 
3
+ ## 7.2.0
4
+
5
+ Forms, popups and booking now work the way AI agent browsers, autofill and screen readers expect, Sonor's schema can't put a template's placeholders on a live page, and the retired Engage module is gone from the kit. No site changes needed: update the package and rebuild.
6
+
7
+ ### Engage is gone
8
+
9
+ Engage was retired in Sonor; its chat lives in Messages and its popups in Website. The kit now matches.
10
+
11
+ - **Chat and popups load on their own.** Website chat is `@sonordev/site-kit/chat` and popups are `@sonordev/site-kit/website/popups`. `SiteKitLayout` mounts `SiteChat` and `SitePopups` as separate deferred siblings, so a site with popups off never downloads the chat, and the other way round. The popups entry is 16% smaller.
12
+ - **Popups render from blocks only.** A popup made in Engage Studio (one without blocks) isn't drawn: make it again in Website → Popups & Banners. `DesignRenderer` and its types are removed.
13
+ - **New:** `SiteChat` in `./chat`, the standalone chat mount (idle-deferred, gated like analytics), and the popup types under their own names in `./website/popups` (`SitePopup`, `SitePopupConfig`, `SitePopupTargeting`, `SitePopupTrigger`, `SitePopupType`).
14
+ - **`@sonordev/site-kit/engage` still builds through 7.x.** `ChatWidget` and the chat types re-export from it, the popup types keep their old names (`EngageElement` is `SitePopup`), and `EngageWidget` draws `SiteChat` and `SitePopups`. sonor-setup's codemod moves the imports. It's removed in 8.0.
15
+
16
+ See [Website chat](src/chat/README.md) and [Popups and banners](src/website/README.md).
17
+
18
+ ### Agent-ready forms
19
+
20
+ - **Autocomplete tokens.** Name, email, phone, company, address and website fields carry the matching `autocomplete` token (`given-name`, `email`, `tel`, `organization`, `postal-code`, and so on), read from the field's CRM destination, then its type, slug or a short label. A field it can't match confidently gets none, since a wrong token is worse than no token.
21
+ - **Accessible wiring.** Every control is tied to its label, help text and error (`aria-describedby`, `aria-invalid`, errors announced with `role="alert"`). Radio and checkbox groups are named by their question, rating stars say which one is chosen, and the required asterisk is hidden from assistive tech, since `required` already says it.
22
+ - **WebMCP.** The interactive form declares itself as a tool (`toolname`, `tooldescription`, and `toolparamdescription` on fields with help text), so a browser that supports WebMCP can offer it to an agent. Forms never set `toolautosubmit`: an agent fills the form and the person still sends it. When an agent does send it, the browser gets the outcome back, and the submission is marked "Sent by an AI assistant" in Sonor.
23
+ - **Nothing typed is lost.** The server-rendered form stays on the page until the interactive one has loaded. What was typed or autofilled carries across, focus stays in its field, and a Send pressed early is held and sent once the form is ready. The server form's Send button is disabled until it can catch a submit, and a visitor without JavaScript is told why the form won't send.
24
+ - **`submit()` returns the outcome.** From `useForm` and the form render props: `{ status: 'sent' | 'invalid' | 'failed' | 'busy' | 'next_step', ... }`. `useForm` also returns `handleSubmit` and `toolAttributes` for custom markup: `<form {...toolAttributes} onSubmit={handleSubmit}>`.
25
+ - `ServerForm`'s `enhance` prop is deprecated and ignored. Every server form upgrades to the interactive one.
26
+
27
+ See [Forms](src/forms/README.md).
28
+
29
+ ### Popups wait for forms, and every popup is a named dialog
30
+
31
+ - A popup or toast that opens on its own (immediately, after a delay, on scroll or on exit intent) waits while the visitor is in the middle of a form: focus in a field, a filled-in field not yet sent, or a form that's sending. It opens a moment after they leave the form, or once it's sent. Banners still show at once.
32
+ - A popup is a modal dialog named by its heading: it takes focus, keeps Tab inside, closes on Escape and returns focus where it was. A toast is a named non-modal dialog and a bar is a named region. Close buttons are named "Close".
33
+
34
+ See [Popups and banners](src/website/README.md).
35
+
36
+ ### BookingWidget
37
+
38
+ - Day buttons are named with the full date, year included ("Wednesday, October 7, 2026"), and time buttons with the time and date in the booking's time zone. The picked day and time are pressed (`aria-pressed`). Confirm is named with what it confirms ("Confirm 10:30 AM, Wednesday, October 7, 2026"). Every name starts with what its button shows, so voice control still works. What the widget sends is unchanged.
39
+
40
+ See [Sync](src/sync/README.md).
41
+
42
+ ### SEO and AI visibility
43
+
44
+ - **Template placeholders never reach the page.** `ManagedSchema`, `LLMSchema` and `generateAllArticleSchemas` drop placeholder nodes from Sonor-supplied schema: a URL on a reserved example domain, a name like "Example" or "Your Business Name", a placeholder phone, a slot like `[Resident Name]` or `{plan.name}`, or an object that's only a note. Real siblings and parents stay, and an article whose stored schema is nothing but placeholders gets its generated schema instead. Your own `additionalSchemas` are never touched. The rule is `@sonordev/contracts/schema-placeholders`.
45
+ - **llms.txt summaries end at a word.** A long page summary ends at a sentence or a whole word, never mid-word.
46
+
47
+ See [SEO](src/seo/README.md) and [Articles](src/articles/README.md).
48
+
49
+ ### Also
50
+
51
+ - Built and tested against Next.js 16.3.8, the September 30 security release. Update `next` on each site; the peer range is unchanged.
52
+ - The 7.0 migration guide covers sonor-setup 7.1.2's proxy fix: on a `src/app` site the proxy belongs in `src/`.
53
+
54
+ ## 7.1.2
55
+
56
+ Docs and comments only; no code changes.
57
+
58
+ - The module READMEs, `AGENTS.md`, the 7.0 migration guide and the code comments use fictional businesses and example.com in their examples, and describe spam protection without the detail of how it decides.
59
+ - Messages and comments say "Sonor" where they said "Portal", the product's old name. Identifiers are unchanged.
60
+ - This changelog starts at 7.0. Earlier releases are listed in the repository's `docs/CHANGELOG-BEFORE-7.md`.
61
+
3
62
  ## 7.1.1
4
63
 
5
64
  - BookingWidget now asks every guest for a phone number before confirming a meeting, and sends it with the booking for CRM and Google Contacts use.
@@ -92,13 +151,13 @@ A major: one entry per Sonor module, a root that runs nothing, ESM only,
92
151
  Next 16 only, a 0.6 MB package, Cache Components support, and MCP tools
93
152
  every site can give agents. Most sites move in one command when next
94
153
  touched: `npx sonor-setup codemod --write`, then build. The full guide is
95
- [docs/MIGRATING-TO-7.md](docs/MIGRATING-TO-7.md). Copies of three fleet
96
- sites on 4.2, 5.8 and 6.5 were migrated that way and built.
154
+ [docs/MIGRATING-TO-7.md](docs/MIGRATING-TO-7.md). Copies of sites on 4.2,
155
+ 5.8 and 6.5 were migrated that way and built.
97
156
 
98
157
  ### Agents: MCP tools for every site (new)
99
158
 
100
- - **`@sonordev/site-kit/mcp/sonor`**: the tools ten sites hand-rolled, once,
101
- over the fetchers the site's own pages use. `sonorMcpServer` /
159
+ - **`@sonordev/site-kit/mcp/sonor`**: the tools sites used to hand-roll,
160
+ written once over the fetchers the site's own pages use. `sonorMcpServer` /
102
161
  `sonorMcpTools`: `get_business_profile`, `list_services`, `search_faq`,
103
162
  `find_pages`, `list_articles`, `get_article`, `get_reviews`, plus opt-in
104
163
  `list_offerings`, `check_availability` and `get_inquiry_form` +
@@ -113,7 +172,7 @@ sites on 4.2, 5.8 and 6.5 were migrated that way and built.
113
172
  with reporting, the server card at both well-known paths, and on Netlify
114
173
  the rate-limited relay plus `MCP_TRANSPORT_SECRET`.
115
174
  - **A custom MCP server is left alone.** A site that runs its own
116
- (upforge.io, re-site-kit sites) gets nothing automatic: `sonor-setup mcp`
175
+ (a re-site-kit site, say) gets nothing automatic: `sonor-setup mcp`
117
176
  writes nothing there, and no llms.txt section is added. Every piece is
118
177
  opt-in for it (reporting, some built-in tools, the section). See
119
178
  src/mcp/README.md.
@@ -128,7 +187,7 @@ sites on 4.2, 5.8 and 6.5 were migrated that way and built.
128
187
  - **ESM only.** `"type": "module"`; each export is `{ types, default }`. Next
129
188
  and the kits are unaffected; Node 20.19+/22.12+ `require()` it, which
130
189
  covers a `next.config.ts`. `engines.node` is `>=20.19`.
131
- - **Next 16 only** (`next` peer `^16`; every fleet site is on 16).
190
+ - **Next 16 only** (`next` peer `^16`).
132
191
  - **The root entry is types-only** (plus `SITE_KIT_VERSION`). `BookingWidget`
133
192
  → `./sync`, `AffiliatesWidget`/`useAffiliates` → the new `./affiliates`,
134
193
  commerce → `./commerce`, `ManagedImage` → `./website/images`, signal →
@@ -166,10 +225,9 @@ sites on 4.2, 5.8 and 6.5 were migrated that way and built.
166
225
  render stamp is set on mount). The integration harness builds the fixture
167
226
  both ways.
168
227
  - **`@sonordev/contracts`**, a new dependency-free package: the rules
169
- site-kit shares with sonor-api, signal-api and the dashboard (popup
170
- blocks, site hosts, seo_pages resolution, title quality, llms sanitizers,
171
- honeypot, fleet, slots, portfolio). site-kit's `*/contract` entries are
172
- the same code; the APIs' byte-identical copies and drift tests are gone.
228
+ site-kit shares with the Sonor APIs and the dashboard (popup blocks, site
229
+ hosts, seo_pages resolution, title quality, llms sanitizers, forms, fleet,
230
+ slots, portfolio). site-kit's `*/contract` entries are the same code.
173
231
  - **`sonor-setup codemod`** moves `middleware.ts` to `proxy.ts` (Next 16's
174
232
  rename) with `createMiddleware` → `createProxy`, deterministically and
175
233
  offline. It flags a file that sets `runtime` instead of moving it.
@@ -192,3419 +250,3 @@ sites on 4.2, 5.8 and 6.5 were migrated that way and built.
192
250
  - `SiteKitLayout`'s `engage` prop, alongside `chat` and `popups`.
193
251
  - Deprecated options sites still pass (`contentSignals`, `nativeReturnTo`,
194
252
  `ManagedScripts`) are no-ops until 8.0.
195
-
196
- ## 6.6.0
197
-
198
- ### Popups in the site's own design (Website → Popups & Banners)
199
-
200
- - **Blocks.** A popup is now an ordered list of blocks (heading, formatted
201
- text, image, button, divider), drawn by one renderer, `PopupBlocks`, in
202
- the site's own colors, fonts and corners. It's an accessible dialog
203
- (labelled, focus kept inside and handed back, Escape closes), and it
204
- re-sanitizes text when it renders. `EngageWidget` prefers blocks when
205
- sonor-api sends them and lazy-loads the whole path, so the engage bundle
206
- grows 0.7%. Kits before 6.6 keep drawing `design_json`, which sonor-api
207
- still sends.
208
- - **The site's design, measured.** `SiteDesignReporter` (mounted by
209
- `SiteKitLayout`) reads the rendered home page at idle: page background,
210
- the text most copy is set in, the accent its buttons share, fonts, radius
211
- and card surfaces. It reports them to Sonor when they change (or weekly),
212
- never from a cross-origin frame or localhost, so the popup builder
213
- previews in the same design. `--sk-*` variables count as declared, and a
214
- new `design` prop declares anything outright (`{ primary: 'var(--brand)' }`),
215
- keeps it on the site (`{ report: false }`) or turns it off (`false`).
216
- - **`@sonordev/site-kit/website/contract`**: the pure contract (design
217
- tokens, popup blocks, the text sanitizer, URL rules) that sonor-api and
218
- the dashboard share.
219
-
220
- ### One entry per Sonor module
221
-
222
- Someone who knows the dashboard can now guess the import path:
223
-
224
- - `./website/{popups,images,slots,cms,landing,cta-bar}` (Website);
225
- - `./seo/{sitemap,robots,indexnow,redirects,og,llms}` and
226
- `./seo/{meta,pages}/contract` (SEO);
227
- - `./chat` (Website chat, out of `./engage`).
228
-
229
- Each is a re-export of the module's existing home. **Old paths keep
230
- working** and resolve to the same module; the agent manifest marks them
231
- deprecated with their new home. `SiteKitLayout` gains `chat` and `popups`
232
- props; `engage` stays as an alias (`engage={false}` still turns both off).
233
-
234
- ### Fixes
235
-
236
- - **Reputation:** `fetchReviews` kept one cache for every call, so a second
237
- caller with different options (another service, a limit, featured-only)
238
- got the first caller's reviews. The cache is now keyed by key and URL, and
239
- a keyless call never reads it.
240
-
241
- ## 6.5.0
242
-
243
- ### Portfolio: client-reported numbers
244
-
245
- `@sonordev/site-kit/portfolio/contract` now owns **metric provenance**: the
246
- three sources a case-study number can have, and what each may do.
247
-
248
- - **`measured`**: we measured it. **`reported`**: the client told us, and a
249
- named person at the client stands behind it. **`estimated`**: a projection.
250
- (`METRIC_SOURCES`, `MetricSource`, `isMetricSource`.)
251
- - **A reported number must say whose it is.** It carries
252
- `reportedBy: { name, role?, organization, date }`, and
253
- `metricSourceProblem()` refuses one without it (or with an unknown source).
254
- The KPI JSON schema enforces the same with `if`/`then`.
255
- - **What may headline** (a hero tile, a gallery card, a carousel, a social
256
- post): `isHeadlineMetric()`: measured, or reported with a name behind it.
257
- Never an estimate, never an unlabelled legacy number.
258
- - **Credits:** `formatAttribution()` gives "Reported by Scott Mann, Director
259
- of Business Development, True Power Systems, September 25, 2026";
260
- `shortAttribution()` gives "per True Power Systems"; `metricSourceLabel()`
261
- gives the badge text.
262
- - **`carryReportedMetrics()`** keeps a person's reported numbers through a
263
- regeneration. The generator can't author one (it can't see a client's
264
- books), so a rewritten hero would otherwise erase them.
265
-
266
- Additive: nothing changes for a site until it reads `source: 'reported'`.
267
- sonor-api keeps a byte-identical mirror, guarded by the same test vectors.
268
-
269
- ### Showcase frames: tell the embedder, stay out of its page
270
-
271
- Agency case studies show a client's live site in device frames over a
272
- screenshot of it. The embedder can't tell from outside whether a
273
- cross-origin frame painted (its document reads as null either way), so the
274
- screenshot never gave way.
275
-
276
- - **`announceFrameReady()`**: a page framed by another origin posts
277
- `{ type: 'sonor:frame-ready', v: 1 }` to its parent once it has loaded and
278
- painted. `SiteKitClientProviders` calls it; nothing to configure. At the
279
- top level or in a same-origin frame it does nothing.
280
- - **`FRAME_READY_MESSAGE` / `isFrameReadyMessage`** are exported from
281
- `@sonordev/site-kit/portfolio/contract` for the embedder's side.
282
- - **Engage stays out of someone else's page**: no popups or chat launcher in
283
- a cross-origin frame (the agency's visitor isn't this site's, and the
284
- impressions would count here). Opt back in with `engage.allowInFrame`, the
285
- same default and switch analytics has had since the send gate.
286
-
287
- ## 6.4.0
288
-
289
- Two pieces of fleet logic that sites were copying between each other now live
290
- in the kit; nothing changes for a site until it imports them. Author JSON-LD
291
- does change on upgrade: author pages become ProfilePages and a person carries
292
- one `@id` across sites (last section). Engage popups also get safer and
293
- better-behaved (next section).
294
-
295
- ### Engage: popups, banners and toasts
296
-
297
- Popups made in Sonor (Website → Popups & Banners, or by an agent through the
298
- Sonor MCP) work on every released kit from 3.2 on: Sonor sends the shape the
299
- widget already renders. This release is the kit's own half:
300
-
301
- - **Links and images are checked here too.** `DesignRenderer` follows a
302
- button or link only to a site path, an anchor, http(s), mailto or tel, and
303
- loads only http(s) or same-site images (`safeActionUrl`, `safeImageSrc` in
304
- `engage/element-rules.ts`). Sonor refuses anything else when a popup is
305
- saved; before, the renderer would have run a `javascript:` URL.
306
- - **Clicking the button counts as seen.** The frequency cap was recorded
307
- only on close, so a "once" popup came back on the page its own button led
308
- to.
309
- - **Popups leave pages they don't target.** On a client-side navigation the
310
- widget re-checks every element and hides one that no longer matches (it
311
- used to stay up until closed).
312
- - **One centred popup at a time.** A second waits until the first is closed;
313
- banners and toasts still show alongside.
314
- - **Buttons take an accessible name** (`props.ariaLabel`), which Sonor sets on
315
- the close button.
316
- - The elements request sends `deviceType`.
317
-
318
- ### MCP: `@sonordev/site-kit/mcp/transport`, the rate-limited public relay
319
-
320
- Netlify rate-limits a native function, never a Next.js route handler, so
321
- upforge.io (2026-09-21) and gmwlaw.org put their public MCP endpoint behind a
322
- native function that signs each call and relays it to a Next route. Both sites
323
- carried the same four files. The new subpath is that relay:
324
-
325
- - `createNetlifyMcpRelay(options?)`: the default export for
326
- `netlify/functions/mcp.mjs`. HMAC-SHA256 signature in a transport header
327
- (overwriting any client-sent value), relay to `/api/mcp-internal` on the
328
- deploy permalink, 64 KB body cap, `redirect: 'error'`, 55 s timeout, 502 on
329
- upstream failure, `Cache-Control: no-store` and the
330
- `netlify-rate-limited-v1` marker on responses. The function file keeps its
331
- own literal `config` (`path` + `rateLimit`), because Netlify reads it
332
- statically.
333
- - `protectMcpHandlers(handlers, options?)` for `/api/mcp`: 403 for unsigned
334
- calls when `NODE_ENV === 'production'`.
335
- - `createMcpInternalRoute(handlers, options?)` for `/api/mcp-internal`: 403
336
- unless signed, in every environment, then dispatch by method.
337
- - `hasMcpTransportAuthorization(request, options?)` and `signMcpTransport`,
338
- constant-time via `timingSafeEqual`.
339
-
340
- Header and label are configurable (`{ header, label }`) and default to
341
- `x-site-mcp-transport` / `site-mcp-transport-v1`, so a site with live names
342
- (upforge.io's `x-upforge-mcp-transport`, which its release check asserts) keeps
343
- them. The entry is Node only, imports no `server-only` (it throws in a plain
344
- Node function) and is not re-exported from `@sonordev/site-kit/mcp`. Setup and
345
- the three files: `src/mcp/README.md`, step 5.
346
-
347
- ### llms: `createSeoRevalidationHandler`, Sonor's SEO webhook
348
-
349
- heinrich-law-nextjs and wirsch-law-nextjs each carried
350
- `lib/seo-revalidation.ts`, the `/api/seo-revalidate` handler Sonor calls when
351
- a title, description or schema changes. It is now
352
- `createSeoRevalidationHandler({ secret, revalidatePath, revalidateTag, publicationBasePath? })`
353
- in `@sonordev/site-kit/llms`, wrapping `createLlmsRevalidateHandler`:
354
-
355
- - `Authorization: Bearer` only, constant-time compare. No `?secret=` form.
356
- - 16 KB body cap, read without buffering past it (413). At most 100 paths and
357
- tags; every path must be a same-site local path, even after
358
- percent-decoding (400, nothing revalidated).
359
- - Regenerates the named pages, `/sitemap.xml` and both llms files. A full or
360
- tag-only `seo` refresh also regenerates the root layout. Tags expire now
361
- (`{ expire: 0 }`).
362
- - `publicationBasePath` (optional) also refreshes the publication index and
363
- feeds, and maps legacy `/blog/...` paths onto the publication root.
364
-
365
- `parseSeoRevalidationPayload` is exported for sites that want the validation
366
- alone.
367
-
368
- Three options let a package build on the handler instead of copying it.
369
- agency-site-kit 0.9.0's portfolio `createRevalidateHandler` is now a
370
- configuration of it:
371
-
372
- - `secret` may be a getter, read on every call, so a route can create the
373
- handler once at module scope.
374
- - `extraPaths`: local paths regenerated on every call (a portfolio hub).
375
- Validated when the handler is created.
376
- - `extendPayload(payload, body)`: add paths or tags from body fields the
377
- handler doesn't read (portfolio `slug`/`slugs`, a default tag). Its result
378
- is validated like the body, and a throw is a 400, so an extension can't
379
- widen what a caller may revalidate.
380
-
381
- ### llms: `createLlmsRevalidateHandler` compares its secret in constant time
382
-
383
- It used `!==`. It now shares the constant-time compare with the SEO handler.
384
- Behavior is otherwise unchanged, including the documented `?secret=` form.
385
-
386
- ### Articles: author pages are ProfilePages, one Person `@id` everywhere
387
-
388
- Google reads an author page as a profile when it is a `ProfilePage` whose
389
- `mainEntity` is the `Person`, and it joins mentions of one person when every
390
- page gives them the same `@id`. The kit emitted a bare `Person` with no id, so
391
- Ramsey Deal's upforge.io author page, his Forge articles and ramseydeal.com
392
- described three unlinked people (2026-09-24, while working toward his
393
- Knowledge Panel).
394
-
395
- - **`generateAuthorSchema` now returns a `ProfilePage`** (`@id`
396
- `<page>#profilepage`, `url`, `name`, `dateCreated`/`dateModified` from the
397
- row) with the `Person` as `mainEntity`. Every current caller (upforge.io,
398
- qcr-nextjs, `AuthorPage`) renders it as its own script, so none double-wraps.
399
- To embed the person in a bigger graph, use the new
400
- **`generateAuthorPersonSchema`**: the same `Person`, without `@context`.
401
- - **One `@id` rule, `authorEntityId`.** The author row's new `entity_id`
402
- (`blog_authors.entity_id`, an absolute https URI such as
403
- `https://ramseydeal.com/#person`) wins; otherwise the Person is
404
- `<author profile URL>#person`. A relative URL (no `siteUrl`) gives no `@id`.
405
- sonor-api serves author rows with `select('*')`, so the column reaches sites
406
- with no API change; the author DTOs accept it for writes.
407
- - **Article bylines share it.** `generateArticleSchema` and
408
- `generateClusterArticleSchema` build `author` through the new
409
- `generateArticleAuthorNode`, which adds `@id` and the author page `url`. A
410
- byline links to an author page only when the site declares one (the new
411
- **`authorPages`** routing option) or the row has `author_page_url`: bd-aec,
412
- ccc and heinrich have no author pages, and a byline URL that 404s is worse
413
- than none. A post with no author now omits `author` instead of emitting a
414
- nameless `Person`.
415
- - **Stored `schema_json` too.** Signal freezes a byline into the post at
416
- publish time. `generateAllArticleSchemas` now passes stored nodes through
417
- `withArticleAuthorIdentity`, which adds the author's `@id` and `url` to an
418
- article node whose `Person` byline has the same name and lacks them. Stored
419
- values always win and the row is never rewritten.
420
- - **`authorPages` routing option**: `true` for author pages at
421
- `<publication>/author/<slug>`, or a function for a custom path (upforge.io
422
- serves `/author/<slug>` beside a `/theforge` publication).
423
- - **`AuthorPage` takes `siteUrl`, `siteName` and `jsonLd`.** Without
424
- `siteUrl` its JSON-LD had relative URLs and no id. `jsonLd={false}` turns it
425
- off for a page that renders `generateAuthorSchema` itself.
426
-
427
- Upgrading a site:
428
-
429
- - **upforge.io** renders `generateAuthorSchema` *and* `<AuthorPage>`, so each
430
- author page carries two author schemas today (one with a relative `url`).
431
- Keep the page's own `generateAuthorSchema` (server-side, with `siteUrl` and
432
- its root `/author` route) and pass `jsonLd={false}` to `AuthorPage`. Don't
433
- hand `AuthorPage` a function `routing.authorPages` from a server page: it's
434
- exported from the client-stamped `articles` barrel, and Next can't pass a
435
- function to a client component. (Corrected after publish; the first version
436
- of this note said to do exactly that.)
437
- - **qcr-nextjs** (author pages under `/paddlewheel-post/author`): add
438
- `authorPages: true` to `paddlewheelRouting` so article bylines link.
439
- - Set `entity_id` on an author row only for a person with a canonical id of
440
- their own (Ramsey: `https://ramseydeal.com/#person`).
441
-
442
- ## 6.3.5
443
-
444
- ### Forms: the first submit on a reCAPTCHA form works again
445
-
446
- On a cold page, `enterprise.js` fires `onload` while `grecaptcha.enterprise`
447
- is still Google's loader stub, which has `ready()` and no `execute`.
448
- `getRecaptchaToken` checked for `execute` before awaiting `ready()`, saw the
449
- stub, returned no token, and `submitForm` threw "Verification could not be
450
- completed". The visitor saw "Something went wrong sending your submission" and
451
- **no request reached Sonor**, so nothing was stored or refused either. It hit
452
- the first submit on every form of a project with `requireRecaptcha` (upforge.io
453
- and upforgeapps.com). A second click usually worked, which is why it hid.
454
- Rania Lombera (Pollard Properties) filled in upforge.io/free-audit on
455
- 2026-09-21, pressed submit once, got the error and booked a call instead.
456
-
457
- - `getRecaptchaToken` now loads, awaits `ready()` (bounded), and only then
458
- requires `execute`.
459
- - Every managed form starts loading reCAPTCHA on its first field focus
460
- (`preloadRecaptcha`, wired through `FormClient` and `useForm`), so the script
461
- is ready long before the submit. Tokens are still minted at submit; they
462
- expire after about two minutes.
463
-
464
- ### Forms: `onSuccess` gets the submitted values
465
-
466
- `onSuccess` was typed as `FormSubmission` (a server row with `data`,
467
- `routing_type`, `is_spam`...) but received Sonor's receipt,
468
- `{ success, message, submissionId, redirectUrl }`, so `submission.data` was
469
- always undefined. It now receives `FormSubmitResult`: the receipt plus `id` and
470
- `data`, the visible values that were sent. `FormSubmission` is now a deprecated
471
- alias of `FormSubmitResult`, so annotated callbacks keep compiling. Code that
472
- read `routing_type`, `is_spam` or similar was reading undefined and now fails
473
- to compile, which is the point: Sonor answers spam and accepted submissions
474
- identically on purpose.
475
-
476
- ## 6.3.4
477
-
478
- ### robots.txt: no more Content-Signal line
479
-
480
- `createRobotsTxtHandler` no longer writes a `Content-Signal:` line. Google's
481
- robots.txt parser reports it as an "Unknown directive" error in Search Console
482
- (cincinnaticondoconnection.com, 2026-09-22), and robots.txt is the one file
483
- every crawler has to parse cleanly. 37 fleet sites were sending it.
484
-
485
- - `contentSignals` is a deprecated no-op, kept so existing call sites
486
- type-check. Remove it when you next touch a site's `app/robots.txt/route.ts`.
487
- - `formatContentSignals` is deprecated. Express AI-training preferences with
488
- `buildAiCrawlerRules({ training: 'allow' | 'block' })`, which every crawler
489
- understands.
490
- - The handler now emits only User-Agent, Allow, Disallow, Sitemap and Host.
491
-
492
- ## 6.3.3
493
-
494
- ### Maps: no Google key in page HTML
495
-
496
- `SiteKitLayout` no longer copies `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` (or
497
- `GOOGLE_MAPS_BROWSER_KEY`) into every page. It shipped the key in the HTML of
498
- every route, even on sites with no Google map, and Netlify's secrets scanner
499
- now fails the build on it (cincinnaticondoconnection.com, 2026-09-22).
500
-
501
- Client maps already got their key from Sonor first: `fetchMapsConfig()` calls
502
- `/api/public/maps/config`, which returns Sonor's managed, HTTP-referrer and
503
- API-restricted browser key, and `fetchNearbyPlaces()` proxies Places through
504
- Sonor's server key. That's now the only client path, so **a site needs no
505
- Google key of its own**: delete `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` from its env.
506
- A server render can still fall back to that variable if it's set.
507
-
508
- - `publishSiteCredential` no longer takes `mapsBrowserKey` or sets
509
- `window.__GOOGLE_MAPS_API_KEY__`.
510
-
511
- ## 6.3.2
512
-
513
- ### Forms: the no-JavaScript submission path is gone
514
-
515
- Managed forms no longer render a native `action` pointing at
516
- `/api/public/forms/submit-native` or a hidden `_sk_token`, and sonor-api no
517
- longer has that endpoint. The token sat in the page's HTML, so a bot only had
518
- to download the page to borrow it: from 2026-08-14 that path took 10,500 bot
519
- submissions across the fleet and not one real lead.
520
-
521
- A form now takes exactly two routes in: a browser running site-kit, or a named
522
- agent through `/forms/agent-inquiry`.
523
-
524
- - `StaticForm` and the classic form render `method="post"` with no `action`,
525
- so a click before hydration never puts what someone typed in a URL.
526
- - `nativeReturnTo` (ManagedForm, FormEnhancer, StaticForm) and `returnTo`
527
- (ServerForm) are deprecated no-ops, kept so existing call sites still
528
- type-check. Remove them when you next touch the file.
529
- - `native_token` is gone from `ManagedFormConfig`.
530
-
531
- ## 6.3.1
532
-
533
- ### admin-auth: gated API routes and `getRequestSession`
534
-
535
- - **`apiPaths`** on `createSonorSso`: API prefixes the middleware should
536
- guard (e.g. `['/api/studio']`). Without a session they answer 401 JSON
537
- instead of redirecting to the login page. Handlers still check the session
538
- themselves; this is the second lock, not the only one.
539
- - **`getRequestSession(request)`**: the identity from a request's cookie, for
540
- middleware and route handlers that have the request in hand.
541
-
542
- ## 6.3.0
543
-
544
- ### Sign in with Sonor: `@sonordev/site-kit/admin-auth`
545
-
546
- A site's own admin area (a bid tool, a registrations dashboard, a design
547
- studio, a property editor) can now sit behind a Sonor login with one factory
548
- instead of a hand-copied auth folder. 4m-lawn-care, legacy-clay-classic and
549
- the CCG builder each carried their own copy of this handshake, and the copies
550
- had drifted: different crypto, different session lengths, roles in only one.
551
-
552
- ```ts
553
- // lib/sonor-sso.ts
554
- export const sso = createSonorSso({ paths: '/admin' })
555
-
556
- // middleware.ts
557
- export default createProxy({ before: sso.gate, securityHeaders: true })
558
-
559
- // app/api/auth/callback/route.ts
560
- export const GET = sso.handleCallback
561
-
562
- // app/api/auth/logout/route.ts
563
- export const GET = sso.handleLogout
564
- export const POST = sso.handleLogout
565
- ```
566
-
567
- - **`gate`** plugs into `createProxy({ before })`. It redirects to the login
568
- page with a `return_to`, marks the area `noindex`, and returns nothing for
569
- public pages so redirects and AI discovery headers still run there.
570
- - **`getSession()`** is the check for server components, server actions and
571
- API routes. The middleware matcher skips `/api/*`, so every API route that
572
- exposes admin data has to call it.
573
- - **`getLoginUrl({ returnTo })`** builds the `app.sonor.io/sso/grant` link.
574
- The project id comes from `SONOR_API_KEY`; no extra env var.
575
- - **`handleCallback`** verifies the token with Sonor, which rejects tokens
576
- issued for any other project, and sets a signed httpOnly cookie. The
577
- return path is limited to the gated area, so the callback can't be used as
578
- an open redirect. The token's URL gets `Referrer-Policy: no-referrer`.
579
- - Sessions last **30 days** by default (`sessionLifetimeSeconds`). Rotating
580
- `SONOR_SESSION_SECRET` (`secretEnv` to rename it) signs everyone out.
581
- - **`adminEmails`** marks admins within the area. `isAdmin` is computed from
582
- the verified cookie on every read, never stored in it.
583
- - Web Crypto throughout, so the gate runs in Edge middleware. The cookie
584
- format matches the pre-kit copies: pass a site's old `cookieName` and
585
- `secretEnv` and nobody is signed out by the switch.
586
- - Outside production the gate lets everyone through as a dev identity
587
- (`devBypass: false` to turn that off).
588
-
589
- ## 6.2.0
590
-
591
- ### Motion: `<CountUp>`, `scrollIn`, and parallax anchored at load
592
-
593
- Two helpers every animated site ended up writing for itself now live in the
594
- kit. hometown-mortgage and art-realty each carried their own copies.
595
-
596
- - **`<CountUp>`** (`@sonordev/site-kit/motion/gsap`): a stat that rolls up
597
- to its value as it scrolls into view. Pass the formatted value as its
598
- text (`<CountUp>$412,500</CountUp>`); it rolls the first number and keeps
599
- the formatting around it. The server HTML is the real text, a stat
600
- already on screen keeps its number, and the real text is restored at the
601
- end of the roll and by the no-scroll failsafe. `parseCountUp` is exported.
602
- - **`scrollIn(gsap, el, build, { start })`** (same subpath): the fold check,
603
- offscreen arm and 2.5s no-scroll failsafe as one helper for any
604
- below-the-fold entrance built in a `useGsap` setup. CountUp is built on
605
- it.
606
- - **`anchor: 'load'`** on `registerScene`, `useScrollScene` and
607
- `<Parallax>` / `useParallax`: progress counts from where the page was
608
- when the scene first rendered, so an above-the-fold layer (a hero photo,
609
- usually the LCP element) renders its rest state and moves only when the
610
- visitor scrolls. Before, it jumped on hydration to the frame for its
611
- partway-through-the-trip position. The rate is unchanged, and a scene
612
- that arms below the fold behaves exactly as before. `SceneAnchor` is
613
- exported.
614
-
615
- ### Motion: `useGsap` loads gsap at idle, not on approach
616
-
617
- `useGsap` used to start downloading gsap + ScrollTrigger only once its
618
- element came within 200px of the viewport. A visitor scrolling at a normal
619
- pace reached the section before the ~46KB chunk had arrived, so setup ran
620
- with the section already on screen and its animation was skipped or started
621
- late.
622
-
623
- - gsap now loads once per page at idle: after the `load` event, on the next
624
- `requestIdleCallback` (2s timeout; Safari, which lacks it, waits 200ms
625
- after load). Still off the LCP critical path and clear of hydration.
626
- - Setup timing is unchanged: it still runs when the element comes within
627
- `near` (default `200px 0px`), now with gsap already in hand.
628
- - Reduced-motion visitors still never download gsap.
629
- - New export `whenIdle()` from `@sonordev/site-kit/motion/gsap`: the idle
630
- gate itself, for anything else that should wait for the same moment.
631
-
632
- Echo now follows the project's **Enable Chat Widget** switch in Sonor. Before,
633
- the launcher showed on every site with engage on, whatever the switch said:
634
- `ChatWidget` fetched the setting and never read it.
635
-
636
- - A project that never saved chat settings counts as on (its config has no
637
- `is_enabled`, and only an explicit `false` hides Echo), so no site that
638
- shows Echo today loses it. As of this release no project has switched it
639
- off.
640
- - `ChatWidget` renders nothing until the widget config arrives, so a
641
- switched-off site never flashes a launcher. If the config can't be fetched,
642
- the launcher stays hidden.
643
- - Availability polling starts only once Echo is on, instead of on every page
644
- of every engage site.
645
- - `isChatEnabled(config)` is the one visibility check. The Echo UI moved into
646
- `EchoChat`, which `ChatWidget` mounts; it isn't part of the public API.
647
-
648
- ### Echo: a chat started from a quick-action chip showed the visitor's message twice
649
-
650
- Clicking a welcome quick-action chip on an AI-mode widget drew the visitor's
651
- message as two consecutive bubbles, on every Echo site. `startChat` drew the
652
- bubble, then the session-init effect drew it again before sending it to Echo.
653
- Only the doubled bubble was wrong: Echo was called once and answered once.
654
- Live-chat (socket) mode never doubled; the server doesn't echo a visitor's own
655
- message back.
656
-
657
- - One rule now, in `src/engage/chat-messages.ts`: a visitor's bubble is drawn
658
- once, when they act. Delivery afterwards only sends. `openingMessages`
659
- draws the chip's message when the chat begins; `deliverOpeningMessage`
660
- sends it once the session is up and has no way to draw it.
661
- - The composer, the `suggest_action` chips and the opening message share one
662
- AI turn, `askEcho`. Three copies of the reply/error/loading handling
663
- collapsed into one, and the composer's "talk to a person" offer now reads the
664
- message it just sent instead of the visitor's previous one.
665
- - Regression tests replay the opening sequence against a plain transcript
666
- (the package's vitest has no DOM), in AI and live mode, plus a source guard
667
- that fails if `ChatWidget` builds a visitor bubble or an Echo error reply
668
- inline again. Checked in Chrome against the old code: two bubbles before,
669
- one after.
670
-
671
- ## 6.1.3
672
-
673
- ### `CtaBarAction` is polymorphic on `as`
674
-
675
- `as` forwarded every prop to the component at runtime, but the props type
676
- only admitted anchor and button attributes. `<CtaBarAction
677
- as={ScheduleTourButton} values={tourValues}>` failed with TS2322, so the MDG
678
- unit pages wrapped the tour button in a local adapter just to bind `values`.
679
-
680
- - `CtaBarActionProps<C>` is generic on `as`. With `as`, the action takes that
681
- component's own props, required ones included, minus the five it owns
682
- (`icon`, `variant`, `collapse`, `children`, `className`). A Next `Link`
683
- takes its object `href` and `prefetch`; forgetting a prop the component
684
- requires is now a type error instead of a silent runtime gap.
685
- - Without `as`, nothing changes: the same anchor and button attributes as
686
- 6.1.0, and unknown props still error.
687
- - Type-level tests in `src/cta-bar/cta-bar.test-d.tsx`. `pnpm test` now runs
688
- vitest's typecheck mode for `*.test-d.ts[x]` files, so type contracts are
689
- guarded by the same command as the rest (checked with a negative control:
690
- the 6.1.2 typing fails seven assertions). `tsconfig.build.json` keeps them
691
- out of `dist`.
692
-
693
- ## 6.1.2
694
-
695
- Echo drew the raw brand colour as text on its light panel. On a light brand
696
- that failed WCAG AA: Gunning Homes' `#d4af37` gold sat at 2.1:1, and the
697
- fleet's teal `#39bfb0` at 2.3:1. It predates 6.1 (the solid white window had
698
- the same problem).
699
-
700
- - One helper, `src/engage/brand-color.ts`, now derives Echo's brand text
701
- colour. It's the raw brand when that already clears 4.5:1 against the panel
702
- and its brand-tinted chips; otherwise the brand is pulled toward black on a
703
- light panel, or white on a dark one, only as far as AA needs. Gold becomes
704
- `#887023` (4.8:1), keeping its hue. Blues, reds and other dark brands are
705
- unchanged, and so are dark panels whose brand already reads (the TPS power
706
- studies microsites' navy Echo).
707
- - It covers the welcome quick-action chips, the "Or call us at" phone link,
708
- markdown link buttons, suggestion chips (Echo's and the inline
709
- `suggest_action` ones), the "Talk to a person" button and the sent-message
710
- check. Fills (launcher, avatar tile, the visitor's bubbles, buttons) keep
711
- the raw brand.
712
- - `--sk-primary` in `rgb()`, `hsl()` or 3-digit hex is now read properly.
713
- Before, a non-hex brand put white text on a light brand's buttons and
714
- bubbles and tinted every chip with the default blue.
715
- - The inline Echo form's submit button drew white text on the brand
716
- regardless of the brand; it now follows the rest of the widget (dark text
717
- on a light brand).
718
- - Colour parsing moved to `src/shared/color.ts`, shared with the brand-profile
719
- extractor, instead of two parsers that disagreed.
720
-
721
- ## 6.1.1
722
-
723
- Found piloting 6.1.0 on gunning-homes.
724
-
725
- - The CTA bar, its spacer, the Echo launcher and the Echo window stay off
726
- paper (`@media print`). Fixed chrome repeats on every printed sheet;
727
- sites were hiding it by hand.
728
- - Echo's reduced-motion rule moved into a hoisted `sk-echo` stylesheet, so it
729
- is present before the window first opens.
730
- - The fixture app now also renders a page-level bar inside `<main>` (how
731
- gunning-homes and the MDG unit pages use it), so the axe gate covers both
732
- placements. It passes: axe 4.13 retired `landmark-complementary-is-top-level`.
733
-
734
- ## 6.1.0: Liquid Glass
735
-
736
- One glass material for the kit's floating chrome (`src/shared/glass.tsx`),
737
- and the two places visitors touch it most: a new mobile CTA bar, and Echo.
738
- No breaking API changes. Echo looks different on every site that bumps.
739
-
740
- ### New: `@sonordev/site-kit/cta-bar`
741
-
742
- `<CtaBar>` + `<CtaBarAction>`, the floating glass capsule that keeps a site's
743
- one or two highest-intent actions a thumb away on phones. It replaces the
744
- fleet's eleven hand-rolled sticky mobile bars, each of which had solved a
745
- different part of the same problem:
746
-
747
- - `hideOver`: steps aside while the form it points at (or the footer) is on screen.
748
- - `showAfter`: stays off the hero until the hero CTA scrolls away; server-rendered hidden, so nothing slides over the hero during hydration.
749
- - `hideWhileTyping` (default on): never sits on the on-screen keyboard.
750
- - `compactOnScroll` (default on): tightens on the way down, an icon-bearing secondary action folds to its icon, and scrolling up restores it.
751
- - Lifts the Echo launcher above itself while it's on screen, below its breakpoint, by setting `--sk-echo-offset-bottom`. No site CSS.
752
- - Reserves its own height at the end of the page (a spacer), so sites drop their `padding-bottom` hacks. Or pass `spacer={false}` and pad the footer with `--sk-cta-bar-space`, published on `<html>` while a bar is present, so the footer's background runs under the bar.
753
- - Emits `cta_click` through the standalone analytics dispatch.
754
- - Server component with a childless behaviour island: a server layout passes `as={Link}` directly. About 4.4 KB gzipped, glass and analytics included.
755
- - Solid when there's no `backdrop-filter`, reduced transparency or more contrast; no transitions under reduced motion; visible with JS off.
756
-
757
- ### Echo: the Liquid Glass update
758
-
759
- - The launcher is brand-tinted glass, and its icon turns dark on a light brand (it was always white).
760
- - The chat window is a glass panel that grows out of the launcher's corner. The header is part of the sheet with a brand wash instead of a solid gradient block; the brand lives on the avatar tile, the visitor's bubbles, the send button and the launcher.
761
- - Bubbles and the composer stay near-opaque on the glass, for contrast.
762
- - The launcher glides when a CTA bar lifts it.
763
- - Chat input, offline form and inline Echo form fields are 16px, so iOS Safari no longer zooms the page when a visitor taps into them.
764
- - `--sk-glass-panel-opacity: 100%` restores a solid window; `--sk-glass-brand-opacity: 100%` a solid launcher.
765
-
766
- ## 6.0.1
767
-
768
- ### Build-time llms.txt links clusters to the site's own article path
769
-
770
- 6.0 moved the default publication path to `/articles`. `generateLLMsTxt`
771
- took a `publication` option, but the two writers that run at build time
772
- didn't pass one, so a site whose articles live at `/blog` or `/insights`
773
- would get topic-cluster links to `/articles` whenever llms.txt falls back to
774
- local generation.
775
-
776
- - `createSitemap({ publication: { basePath: '/insights' } })` passes it to
777
- the build-time llms.txt write.
778
- - `writeLLMsTxtToPublic({ publication })` passes it through.
779
- - `sonor-register-sitemap --write-llms --publication /insights` for
780
- postbuild writers.
781
-
782
- ## 6.0.0
783
-
784
- ### Breaking: blog is now articles
785
-
786
- Sonor publishes articles from Broadcast, and site-kit's API says so.
787
- `@sonordev/site-kit/blog*` is `@sonordev/site-kit/articles*`, every
788
- Blog-named export has an Article or Publication name, the default publication
789
- path is `/articles`, and default CSS classes are `.sk-article-*`. No aliases.
790
- The full old-to-new table and the one thing that moves URLs (`basePath`) are
791
- in MIGRATION.md.
792
-
793
- - The `articles` client barrel no longer exports async server components;
794
- they're in `articles/server-ui`. That removes the last exception in the
795
- client-entry boundary test.
796
- - Reads go to `/public/articles/*` on the Sonor API.
797
- - llms.txt topic-cluster links use the site's publication routes (new
798
- `publication` option) instead of a hardcoded `/blog/<slug>`.
799
- - Author Person schema URLs follow the publication's author route.
800
-
801
- ### IndexNow key route: Bing hears about an article the moment it's published
802
-
803
- Sonor now submits every published, edited or removed article to IndexNow
804
- (Bing, and through it ChatGPT search and Copilot, plus Yandex, Seznam and
805
- Naver). IndexNow only accepts that from a host that serves the project's key,
806
- and Sonor checks the host first, so a site gets it by adding one file:
807
-
808
- ```ts
809
- // app/indexnow.txt/route.ts
810
- export { GET } from '@sonordev/site-kit/robots/indexnow'
811
- ```
812
-
813
- The key comes from Sonor with the site's `SONOR_API_KEY` and isn't a secret.
814
- Without the route the site is skipped, never refused.
815
-
816
- ### Image sitemap and Google News sitemap for articles
817
-
818
- - `generateArticleSitemap` adds each post's featured image as an `images` entry,
819
- which Next renders as `<image:image>`.
820
- - `createSitemap`'s `additionalPaths` items take `images` (relative URLs
821
- resolve against the site) and `lastModified`, so a site listing its own
822
- articles gets an image sitemap and real modified dates instead of the build
823
- time.
824
- - New `generateNewsSitemap({ siteUrl, publicationName, ...routing })` returns a
825
- Google News sitemap of posts published in the last two days. Serve it from
826
- `app/news-sitemap.xml/route.ts` and list it in robots.txt. The pure builder
827
- (`buildNewsSitemapXml`) is exported for sites with their own post source.
828
-
829
- ### Feeds carry the 20 newest posts, not 100
830
-
831
- `generateRssFeed` and `generateAtomFeed` put every post's full HTML in the
832
- feed, and fetched up to 100 posts, so an active blog's feed ran past half a
833
- megabyte, where some readers and aggregators stop fetching. They now carry the
834
- 20 newest (`maxItems` to change it); older posts stay in the sitemap. A `]]>`
835
- inside a post body no longer ends the CDATA section early and breaks the feed.
836
-
837
- ## 5.8.3
838
-
839
- ### AEO components and SpeakableSchema get a client-safe entry point
840
-
841
- `@sonordev/site-kit/llms` is one barrel that also exports `writeLLMsTxtToPublic`
842
- and the `createLLMsTxtHandler`/`createLLMsFullTxtHandler` route handlers, both
843
- of which import Node's `fs` at module scope. Importing `AEOBlock` (or any AEO
844
- component, or `SpeakableSchema`) from a Client Component pulls that whole
845
- module graph into the browser bundle, and `fs` doesn't resolve there — the
846
- build fails outright, even though the component itself never touches the
847
- filesystem. Hit on spade-nextjs, cincy-mahjong-club-nextjs, and the MDG
848
- apartment sites' apply page during the 2026-09-16 fleet AEO rollout.
849
-
850
- - New `@sonordev/site-kit/llms/client` exports the ten `AEO*` components,
851
- `SpeakableSchema`, `createSpeakableSchema`, and their types, nothing else.
852
- Import AEO markup from here in a Client Component; keep using
853
- `@sonordev/site-kit/llms` from server components and route handlers.
854
-
855
- ### commerce/server's helpers were 404ing: missing `/api` segment
856
-
857
- Every server-side commerce helper (`getOfferingBySlug`, `getOfferings`,
858
- `getOfferingPaths`, `getUpcomingEvents`, and their `*Result` variants)
859
- fetched `${apiUrl}/public/commerce/...`, missing the `/api` segment the Sonor
860
- API actually serves. Every other module in the package, including
861
- `commerce/api.ts` (the client-side fetcher for the same data), already used
862
- `/api/public/commerce/...`; only the server helpers had drifted. Two sites
863
- (gwa-nextjs, moore-canine-co-nextjs) had worked around it with a raw fetch
864
- instead of these helpers.
865
-
866
- - Fixed the endpoint on all seven calls. Added a test asserting the real URL
867
- for each exported helper — the existing regression tests mocked `fetch`
868
- without ever inspecting what was passed to it, so the wrong path shipped
869
- with a green suite.
870
-
871
- ### ManagedSchema stopped double-counting a breadcrumb inside a managed `@graph`
872
-
873
- Its auto-generated BreadcrumbList only fires when none of the combined
874
- schemas already has one, but the check looked for `@type === 'BreadcrumbList'`
875
- on the top level only. Sonor's `managed_schema` and entity-enhanced schemas
876
- are frequently shipped as one `{ "@graph": [...] }` wrapper rather than a flat
877
- node, so a breadcrumb nested inside the graph was invisible to it —
878
- art-realty-nextjs was shipping three BreadcrumbList blocks on some pages (its
879
- own component's, Sonor's, and the kit's synthesized one) before this was
880
- caught.
881
-
882
- - The check now looks inside `@graph` too, and treats a string-array `@type`
883
- as a match.
884
-
885
- ## 5.8.2
886
-
887
- ### A form that changes slug mid-fill no longer wipes what was typed
888
-
889
- `useForm` re-seeded its values from scratch every time a config arrived, so a
890
- form whose slug changed while someone was filling it in lost everything they
891
- had entered. That is not an exotic case: notification recipients are per-form,
892
- so a site that routes to different inboxes has to put a picker on the form and
893
- swap which managed form receives the lead. Choosing from that picker emptied
894
- the name, email and phone the visitor had already given.
895
-
896
- - Values now carry across a config change, for any slug the new config still
897
- has. Precedence, weakest first: config defaults, the caller's
898
- `initialValues`, then anything already entered.
899
- - A field the new config does NOT have is dropped rather than carried, so a
900
- value can't be submitted invisibly on a form that never asked for it.
901
- - An entered empty string counts as entered, so a default cannot silently
902
- refill a field the visitor deliberately cleared.
903
- - Errors are pruned the same way. A message on a field that is gone could
904
- never be cleared: nothing on screen corrects it and validation never
905
- revisits it.
906
- - The precedence lives in `forms/form-values.ts` as a pure function, following
907
- `field-rules.ts`, so it is tested on its own. `FormClient` does not use it:
908
- its config comes from a prop and never changes.
909
-
910
- ## 5.8.1
911
-
912
- ### The published package is 40% smaller
913
-
914
- `sonor-setup` bundles Babel for its migrate codemods, and the source maps for
915
- that bundle — maps describing Babel's own source, which no consuming site ever
916
- steps through — were 2.4 MB of the tarball's 3.9 MB of gzipped maps. Over half
917
- the package existed to debug the CLI.
918
-
919
- - A build step (`scripts/prune-cli-maps.cjs`, run from tsup's `onSuccess`, so
920
- `pnpm build` and `prepublishOnly` both get it) drops source maps for output
921
- nothing but the CLI can reach. It reads reachability from the emitted files
922
- rather than a hand-kept list, and prunes a file only when it is unreachable
923
- from every non-CLI entry, so anything shared with the library keeps its map.
924
- - **Library maps are untouched, embedded sources and all.** A site stepping
925
- through analytics, forms or blog in devtools still lands on real site-kit
926
- source.
927
- - Declarations no longer include tests. 109 `.test.d.ts` files were shipping to
928
- every site. `tsconfig.build.json` drives the declaration build now;
929
- `tsconfig.json` still includes the tests, so `pnpm typecheck` keeps checking
930
- them — that's where the compile-time guards live.
931
-
932
- Net: **5.92 MB → 3.53 MB packed**, 26.9 → 15.1 MB unpacked, 1540 → 1304 files.
933
- That's install and CI download time only. Nothing a site ships to browsers
934
- changes, and the per-entry bundle budgets are unchanged.
935
-
936
- ## 5.8.0
937
-
938
- ### Build-time reads stopped replaying an old build's response
939
-
940
- `public/llms.txt` could be frozen for months. The build-time fetch behind it
941
- carried no cache option, so a statically prerendered route stored it in Next's
942
- Data Cache with a one-year revalidate, and hosts persist `.next/cache` between
943
- builds — every later build rewrote the same stale file. Found on
944
- vsfconsultingservices.com on 2026-09-15: nine new pages were missing from a file
945
- last refreshed on Aug 26, and llms.txt is the file handed straight to AI
946
- crawlers.
947
-
948
- - `shared/fresh-fetch.ts` is the single source of truth for requests that must
949
- skip the Data Cache: it calls the original fetch Next keeps on its patched
950
- one, so nothing is cached and the route's staticness is untouched.
951
- `cache: 'no-store'` (bails static generation) and `next: { revalidate }`
952
- (lowers the route's window) are both wrong here, and the file says why. The
953
- mint's `resolveFetch` moved here; `server/mint-site-token` re-exports it.
954
- - `getOptimizedLLMsTxt` always reads fresh. `writeLLMsTxtToPublic` also asks its
955
- local fallback for fresh reads (`fresh` on `generateLLMsTxt`), and
956
- `createSitemap`'s Sonor reads are fresh during `next build` and unchanged at
957
- request time. The llms route handlers keep their one-hour window.
958
-
959
- **Rebuild once on this version to refresh a stale `public/llms.txt`.** Nothing
960
- else to change.
961
-
962
- ### The llms.txt write can no longer fail a build, and has a home that fits
963
-
964
- With reads fresh, the in-route write runs for real on every build, and Sonor
965
- generates llms.txt with an LLM call that took ~56s for a 147-page site — more
966
- than the 60s Next allows a prerendered route. The sitemap route then exhausted
967
- its retries and the build exited.
968
-
969
- - The write now gets what's left of a 50s share of the route's budget
970
- (`llmsWriteTimeoutMs` still overrides). If Sonor answers in time the file
971
- refreshes; if not, the existing file keeps serving, the build passes, and the
972
- warning names the fix below. The old 120000ms default outlasted the route.
973
- - `sonor-register-sitemap --write-llms` (and `--write-llms-full`) writes the
974
- file from your postbuild, where no 60s ceiling applies, after the sync the
975
- generation reads. It runs on the skip path too, which is the common one for a
976
- site whose sitemap route owns the sync. For a guaranteed refresh every build:
977
- `optimizedLLMsTxt: false` in `createSitemap`, and
978
- `"postbuild": "sonor-register-sitemap --write-llms"`.
979
-
980
- ### One set of field rules for every form
981
-
982
- `useForm` and `FormClient` each carried their own copy of conditional
983
- visibility (`show_when`) and validation, and the copies had drifted. They now
984
- share `src/forms/field-rules.ts`, as do the stage and spotlight experiences.
985
- Where the copies disagreed or were wrong, the merged rules decide:
986
-
987
- - **`contains` / `not_contains` read an unanswered field as empty text.**
988
- Managed forms read it as the text "undefined", so `contains "n"` showed a
989
- field before the visitor had typed anything.
990
- - **A multi-value answer is searched option by option,** so a needle can't
991
- match across two options ("r,W" in Solar, Wind).
992
- - **An emptied checkbox group or multi-select is unanswered.** Ticking then
993
- unticking every option left `[]`, which passed `required`, so the form
994
- submitted with nothing selected. It also showed the valid tick, and
995
- `is_empty` said it wasn't empty.
996
- - **A number 0 is an answer**: it satisfies `required`, and `min`/`max` apply.
997
- An unticked single checkbox is still unanswered for `required` and
998
- `is_empty`.
999
-
1000
- ### Forms report field-level drop-off, and abandonment on every kind of leave
1001
-
1002
- The Sonor app's Field Performance card reads `form_analytics.field_interactions`
1003
- and `abandonment_field`, and nothing wrote either: 0 of ~80.6k form sessions on
1004
- 2026-09-15. Abandonment only came from `beforeunload`, which iOS Safari never
1005
- fires and which client-side navigation doesn't trigger, so only ~3.8k of those
1006
- sessions were ever marked abandoned.
1007
-
1008
- - **Per-field tracking.** Every managed form (classic, stage, spotlight) now
1009
- records, per field slug, how often the visitor focused it, how long they
1010
- spent in it, and whether it held a complete, valid value. That goes out as
1011
- `fieldInteractions` with the step, complete and abandon calls. The abandon
1012
- call also carries `abandonmentField`, the field the visitor was on.
1013
- Requires the matching sonor-api release; older APIs ignore the new keys.
1014
- - **Abandonment is reported on page hide, pagehide, and when the form
1015
- unmounts** (client-side navigation, a closed modal), no longer on
1016
- `beforeunload`. The page-hide pair is now one shared helper
1017
- (`shared/page-leave.ts`) that Signal's flush and scroll depth use too.
1018
- - **Only a visit that touched the form can be abandoned.** The session opens
1019
- when the form mounts, so a page load where nobody focused a field, typed,
1020
- or changed step is a view, not an abandonment. Expect `abandoned` rows to
1021
- mean "started and left" from this release on.
1022
- - A visitor who comes back to a hidden tab and keeps going is reported again,
1023
- with where they got to, when they leave. A later submit clears it.
1024
- - The completion call now uses `keepalive`, so a form with a `redirect_url`
1025
- no longer loses it to the navigation.
1026
- - `useForm` and custom renderers (`FormRenderProps`) get `trackFieldFocus` /
1027
- `trackFieldBlur` to wire to their own inputs. Value changes through
1028
- `setFieldValue` are tracked without them.
1029
-
1030
- ### Brand profile: theme from luminance, and the push is opt-in
1031
-
1032
- The brand-profile extractor called a site "dark" whenever its CSS contained
1033
- the substring `.dark` or `dark:`. That caught shadcn's
1034
- `@custom-variant dark (&:is(.dark *))`, a `--surface-dark:` color token, and
1035
- even `.btn-outline-dark:hover`. Postbuilds on 2026-09-15 relabelled seven
1036
- light sites (background `#ffffff`) as dark in production: Watson, Reinhart
1037
- and the five MDG property sites.
1038
-
1039
- - **Theme comes from the page background's luminance**, falling back to the
1040
- text color: what `html`/`body` paint, else the usual tokens, with `var()`
1041
- chains resolved (including Tailwind v4 `@theme`). Reads hex, `rgb()`,
1042
- `hsl()`, shadcn's bare HSL triples and `oklch()`. Dark-mode overrides
1043
- (`.dark`, `:root.dark`, `[data-theme=dark]`, `@media
1044
- (prefers-color-scheme: dark)`) are skipped, so the create-next-app default
1045
- no longer reads as dark. When nothing resolves, `theme` is omitted rather
1046
- than guessed.
1047
- - **New `supports_dark_mode`** records what the old check actually detected:
1048
- the site ships a dark variant.
1049
- - **New `extractor_version: 2`** on every push. The API doesn't trust the
1050
- `theme` of a push without it.
1051
- - **CSS comments are stripped before scanning.** A `{}` inside a `:root`
1052
- comment used to end the block early (upforge.io's background never got read).
1053
- - **`sonor-register-sitemap` no longer pushes the brand profile by default.**
1054
- Pass `--brand-profile` (or `brandProfile: true` to `registerLocalSitemap`)
1055
- to opt in. Brand data has nothing to do with the sitemap, and a heuristic
1056
- that runs on every build of every site shouldn't write to production by
1057
- default. A plain `sonor-register-sitemap --auto-discover` postbuild is back
1058
- to syncing pages only.
1059
-
1060
- ### createSitemap follows `trailingSlash` from next.config
1061
-
1062
- On a site with `trailingSlash: true`, Next 308-redirects `/about` to
1063
- `/about/`. createSitemap didn't know about the setting and emitted `/about`,
1064
- so every entry except `/` redirected, and the sitemap disagreed with the
1065
- site's own canonicals. All five MDG property sites shipped like this
1066
- (found 2026-09-15 by crawling the built sites).
1067
-
1068
- createSitemap now emits the URL the site serves. With `trailingSlash: true`,
1069
- every path ends in `/` except `/` itself, file-like paths (a `.` in the last
1070
- segment, like `/llms.txt` or `/feed.xml`) and `/.well-known/*`, which is the
1071
- same rule Next's own redirects use.
1072
-
1073
- - **No config needed.** The option defaults to the site's next.config value.
1074
- Next inlines `trailingSlash` into every module it bundles
1075
- (`process.env.__NEXT_TRAILING_SLASH`), so the kit reads it with no file
1076
- I/O. Verified on Next 16.3.2 with Turbopack, and with webpack plus
1077
- `transpilePackages`.
1078
- - **`trailingSlash?: boolean` on `SitemapConfig`** for the rare site that
1079
- loads the kit outside Next's bundler (`serverExternalPackages`). An explicit
1080
- value always wins.
1081
- - **The Sonor sync doesn't change.** `register-sitemap` still gets unslashed
1082
- paths, which is how seo_pages stores them, and dedupe, `exclude`,
1083
- `priorities` and `intelligentPriority` still match on the unslashed path.
1084
- - **llms.txt links follow the same rule.** Portal builds llms.txt links from
1085
- `business.website` plus the unslashed page path, so on a `trailingSlash`
1086
- site each one redirected. `writeLLMsTxtToPublic` now adds the slash to this
1087
- site's links, both absolute and root-relative, before it writes
1088
- (createSitemap passes its setting through). `generateLLMsTxt` and the
1089
- llms.txt route handlers do the same. Links to other hosts, files and the
1090
- bare origin are left alone. Both take a `trailingSlash` option, which
1091
- defaults to next.config. Pass it explicitly when you call
1092
- `writeLLMsTxtToPublic` from a plain-Node postbuild script, where there's
1093
- no Next bundle to read the setting from.
1094
- - **`ClusterNavigation`'s `trailingSlash` prop** defaults to next.config too,
1095
- and runs through the same rule.
1096
-
1097
- `## Optional` entries in a generated llms.txt that aren't in the page list
1098
- used to get a slash forced onto them, which redirected on a default site.
1099
- They now follow the site's setting like every other link.
1100
-
1101
- ### `sonor-setup doctor` flags sitemap URLs that redirect
1102
-
1103
- The new `sitemap.trailing-slash` check warns when next.config sets
1104
- `trailingSlash: true` and the sitemap lists unslashed URLs. It reads the
1105
- built sitemap (`.next/server/app/sitemap.xml.body`, or the
1106
- `generateSitemaps` shards) when there is one, because that's what crawlers
1107
- get, however the route was written. Without a build, it checks the source for
1108
- a `trailingSlash: false` that overrides next.config.
1109
-
1110
- **What changes on upgrade:** sites with `trailingSlash: true` get slashed
1111
- sitemap and llms.txt URLs on their next build. For sites that wrapped
1112
- createSitemap to add the slash themselves (the MDG property template), the
1113
- wrapper can go. Sites on Next's default see no change.
1114
-
1115
- ### A missing blog post is a 404, not a soft 404
1116
-
1117
- `BlogPost` rendered its "Post Not Found" message with HTTP 200, and
1118
- `generateBlogPostMetadata` gave it an indexable "Post Not Found" title, so
1119
- every mistyped or deleted post URL was a soft 404 (vsf-consulting-nextjs
1120
- /insights, 2026-09-15). Both now call Next's `notFound()`, and there's a
1121
- helper for the page:
1122
-
1123
- - **`requireBlogPost(slug, { site? })`** from `@sonordev/site-kit/blog/server`
1124
- returns the post or calls `notFound()`. Call it from `generateMetadata`:
1125
- metadata resolves before the page streams, so the status is a real 404.
1126
- - **`generateBlogPostMetadata`** calls `notFound()` for a missing post.
1127
- `notFound: false` returns the old placeholder, now marked `noindex`.
1128
- - **`BlogPost`** calls `notFound()` for a missing post. `notFound={false}`
1129
- renders the message instead.
1130
-
1131
- A failed fetch still counts as a missing post, as it always has.
1132
-
1133
- **What changes on upgrade:** unknown post slugs answer 404 with `noindex`.
1134
- Sites that wrote their own `requirePost()` (vsf-consulting-nextjs) can switch
1135
- to `requireBlogPost`.
1136
-
1137
- ### `generateBlogPostMetadata({ images: false })` for posts with their own card
1138
-
1139
- The featured image was always declared as `openGraph.images` and
1140
- `twitter.images`, and Next lets declared images beat a route's
1141
- `opengraph-image` file, so a per-post card never shipped. `images: false`
1142
- leaves both keys out entirely. (Not `images: undefined`: Next checks
1143
- `hasOwnProperty('images')`, so even an undefined value hides the card.) The
1144
- doctor flags a post route with an `opengraph-image.tsx` whose page doesn't
1145
- pass it.
1146
-
1147
- ### `createOgImage`: the runtime card for `opengraph-image.tsx`
1148
-
1149
- `createOgImageRoute` returns `GET(request, { params })`, but a metadata image
1150
- file is called as `default({ params })`, so watsonhac.com, reinhart and
1151
- vsf-consulting-nextjs each wrote an adapter that built a dummy Request.
1152
- `createOgImage(async (params, { id }) => card | null)` is that file's default
1153
- export: params (and a `generateImageMetadata` id) arrive awaited, null is a
1154
- 404. `size` and `contentType` are exported for re-export from the file. Both
1155
- factories render through one function.
1156
-
1157
- ### The runtime card fits its title, and its bar is readable
1158
-
1159
- The runtime (Satori) card set the title at a fixed 84px, uppercase, with no
1160
- fitting, so a long post title ran off the card beside the photo. Sites dropped
1161
- the photo past a hand-picked 40 or 50 characters. The title is now fitted over
1162
- the build-time card's range (104px down to the 56px floor) from a width
1163
- estimate, since Satori can't measure, and clipped on a whole line as a
1164
- backstop. When it can't fit beside the photo even at the floor, the photo is
1165
- dropped. A relative `photoUrl`, which Satori can't load, is ignored instead of
1166
- failing the render.
1167
-
1168
- The bar set `theme.text` on `theme.accent`: navy on crimson on watsonhac.com,
1169
- ink on green on reinhart, so both left the bar off. Both cards now pick the
1170
- bar text from one rule (`og/contrast.ts`): `theme.barText` if set, else the
1171
- first of `surface`, `bg`, `text` that reaches 3:1 (WCAG AA for large text) on
1172
- the accent. The build-time card always used `surface`, and keeps it wherever
1173
- it already read. `barText` is new on `OgTheme`, and the runtime theme accepts
1174
- og.config.ts's `theme` as is.
1175
-
1176
- **What changes on upgrade:** per-post cards with long titles shrink instead
1177
- of overflowing. A bar whose `surface` failed on the accent changes color.
1178
-
1179
- ### `sonor-setup og`: nested routes, per-URL cards, and cleaner titles
1180
-
1181
- - **Static routes under a dynamic segment get a card.** `properties/[slug]/about`
1182
- is keyed `/properties/*/about` and titled from its own name. The generator
1183
- stopped at the first dynamic segment, so 30 Green Bay and SS Oshkosh pages
1184
- shipped with no og:image.
1185
- - **Per-URL cards.** A `cards` key naming one URL under a dynamic route
1186
- (`/floor-plans/1-bedroom`, `/properties/tall-pines/about`) renders to
1187
- `public/_og/<url>.jpg`, with that URL's managed copy under the override.
1188
- The page declares it with `paramCardImage(path, { config })` from
1189
- `@sonordev/site-kit/og`, which answers undefined for a page with no entry.
1190
- The kit owns `public/_og/` and clears it every run. The files end in `.jpg`,
1191
- so `trailingSlash: true` never redirects them. They need `sharp`. The MDG
1192
- sites rendered these with their own script (`og-param-cards.mjs`) served by
1193
- a code route per segment.
1194
- - **Code cards on `trailingSlash` sites.** Next serves `opengraph-image.tsx`
1195
- at an extensionless URL, which `trailingSlash: true` 308-redirects.
1196
- `codeCardImage(path)` gives the slashed URL for the page to declare, and the
1197
- wiring check (og and doctor) flags a code card on such a site whose page
1198
- doesn't.
1199
- - **A route handler at `opengraph-image/route.tsx` counts as a code card.**
1200
- The generator wrote `opengraph-image.jpg` beside that folder, which Next
1201
- refuses to build. It's the shape og/route's own docs suggested.
1202
- - **Titles lose broken suffixes.** A dangling separator (`About Us |`) and a
1203
- domain after a comma (`Privacy Policy, abbeyglenapts.com`) are dropped like
1204
- a brand suffix. A hyphen inside a word is no longer a separator: `Custom
1205
- Walk-In Closets` used to become `Custom Walk`.
1206
- - A `cards` key that matches a dynamic pattern isn't reported as unmatched.
1207
-
1208
- **What changes on upgrade:** sites with static routes under a dynamic segment
1209
- get new `opengraph-image.jpg` files there on the next `sonor-setup og`.
1210
-
1211
- ### doctor `og.card` stops guessing
1212
-
1213
- - **managed_og_image is reported only when it's set.** The check warned on
1214
- every site with per-page cards that `managed_og_image` might override them,
1215
- whether or not any page had one. `sonor-setup og` now reads it while it
1216
- titles cards and names the pages that set one. `doctor --online` does the
1217
- same. Offline, it says nothing.
1218
- - **`summary_large_image` is found where sites set it.** The check grepped the
1219
- root layout only. It now reads the layout, every page, and what they import:
1220
- `lib/` helpers through tsconfig paths, and monorepo workspace packages. A
1221
- `twitter-image` file counts too.
1222
-
1223
- ### Answer engines: Amazonbot, MistralAI-User and YouBot
1224
-
1225
- `buildAiCrawlerRules` names three more agents. MistralAI-User (Le Chat
1226
- fetching for a user) and YouBot (You.com search) are retrieval crawlers.
1227
- Amazonbot is a training crawler, because Amazon says what it collects may
1228
- train its models, so `training: 'block'` covers it. vsf-consulting-nextjs kept
1229
- these three in a local `NOT_YET_IN_KIT` list.
1230
-
1231
- `@sonordev/site-kit/robots` now re-exports `buildAiCrawlerRules`,
1232
- `createRobotsTxtHandler`, `formatContentSignals` and the two lists, which is
1233
- where people looked for them. Same functions as `@sonordev/site-kit/llms`.
1234
- The llms README's robots example used a hand-rolled list and now uses
1235
- `buildAiCrawlerRules`.
1236
-
1237
- **What changes on upgrade:** robots.txt built with `buildAiCrawlerRules`
1238
- gains three named groups. No crawler's access changes on an allow-all site.
1239
-
1240
- ### The llms discovery header can't come from a layout
1241
-
1242
- The llms README's "Option B" told sites to `export async function headers()`
1243
- from the root layout. That isn't a Next API (`headers()` is a next.config
1244
- option), so those sites sent no Link header, and `sonor-setup geo` passed
1245
- them. The README now recommends `createProxy({ llmsDiscovery })`, with
1246
- `withSiteKitConfig({ llmsTxtDiscoveryLink: true })` for sites without a
1247
- proxy, and geo fails a layout `headers()` export and says why.
1248
-
1249
- ### `llmsDiscovery` sends Link to requests with no Accept header
1250
-
1251
- The proxy only sent `Link: rel="describedby"` when the Accept header named
1252
- text/html or `*/*`, so it skipped every request that sends none: curl, link
1253
- checkers, and many crawlers and AI fetchers, which the header is for. A
1254
- missing Accept header means anything, so it counts now. Still skipped:
1255
- non-GET/HEAD requests, Accept headers that name only non-HTML types,
1256
- file-like paths (`/llms.txt`, `/sitemap.xml`), `/api/` routes, and Next's RSC
1257
- navigation requests. The rule is `wantsLlmsDiscoveryLink`, exported from
1258
- `@sonordev/site-kit/llms`.
1259
-
1260
- ### `useGsap` loads plugins inside its context
1261
-
1262
- The docs said to `import('gsap/SplitText')` inside the setup. gsap.context
1263
- only records what runs synchronously inside it, so everything the plugin made
1264
- landed outside the context: unscoped, and never reverted on unmount.
1265
- vsf-consulting-nextjs's SplitHeading built a second context to clean up. Pass
1266
- plugins as `options.plugins: { SplitText: () => import('gsap/SplitText') }`.
1267
- They load with gsap (once per page), get registered, and reach setup as
1268
- `plugins.SplitText`, so setup stays synchronous. Setup also gets `context`,
1269
- for anything it has to start later (`context.add(() => ...)`).
1270
- `loadGsapPlugins` and `runGsapSetup` are exported.
1271
-
1272
- ### `ManagedSchema` `excludeTypes` drops nested nodes
1273
-
1274
- `excludeTypes` dropped a schema row only when its `schema_type` matched, so a
1275
- FAQPage embedded in a page-level row got through (MDG Green Bay's Georgetown
1276
- and Westbrooke pages, whose FAQPage describes Q&A the pages don't show). An
1277
- excluded type now goes wherever it sits in Sonor's schema: a whole row, an
1278
- `@graph` member, or a nested value like `mainEntity`, in `managed_schema` and
1279
- the entity graph too. A row left empty is dropped. Your `additionalSchemas`
1280
- are never filtered, and `includeTypes` is unchanged.
1281
-
1282
- **What changes on upgrade:** a site that passes `excludeTypes` loses nodes of
1283
- those types that were nested in other rows.
1284
-
1285
- ### doctor and geo read helpers and workspace packages
1286
-
1287
- Both read only the file in the site, so wiring in a helper was invisible: geo
1288
- failed three MDG sites whose `app/sitemap.ts` calls the workspace package's
1289
- createSitemap (which turns the build-time llms.txt off) until each restated
1290
- `optimizedLLMsTxt: false`. Route, sitemap, proxy and metadata checks now read
1291
- the file plus what it imports, through one reader (`shared/source-graph.ts`):
1292
- relative imports, tsconfig `paths`, and workspace packages resolved through
1293
- their `exports`. Published packages in node_modules, this kit included, are
1294
- never followed, and comments are ignored, so a helper's doc comment can't pass
1295
- or fail a check.
1296
-
1297
- ### `sonor-register-sitemap` sends the site host
1298
-
1299
- The CLI registered pages with no `site`, so on a multi-site project every
1300
- page synced as unattributed rather than as this host's. It now sends
1301
- `NEXT_PUBLIC_SITE_URL`'s host (it loads `.env` and `.env.local`), or the host
1302
- from `--site`. `registerSitemap` and `registerLocalSitemap` on `seo/server`
1303
- had the same gap and take `site` too. All of them and createSitemap's own sync
1304
- build the request in one place (`seo/register-sitemap-request.ts`).
1305
-
1306
- **What changes on upgrade:** a microsite's postbuild sync tags its pages with
1307
- its host. Single-site projects and API servers that predate the site
1308
- dimension ignore it.
1309
-
1310
- ## 5.7.2
1311
-
1312
- ### Managed FAQs, internal links and content blocks send the site host
1313
-
1314
- Multi-site projects can now have managed SEO content per site. A managed FAQ,
1315
- internal link or content block with no site applies to every host. One tagged
1316
- `bd-charlotte.com` applies only there. The API answers each read with the
1317
- asking host's rows plus the project-wide ones, and treats a read with no site
1318
- as the project's primary domain, so these reads now say which site is asking:
1319
-
1320
- - `getFAQData`, `getInternalLinks` and `getContentBlock` (and so
1321
- `ManagedFAQ`, `ManagedInternalLinks` and `ManagedContent`) carry `site` in
1322
- the POST body to `/api/public/seo/faq`, `/internal-links` and `/content`.
1323
- - `getFAQItems` from `@sonordev/site-kit/llms` carries `?site=`, like the
1324
- other llms reads.
1325
- - The host comes from `resolveSiteHost`, the same resolver the blog, sitemap
1326
- and llms.txt reads use: an explicit `site`, then the `NEXT_PUBLIC_SITE_URL`
1327
- host, then `window.location.host` in the browser. When nothing resolves,
1328
- `site` is left off entirely.
1329
-
1330
- **What changes on upgrade:** nothing for most sites. Every microsite already
1331
- sets `NEXT_PUBLIC_SITE_URL`, so its managed content is scoped on the next
1332
- deploy. Single-site projects get the same rows as before, and API servers that
1333
- predate the site column ignore the field.
1334
-
1335
- To pin a host, pass `site`. The three components take it as a prop. The
1336
- fetchers take it as a new optional last argument:
1337
- `getFAQData(path, site)`, `getInternalLinks(path, { position, limit, site })`,
1338
- `getContentBlock(path, section, site)`,
1339
- `getManagedContentData(path, section, site)` and
1340
- `getFAQItems(projectId, limit, site)`. Existing calls keep working unchanged.
1341
-
1342
- ## 5.7.1
1343
-
1344
- ### Blog reads send the site host, so each host gets its own posts
1345
-
1346
- Multi-site projects (one Sonor project serving many domains, like bd-aec.com
1347
- and its city microsites) can now have a blog per site. A post with no site is
1348
- project-wide and shows on every host. A post tagged `bd-charlotte.com` shows
1349
- only there. For the API to tell them apart, it has to know which site is
1350
- asking, so every blog read now says:
1351
-
1352
- - Every GET under `/public/blog/*` carries `?site=<host>`: posts, slugs,
1353
- categories, tags, recent, clusters and authors.
1354
- - The related-posts and view-count POSTs carry `site` in the body.
1355
- - The host comes from `resolveSiteHost`, the same resolver the sitemap sync
1356
- and llms.txt reads use: an explicit `site`, then the `NEXT_PUBLIC_SITE_URL`
1357
- host, then `window.location.host` in the browser. When nothing resolves,
1358
- `site` is left off entirely.
1359
-
1360
- **What changes on upgrade:** nothing for most sites. Every microsite already
1361
- sets `NEXT_PUBLIC_SITE_URL`, so its blog reads are scoped on the next deploy.
1362
- Single-site projects see the same posts as before, and API servers that
1363
- predate the site dimension ignore the param.
1364
-
1365
- To pin a host, pass `site`. Components take it as a prop (`BlogList`,
1366
- `BlogPost`, `BlogSidebar`, `BlogLayout`, `RelatedPosts`,
1367
- `ClusterLandingPage`). Fetchers that take arguments accept
1368
- `{ site }`, for example `getBlogPost(slug, { site })`,
1369
- `getAllBlogPosts({ site })` and `getRelatedInsights(slug, { site })`.
1370
- `getAllBlogSlugs()` and `getAllAuthorSlugs()` stay zero-argument so they can
1371
- still be exported as `generateStaticParams`, and always use
1372
- `NEXT_PUBLIC_SITE_URL`. `BlogPost`'s view counter uses its `site` prop, or
1373
- else the host SiteKitLayout published, the same one analytics tags page views
1374
- with.
1375
-
1376
- The code that appends `site` is shared by the llms and blog reads
1377
- (`sites/site-param`). The post, category and related-posts fetches that were
1378
- copied between `blog/server` and the components now share one module too.
1379
-
1380
- ### `normalizeSiteHost` from `@sonordev/site-kit/blog` is now `linkClassificationHost`
1381
-
1382
- The blog module had its own `normalizeSiteHost`, which strips `www.` to decide
1383
- whether a link in a post is internal or external. It shared a name with the
1384
- multi-site `normalizeSiteHost` from `sites/contract`, which keeps `www.`
1385
- because `www.example.com` and `example.com` can be different sites. It's now
1386
- called `linkClassificationHost`, with the same behavior. The old name is still
1387
- exported from `@sonordev/site-kit/blog` as a deprecated alias, so existing
1388
- imports keep working.
1389
-
1390
- ## 5.7.0
1391
-
1392
- 5.6.1 was versioned in the repo but never published to npm, so its changes ship
1393
- in this release. Everything in this section is new since 5.6.0.
1394
-
1395
- ### llms.txt handlers send `X-Robots-Tag: noindex` by default
1396
-
1397
- `createLLMsTxtHandler` and `createLLMsFullTxtHandler` now send
1398
- `X-Robots-Tag: noindex`. llms.txt is a plain-text restatement of pages the site
1399
- already serves as HTML, so a search index that picks it up holds a thin
1400
- duplicate of the site. AI crawlers still fetch it: they request `/llms.txt` by
1401
- convention, and noindex doesn't stop them.
1402
-
1403
- - Pass `noindex: false` to either handler if you want the file in search
1404
- results.
1405
- - A static `public/llms.txt` (the build-time write) is served straight from the
1406
- CDN and never runs the handler. Set the header in `netlify.toml`,
1407
- `public/_headers` or `vercel.json` instead. The llms README has the
1408
- `netlify.toml` block.
1409
- - Keep llms.txt out of the XML sitemap, which lists indexable HTML pages.
1410
- `includeLlmsTxtInSitemap` and `includeLlmsFullTxtInSitemap` already defaulted
1411
- to off. `sonor-setup scaffold` no longer turns them on, and `sonor-setup geo`
1412
- now warns when a sitemap lists them (it used to fail sites that didn't).
1413
-
1414
- **What changes on upgrade:** every site that serves llms.txt through these
1415
- handlers starts sending the header on its next deploy. No code change is needed.
1416
-
1417
- ### `export const generateStaticParams = generateBlogStaticParams` type-checks again
1418
-
1419
- Since 5.4.0, `generateBlogStaticParams` took `BlogRoutingOptions` as its first
1420
- parameter, so the documented direct export failed Next 16's route type check:
1421
-
1422
- ```
1423
- .next/types/validator.ts: Type '(options?: BlogRoutingOptions) => Promise<...>'
1424
- is not assignable to type '(props: { params: { slug: string } }) => any[] | Promise<any[]>'.
1425
- ```
1426
-
1427
- Webpack builds failed the older `.next/types/app/**/page.ts` guard as well.
1428
- At runtime Next's `{ params }` argument was also being read as routing options.
1429
- It was harmless because no keys overlap, but it only worked by luck. Found when
1430
- spade-nextjs went from 4.2.2 to 5.6.0.
1431
-
1432
- - `generateBlogStaticParams` now has a second overload that accepts Next's
1433
- props (`NextStaticParamsProps`, exported from `blog/server`), and any
1434
- argument with a `params` key is ignored. Direct exports type-check under both
1435
- of Next's checks and use the default routes.
1436
- - Routing options still work the same way from a wrapper:
1437
- `export function generateStaticParams() { return generateBlogStaticParams(routing) }`.
1438
- - `generateCategoryStaticParams` and `generateAuthorStaticParams` take no
1439
- arguments, so they were never affected. A compile-time test now covers all
1440
- three against both of Next's checks.
1441
-
1442
- **If your site patched around this:** a wrapper like
1443
- `export function generateStaticParams() { return generateBlogStaticParams() }`
1444
- keeps working. You can switch back to the one-line export, but you don't have to.
1445
-
1446
- ### robots.txt never blocks `/_next/`
1447
-
1448
- `createRobots`, `buildAiCrawlerRules` and `createRobotsTxtHandler` now drop
1449
- any `/_next` disallow path (`/_next`, `/_next/`, `/_next/*`,
1450
- `/_next/static`, `/_next/image`) and log a warning for each one. Googlebot
1451
- renders pages with the CSS, JS and optimized images served from `/_next/`,
1452
- and `/_next/image` is how a site's photos reach Google Images. A 2026-09
1453
- fleet sweep found ten sites disallowing it, two of them through these
1454
- helpers. None of the helpers ever added it by default. `isNextInternalsPath`
1455
- is exported from `@sonordev/site-kit/robots` for sites that build robots.txt
1456
- by hand.
1457
-
1458
- ### `resolveManagedRedirect()` is deprecated: it made every page dynamic
1459
-
1460
- Calling `resolveManagedRedirect()` from `app/not-found.tsx` opts every route
1461
- into dynamic rendering. Next renders the root not-found boundary inside every
1462
- page, and the resolver reads `headers()`. On nkylawfirm.com every static route
1463
- turned dynamic, and reverting only `not-found.jsx` put them back. The 404
1464
- boundary can't be made static-safe: skipping the lookup at build time leaves
1465
- the 404 page prerendered static, so the redirects would never run.
1466
-
1467
- - The recommended setup is `createProxy({ redirects: true })` again (it was
1468
- always the default). The rule list is cached in memory for five minutes, so
1469
- page loads rarely wait on it. The proxy docs no longer tell sites to prefer
1470
- `redirects: false`.
1471
- - `sonor-setup scaffold` now writes `createProxy({ redirects: true })` and a
1472
- plain `not-found.tsx` that reads no request APIs.
1473
- - `resolveManagedRedirect()` still works, so existing sites keep building, but
1474
- it's marked `@deprecated` and logs a one-time warning.
1475
- - The agent manifest has a new failure mode, `static.dynamic-not-found`, and a
1476
- caution on `redirects/not-found`.
1477
-
1478
- **If your site calls it:** delete the call from `not-found.tsx`, set
1479
- `redirects: true` in `proxy.ts`, and rebuild. The route table should show ○/●
1480
- again instead of ƒ.
1481
-
1482
- ### Blog metadata no longer hides a route's OG card
1483
-
1484
- `generateBlogPostMetadata`, `generateBlogIndexMetadata`,
1485
- `generateBlogCategoryMetadata` and `generateAuthorPageMetadata` returned
1486
- `images: undefined` on `openGraph` and `twitter` when there was no image. Next
1487
- reads the bare key as "this route has no images", which hid the route's own
1488
- `opengraph-image` card. The key is now left out, the same way
1489
- `getManagedMetadata` already did it. Found on nkylawfirm.com, where `/insights`
1490
- served no `og:image` despite having a card file.
1491
-
1492
- ### `sonor-setup og` leaves code-based cards alone
1493
-
1494
- The generator wrote a static `opengraph-image.jpg` into every page folder,
1495
- including ones that already render their own card with
1496
- `opengraph-image.(tsx|jsx|ts|js)`, such as a per-city or per-article card. That
1497
- left two `opengraph-image` files in one folder. Those folders are now skipped
1498
- and reported (`og.code-card`), and a static card an earlier run left there is
1499
- removed.
1500
-
1501
- ### `sonor-setup` reads `.jsx` sites and proxy-based discovery correctly
1502
-
1503
- The CLI gave false failures on heinrich-law-nextjs, whose app files are `.jsx`,
1504
- and on nkylawfirm.com, which sends its llms discovery header from `proxy.ts`.
1505
-
1506
- - `doctor` reported "no app/layout.tsx found" when the root layout was
1507
- `app/layout.jsx`. Every lookup of the root layout, pages, route handlers and
1508
- proxy/middleware now accepts `.tsx`, `.ts`, `.jsx` and `.js`, and checks
1509
- `app/` before `src/app/` the way Next does. That covers `doctor`'s layout,
1510
- sitemap and proxy checks, `init`'s layout injection, `og`, and route
1511
- discovery.
1512
- - The `og.card` check now counts code-generated cards (`opengraph-image.tsx`
1513
- and friends) as page cards.
1514
- - `geo` failed the discovery-header check for sites using
1515
- `createProxy({ llmsDiscovery })`. It now reads proxy/middleware (root and
1516
- `src/`), next.config, the root layout and host config, and treats
1517
- `llmsDiscovery: false` as off.
1518
- - `geo --ensure-routes` no longer writes `route.ts` beside an existing
1519
- `route.js`, which Next refuses to build.
1520
- - `migrateSitemap` wrote `sitemap.ts` into `src/app/` when a project had both
1521
- `app/` and `src/app/`. Next reads `app/`, so the file was ignored. It now
1522
- writes to `app/`.
1523
- - Route auto-discovery skipped `page.ts` routes, in the CLI and in
1524
- `registerLocalSitemap({ autoDiscover: true })`. It finds them now.
1525
-
1526
- ## 5.6.0
1527
-
1528
- ### Local builds stay out of production analytics
1529
-
1530
- Browser pages on `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`, and
1531
- `0.0.0.0` no longer send analytics, fleet heartbeats, or client-side sitemap
1532
- registrations. Engage stays unmounted there, including its chat transport and
1533
- impression/click tracking. This covers `next start`, Lighthouse, and headless
1534
- verification, regardless of `NODE_ENV` or a production `analytics.site` /
1535
- `NEXT_PUBLIC_SITE_URL` setting. The gate reads the actual browser hostname.
1536
-
1537
- Explicitly enable local reporting with
1538
- `<SiteKitLayout analytics={{ allowLocalhost: true }}>`. Standalone
1539
- AnalyticsProvider, WebVitals, FleetHeartbeat, SitemapSync, EngageWidget and
1540
- sendFleetHeartbeat accept the same option. Local cross-origin frames need both
1541
- `allowLocalhost` and `allowInFrame`. These options also flow from the layout to
1542
- fleet, Engage and SitemapSync, which now share the analytics send gate.
1543
-
1544
- Build-time sitemap sync and Node fleet sends are unchanged. This client release
1545
- doesn't protect sites still on older kit versions; see
1546
- [the read-only audit and server follow-up](docs/localhost-analytics-audit.md).
1547
- No analytics rows were deleted.
1548
-
1549
- ### CLI project config and Sonor state paths
1550
-
1551
- `sonor-setup migrate`, `setup`, `faqs`, and `locations` now share one config
1552
- resolver and work with only the `SONOR_API_KEY` written by `init`. Commands
1553
- that need a full project UUID resolve it through the API; saved IDs and the
1554
- key's eight-character prefix are never used as the full UUID. API URL
1555
- overrides apply to both project resolution and subsequent requests.
1556
-
1557
- CLI state writes now use `.sonor/templates`, `.sonor-images.json`, and
1558
- `~/.sonor/credentials.json`; `.sonor/config.json` is the preferred config
1559
- path. Legacy state remains a read-only fallback, including image refresh.
1560
- The auth client is renamed to `src/cli/api/sonor.ts`, and the environment
1561
- variable regression test now covers the CLI too.
1562
-
1563
- ### One calendar entry per booked meeting
1564
-
1565
- When the host has Google Calendar connected, Google invites the guest to the
1566
- host's event. BookingWidget's success screen still offered Google, Outlook and
1567
- iCal "Add to your calendar" buttons, and the Google and Outlook ones build a
1568
- separate personal event with no tie to that invitation. A guest who clicked
1569
- one had two entries for the same meeting.
1570
-
1571
- `BookingResult` has a new `invitation` field, `'google' | 'sonor'`, that says
1572
- who sends the guest's invite. When it's `'google'`, the success screen drops
1573
- the buttons and says who the invite is coming from: "Your calendar invite is
1574
- on its way from Jordan Lee." When it's `'sonor'`, or when an older API doesn't
1575
- send it, the buttons stay. The API keeps sending `calendarLinks` for every
1576
- booking, so widgets on older versions work as before. A custom success screen
1577
- built on `createBooking` should check `invitation` before it renders
1578
- `calendarLinks`.
1579
-
1580
- ### Booking errors explain the next step
1581
-
1582
- Sync's format, availability and reservation requests now share one error
1583
- reader that understands both Sonor's nested error response and older flat
1584
- responses. An address outside the travel radius shows the server's guidance
1585
- to meet virtually or at the office. Invalid or missing responses use a
1586
- visitor-facing fallback.
1587
-
1588
- ## 5.5.0 — 2026-09-10
1589
-
1590
- ### The chat launcher can be moved without `!important`
1591
-
1592
- The Echo launcher's placement is an inline style: fixed, 20px from the side,
1593
- `calc(20px + env(safe-area-inset-bottom))` from the bottom, z-index 9999.
1594
- `EngageConfig` offered a corner and nothing else, and an inline style beats any
1595
- stylesheet, so a site that needed the launcher somewhere else had one tool: an
1596
- `!important` rule aimed at the kit's markup. Three sites wrote one.
1597
-
1598
- gunninghomes.com is the one that cost something. Its mobile pages carry a
1599
- full-width conversion bar, and at 390x844 the launcher occupied x 310-370,
1600
- y 764-824 while the bar's call to action spanned x 76-374. The bubble covered
1601
- the right 60px of the primary button on every phone, and at z-index 9999
1602
- against the bar's 40 it always won.
1603
-
1604
- `offsetBottom` sets the launcher's distance from the bottom edge:
1605
-
1606
- ```tsx
1607
- <SiteKitLayout engage={{ offsetBottom: '88px' }}>…</SiteKitLayout>
1608
- ```
1609
-
1610
- Any CSS length works, and a number is pixels. The safe-area inset is still
1611
- added on top, so pass the clearance you want, not the inset.
1612
-
1613
- When the offset depends on the page or the breakpoint, set
1614
- `--sk-echo-offset-bottom` from a stylesheet instead; it wins over the option.
1615
- The launcher is portalled to `<body>`, so a declaration on `body` reaches it:
1616
-
1617
- ```css
1618
- @media (max-width: 1023.98px) {
1619
- body:has(.sticky-cta) {
1620
- --sk-echo-offset-bottom: 5.5rem;
1621
- }
1622
- }
1623
- ```
1624
-
1625
- That is now gunninghomes.com's entire override. It declares a value the kit
1626
- reads instead of beating the kit's inline style, and it no longer names the
1627
- kit's markup. The kit never declares the property itself, which is
1628
- load-bearing: an unset property falls through to `offsetBottom`, then to 20px.
1629
-
1630
- ### The chat popup opens above the launcher, wherever the launcher is
1631
-
1632
- The popup had its own hardcoded `bottom: calc(90px + inset)`. An override that
1633
- lifted only the launcher `<button>` left the popup behind, and the launcher
1634
- then sat on top of the popup's input row and send button. Both elements are
1635
- now placed from one clearance (`engage/launcher-placement.ts`). The popup
1636
- opens 70px above the launcher's bottom edge (its 60px height plus a 10px gap),
1637
- and its max height gives up the same clearance plus 100px, so it keeps 30px
1638
- clear of the top edge.
1639
-
1640
- With nothing set, the geometry is what it was: launcher at 20px, popup at
1641
- 90px, max height `100dvh - 120px`. One deliberate difference: the safe-area
1642
- inset now also comes off the popup's max height. Before, on a phone with a
1643
- home indicator (34px) and a short viewport, a full-height popup ran 4px past
1644
- the top of the screen.
1645
-
1646
- ### `VisualViewportGap` moves into the kit
1647
-
1648
- queencityriverboats.com and destinyyachtcharters.com each shipped a
1649
- byte-identical `VisualViewportGap.tsx`. On a phone the layout viewport can be
1650
- taller than what the visitor sees, `position: fixed` is measured against the
1651
- layout viewport, and so their launcher sat below the fold. The component
1652
- measures the difference and publishes it on `<html>` as `--sk-vv-layout-gap`.
1653
-
1654
- It is now exported from `@sonordev/site-kit/client` as `VisualViewportGap`
1655
- (and `useVisualViewportGap`, for an existing client component), and the
1656
- launcher and popup include `var(--sk-vv-layout-gap, 0px)` in their placement.
1657
- Mounting it is all a site needs. It also writes the property only when the
1658
- value changes: a custom property on `<html>` restyles the whole document, and
1659
- `visualViewport` fires continuously during a pinch or a toolbar animation.
1660
-
1661
- It stays opt-in. While a field has focus the gap grows to the keyboard's
1662
- height, which lifts everything that reads it above the keyboard. That suits
1663
- those two sites; it is not a default to impose on the fleet. Without the
1664
- tracker the property is unset, resolves to 0px, and nothing moves.
1665
-
1666
- The `./client` export stays in place, including for the QCR and Destiny
1667
- migrations already prepared for 5.5.0. Its integration bundle baseline is
1668
- deliberately refreshed for this shared viewport tracker. The gate measures
1669
- the barrel's entire static import graph before consumer tree-shaking, so
1670
- it counts the tracker even when a site only imports `useDeferredActivation`.
1671
- The package declares JS side-effect-free, allowing consumer bundlers to
1672
- remove the unused tracker. The 10% growth limit remains unchanged.
1673
-
1674
- ### One placement type
1675
-
1676
- `position` was declared separately on the layout's `EngageConfig`, engage's
1677
- `EngageConfig`, `EngageWidget`'s props and `ChatConfig`. All four now extend
1678
- `ChatLauncherPlacement` (exported from `@sonordev/site-kit/engage`), which is
1679
- where `offsetBottom` lives, so the next placement option is added once.
1680
-
1681
- ### `zIndex` reaches the chat
1682
-
1683
- `engage={{ zIndex }}` stacked popups, nudges and bars, but the chat never got
1684
- it. ChatWidget hardcoded 9999 on the launcher and 9998 on the popup, and
1685
- EngageWidget didn't pass the value on, so
1686
- `<SiteKitLayout engage={{ zIndex: 50 }}>` left the chat above everything a
1687
- site drew. EngageWidget now passes it through: the launcher sits on `zIndex`
1688
- and the popup one layer beneath it, the same relationship as before. Unset,
1689
- both render exactly as they did.
1690
-
1691
- `zIndex` joined `position` and `offsetBottom` in `ChatLauncherPlacement`, so
1692
- its three separate declarations (both `EngageConfig`s and `EngageWidget`'s
1693
- props) are now one, and `ChatConfig` accepts it for a `ChatWidget` mounted on
1694
- its own. At `zIndex` 0 or below the popup shares the launcher's layer rather
1695
- than dropping to -1, which would paint it behind the page.
1696
-
1697
- ### `SiteKitConfig` is deprecated
1698
-
1699
- The root export `SiteKitConfig` was the props shape of `SiteKitProvider`. 4.0
1700
- removed the provider, and nothing in the kit reads the type now. Its
1701
- `engage` field is a fifth copy of launcher placement (`position` and `zIndex`,
1702
- no `offsetBottom`), and it stopped matching the day the other four became
1703
- `ChatLauncherPlacement`.
1704
-
1705
- No fleet site imports it, so it's marked `@deprecated` rather than reworked,
1706
- and it goes in 6.0 (removing an exported type is breaking). Code that still
1707
- uses it should switch to `SiteKitLayout`'s config types, `EngageConfig` and
1708
- `AnalyticsConfig` from `@sonordev/site-kit/layout`.
1709
-
1710
- ### `ManagedImage`'s picker toggle is `?sonor_dev=true`
1711
-
1712
- The image picker could be forced open on any host with `?uptrade_dev=true`,
1713
- the last Uptrade name on a runtime path. It's `?sonor_dev=true` now, read as a
1714
- real query parameter instead of a substring match. Nothing in the workspace
1715
- generated the old parameter, so there's no alias: a bookmarked `uptrade_dev`
1716
- link just stops opening the picker. `localhost`, `NODE_ENV=development` and
1717
- `forceDevMode` behave as before.
1718
-
1719
- ### `sonor-setup migrate` stops writing `SONOR_PROJECT_ID` into sites
1720
-
1721
- The migrator's templates put `projectId={process.env.SONOR_PROJECT_ID!}` on
1722
- every `ManagedSchema`, `ManagedFAQ` and `getManagedMetadata` call they added.
1723
- The prop has been ignored since the project started coming from the key, and
1724
- sites configure exactly one env var. Generated code now passes only `path`.
1725
- Sites migrated earlier keep working; delete the stray prop whenever the file is
1726
- next touched.
1727
-
1728
- ### `ssr.render` stops blaming the layout
1729
-
1730
- Since 3.0.2 `SiteKitLayout` renders `{children}` first and mounts analytics,
1731
- engage, sitemap sync and the heartbeat after them as deferred, childless
1732
- siblings, and 4.0 made that structural. The CLI never caught up. When a route
1733
- bailed to client rendering, `verify` and `doctor` told the site to run
1734
- `SiteKitLayout analytics={false}` and hand-roll a deferred analytics sibling,
1735
- which was the workaround from before 3.0.2. On a current install that changes
1736
- nothing: the plain layout isn't the cause, so the real one survives the fix.
1737
-
1738
- Measured on the integration fixture under Next 16.3: a plain `<SiteKitLayout>`
1739
- prerenders every route static with its content in the HTML, and none of the 9
1740
- initial scripts carries analytics code. A site's own
1741
- `next/dynamic({ ssr: false })` provider around `{children}` bails the route to
1742
- 0 content tags, and the old fix pointed at the layout anyway.
1743
-
1744
- - The `ssr.render` fix now says to keep the layout, find the component around
1745
- `{children}` that skips server rendering, and import it statically or mount
1746
- it childless. `analytics={false}` survives only as a stopgap below 3.0.2. The
1747
- text lives in `src/cli/agent/ssr-bailout.ts`, and a test pins the agent
1748
- manifest's `ssr.bailout` failure mode to it.
1749
- - The `analytics-sibling` codemod no longer flags a plain
1750
- `<SiteKitLayout>{children}</SiteKitLayout>` (the manifest's own blessed
1751
- layout) as a manual follow-up. It still flags `<AnalyticsProvider>` wrapped
1752
- around `{children}`.
1753
- - AGENTS.md, the site-kit skill, the manifest's `analytics.deferred-sibling`
1754
- pattern and the analytics README describe the plain layout. The README's
1755
- standalone example no longer wraps `{children}` or mounts a second
1756
- `WebVitals`, and custom events use `trackEvent` / `trackConversion`, because
1757
- `useAnalytics()` throws outside a provider.
1758
- - The integration fixture runs a plain `SiteKitLayout`, so the prepublish SSR
1759
- gate covers the layout sites are told to use.
1760
-
1761
- Nothing breaks for a site that still carries `analytics={false}` plus its own
1762
- deferred sibling (two fleet sites do). It can delete both the next time its
1763
- layout is touched.
1764
-
1765
- ### Upgrading
1766
-
1767
- Nothing moves until a site opts in. A site that overrides the launcher by hand
1768
- should drop the override in the same deploy that picks up this release, and
1769
- not before: 5.4.x ignores `--sk-echo-offset-bottom` and exports no
1770
- `VisualViewportGap`, so an early migration either puts the launcher back where
1771
- it was or fails the build.
1772
-
1773
- - A `button[data-sk-echo-launcher] { bottom: … !important }` rule becomes
1774
- `offsetBottom`, or a `--sk-echo-offset-bottom` declaration if the rule was
1775
- scoped to a page or a breakpoint.
1776
- - A local `VisualViewportGap` becomes the kit's. Keep any
1777
- `var(--sk-vv-layout-gap)` in the site's own CSS; the property name is
1778
- unchanged.
1779
-
1780
- ### `optionalPagePaths` moves pages to `## Optional` instead of listing them twice
1781
-
1782
- This was written up as 5.4.1 and never released on its own; it ships here.
1783
-
1784
- llmstxt.org's `## Optional` is the section a short-context parser may skip, so
1785
- it can spend its budget on the pages that matter. `optionalPagePaths` is how a
1786
- site puts pages there, but the generator only ever **appended** the section.
1787
- `## Site Pages` was still built from the full page list, so every demoted page
1788
- was listed twice. The page a parser was told it could skip was still in the part
1789
- it reads, and it still used up one of the index's `maxPages` slots.
1790
-
1791
- It was measured on live sites, not just read in the code. homesinkentucky.com
1792
- (art-realty-nextjs) lists three of its four demoted pages (`/privacy`,
1793
- `/accessibility`, `/fair-housing`) in both sections. nkylawfirm.com
1794
- (heinrich-law-nextjs) lists `/accessibility` in both, and its `## Site Pages` is
1795
- full at exactly 50 entries, so the duplicate pushes a real page out of the
1796
- index. gunning-homes-nextjs tried `optionalPagePaths:
1797
- ['/privacy']` on 5.4.0, got the privacy policy twice, and turned the option back
1798
- off. A comment in its llms.txt route explains why.
1799
-
1800
- Matching pages now **move**. They're filtered out of `## Site Pages` before
1801
- `maxPages` is applied, so demoting a page frees its slot for the next one rather
1802
- than using one up. Matching ignores leading and trailing slashes (`/privacy`,
1803
- `privacy` and `/privacy/` are one page, and get one line), and one normaliser
1804
- serves both sections, so the index and Optional can't disagree about which page
1805
- a path means.
1806
-
1807
- A moved page also keeps the line it had in the index: the same URL, the same
1808
- note. Optional used to rebuild the URL from the path with a forced trailing
1809
- slash. On a site with Next's default `trailingSlash: false`, that's a 308:
1810
- `https://nkylawfirm.com/accessibility/` redirects to the URL `## Site Pages` had
1811
- already given. Both sections now render a page through one `formatPageLine`, so
1812
- moving a page doesn't change where it links. A path with no matching page is
1813
- still listed under Optional, unchanged.
1814
-
1815
- `## Optional` still needs a resolvable base URL. Without one it's skipped, as
1816
- before, and the demoted pages now stay in `## Site Pages` rather than dropping
1817
- out of the file.
1818
-
1819
- ### Upgrading for `optionalPagePaths`
1820
-
1821
- Sites on `^5.3.0` or later pick this up on a plain reinstall. Sites that already
1822
- set the option (art-realty-nextjs, heinrich-law-nextjs) lose their duplicate
1823
- listings, and their Optional links will match the index URLs. gunning-homes-nextjs
1824
- can turn `optionalPagePaths` on now.
1825
-
1826
- ## 5.4.0 — 2026-09-09
1827
-
1828
- ### Meeting formats and recoverable booking
1829
-
1830
- BookingWidget supports virtual meetings, office visits, and visits to the guest's
1831
- location when the project's meeting settings enable them. It validates the
1832
- location before loading availability and carries the prepared meeting through
1833
- the time reservation and booking request. Pending requests show their actual
1834
- status and don't offer confirmation-only calendar links.
1835
-
1836
- Guests can change formats or return to the service selector without losing their
1837
- address, access notes, or contact details. Navigation is locked during reservation
1838
- and booking requests. Stale responses can't reopen old steps, and abandoned holds
1839
- are released. Expired reservations offer recovery with the guest's details intact.
1840
- A valid hold remains usable after its preparation token expires, until the hold's
1841
- own deadline.
1842
-
1843
- ### Article artwork and complete cards
1844
-
1845
- `BlogPost` exposes optional `editorial_image` and `editorial_image_alt` fields.
1846
- The stock article component and generated article schemas use editorial artwork
1847
- when available. Explicit empty alt text remains empty for decorative artwork.
1848
- Older API responses fall back to the featured image.
1849
-
1850
- Homepage/list, related, sidebar, and author cards keep the complete featured
1851
- image. Sonor's composed cards render without cropping or image hover zoom;
1852
- editor-selected photographs retain their existing layout. OG, Twitter, and feed
1853
- share images remain on the featured card. Supplied editorial schemas aren't
1854
- rewritten. Custom layouts can use `resolveBlogArtwork(post, 'article' | 'card')`.
1855
-
1856
- ### Accessible article tables
1857
-
1858
- The stock article wraps tables in named, keyboard-focusable scroll regions with
1859
- native touch scrolling and themed scrollbars. It preserves captions, table
1860
- semantics, and the author's HTML, leaves code examples alone, and doesn't force
1861
- small tables to scroll. Custom layouts can use `wrapBlogTables` and `blogTableCss`.
1862
-
1863
- ### One publication routing contract
1864
-
1865
- `createBlogRoutes` supplies the publication root and article, category, cluster,
1866
- author, and feed paths. Use `basePath`, `includeCategoryInPath`, or custom
1867
- `postPath`/`categoryPath` callbacks. Existing `blogBasePath` metadata configuration
1868
- continues to work. Stock components accept a shared `routing` prop; metadata,
1869
- schema, RSS, Atom, sitemap, and static-parameter helpers accept the same options.
1870
-
1871
- Generated SEO URLs and feeds honor supplied canonical URLs. Sitemap generation
1872
- reads full, paginated post records so canonical URLs and category segments agree
1873
- with the article, without the feed's 100-post cap. Sitemap category and cluster
1874
- entries can be disabled when those routes aren't implemented. `/blog` remains
1875
- the default, and existing cluster navigation behavior is retained when no new
1876
- routing configuration is supplied.
1877
-
1878
- ### Upgrading
1879
-
1880
- Deploy the compatible Sonor booking API and migrations before enabling meeting
1881
- formats. Upgrade and deploy consuming sites before enabling the format setting;
1882
- older widgets can't send a prepared meeting token. Projects without meeting
1883
- formats retain their existing booking flow.
1884
-
1885
- The publishing fixes need no new schema or generated artwork. The atmospheric
1886
- Forge header remains an Upforge design choice; package styles use the shared
1887
- `--sk-*` theme tokens.
1888
-
1889
- ## 5.3.2 — 2026-09-09
1890
-
1891
- ### Embedded previews no longer report analytics against the site they embed
1892
-
1893
- A site loaded in a **cross-origin iframe** now sends nothing: no page views,
1894
- journey/session rows, scroll depth, heatmap clicks, web vitals, events or
1895
- conversions. The visitor is on whoever framed the page, not on this site, so
1896
- every metric the frame produced was phantom traffic in the analytics its owner
1897
- reads — and, for an agency, reports to the client.
1898
-
1899
- This was measured, not theorised. Upforge case studies embed each client's
1900
- whole production site in three eager iframes (`DeviceTrifolio`), which
1901
- `DEFAULT_FRAME_ANCESTORS` has permitted since it was introduced. The
1902
- screenshot overlay in those frames hides the **pixels**, not the
1903
- **JavaScript**: the embedded site still loads, hydrates and runs its
1904
- analytics. In `analytics_page_views` on 2026-09-08 the signature was
1905
- unmistakable — three views of `/` sharing one session_id and one visitor_id,
1906
- 21-80ms apart, referrer `https://upforge.io/`:
1907
-
1908
- ```
1909
- abbeyglenapts.com 20:02:13.852 / .900 / .921
1910
- goldenmilenky.com 04:12:23.840 / .868 / 24.624
1911
- queencityriverboats 22:12:33.609 / .690 / .784
1912
- ```
1913
-
1914
- 23 client sites carried this traffic, the oldest row from 2026-06-11. Three
1915
- loads of one page inside 70 milliseconds is not a person; it is the desktop,
1916
- tablet and mobile frames of one case study.
1917
-
1918
- **Same-origin frames still report.** The block is on cross-origin embedding,
1919
- not on being framed at all. A site embedding itself (a preview pane, a print
1920
- view, an on-domain booking frame) has a real visitor really on that site and
1921
- no other tenant to pollute.
1922
-
1923
- **Opt back in when the frame IS the product** — a widget, a partner-hosted
1924
- booking or menu page, anything deliberately distributed as an embed:
1925
-
1926
- ```tsx
1927
- <SiteKitLayout analytics={{ allowInFrame: true }}>…</SiteKitLayout>
1928
- ```
1929
-
1930
- `SitemapSync` is gated by the same frame fact, for a different reason. Its
1931
- rows would not be mis-attributed (inside the frame the document still resolves
1932
- its own host and its own sitemap), but a **write** triggered by a third
1933
- party's page view runs a fetch + DOMParser on a visitor's main thread in a
1934
- page that gets nothing from it, and it bumps `seo_pages.updated_at` — the
1935
- tiebreaker in `pickSeoPageRow`'s "most recent" fallback. On a multi-site
1936
- project that is how a shared path like `/` starts resolving to a different
1937
- host's row. A third party's traffic should not be able to move which row wins.
1938
-
1939
- ### One send gate instead of eight hand-rolled copies
1940
-
1941
- `analytics/send-gate.ts` is now the single source of truth for "may this
1942
- document report analytics, and where to". Every phone-home in the module
1943
- resolves credentials through `resolveAnalyticsTarget` and sends through
1944
- `analyticsSend` / `analyticsBeacon`; the eight separate copies of that
1945
- resolution it replaced are exactly how a rule gets fixed in one place and left
1946
- broken in seven. `send-gate.test.ts` fails the build if a new direct
1947
- `sonorFetch` / `sonorBeacon` call appears in the analytics module.
1948
-
1949
- The frame **fact** lives apart from the analytics **policy**:
1950
- `shared/frame.ts` owns how a cross-origin frame is detected (and why
1951
- `window.top` rather than `window.parent`), `send-gate.ts` owns what analytics
1952
- does about it.
1953
-
1954
- New exports from `@sonordev/site-kit/analytics` — `isCrossOriginFrame`,
1955
- `isFramed`, `isTopFrameSameOrigin` — so a site can branch on the same answer
1956
- analytics uses (skipping its own third-party pixels in an embed, say) rather
1957
- than hand-rolling a second `window.top` check that drifts from this one.
1958
-
1959
- ### `BlogPost` exposes the publication index artwork
1960
-
1961
- `index_image` and `index_image_alt`, the dedicated index frame from Sonor's
1962
- atmosphere + content-stage renderer. Both optional; a post without one falls
1963
- back to `featured_image` as before.
1964
-
1965
- ### Upgrading
1966
-
1967
- Sites on `^5.3.0` pick this up on a plain reinstall. **Check any site you
1968
- deliberately distribute as an embed** before deploying: it needs
1969
- `analytics={{ allowInFrame: true }}` or it will go quiet. Sites that are only
1970
- ever framed by Upforge case studies want exactly the new default.
1971
-
1972
- ## 5.3.1 — 2026-09-08
1973
-
1974
- ### `frame-ancestors` reaches upforgelabs.com, which is its own apex
1975
-
1976
- Every managed site's default framing policy was `'self' https://upforge.io
1977
- https://*.upforge.io`. upforgelabs.com is a **separate apex domain**, not a
1978
- subdomain of upforge.io, so `https://*.upforge.io` never matched it and never
1979
- could. Labs case studies embed the live client site in device frames, so every
1980
- one of those frames was refused with `ERR_BLOCKED_BY_RESPONSE`.
1981
-
1982
- The failure is quiet in the way these always are: the page still renders, with
1983
- an empty rectangle where the client's site should be. It surfaced on
1984
- upforgelabs.com/work/abbey-glen as a Lighthouse **Best Practices 92 instead of
1985
- 100** — three blocked frames failing both `errors-in-console` and
1986
- `inspector-issues` — rather than as the missing centrepiece of the page.
1987
-
1988
- `DEFAULT_FRAME_ANCESTORS` now carries `https://upforgelabs.com` and
1989
- `https://*.upforgelabs.com`. `X-Frame-Options` is still not emitted alongside
1990
- it: XFO has no allowlist form, and a stray `DENY` re-blocks what
1991
- `frame-ancestors` just allowed.
1992
-
1993
- upforgeapps.com is deliberately **not** added. It is a third Upforge apex, but
1994
- it only links to case studies on upforge.io/work and frames no client site —
1995
- an origin earns a place on this list by embedding, not by belonging to Upforge.
1996
-
1997
- **This ships to a site only when that site upgrades and redeploys.** Sites on
1998
- `^5.3.0` pick it up on a plain reinstall; sites pinned to 4.x or 5.0.0 need an
1999
- explicit bump. Until then they keep the old policy and keep blocking Labs.
2000
-
2001
- ### A dark form field stops handing you near-black text
2002
-
2003
- `--sk-input-text` fell back straight to `#111827`, so a site that themed
2004
- `--sk-input-bg` dark and `--sk-text-primary` light — never having heard of a
2005
- field-specific token — got near-black text on a dark field anyway. Measured at
2006
- **1.03:1** on upforgelabs.com's contact form: not a contrast score to nudge, a
2007
- field you cannot read your own answer in.
2008
-
2009
- The fallback chain is now `--sk-input-text` → `--sk-text-primary` → `#111827`,
2010
- so naming the field colour still wins and theming the page's text is enough on
2011
- its own. Placeholders follow `--sk-text-tertiary` at `opacity: 1`, since the
2012
- UA's own placeholder alpha compounds the same problem. Neither token is
2013
- declared in `:root`, which is load-bearing rather than an omission — a token
2014
- with a value can never reach the second argument of its own `var()` fallback.
2015
-
2016
-
2017
- ## 5.3.0 — 2026-09-02
2018
-
2019
- ### Motion: the Upforge motion standard ships in the kit
2020
-
2021
- Seventeen of thirty-six sites in the fleet carry GSAP, fourteen of them with
2022
- their own `ScrollReveal` implementation, and every one of those copies is a
2023
- place the same bug has to be fixed. This release replaces them with one
2024
- module, tiered by what each library actually costs, so a site only installs
2025
- and ships what it imports.
2026
-
2027
- **`@sonordev/site-kit/motion` (tier 0, zero dependencies, ~2KB).** What every
2028
- site gets:
2029
-
2030
- - `<Reveal>` — scroll-entrance reveal on a CSS transition. The server HTML is
2031
- fully visible, above-the-fold content is never touched, and a headless
2032
- renderer that never scrolls (Google's included) gets the content back
2033
- after 2.5s, so the indexed snapshot is never transparent.
2034
- - `<Parallax>` / `useParallax` — scroll-linked transform and opacity,
2035
- compositor-only.
2036
- - `<ScrollScene>` / `useScrollScene` — a pinned scene: sticky stage, progress
2037
- 0→1 written to a CSS custom property (`--sk-p`) so choreography can be pure
2038
- CSS, plus an `onProgress` callback for anything that needs code.
2039
- - `registerScene` — the engine itself: one shared requestAnimationFrame for
2040
- every scene on the page, native scroll only, offscreen scenes not rendered,
2041
- `prefers-reduced-motion` held at a still frame. Extracted from
2042
- upforgelabs.com.
2043
-
2044
- **`@sonordev/site-kit/motion/gsap` (tier 1, optional peer `gsap`).**
2045
- `useGsap` runs a setup inside `gsap.context` when its element nears the
2046
- viewport, so the ~46KB of core + ScrollTrigger never sits on the LCP path;
2047
- `loadGsap` and `useExpandCollapse` come along. ScrollSmoother is deliberately
2048
- not included: it drives scrolling by transforming the page body, which fights
2049
- native scroll and costs INP on mobile.
2050
-
2051
- **`@sonordev/site-kit/motion/three` (tier 2, optional peer `three`).**
2052
- `useThreeStage` mounts a renderer on a pinned scene and drives it from
2053
- scroll, owning the canvas, sizing, DPR, context loss, and disposal.
2054
- `canRunWebGL()` keeps the server-rendered still for reduced-motion,
2055
- Save-Data, and no-WebGL visitors. `loadTexture` never throws.
2056
-
2057
- The tiers are enforced, not suggested: a test fails the build if anything
2058
- outside `src/motion/gsap.ts` imports gsap or anything outside
2059
- `src/motion/three.ts` imports three, because bundlers resolve even a dynamic
2060
- `import()` at build time and a stray import would force every consumer of
2061
- `./motion` to install a library it never asked for.
2062
-
2063
- Nothing here is mounted by `SiteKitLayout`. Motion is opt-in per element and
2064
- never wraps the page. Full API in `src/motion/README.md`.
2065
-
2066
- ## 5.2.0 — 2026-08-31
2067
-
2068
- ### Lead conversions can now be reported by Sonor instead of the browser
2069
-
2070
- If your site fires a Google Ads or GA4 conversion when a form is submitted, it
2071
- has been counting spam as leads. That is not a bug in your site — it is
2072
- unavoidable from the browser. Sonor answers every submission with a byte
2073
- identical success payload whether it accepted the lead or quarantined it as
2074
- spam, deliberately, so a bot never learns it was caught. Your page therefore
2075
- cannot tell the two apart, and Smart Bidding learns to buy more of whatever
2076
- traffic produced the spam.
2077
-
2078
- The accept or reject verdict only exists on the server, so that is where the
2079
- conversion has to be reported from. This release supplies the one piece the
2080
- server was missing.
2081
-
2082
- **Every form submission now carries the visitor's existing GA4 identity** —
2083
- `gaClientId` from the `_ga` cookie, and `gaSessions` (a map of container id to
2084
- session id) from the `_ga_<CONTAINER>` cookies. Nothing new is written or
2085
- tracked; these are cookies Google's own tag already set, and they are read, not
2086
- created. The session map is keyed by property so a page carrying two GA4
2087
- properties cannot attach a lead to the wrong one.
2088
-
2089
- **`ManagedFormConfig` gained `server_side_lead_conversion`.** When true, Sonor
2090
- is reporting this project's lead conversions and your site must NOT fire its
2091
- own on submit, or every real lead is counted twice:
2092
-
2093
- ```tsx
2094
- const { form } = useForm(slug, {
2095
- onSuccess: () => {
2096
- if (form?.server_side_lead_conversion) return // Sonor reports it
2097
- gtag('event', 'conversion', { send_to: '...' })
2098
- },
2099
- })
2100
- ```
2101
-
2102
- **Nothing changes until a project turns it on.** The flag is false for every
2103
- project that has not configured server-side reporting in Sonor, so existing
2104
- sites behave exactly as they do today. Turn it on under Forms, Settings, Lead
2105
- Conversion Reporting.
2106
-
2107
- ## 5.1.0 — 2026-08-27
2108
-
2109
- ### Server commerce helpers can finally tell "empty" from "broken"
2110
-
2111
- Every server-side commerce helper returned `[]` (or `null`) when the fetch
2112
- failed, identically to a genuinely empty result. That let calling pages state a
2113
- business fact they did not know.
2114
-
2115
- It bit a real customer: QCR's /public-cruises rendered that `[]` as **"No public
2116
- cruises currently on the schedule"** while three cruises were on sale, and the
2117
- client emailed asking why the site said they had no events. Because the route was
2118
- statically revalidated, Next then cached the claim.
2119
-
2120
- **New `*Result` helpers** return `{ ok, data }`, where `ok: false` means the
2121
- request failed and `ok: true` with empty data means genuinely empty:
2122
-
2123
- - `getUpcomingEventsResult` — use this wherever the UI renders "no upcoming events"
2124
- - `getOfferingsResult` — use this wherever it renders "nothing available"
2125
- - `getOfferingBySlugResult` — separates "no such offering" from "unreachable", so
2126
- a transient failure no longer `notFound()`s a page that exists and de-indexes it
2127
- - `getNextEventResult`
2128
- - `ServerResult<T>` is exported for typing your own wrappers
2129
-
2130
- **Nothing breaks.** `getUpcomingEvents`, `getOfferings`, `getOfferingBySlug` and
2131
- `getNextEvent` keep their exact signatures and behaviour, delegating to the new
2132
- helpers. Existing sites need no change; adopt the `*Result` variants where the
2133
- distinction matters.
2134
-
2135
- ### Framework signals are no longer swallowed as fetch failures
2136
-
2137
- `apiFetch`/`apiPost` caught everything, including Next's own control-flow throws.
2138
- A `DYNAMIC_SERVER_USAGE` digest means "this route can't be prerendered", and
2139
- `redirect()`/`notFound()` throw too — swallowing those logged a fake fetch error
2140
- on every build and made a real outage look identical to the framework working
2141
- correctly. These now rethrow.
2142
-
2143
- ### Failed static-path generation is no longer silent
2144
-
2145
- `getProductPaths` / `getEventPaths` / `getOfferingPaths` returning `[]` on a
2146
- failed fetch prerenders ZERO pages while the build still reports success — a site
2147
- ships with its whole catalogue missing and nothing says so. They now log an
2148
- explicit error. (Return shape unchanged.)
2149
-
2150
- ### Known gap
2151
-
2152
- These helpers still issue a plain `fetch` with no cache control, so a site that
2153
- needs `next: { revalidate }` (to keep a route static rather than flipping to
2154
- per-request SSR) must still wrap them. That is why QCR keeps its own fetcher.
2155
-
2156
- ## 5.0.0 — 2026-08-26
2157
-
2158
- The blog module got a full contract audit against the deployed Sonor API and
2159
- the live database schema. Seventeen confirmed defects, most of them silent —
2160
- clean 200s hiding wrong or empty results. Everything below works against
2161
- today's api.sonor.io and improves further when the paired API deploy lands.
2162
-
2163
- ### The headline: several blog features have never worked
2164
-
2165
- **Related posts always returned nothing.** Two independent bugs: the fetch
2166
- called a path that does not exist, and once that was fixed, the body sent
2167
- `currentPostId` where the API reads `current_post_id`. Both fixed. If your
2168
- site mounts `RelatedPosts` or calls `getRelatedInsights`, a populated
2169
- related-posts section appears for the first time with no change on your
2170
- side — budget for the layout. (The packaged `BlogPost` has its own internal
2171
- related fetch and is unaffected.) `getRelatedInsights`' `category` option is
2172
- still accepted and still ignored; the server derives relatedness from the
2173
- current post.
2174
-
2175
- **RSS and Atom feeds were capped at 12 posts.** The feed fetch sent `limit`,
2176
- a parameter the API never read, so every feed silently got the default page.
2177
- Feeds now paginate properly and include up to 100 posts — so a feed that has
2178
- been serving 12 items grows on the next build, and readers may see older
2179
- posts arrive as "new". A mid-pagination failure now returns the posts
2180
- gathered so far instead of an empty feed. `getPostsByCategory` goes from 12
2181
- to 50 for the same reason.
2182
-
2183
- **"X min read" never rendered.** The components gated on
2184
- `reading_time_minutes`; the API returns `reading_time`. One shared helper
2185
- (`readingTimeMinutes`) now feeds every render site.
2186
-
2187
- **Tag pages were empty for any multi-word tag.** The sidebar linked by
2188
- slugified slug ("case-study") while the API filters by exact stored name
2189
- ("Case Study"). Links now carry the encoded raw name, and the paired API
2190
- deploy also accepts slugs — so old shipped links heal too. Note the URL
2191
- shape changed: sidebar tag links are now `?tag=Case%20Study`, not
2192
- `?tag=case-study`. A site that reads the `tag` search param itself, or that
2193
- has canonicalised the old shape, should expect the new value.
2194
-
2195
- **Signal-generated E-E-A-T JSON-LD was discarded.** The schema resolver now
2196
- reads `schema ?? schema_json`, so stored structured data reaches pages
2197
- instead of falling back to the generic generated Article.
2198
-
2199
- **Author social links now render** from the real `blog_authors` columns
2200
- (linkedin_url, twitter_url, website_url) via one shared helper, in the post
2201
- byline, `AuthorCard`, and `AuthorPage`. The byline normalizer stopped
2202
- dropping those columns on the floor.
2203
-
2204
- ### New
2205
-
2206
- **`BlogViewTracker`** — `BlogPost` now mounts a childless client island that
2207
- counts actual readers: one POST to `/public/blog/view` per post per browser
2208
- session (deduped through a `__sonor_blog_viewed__:<slug>` sessionStorage
2209
- key), deferred to idle, no retries (the increment is not idempotent). Under
2210
- ISR the old server-side count incremented when the *cache revalidated*, so
2211
- `view_count` was measuring cache churn, not people.
2212
-
2213
- This is new outbound traffic from every blog post page, including the
2214
- custom `children` render-prop path, which previously shipped no client
2215
- islands at all. It requires `SiteKitLayout` (it reads the layout's globals
2216
- and sends the minted token) and silently no-ops without it.
2217
-
2218
- The endpoint it calls is already live, so counting starts the moment you
2219
- upgrade — but the *old* server-side increment keeps running until the
2220
- paired api.sonor.io deploy removes it, so a site on 5.0.0 against the
2221
- un-deployed API counts both readers and revalidations for that window.
2222
- Upgrade near the deploy, or expect an inflated stretch. Historical counts
2223
- are left as-is either way: treat pre-cutover numbers as a different metric,
2224
- not a comparable series.
2225
-
2226
- **`NewsletterWidget` actually subscribes people.** The old widget rendered a
2227
- form whose submit handler discarded the email. It now takes either an
2228
- `onSubmit` callback or a `formSlug` pointing at a managed Sonor form —
2229
- formSlug mode drives the managed-forms rail, inheriting newsletter routing,
2230
- honeypot, reCAPTCHA, and attribution. The forms engine is lazy-loaded so
2231
- blog pages that never mount it pay nothing. With neither prop the widget
2232
- renders nothing and warns: a dead form that swallows emails must not come
2233
- back.
2234
-
2235
- **Safe imports for async server components.** `BlogLayout`, `BlogPage`,
2236
- `BlogPostPage`, `CategoryPage`, `BlogSidebar`, and `RelatedPosts` are
2237
- exported from `@sonordev/site-kit/blog/server-ui`. Importing them from
2238
- `@sonordev/site-kit/blog` (a client-stamped entry) produces an HTTP 500 in
2239
- production — that path remains only for backwards compatibility, and a
2240
- ratcheted guard test now pins the offender list so it can only shrink.
2241
-
2242
- ### Also fixed
2243
-
2244
- - Category metadata builds its og:url from a slug — "Case Studies" produced
2245
- `/blog/category/case%20studies` against a real route of
2246
- `/blog/category/case-studies`. Takes an explicit `categorySlug`, respects
2247
- `blogBasePath`.
2248
- - `BlogList` accepts `cluster` to filter by topic-cluster slug; the `author`
2249
- filter is documented as slug-preferred (the paired API deploy resolves
2250
- slugs).
2251
- - `getAuthorPosts` gained an `offset` parameter and hydrates the full author
2252
- row when an older API returns the stripped `{name, slug}` shape.
2253
- - Topic cluster mapping carries `created_at`, and the detail path prefers
2254
- the authoritative `pillar_post_id` over the embedded pillar's id.
2255
- - OG metadata and JSON-LD stopped reading fields that do not exist
2256
- (`og_image`, image width/height columns): image resolution goes straight
2257
- to `featured_image`, and schema images are plain URL strings instead of
2258
- ImageObjects with fabricated dimensions.
2259
- - `sonor-setup sync --blog` stopped POSTing to an endpoint that has never
2260
- existed (every run 404'd). It now inventories local markdown and says
2261
- plainly that the Sonor blog is dashboard-managed.
2262
-
2263
- ### Breaking
2264
-
2265
- The type surface stopped promising fields the wire never carries. **No
2266
- runtime change** — every removed field was already `undefined` at runtime —
2267
- but reads of them are now compile errors, which is the point.
2268
-
2269
- - `BlogTag` is `{ name, slug, post_count? }`. Tags are synthesized from
2270
- post tag strings; no server version can ever supply `id`/`project_id`.
2271
- - `BlogCategory.id` and `.project_id` are optional (real columns, stripped
2272
- from the public response).
2273
- - `BlogPost` loses `og_image`, `featured_image_width`,
2274
- `featured_image_height`, `scheduled_at`, `is_featured`. The real columns
2275
- — `featured` and `scheduled_for` — are typed.
2276
- - `BlogAuthor` loses `email` (the API now strips it server-side too).
2277
- - The `BlogAnalytics` interface is gone; `BlogViewTracker` supersedes it.
2278
- - **`<NewsletterWidget />` with no props now renders nothing** (and warns)
2279
- where 4.x rendered a visible subscribe form. That form discarded every
2280
- email it collected, so this is deliberate — but it is a visible sidebar
2281
- block disappearing, with no compile error to warn you. Pass `onSubmit` or
2282
- `formSlug` to keep it. Sites that already passed `onSubmit` get the
2283
- opposite surprise in their favour: 4.x never invoked it (the prop was
2284
- declared but never destructured), and 5.0.0 does.
2285
-
2286
- **Behaviour change worth reading twice.** On blogs that render the packaged
2287
- `BlogPost` with cluster posts on a *flat* URL structure (no category
2288
- segment), the cluster navigation's pillar link changes from
2289
- `/blog/<slug>` (accidentally correct) to the category-segmented form,
2290
- matching how sibling links already behaved. An explicit URL-shape control
2291
- on `ClusterNavigation` is planned; until then flat-URL blogs with clusters
2292
- should hold on 4.x or pass their own cluster nav.
2293
-
2294
- ### Paired API deploy
2295
-
2296
- The api.sonor.io deploy that pairs with this release fixes the other half
2297
- of several contracts: the author embed no longer shadows the byline column
2298
- (bylines return fleet-wide with zero site rebuilds), `schema_json` is
2299
- mapped to `schema`, internal scoring fields and author emails stop shipping
2300
- publicly, `limit` works as a `per_page` alias, tag and author filters
2301
- accept slugs, category counts stop including drafts, and
2302
- `GET posts/:slug` no longer increments `view_count`. Deploy order is free:
2303
- every site-kit change degrades gracefully against the old API and vice
2304
- versa.
2305
-
2306
- ## 4.3.2 — 2026-08-25
2307
-
2308
- ### A site now describes itself, not its neighbours
2309
-
2310
- One Sonor project can host many domains. `seo_pages` is unique on
2311
- `(project_id, site, url)`, so a shared path like `/` or `/terms` legitimately
2312
- keeps one row per host. The build path never said which host it was, which left
2313
- both halves of that guessing.
2314
-
2315
- **The build-time sitemap sync is tagged with the host.** `createSitemap` now
2316
- sends `site` to `register-sitemap`, the same way the runtime `SitemapSync` and
2317
- the reconciler cron already did. Before this, pages a build discovered landed
2318
- unattributed, so nothing could tell one sibling's rows from another's.
2319
-
2320
- **Every llms.txt read is scoped to the building host.** `?site=` goes out on
2321
- `/api/public/llms/{data,services,pages,txt}`. On a 49-domain network this is the
2322
- difference between a homepage entry that describes the site and one that
2323
- describes whichever sibling deployed most recently. It also stops a host
2324
- advertising paths it does not serve: the hub was publishing `/terms` and
2325
- `/privacy` borrowed from its microsites, both of which answered 404.
2326
-
2327
- **Behaviour change worth reading twice.** A `full-replace` sitemap sync that
2328
- carries a `site` prunes that host's stale rows. A sync without one is the legacy
2329
- project-wide mode, and on a multi-site project the API refuses deletions
2330
- outright. So on projects that span several hosts, build-time pruning becomes
2331
- active where it was previously skipped. That is the correct behaviour and it is
2332
- what makes per-host page sets converge, but it is new, and it is why a build
2333
- whose `additionalPaths()` silently returns short now costs rows rather than
2334
- being absorbed. Single-site projects are unaffected: their scope was already
2335
- everything.
2336
-
2337
- **Host resolution has one source and a defined order.** `resolveSiteHost` moved
2338
- into `sites/resolve`, shared by the browser (which publishes
2339
- `__SITE_KIT_SITE__`) and the build. Precedence is `site` option, then `baseUrl`,
2340
- then `NEXT_PUBLIC_SITE_URL`, then the resolved base URL. A per-call `baseUrl`
2341
- outranks the environment variable deliberately: it is set by this repo for this
2342
- build, while `NEXT_PUBLIC_SITE_URL` is process-wide and can be stale or point at
2343
- a sibling. Because `site` is part of page identity, getting that order wrong
2344
- does not fail loudly, it mints a duplicate page set.
2345
-
2346
- Also on Next 16.3.2. The peer range is unchanged at `^15.0.0 || ^16.0.0`; this
2347
- is the version site-kit itself builds and tests against.
2348
-
2349
- Requires the matching Sonor API release. Older API builds ignore `site` and keep
2350
- working.
2351
-
2352
- ## 4.2.4 — 2026-08-15
2353
-
2354
- ### The generated cards now look like someone made them
2355
-
2356
- 4.2.3 shipped per-page cards that all *fit*. Then someone asked whether they
2357
- were actually any good, and looking at all nineteen instead of the two I had
2358
- checked found four problems the fit report could never catch — it measures
2359
- geometry, not whether copy reads well.
2360
-
2361
- **The home page kept its hand-written card.** Deriving every route from its
2362
- managed title meant `/` lost the crafted headline ("Built by brothers") for the
2363
- SEO string ("Custom Closets Cincinnati Tri-State") — four lines that restated
2364
- the kicker directly above them. `og.config.ts` `content` IS the home page's
2365
- card: it is the one route whose subject is the whole site, and it is written by
2366
- hand. It is no longer overwritten.
2367
-
2368
- **Redundant kickers are dropped.** Managed titles carry the section and the
2369
- city, so a derived kicker frequently repeated the title back at itself. When
2370
- either contains the other, or every kicker word already appears in the title,
2371
- the title wins and the kicker goes — which also returns a line of the frame to
2372
- the headline.
2373
-
2374
- **Subtitles refuse rather than truncate.** A managed description is 150+
2375
- characters of prose written for a SERP row, and at card size no truncation of it
2376
- looks deliberate. A word cut gave "across Cincinnati and…"; cutting at the last
2377
- comma gave "home offices designed, built", which is worse because without an
2378
- ellipsis it looks complete and merely ungrammatical. `clampSubtitle` now takes a
2379
- whole sentence when one fits and otherwise returns nothing, so the card falls
2380
- back to the site's own short subtitle. A clean generic line beats a mangled
2381
- specific one, and the title is already page-specific.
2382
-
2383
- **The bar is checked on both axes.** A long segment wrapped INSIDE its span, so
2384
- `scrollWidth` never exceeded `clientWidth` while the text grew past the bar's
2385
- fixed height and spilled over the copy above it — and the renderer passed it.
2386
- Segments are `white-space: nowrap` now, which turns that into horizontal
2387
- overflow the existing check sees, plus a height check as belt-and-braces. Found
2388
- by rendering a real campaign card with a deadline in the bar.
2389
-
2390
- None of these were visible from the fit report, which is the lesson: the
2391
- renderer can prove a card fits, and only a person can say whether it reads.
2392
-
2393
- ## 4.2.3 — 2026-08-15
2394
-
2395
- ### OG cards: one per page, and cards that verify themselves
2396
-
2397
- Written after being the factory's first real consumer. Two of the three problems
2398
- below only surfaced because a human opened the PNG.
2399
-
2400
- **Every page gets its own card.** `sonor-setup og` still writes the site card to
2401
- `public/og.png`, and now also renders one card per route through the same
2402
- headless-Chrome path. Copy comes from Sonor's managed title and description, so a
2403
- card and its search result say the same thing; without a key the route path is
2404
- titled. Theme, fonts, logo and photo are inherited from `og.config.ts` so the set
2405
- reads as one family, and `cards: { '/path': {…} }` hand-writes the few that
2406
- deserve it. Cards are written as Next's `opengraph-image` file convention beside
2407
- each `page.tsx`, so a site needs no per-page metadata. `--no-pages` opts out.
2408
-
2409
- **Two things about Next metadata that are the opposite of the intuition.** Both
2410
- verified against real builds, both wrong in the first implementation:
2411
-
2412
- 1. *Config metadata beats the file convention.* A route returning
2413
- `openGraph.images` overrides its own card file. With images declared, all 32
2414
- routes on a real site served the site card and every generated page card was
2415
- inert; removing the declaration made each serve its own. This matters most
2416
- for `seo_pages.managed_og_image`, which site-kit serves into
2417
- `openGraph.images` for every managed page — set it and it silently suppresses
2418
- the entire set from the dashboard.
2419
- 2. *File metadata does not cascade.* A card at `services/` is not inherited by
2420
- `services/[city]`; those pages shipped with no `og:image` at all until dynamic
2421
- segments got their own. One static file in a dynamic segment covers every
2422
- param.
2423
-
2424
- **The wiring rule now has one implementation.** It had two — the CLI's
2425
- post-render check and the doctor's `og.card` check — and both said the same wrong
2426
- thing: "add `openGraph.images: ['/og.png']` to the root layout". Since per-page
2427
- cards landed, that advice breaks the site. Both now call `og/wiring.ts`, which
2428
- knows which mode a site is in and, in per-page mode, treats a declared images
2429
- array as the defect. The old CLI check read only the root layout and reported
2430
- "wiring looks right" while 16 routes had no image at all.
2431
-
2432
- **Cards fail loudly instead of silently.** The first card generated in anger had
2433
- the kicker off-canvas, the subtitle buried under the bottom bar and the bar
2434
- wrapped into the crop; the CLI printed a tick. The renderer has a live DOM, so it
2435
- measures before screenshotting: an in-page fitter waits for
2436
- `document.fonts.ready`, steps the title down from 104px until it fits **both**
2437
- axes — height alone is not enough, since an unbreakable word like "WORKBENCHES"
2438
- overflows sideways at a size that fits vertically — and reports anything still
2439
- clipped. `--screenshot` and `--dump-dom` run in one Chrome invocation, so the
2440
- report always describes the image that was actually written. Copy that cannot fit
2441
- fails the command with the element and the overflow in pixels.
2442
-
2443
- 104px is an opening size now rather than a fixed one, with a 56px legibility
2444
- floor: below that a headline stops reading at the ~300px thumbnail width
2445
- platforms actually show, so the answer is shorter copy, not smaller type.
2446
-
2447
- **Smaller fixes**
2448
-
2449
- - `logo` was silently dropped in `split` layout (that branch renders copy+photo
2450
- and never touches the plate). It now renders as a mark above the kicker; the
2451
- "no `.plate` in split" invariant still holds.
2452
- - Page cards are re-encoded to JPEG when `sharp` resolves: 5.6 MB → 1.4 MB across
2453
- 18 cards, at no visible cost on a photo-plus-flat-colour card.
2454
- - Subtitles clamp to a real sentence where one fits, and no longer produce `….`
2455
- by appending an ellipsis after existing punctuation.
2456
- - The legibility preview moved out of `public/` (where it deployed with the site)
2457
- to `.sonor/`, and the Facebook debugger link is pre-filled with the site's
2458
- domain.
2459
- - New `src/og/README.md` documents the precedence rule, the copy budget, and the
2460
- tier-2 escape hatch.
2461
-
2462
- **Upgrading:** running `sonor-setup og` now writes `opengraph-image` files into
2463
- your app directory and, if it finds them, asks you to REMOVE `openGraph.images`
2464
- from the root layout and clear `managed_og_image` in Sonor. That is the correct
2465
- direction — it is what makes per-page cards take effect — but it is a change of
2466
- advice from every previous version, so read the wiring output rather than
2467
- skimming it. `--no-pages` keeps the old single-card behaviour.
2468
-
2469
- ## 4.2.2 — 2026-08-14
2470
-
2471
- > 4.2.1 was tagged but never published, so upgrading from 4.2.0 also picks up
2472
- > its `sitemapSync` default flip — see the note at the end of this entry.
2473
-
2474
- ### Every managed form logged two "form started" rows per page load
2475
-
2476
- One page load wrote **two** `form_analytics` rows, milliseconds apart, each with
2477
- its own `session_id`. `form_analytics` is the start signal behind funnel
2478
- reporting, so every reported start → submission conversion rate was **half its
2479
- true value** — a page converting at 10% reported 5%.
2480
-
2481
- The cause was two trackers on one form. `ManagedForm` called `useForm`, which
2482
- tracks, *and* rendered `FormClient`, which tracks again. Two independent
2483
- `useFormTracking` instances, two session uuids, two `POST /analytics/start`.
2484
- Both `ServerForm`/`FormEnhancer` and a plain client `<ManagedForm>` end up in
2485
- the same place, which is why every rendering path doubled.
2486
-
2487
- It was not an effect firing twice. The proof is in the shape of the data: paired
2488
- rows where exactly one ever completed (only `FormClient`'s instance owns submit,
2489
- so `useForm`'s row could never be completed) but **both** abandoned. One hook has
2490
- one analytics row id and one `beforeunload` listener — it cannot abandon two
2491
- rows. Only two independent instances can. So every successful submission also
2492
- wrote a phantom abandonment.
2493
-
2494
- The fix is one owner per form. `ManagedForm` now passes `trackAnalytics: false`
2495
- to `useForm` — it delegates rendering, step navigation and submission to
2496
- `FormClient`, so `FormClient` owns the funnel. `useForm` and `FormClient` remain
2497
- fully tracked when used on their own; only the composition changed.
2498
-
2499
- Underneath that, `forms/tracking-session.ts` is now the single source of truth:
2500
- one start per `formId` per page load, with every mounted tracker joining one
2501
- session and **sharing** its analytics row id. Sharing rather than silencing the
2502
- second tracker is deliberate — whichever component owns submit can still
2503
- complete the row no matter which one opened it, so the guard does not depend on
2504
- which instance mounts first. A short grace period on unmount also absorbs React
2505
- StrictMode's dev-mode remount and the `FormEnhancer` static → interactive swap,
2506
- both of which are one page load and must stay one row.
2507
-
2508
- Two smaller correctness fixes came with it:
2509
-
2510
- - Abandonment is now recorded once per row rather than once per tracker, and a
2511
- completed form is never also reported as abandoned.
2512
- - `trackStepChange` / `trackComplete` now wait on an in-flight start instead of
2513
- silently dropping. A fast submit used to race the start POST and lose the
2514
- completion, understating conversions the same way double-starting overstated
2515
- them.
2516
-
2517
- `sonor-api` gained a matching server-side guard (a partial unique index on
2518
- `form_analytics`, keyed on the request rather than the client's `sessionId`),
2519
- since sites pin their own site-kit version and the fleet updates slowly.
2520
-
2521
- **Historical data:** rows written before this fix are affected but **not
2522
- uniformly** — 96% of page loads doubled in 2026-01, 40% in 2026-07, across up to
2523
- 29 projects, because only the `ManagedForm` path double-tracked. Do not apply a
2524
- blanket 50% correction. To recount a period honestly, collapse rows sharing
2525
- `(form_id, date_trunc('second', started_at))` rather than scaling totals.
2526
-
2527
- ### Also included: `sitemapSync` now defaults to false (from the unpublished 4.2.1)
2528
-
2529
- Sitemap registration is a build-time and server-side job — build-time
2530
- `createSitemap` is canonical, and sonor-api's nightly reconciler fetches each
2531
- host's `/sitemap.xml` itself. But `sitemapSync` defaulted to `true` and both its
2532
- guards live in browser storage (throttle in `sessionStorage`, content hash in
2533
- `localStorage`), so every first-time visitor, incognito tab and crawler missed
2534
- both and re-POSTed the entire sitemap. Sitemap traffic scaled with visitor count.
2535
- `SiteKitLayout` now defaults it off; sites that genuinely want runtime sync can
2536
- still pass `sitemapSync`.
2537
-
2538
- ## 4.2.0 — 2026-08-14
2539
-
2540
- ### `landing/server`: the landing module's own documented pattern builds again
2541
-
2542
- `@sonordev/site-kit/landing` documents this:
2543
-
2544
- ```tsx
2545
- export const metadata = landingPageMetadata({ ... })
2546
- ```
2547
-
2548
- It did not build. Next forbids exporting `metadata` from a client module, and
2549
- `landingPageMetadata` was shipping from a chunk stamped `'use client'` — so
2550
- every campaign route following the README failed, and the workaround was to
2551
- hand-write the noindex robots block and skip the helper.
2552
-
2553
- Neither helper needed the client. `landing/metadata.ts` and
2554
- `landing/contract.ts` have no hooks, no directive and no browser globals. The
2555
- stamp did NOT come from `CLIENT_ENTRIES` (`landing/index` is not in it): the
2556
- build also stamps any shared chunk that *contains* React hooks, `<LandingPage>`
2557
- has them, and the two pure functions were bundled alongside it. The same
2558
- mechanism put `DatePicker`'s hooks on the `FormField` chunk in 4.0.2.
2559
-
2560
- ```tsx
2561
- import { LandingPage } from '@sonordev/site-kit/landing'
2562
- import { landingPageMetadata } from '@sonordev/site-kit/landing/server'
2563
- ```
2564
-
2565
- Splitting the entry also gives the helpers their own hook-free chunk, so the
2566
- original barrel import works again too. That is a consequence of chunk
2567
- splitting and could silently re-merge, so the dedicated entry is the guarantee
2568
- and `landing/server-entry.test.ts` pins it at source level.
2569
-
2570
- This is the third instance of one root cause — a server-usable export made
2571
- unusable by sharing a chunk with hook-bearing code. `forms/static` (4.0.2) and
2572
- the `FIELD_CONTROLS` injection (4.0.2) were the first two. A build-time guard
2573
- that fails when a hook-free module lands in a stamped chunk would catch the
2574
- fourth before a consumer's build does.
2575
-
2576
- ## 4.1.0 — 2026-08-14
2577
-
2578
- ### The CMS module builds on Next 16 again
2579
-
2580
- Every site importing `@sonordev/site-kit/cms` has been failing `next build` at
2581
- page-data collection with:
2582
-
2583
- ```
2584
- Error: dynamic usage of require is not supported
2585
- ```
2586
-
2587
- `cms/server-api.ts` reached for React's `cache` with
2588
- `const { cache } = require('react')`. tsup compiles a bare `require` in ESM
2589
- output to its `__require` interop shim, and Turbopack refuses to evaluate that.
2590
- Five other modules — `seo/api.ts`, `seo/server-api.ts`, `llms/api.ts`,
2591
- `slots/server-api.ts`, `seo/LocationPageContent.tsx` — already did the same
2592
- thing correctly with a static `import { cache } from 'react'`. This one file
2593
- had drifted, and it took the whole CMS module down with it.
2594
-
2595
- This was **not** a 4.0.3 regression — published 4.0.2 fails identically. It has
2596
- been broken for as long as sites have been on Next 16. A test now scans all
2597
- shipped source (everything outside `src/cli`, which is CJS by design) for bare
2598
- `require()` calls, so the next drift fails here instead of at a customer build.
2599
-
2600
- ### React 19 is now the floor
2601
-
2602
- `peerDependencies` asked for `react: ^18.0.0 || ^19.0.0`. That was never true.
2603
- Six modules import React's `cache` statically — `cms/server-api.ts`,
2604
- `seo/api.ts`, `seo/server-api.ts`, `llms/api.ts`, `slots/server-api.ts` and
2605
- `seo/LocationPageContent.tsx` — and **React 18 does not export `cache` at all**
2606
- (confirmed `undefined` in both 18.2.0 and 18.3.1). A site on React 18 could not
2607
- have used site-kit's SEO, CMS, LLMs or slots modules regardless of what the
2608
- range claimed.
2609
-
2610
- - `react` / `react-dom` peers are now `^19.0.0`.
2611
- - `next` peer drops `^14.0.0` (now `^15.0.0 || ^16.0.0`): Next 14 pins React
2612
- 18.2, so `next@14` + `react@19` was an unsatisfiable pair once React 18 was
2613
- gone. Every repo in the fleet is on Next 15 or 16 — none on 14.
2614
- - The `next` devDependency moves 16.3.0 → 16.3.1 to match current.
2615
-
2616
- This narrows a published range, so treat it as the breaking part of this
2617
- release. In practice the blast radius is zero: every fleet repo is already on
2618
- React 19, and the single React 18 project doesn't depend on site-kit.
2619
-
2620
- ### Sanity packages are optional peers now
2621
-
2622
- `@portabletext/react` and `@sanity/image-url` moved from `dependencies` to
2623
- optional `peerDependencies`. They serve only the CMS module — 8 packages and
2624
- ~1.7 MB that every site on the fleet was installing to render Sanity content
2625
- most of them never touch.
2626
-
2627
- **If you use `@sonordev/site-kit/cms`, add them:**
2628
-
2629
- ```bash
2630
- npm i @portabletext/react @sanity/image-url
2631
- ```
2632
-
2633
- **If you don't, there's nothing to do** — you simply stop installing them.
2634
- `@sonordev/site-kit/cms/server` is unaffected either way; it has no Sanity
2635
- imports and needs no peers.
2636
-
2637
- This ships as a minor rather than a major because the blast radius is narrow
2638
- and the failure is loud: only a site that explicitly imports `./cms` is
2639
- affected, and it gets a build-time `Module not found: Can't resolve
2640
- '@portabletext/react'` naming exactly what to install — not a silent runtime
2641
- break. Verified on real Next 16 builds in all three states: no-CMS site builds
2642
- and skips the packages, CMS site without peers fails with that message, CMS
2643
- site with peers builds and renders.
2644
-
2645
- The same treatment is **not** possible for `react-markdown` (85 packages,
2646
- ~8 MB), even though it is used by a single component. It is reachable from
2647
- `./engage`, which chat sites do import, and bundlers resolve even a *dynamic*
2648
- import at build time — so there is no runtime fallback to degrade into. What
2649
- makes the Sanity packages safe is subpath isolation: nothing outside
2650
- `src/cms/` imports them and no shared chunk carries them, exactly like
2651
- `@vis.gl/react-google-maps` for `./maps`. Both rules are asserted by tests.
2652
-
2653
- ## 4.0.3 — 2026-08-14
2654
-
2655
- ### Security: 4.0.3 is the version that clears the socket.io advisories
2656
-
2657
- **Bump the fleet to 4.0.3 to clear all four.** Sites on 4.0.2 and earlier
2658
- inherit them through the Engage chat's socket.io dependency:
2659
-
2660
- | Advisory | Package | Severity | Patched at |
2661
- |---|---|---|---|
2662
- | [GHSA-96hv-2xvq-fx4p](https://github.com/advisories/GHSA-96hv-2xvq-fx4p) — memory exhaustion DoS from tiny fragments | ws | High | 8.21.0 |
2663
- | [GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx) — uninitialized memory disclosure | ws | Moderate | 8.20.1 |
2664
- | [GHSA-2m8v-j782-fhvr](https://github.com/advisories/GHSA-2m8v-j782-fhvr) — zero-attachment memory exhaustion | socket.io-parser | High | 4.2.7 |
2665
- | [GHSA-677m-j7p3-52f9](https://github.com/advisories/GHSA-677m-j7p3-52f9) — unbounded binary attachments | socket.io-parser | High | 4.2.6 |
2666
-
2667
- The obvious fix doesn't exist: `socket.io-client@4.8.3` is already the latest
2668
- release, and its own ranges (`engine.io-client: ~6.6.1`,
2669
- `socket.io-parser: ~4.2.4`) are wide enough to resolve *either* the vulnerable
2670
- or the patched versions. A fresh install today happens to land on the patched
2671
- ones. That's the trap — it means the ranges look fine while the fleet isn't.
2672
-
2673
- What actually pins a site to the vulnerable tree is its **lockfile**. npm won't
2674
- touch a transitive dependency that still satisfies the existing range, so a
2675
- site can bump site-kit, see the version change, and keep shipping ws 8.18.3.
2676
- Verified on a stale lockfile: bumping with the ranges alone left
2677
- engine.io-client 6.6.4 / ws 8.18.3 / socket.io-parser 4.2.5 in place and three
2678
- advisories still open.
2679
-
2680
- So 4.0.3 raises the declared floor instead. `engine.io-client: ^6.6.6` and
2681
- `socket.io-parser: ^4.2.7` are now direct dependencies — not because anything
2682
- imports them, but because a *library* can't fix this any other way: npm and
2683
- pnpm only honour `overrides`/`resolutions` from the root project, so site-kit's
2684
- own overrides would never reach a consuming site. A declared floor does.
2685
- 6.6.6 is the exact floor that matters — 6.6.5 still pulls ws `~8.20.1`, which
2686
- is short of the High-severity DoS fix; only 6.6.6 depends on ws `~8.21.0`.
2687
-
2688
- - **Fleet sequencing:** bump to `@sonordev/site-kit@4.0.3` and run
2689
- `npm install` (or `pnpm install`) so the lockfile regenerates. `npm ci`
2690
- against an un-regenerated lockfile will fail rather than quietly reinstall
2691
- the vulnerable tree, which is the intended behaviour — the floors make the
2692
- stale lock impossible to satisfy instead of merely unlucky.
2693
- - No API change, no bundle change. socket.io is still lazy-loaded on chat open
2694
- by `engage/socket-loader`, so the resolved versions move but nothing new
2695
- enters the initial chunk.
2696
- - The Engage `ChatWidget` websocket path was verified end-to-end against the
2697
- patched stack: namespace handshake, `visitor:message`/`message` round trip,
2698
- binary attachments over the patched parser, and auto-reconnect after a
2699
- transport drop.
2700
- ### Repo hygiene that came out of the same investigation
2701
-
2702
- - **One lockfile.** The repo tracked both `package-lock.json` and
2703
- `pnpm-lock.yaml`. They had drifted six days apart, and the npm one was the
2704
- artifact still holding the vulnerable pins. `package-lock.json` is deleted
2705
- and gitignored, and `packageManager` now pins pnpm so the fork can't recur.
2706
- - **`pnpm audit` was never going to catch this.** No lockfile ships in the
2707
- published tarball, so a repo-local audit describes a tree no consumer ever
2708
- installs — and it buries the signal under devDependency noise. New
2709
- `pnpm audit:consumer` packs the real tarball, installs it into a throwaway
2710
- project, and audits the production tree only. It runs in `prepublishOnly`,
2711
- so a release that would hand the fleet an advisory now fails to publish.
2712
- - **`react-markdown` stays a dependency, deliberately.** It's 85 packages /
2713
- ~8 MB for one component, so making it an optional peer looks like free
2714
- savings. It isn't possible: bundlers resolve even a *dynamic* import at
2715
- build time, so a site that didn't install it fails with "Module not found"
2716
- before any runtime fallback can run — confirmed against Next 16 / Turbopack.
2717
- A test now records this so the experiment doesn't get repeated.
2718
-
2719
- ## 4.0.2 — 2026-08-13
2720
-
2721
- Managed forms are Server Components. The client runtime is now the optional
2722
- half.
2723
-
2724
- ### The form renders on the server, with zero JavaScript
2725
-
2726
- `ManagedForm` was a client component for reasons that stopped being true:
2727
- it fetched its own config (4.0's `getFormConfig` ended that) and it could
2728
- only submit over JSON (4.0.1's native endpoint ended that). Nobody moved the
2729
- boundary afterward, so every site that put a form on a page shipped the whole
2730
- forms runtime — validation, spotlight, celebration, momentum — as an INITIAL
2731
- script on the LCP-critical path.
2732
-
2733
- Measured on upforge.io's homepage: 48 KB of form code in the initial chunk set
2734
- cost **6 Lighthouse points** (88 → 82, LCP 3.8s → 4.7s). Deferring the client
2735
- component recovered the score but deleted the form from the HTML, taking the
2736
- no-JS floor and crawlable fields with it. Neither half was acceptable.
2737
-
2738
- ```tsx
2739
- import { ServerForm } from '@sonordev/site-kit/forms/server'
2740
-
2741
- export default function ContactPage() {
2742
- return <ServerForm formId="contact" returnTo="https://example.com/contact/" />
2743
- }
2744
- ```
2745
-
2746
- One line, in a Server Component. Fetches the config server-side, renders a
2747
- complete working `<form>` into the HTML, and upgrades it to the interactive
2748
- experience at idle. Client JS on the critical path drops from ~48 KB to
2749
- **4.8 KB** (the enhancer plus the shared idle gate) — a 90% cut — and the
2750
- Lighthouse regression is fully recovered at 88 with LCP back to 3.8s.
2751
-
2752
- The progression is now: no JS at all → native POST, works. JS but pre-idle →
2753
- native POST, works. Post-idle → JSON submit with the full experience. Chunk
2754
- fails to load → the shell stays and still submits. A form is never blank.
2755
-
2756
- - **`ServerForm`** (`forms/server`) — the recommended way to render a form.
2757
- - **`StaticForm`** (`forms/server`) — the zero-JS form alone, if you want to
2758
- compose the enhancement yourself. `enhance={false}` on ServerForm ships no
2759
- form JavaScript at all.
2760
- - **`FormEnhancer`** (`forms`) — the ~3 KB client boundary that swaps the
2761
- shell for the interactive form at idle.
2762
- - The client `ManagedForm` is unchanged and still exported. Use it when the
2763
- form must live inside an existing client component (a modal, a chat panel)
2764
- where a Server Component cannot go.
2765
-
2766
- ### Entrance animation for the idle upgrade
2767
-
2768
- An above-the-fold form upgrades while the visitor is looking at it, so the
2769
- swap must not pop. The mount wrapper carries `data-sk-form-mount`
2770
- (`shell` → `enhanced`) and the arriving form gets `data-sk-enter` for one
2771
- frame, with defaults driven by `--sk-form-enter-duration` / `-easing` /
2772
- `-distance`. `enter="none"` opts out; `enter="custom"` emits the hooks and no
2773
- styles so the site owns the animation entirely. All defaults collapse under
2774
- `prefers-reduced-motion`.
2775
-
2776
- ### One field renderer, two modes
2777
-
2778
- `FormField` lost its `'use client'` directive and now renders controlled
2779
- (client) or uncontrolled/`defaultValue` (server) from the same code, so the
2780
- shell and the enhancement cannot drift — a divergence would show up to a
2781
- visitor as a jump on swap. `field-parity` tests pin the structure.
2782
-
2783
- - The file-upload branch moved to `FileField.tsx`; it was the only thing in
2784
- the file that needed state.
2785
- - `DatePicker` and `FileField` are now **injected and fetched on demand**
2786
- (`useFieldControls`) rather than imported. Importing them put 17 hooks in
2787
- `FormField`'s chunk and the build stamped the whole thing `'use client'` —
2788
- a "server" field renderer that shipped 38 KB of date picker to render one
2789
- text input. Injection alone still left both in the forms entry's eager
2790
- graph, so every form paid for a date picker most forms have no field for;
2791
- the hook now loads each control only when the field list contains one.
2792
- Until the chunk lands — and in static mode, and if the fetch fails — the
2793
- browser's own `date` / `file` controls render, and they submit natively
2794
- anyway, so the wait is invisible and never costs a submission.
2795
- - `normalizeFormConfig` moved to `normalize-config.ts` so the server shell and
2796
- the client fetch share one normalizer.
2797
-
2798
- ### Fixed: a `url` field rendered no input at all
2799
-
2800
- `FieldType` had no `'url'` branch, so a field configured as a URL emitted its
2801
- label and **no control**. On upforge.io's free-audit form that silently
2802
- dropped the one required value the entire feature needs — the site to audit —
2803
- and it only surfaced by grepping the built HTML. Every field type is now
2804
- covered by a test asserting it emits a named, submittable control.
2805
-
2806
- ## 4.0.1 — 2026-08-13
2807
-
2808
- ### No-JS submissions: the floor under every managed form
2809
-
2810
- A single-step managed form now works with JavaScript disabled. When the
2811
- forms/config response carries a `native_token` (sonor-api mints it once the
2812
- no-JS train is deployed), the classic form — which is already the SSR/no-JS
2813
- surface under the spotlight and stage experiences — renders a real
2814
- `action`/`method` pointing at `POST /api/public/forms/submit-native`, plus a
2815
- hidden `_sk_token` control field. With JS running, nothing changes: the
2816
- existing `onSubmit` intercepts and the JSON path wins. Without JS, the
2817
- browser performs an ordinary form-encoded POST, sonor-api authenticates via
2818
- the signed token, runs the honeypot + render-timestamp + quarantine spam
2819
- stack, and 303s back to the page with `?submitted=1`.
2820
-
2821
- - New `nativeReturnTo` prop on `ManagedForm` — the absolute page URL the
2822
- native redirect should land on. Optional: without it the server falls back
2823
- to the Referer origin (right site, homepage instead of this page).
2824
- - Token-less configs (older API deployments) and multi-step forms render
2825
- exactly as before — no action attribute, so a browser is never pointed at
2826
- an endpoint that would reject it. Step navigation is JS; the native floor
2827
- is deliberately single-step, which is the overwhelming lead-capture case.
2828
- - `getApiConfig`'s server branch now honors `SONOR_API_URL`, matching what
2829
- `SiteKitLayout` feeds the client — so SSR-rendered absolute URLs point at
2830
- the same API the browser uses (staging sites included).
2831
-
2832
- ## 4.0.0 — 2026-08-13
2833
-
2834
- Major release. Absorbs the unpublished 3.9.0 (Next 16 alignment + the
2835
- redirect rail off the hot path) and adds the provider-island removal, the
2836
- OG card factory, site search, events polish, and hardening below.
2837
-
2838
- ### The provider island is gone (children are never wrapped)
2839
-
2840
- The prerender-bailout / force-static / remount-flash bug class traced to one
2841
- structural fact: client providers wrapped page children. Now children render
2842
- first and every module mounts as a keyed childless sibling. SiteKitProvider
2843
- (deprecated) and SiteKitIdentityProvider are removed; useSiteKitIdentity reads
2844
- the storage singletons; useSignal reads a buffering module store and — BREAKING
2845
- — no longer throws outside a bridge (bounded no-op stub instead).
2846
-
2847
- ### OG card factory
2848
-
2849
- `sonor-setup og` renders og.config.ts (defineOgCard — the SITE owns the theme;
2850
- Sonor brand is only the zero-config seed) through headless Chrome to a static
2851
- public/og.png + a 300px legibility preview, and verifies metadata wiring.
2852
- Doctor gains `og.card`. 24 of 66 fleet sites had no card.
2853
-
2854
- ### Per-entity runtime OG cards (tier 2)
2855
-
2856
- createOgImageRoute() in @sonordev/site-kit/og/route: unique cards per blog
2857
- post/event/product via next/og in a route handler — resolver returns the
2858
- card (title, page photo as absolute URL, theme) or null for a clean 404.
2859
- Satori constraints documented; fonts passed as ArrayBuffers.
2860
-
2861
- ### Fleet migration command
2862
-
2863
- sonor-setup next16 wraps the official middleware-to-proxy codemod and
2864
- verifies the result with the doctor check — fleet sweeps as a command
2865
- instead of bespoke bash.
2866
-
2867
- ### Parts contract on legacy commerce components
2868
-
2869
- CalendarView (nav, month title), EventTile, OfferingCard, and EventsWidget
2870
- cards now carry data-sk-part alongside data-category.
2871
-
2872
- ### Site search (new)
2873
-
2874
- @sonordev/site-kit/search: useSiteSearch + unstyled SiteSearch against the new
2875
- POST /api/public/search (pages + posts + offerings, ranked; multi-site aware).
2876
- Degrades to inert against older APIs.
2877
-
2878
- ### Events polish
2879
-
2880
- ICS + Google Calendar links (RFC 5545, all-day correct), EventJsonLd
2881
- (schema.org/Event with seat-count-honest availability), CapacityBadge
2882
- (spots_remaining is the only honest sold-out signal), and EventsAgenda — the
2883
- month-grouped list layout with range + all-day support and data-sk-part
2884
- theming hooks. Commerce surfaces expose data-category/data-offering-type.
2885
-
2886
- ### Security posture (verified during the 4.0 audit)
2887
-
2888
- Of the tracked key-leak bypass trio: the blog barrel is pinned shut by the
2889
- client-entry boundary test; Echo chat HTTP flows through sonorFetch's token
2890
- path; and the chat WebSocket handshake carries NO credential at all (project
2891
- + visitor + session ids only). The remaining bypass lives in
2892
- @sonordev/agency-site-kit and ships with that package's own release. No-JS
2893
- form submission is specced (docs/SITE-KIT-4.0-NOJS-FORMS.md) but gated on
2894
- sonor-api spam-defense work — a native POST cannot carry a reCAPTCHA token.
2895
-
2896
- ### Hardening
2897
-
2898
- - Honeypot is CSS-proof: inert + belt-and-suspenders inline hiding +
2899
- data-sk-honeypot (a site's own form CSS once un-hid it).
2900
- - SpeculationRules component / `speculation` prop on SiteKitLayout:
2901
- declarative prerender-on-intent for static sites, zero JS.
2902
- - Scaffold emits og.config.ts; postbuild gating recipe: `sonor-setup verify`
2903
- and `doctor` already exit non-zero on error-level checks — wire them into CI.
2904
-
2905
- ### Absorbed from the unpublished 3.9.0
2906
-
2907
- Next 16 alignment + the redirect rail moves off the hot path. Driven by fleet
2908
- research: both redirect tables have 0 rows platform-wide, yet 33 sites paid a
2909
- blocking rules fetch on every request (measured: ~130ms warm TTFB, 2.4s cold).
2910
-
2911
- ### Redirects: bounded, and resolvable at the 404 boundary
2912
-
2913
- - `fetchRedirectRules` now aborts at 300ms (`fetchTimeoutMs`, guarded
2914
- AbortSignal.timeout) and caches failures for 30s. Previously it failed open
2915
- on error but NOT on slow — a degraded Portal became every site's TTFB.
2916
- Build-time `generateNextRedirects` uses a 10s budget.
2917
- - NEW `resolveManagedRedirect()` in `@sonordev/site-kit/redirects/not-found`:
2918
- resolve managed redirects in `app/not-found.tsx` — the only place they can
2919
- matter — instead of on 100% of page loads. Dashboard edits still apply
2920
- instantly. The proxy always stamps `x-sk-path` (pathname + search) on the
2921
- request so the 404 boundary knows the URL; pure header write, no fetch.
2922
- - Proxy `redirects: true` still works (now bounded) but is documented as
2923
- legacy; the scaffold emits `redirects: false` + the not-found resolver.
2924
-
2925
- ### Next 16 correctness
2926
-
2927
- - Scaffold emits `proxy.ts` (not deprecated `middleware.ts`) with the matcher
2928
- INLINED — `export const config = siteKitMatcher` is a Turbopack build error
2929
- because matchers must be statically analyzable. All docs updated;
2930
- `siteKitMatcher` is now documented as a copy-source reference value.
2931
- - Doctor: recognizes `proxy.ts`/`src/proxy.ts` (previously reported "No
2932
- middleware file" on correctly-configured Next 16 sites), errors on any
2933
- `runtime` option in proxy files (Next 16 throws on it), warns on the
2934
- deprecated middleware convention with the official codemod command, and no
2935
- longer gates on netlify.toml (UI-deployed Netlify sites have none).
2936
-
2937
- ### Commerce
2938
-
2939
- - `getUpcomingEvents`/`getNextEvent` (server) now use the events endpoint and
2940
- return offerings WITH `schedules[]` and `category` embedded — the offerings
2941
- list endpoint never embedded schedules, so the server helper was useless for
2942
- calendars and sites hand-rolled fetchers. Falls back to the legacy query on
2943
- older APIs.
2944
- - Commerce surfaces expose `data-category` / `data-offering-type` attributes
2945
- (CalendarView/EventCalendar chips, EventTile, EventsWidget, OfferingCard) so
2946
- sites can theme categories with CSS attribute selectors.
2947
-
2948
- ### Forms
2949
-
2950
- - NEW `getFormConfig()` in `@sonordev/site-kit/forms/server` +
2951
- `ManagedForm initialConfig` prop: fetch the form config in a Server
2952
- Component and the fields render on the first client render — no more
2953
- "Loading form..." flash. Falls back to the client fetch on any failure, and
2954
- both paths share one normalizer so they cannot drift.
2955
- - **NEW: the delight layer.** Forms are the conversion surface — filling one
2956
- now feels like progress, not paperwork. All zero-config on every
2957
- `ManagedForm`, all `--sk-primary`-themed, all collapsed by
2958
- `prefers-reduced-motion`:
2959
- - `FormCelebration` success state: brand disc springs in, the check draws
2960
- itself, a particle burst fires, plus a soft haptic tick on mobile.
2961
- - Field lock-in: a valid field earns a drawn check + ripple ring and a
2962
- one-shot border flash. Focused fields lift with a brand aura.
2963
- - `FormMomentum`: a fields-completed meter (endowed progress) with live
2964
- sheen, advance flash, leading-edge spark, and an "N of M" label that
2965
- flips to READY and charges the bar when every requirement clears.
2966
- - The submit button wakes up (pop + breathing glow) the moment the form is
2967
- actually submittable, and pulses while submitting
2968
- (`data-sk-ready` / `data-sk-state` hooks for site-level styling).
2969
- - Every element carries `data-sk-part` hooks, and all chrome mixes
2970
- `--sk-primary` toward `currentColor`, so it stays visible even inside
2971
- sections painted with the brand color itself.
2972
- - **NEW `FormReveal` — entrance theater for form/CTA tiles.** The tile
2973
- animates open when scrolled into view and its contents cascade in; pass a
2974
- brand mark (`logoPath`) and the logo pops in and **morphs into the tile**
2975
- (MorphSVG, free since GSAP 3.13) before handing off to the cascade.
2976
- `animate` prop: `auto` (default — static when the tile starts inside the
2977
- viewport, so above-the-fold forms never pay an entrance), `off`, `reveal`,
2978
- `morph`. SSR markup is always fully visible (LCP-safe by construction);
2979
- reduced motion, missing IntersectionObserver, or a failed chunk all degrade
2980
- to static, with a safety un-hide timer behind the observer.
2981
- - **gsap is now a site-kit dependency** (`^3.13.0`), loaded ONLY via the
2982
- shared idle loader (`loadGsap` / `warmGsapAtIdle`): dynamically imported in
2983
- its own chunk, prefetched at browser idle, never in the critical bundle —
2984
- static tiles still warm it so interaction animations are ready on first
2985
- touch. Fleet sites already shipping their own gsap dedupe to one copy.
2986
- - Inputs now inline `color: var(--sk-input-text, #111827)` paired with the
2987
- existing `--sk-input-bg` default, and the border width is themeable via
2988
- `--sk-input-border-w`. fg/bg must come from the same source: the kit used
2989
- to inline the background but let `color` cascade from the section, which
2990
- produced cream-on-white inputs on brand-colored panels. Theme inputs
2991
- through the `--sk-input-*` variables (raw `input {}` rules can't beat the
2992
- inline layer).
2993
-
2994
- ### Scaffold & guardrails
2995
-
2996
- - Scaffold writes `.env.local` (skipped when present) instead of
2997
- `.env.example` — one real env file per project.
2998
- - Doctor: new `images.dims-drift` check compares `<Image width/height>`
2999
- against the referenced SVG's actual viewBox and warns on >2% drift (a
3000
- re-exported logo changed aspect 3.16:1 → 5.52:1 and the stale props kept
3001
- the old shape).
3002
- - New regression test bans raw `crypto.randomUUID()` outside the guarded
3003
- shared helpers — the 3.6.0 hydration-crash class, made unrepresentable.
3004
-
3005
- ## 3.3.1 — 2026-07-21
3006
-
3007
- Sitemap-sync safety release. Fixes the "sitemap oscillation" bug where the
3008
- `sonor-register-sitemap --auto-discover` postbuild fought a site's
3009
- `app/sitemap.{ts,js}` on every build — deleting and recreating each other's
3010
- `seo_pages` rows, and destroying managed metadata, Signal optimizations, LLM
3011
- schemas, and Search Console history on real (especially dynamic-route) pages.
3012
- No API surface changes. **Behavior change:** `--auto-discover` no longer does a
3013
- `full-replace` by default — see below. Recommended for every site.
3014
-
3015
- ### Fixed: `--auto-discover` can no longer delete pages it can't see
3016
-
3017
- The root cause was architectural: a filesystem scan of `app/` is structurally
3018
- **incomplete** — it can't enumerate the params of a dynamic route
3019
- (`/work/[slug]`, `/blog/[cat]/[id]`) and it ignores the app's sitemap `exclude`
3020
- config. Driving a `full-replace` from that incomplete set tells the API to
3021
- delete every page not in it. Two independent fixes, both shipped:
3022
-
3023
- - **Self-skip when a sitemap route exists.** `--auto-discover` now detects an
3024
- `app/sitemap.{ts,tsx,js,jsx,mjs}` (or `src/app/…`) and **skips entirely** with
3025
- a clear message. That route's `/sitemap.xml` is the authoritative page set,
3026
- and site-kit's runtime `SitemapSync` already mirrors it (additively). The
3027
- postbuild line is now **safe to leave in any site** — it self-skips. The
3028
- brand-profile push still runs on the skip path, so keeping the line doesn't
3029
- silently stop refreshing brand awareness.
3030
- - **Additive by default otherwise.** When there's no sitemap route (a
3031
- pure-static site where the CLI is the only page source), the sync is now
3032
- `additive` — it adds/updates pages but **never deletes**. Pass the new
3033
- **`--full-replace`** flag to opt back into pruning removed pages.
3034
-
3035
- The same `autoDiscover → full-replace` default was also fixed in the
3036
- programmatic `registerLocalSitemap` export (`@sonordev/site-kit/seo/server`):
3037
- it now defaults to `additive`; pass `mode: 'full-replace'` for the old behavior.
3038
-
3039
- ### Changed: consolidated triplicated sitemap logic
3040
-
3041
- `inferPageType` and the sitemap URL/path-normalization helpers were copy-pasted
3042
- (and had already drifted) across `createSitemap`, the runtime `SitemapSync`
3043
- component, and the CLI. They now live in one pure, browser-safe module
3044
- (`sitemap/shared.ts`), and the filesystem route-discovery — previously duplicated
3045
- between the CLI impl and `seo/routing.ts` — is unified in `sitemap/discover.ts`.
3046
- No public API change.
3047
-
3048
- ## 3.3.0 — 2026-07-21
3049
-
3050
- BookingWidget friction + theming release. No breaking changes — a drop-in bump
3051
- for every 3.x site. New behavior is on by default but can be disabled.
3052
-
3053
- ### Added: auto-select first available day (`autoSelectFirstDay`, default true)
3054
-
3055
- When the calendar loads, the widget now probes availability starting at the
3056
- earliest bookable day (min-notice aware, bounded sequential probe, stops at the
3057
- first day with open slots), selects that day, and shows its times — guests land
3058
- on pickable times instead of an empty "select a date" state. The probe's fetch
3059
- is reused for the selected day (no duplicate request), aborts cleanly if the
3060
- guest clicks a day mid-probe, and fails silent (manual picking still works).
3061
- Pass `autoSelectFirstDay={false}` to restore the old behavior.
3062
-
3063
- ### Added: skeleton loading for time slots
3064
-
3065
- Slot loading (and the auto-select probe) now renders shimmer placeholders
3066
- sized like real slot buttons instead of a spinner, so the times column doesn't
3067
- collapse and jump. Respects `prefers-reduced-motion`.
3068
-
3069
- ### Fixed: dark-theme derived colors
3070
-
3071
- `--sk-primary-light` now mixes the primary color with `styles.backgroundColor`
3072
- instead of always white, so selected-slot/hold-notice tints no longer glow on
3073
- dark panels. The error banner's hardcoded light-red palette is now derived from
3074
- `--sk-error` + `--sk-bg` the same way.
3075
-
3076
- ### Improved: tap targets and keyboard focus
3077
-
3078
- Time-slot buttons and the slot Confirm button are now ≥44px tall and the
3079
- submit button ≥48px (mobile tap-target guidance); all widget buttons and links
3080
- get a visible `:focus-visible` outline in the primary color.
3081
-
3082
- ## 3.2.1 — 2026-07-13
3083
-
3084
- Accessibility patch. No API changes, no breaking changes — a drop-in bump for
3085
- every 3.x site. Recommended for any site using the commerce (events/products),
3086
- forms, engage, or booking widgets.
3087
-
3088
- ### Fixed: default status colors now pass WCAG AA contrast (both directions)
3089
-
3090
- The default `--sk-error`, `--sk-success`, and `--sk-warning` tokens shipped as
3091
- the Tailwind -500/-600 shades, all of which fail WCAG AA (4.5:1) — both as text
3092
- on white and as a solid background under white text. Lighthouse flagged this on
3093
- mahjcincy.com, where the events widget's "N left" urgency badge renders white on
3094
- `--sk-error`. Because contrast is symmetric, one darker value fixes both uses:
3095
-
3096
- | Token | Was | Now | Contrast on white |
3097
- |-------|-----|-----|-------------------|
3098
- | `--sk-error` | `#ef4444` (red-500) | `#dc2626` (red-600) | 3.76 → **4.83** ✓ |
3099
- | `--sk-success` | `#059669` (emerald-600) | `#047857` (emerald-700) | 3.77 → **5.48** ✓ |
3100
- | `--sk-warning` | `#d97706` (amber-600) | `#b45309` (amber-700) | 3.19 → **5.02** ✓ |
3101
-
3102
- - Updated the canonical defaults in `brand.css`, the `forms/styles.css` mirror,
3103
- every inline `var(--sk-error, …)` fallback across forms/engage/commerce, the
3104
- `BookingWidget` inline token root (whose success/warning were the even-lighter
3105
- -500 shades), and the one hardcoded `#ef4444` in `ManagedForm` (now uses the
3106
- token). The light-tint error-bg pattern (`--sk-error-bg #fef2f2` + `#dc2626`
3107
- text) was already correct and is unchanged.
3108
- - This only moves the **defaults**. Any site that sets its own `--sk-error`
3109
- (etc.) is unaffected. Sites can't pick up the fix without re-installing, so as
3110
- an interim they may override `--sk-error: #dc2626` in their own `:root`.
3111
- - New `src/brand/status-contrast.test.ts` (vitest) reads the real CSS defaults
3112
- and every inline status fallback in `src/` and asserts each clears 4.5:1 on
3113
- white — with a teeth self-check proving it catches the old failing values. The
3114
- integration axe harness now renders an error/success/warning swatch block on
3115
- the `/form` fixture route (real-browser contrast) plus a teeth check that flips
3116
- the tokens back to the -500s and confirms axe reports `color-contrast`.
3117
-
3118
- ## 3.2.0 — 2026-07-13
3119
-
3120
- Transport, trust, and tooling release. No breaking changes; recommended target for every 2.x/3.0.x site. (Supersedes unpublished 3.0.6/3.1.0 work.)
3121
-
3122
- ### New: agent-native CLI — the site-kit toolchain is now driveable by coding agents
3123
-
3124
- `sonor-setup` is built to be driven by a coding agent (Claude Code et al.), not
3125
- just a human reading `README.md`. North star: an agent takes a bare Next.js repo
3126
- → a fully wired, **verified-green** Sonor site with zero human intervention. See
3127
- `docs/SITE-KIT-AGENT-NATIVE.md` and the new shipped `AGENTS.md`.
3128
-
3129
- - **Machine-readable everything.** New shared output layer (`src/cli/agent/`):
3130
- every agent-native command supports `--json`, emitting exactly one stable
3131
- envelope (`schemaVersion: 1`) to stdout — all human logs go to stderr, so
3132
- stdout is always `JSON.parse`-clean. Frozen exit-code set (`0` OK · `1` FAILED
3133
- · `2` USAGE · `3` CONFIG · `4` NETWORK · `5` INTERNAL). Wired on `verify`,
3134
- `doctor`, `status`, `codemod`, `manifest`, `init`, `install`, `upgrade`.
3135
- - **Never hangs an agent.** When `--json`, `--yes`, or a non-TTY is detected, no
3136
- command creates an interactive prompt — a command that needs input exits `3`
3137
- with an actionable `fix` naming the exact flag. `init` runs fully
3138
- non-interactive with `--api-key … --yes`.
3139
- - **`verify` — the definition of done.** New command composes static health + a
3140
- key-validity ping + a **post-build SSR check** (asserts the built/live page
3141
- server-renders real content instead of shipping an empty shell + RSC flight
3142
- data — the #1 fleet perf trap). Exit 0 only when the integration is genuinely
3143
- green. `--url <url>` checks a live/preview deploy; `--offline` skips the ping.
3144
- - **`doctor` — fast, offline health** with stable check ids (`env.api-key`,
3145
- `layout.sitekit`, `ssr.render`, `middleware.netlify`, `key.valid`, …), each
3146
- carrying a `fix`/`fixCommand`. `status` is the same engine for humans. All
3147
- check logic is consolidated in one library (`src/cli/agent/checks.ts`) — no
3148
- more forked copies (the old `status.ts` hand-rolled them; a broken WIP
3149
- `doctor.ts` imported non-exported helpers — both replaced).
3150
- - **`codemod` — deterministic 2.x→3.x transforms.** Offline, idempotent,
3151
- minimal-diff. Dry-run by default; `--write` applies (with `.bak`); `--check`
3152
- is the CI/agent gate (exit 1 if pending). Transforms: `provider-to-layout`
3153
- (SiteKitProvider→SiteKitLayout), `uptrade-to-sonor` (package/env/token
3154
- remnants; flags `uptrade_` key *values* it can't mint a replacement for), and
3155
- `analytics-sibling` (detects the SSR-bailout wrapper and flags the exact fix
3156
- rather than risk a blind rewrite).
3157
- - **The package ships its own agent instructions.** `AGENTS.md`
3158
- (consumer/agent-facing), `agent-manifest.json` (machine manifest — modules
3159
- derived from `package.json` exports so it can't drift, plus env contract,
3160
- blessed patterns, and failure modes), and a Claude Code skill under `skills/`
3161
- are now in the npm tarball. Read them with `cat node_modules/@sonordev/site-kit/AGENTS.md`
3162
- or `npx sonor-setup manifest --json` — no web access needed. Contributor
3163
- branding rules moved to `CONTRIBUTING.md`.
3164
- - **MCP server: evaluated, deferred.** For agents with a shell (the target),
3165
- `npx sonor-setup <cmd> --json` already beats an MCP tool on install/config
3166
- friction and CI parity — recommendation is CLI-first, revisit a thin MCP
3167
- wrapper only for shell-less agents (rationale in the design doc §9).
3168
-
3169
- ### New: canonical client transport (`sonorFetch`)
3170
-
3171
- Every client-side module (analytics, forms, commerce, engage config/telemetry,
3172
- maps, images, SitemapSync) now routes through one shared transport instead of
3173
- bare `fetch()`:
3174
-
3175
- - **Per-attempt timeout + retry with backoff.** A hung request left to the
3176
- browser's own network timeout logs `net::ERR_TIMED_OUT` and fails the
3177
- Lighthouse best-practices `errors-in-console` audit (observed in production
3178
- via SitemapSync). Non-idempotent paths (checkout, payment intents, uploads,
3179
- page-views) run `retries: 0` — timeout only, never an automatic retry.
3180
- - **`x-sitekit-version` header on every request.** The platform can now see
3181
- which kit version each site runs from live traffic — the foundation for
3182
- fleet visibility.
3183
- - **Auth circuit breaker.** After a 401/403 the key is paused for 5 minutes
3184
- with ONE friendly `console.info` — a site on a stale or canceled key no
3185
- longer spams the API or the visitor's console.
3186
- - **Beacons keep the key out of URLs.** Unload-time telemetry (session end,
3187
- scroll depth) previously used `sendBeacon` with `?key=` in the URL — which
3188
- lands in server/CDN access logs. It now uses keepalive fetch with header
3189
- auth (`sonorBeacon`).
3190
- - Deliberately excluded: `engage/ChatWidget` message paths (Echo replies can
3191
- exceed the transport timeout; will be brought under with tuned limits).
3192
-
3193
- ### New: canonical cross-repo contracts
3194
-
3195
- `sites/contract` (host normalization), `seo-pages/contract` (seo_pages row
3196
- resolution), `portfolio/contract` (Lighthouse KPI display policy), and
3197
- `forms/contract` (honeypot field) — the logic that previously lived as
3198
- "keep these in sync" comment-twins across sonor-api, signal-api, and
3199
- agency-site-kit. Both APIs now import these; they require this release.
3200
-
3201
- ### New: agent-native CLI engine + integration harness
3202
-
3203
- All CLI health logic consolidated in `cli/agent/checks.ts` with a stable JSON
3204
- envelope (`--json` everywhere, exit codes as verdicts, fix commands attached):
3205
- `doctor` (fast, offline-by-default), `status` (same engine, network on), and
3206
- `verify` (health + key + SSR = definition of done). The package ships an
3207
- agent manifest (`agent-manifest.json`) generated at build. A Playwright
3208
- integration harness (fixture Next app: SSR-integrity, axe, zero-console, and
3209
- per-entry gzipped bundle budgets) now gates `npm publish`.
3210
-
3211
- ### New: `@sonordev/site-kit/fleet` — fleet heartbeat (contract v1)
3212
-
3213
- Fire-and-forget, idle-deferred heartbeat reporting the site's own build
3214
- fingerprint (kit version, active client modules, Next.js version) so the
3215
- platform can see what the fleet actually runs. No visitor data; project
3216
- identity resolves server-side from x-api-key like every public endpoint.
3217
- `fleet/contract` is the wire contract for the sonor-api ingest side (same
3218
- pattern as `llms/contract` / `slots/contract`).
3219
-
3220
- ### New: `sonor-setup doctor`
3221
-
3222
- Executable health checks with `--json` (stable schema; exit code is the
3223
- verdict) so agents and CI can consume it: env + API connectivity + **real key
3224
- validity** (authed endpoint, not `/health`), **SSR integrity** (detects pages
3225
- that bailed to client rendering — only RSC flight data, no content tags),
3226
- sitemap, llms.txt, and the Netlify `runtime: 'nodejs'` middleware trap.
3227
-
3228
- ### New: client auth hardening — minted tokens, domain-binding, per-key limits
3229
-
3230
- The project API key (`sonor_{uuid8}_{secret}`) is a long-lived secret; shipping
3231
- it to every visitor's browser (`window.__SITE_KIT_API_KEY__`) let anyone scrape
3232
- it and reuse it anywhere, forever — the form-spam incident. Three layered,
3233
- backward-compatible changes (2.x sites keep sending raw keys forever):
3234
-
3235
- - **Minted tokens.** `SiteKitLayout` (a server component) now mints a
3236
- short-lived (~1h), project-scoped HMAC token server-to-server and injects
3237
- THAT instead of the raw key. `sonorFetch` refreshes a stale/expired token
3238
- transparently (a static page's seed can be days old on first view) and
3239
- retries once on a 401. If minting is unavailable (older API, transient
3240
- outage) the layout falls back to the raw key — the build never breaks. New
3241
- endpoints on api.sonor.io: `POST /api/public/site-token` (mint, server-side),
3242
- `POST /api/public/site-token/refresh` (browser, domain-bound). Guards on both
3243
- APIs accept a token OR a raw key.
3244
- - **Domain-binding.** A browser-originated credential is checked against the
3245
- project's registered domains (`projects.domain` + settings + the multi-site
3246
- `site` hosts). Server-to-server calls (no Origin) are unaffected, so SSR keeps
3247
- working. Rolls out log-only; enforced per project via
3248
- `settings.site_auth.enforce_domain_binding` once its domains are verified. Not
3249
- a hard wall (Origin is forgeable by non-browser clients) — it stops casual
3250
- cross-origin reuse and pairs with the token + rate-limit layers.
3251
- - **Per-key rate limits.** Token mint/refresh and the AI/widget endpoints are
3252
- throttled per project credential (not per IP), so a distributed bot on one
3253
- site's key hits one bucket. Signal API's global throttler is now key-scoped.
3254
- - **Incident response.** Bumping `settings.site_auth.token_version` instantly
3255
- invalidates every outstanding token for a project; deactivating a key kills
3256
- its tokens.
3257
- - **doctor:** adds `Token minting` + `Key is domain-bound` online checks.
3258
-
3259
- Set `SITE_TOKEN_SECRET` (same value on api + signal) to enable minting; unset,
3260
- the kit transparently keeps injecting the raw key. See `docs/CLIENT-AUTH.md` for
3261
- the endpoint contracts and rollout runbook.
3262
-
3263
- ### Fixed
3264
-
3265
- - **Forms: honeypot input hidden from assistive technology.** The spam
3266
- honeypot (`_hp_field`) had no `aria-hidden`, so screen readers announced an
3267
- unlabeled input and Lighthouse failed the accessibility `label` audit on any
3268
- page with a managed form. Now `aria-hidden="true"`.
3269
- - **Default brand color now passes WCAG AA color-contrast.** `--sk-primary`
3270
- (and the mirrored `--sk-btn-bg` in `forms/styles.css`) was blue-500
3271
- `#3b82f6` — only ~3.68:1 against the white button text, a "serious" axe
3272
- color-contrast violation. Any fleet site that shipped the default without
3273
- overriding its brand color failed Lighthouse's a11y contrast audit on the
3274
- submit button. The default is now blue-600 `#2563eb` (~5.2:1, AA), hover
3275
- blue-700 `#1d4ed8`. Sites that set their own `--sk-primary` are unaffected.
3276
- The same blue-500 default was swept out of every `var(--sk-primary, …)`
3277
- fallback and JS brand default across forms/blog/engage so the package-wide
3278
- default is consistent (commerce already defaulted to `#2563eb`).
3279
- - **Load-path `console.error` downgraded to `console.warn`** in commerce,
3280
- maps, and images. Error-level console output from transient API failures
3281
- fails the best-practices audit; the kit's production paths now stay at
3282
- warn/info.
3283
- - `sonor-setup status`: the Sitemap Sync check only recognized legacy
3284
- `uptrade` postbuild scripts; it now recognizes `sonor` ones.
3285
-
3286
- ### Internal
3287
-
3288
- - `fetchWithRetry` moved from `src/forms/` to `src/shared/` — single source of
3289
- truth for resilient fetch; `sonorFetch` wraps it. Removed maps' third
3290
- hand-rolled retry implementation.
3291
- - `src/shared/version.ts` (`SITE_KIT_VERSION`) — asserted against
3292
- package.json in tests and at publish (verify-dts).
3293
-
3294
- ## 3.0.0 — 2026-06-18
3295
-
3296
- **Site-Kit 3.0 — "the site acts."** The 3.0 line begins the shift from
3297
- site-as-sensor to site-as-actuator (see `docs/SITE-KIT-3.0-VISION.md`). This
3298
- first release ships the foundation: the Managed Slots primitive, visitor→contact
3299
- identity binding, and an opt-in edge identity/segment pass. It is **additive
3300
- over 2.x** for every existing module (SEO, Analytics, Engage, Forms, Blog, CMS,
3301
- Commerce, …) — upgrading does not change their behavior.
3302
-
3303
- ### New module: `@sonordev/site-kit/slots` — Managed Slots (Pillar 1 of the 3.0 vision)
3304
-
3305
- First slice of the 3.0 "the site acts" spine (see `docs/SITE-KIT-3.0-VISION.md` and
3306
- `docs/SITE-KIT-3.0-SPEC-SLOTS.md`). A slot is a named, Sonor-managed text region
3307
- inside an element the developer still owns:
3308
-
3309
- ```tsx
3310
- <h1 className="hero-title">
3311
- <ManagedSlot id="home-hero-headline">
3312
- New Homes in Cincinnati & Northern Kentucky
3313
- </ManagedSlot>
3314
- </h1>
3315
- ```
3316
-
3317
- No managed content → the static children render, byte-for-byte what the site ships
3318
- today. Content exists in Sonor (owner edit or approved Signal proposal) → it renders
3319
- instead, with no deploy.
3320
-
3321
- Design guarantees, all tested:
3322
-
3323
- - **Static-safe**: RSC resolution via ISR-cached fetch (`tags: ['sonor-slots']`),
3324
- no request access, fragment render with zero hydration cost — pages stay `○`.
3325
- - **Fallback-first**: every failure (missing key, network, HTTP error, 404 while the
3326
- API endpoint isn't deployed, malformed payload) renders the fallback; slots can
3327
- never break a build or a page.
3328
- - **Signed from contract v1**: payloads carry an HMAC-SHA256 signature keyed with the
3329
- project API key (`slots/contract`, shared with sonor-api like `llms/contract`);
3330
- site-kit drops anything unsigned or tampered. Plain-text content type only in v1 —
3331
- rendered escaped, never as HTML.
3332
- - `createSlotsRevalidateHandler` gives Sonor an on-demand cache-bust webhook so
3333
- approved changes go live in seconds.
3334
-
3335
- Resolve requests also report each slot's current static text (`fallback`,
3336
- sent automatically when ManagedSlot children are a plain string) so the
3337
- dashboard editor shows the live copy next to the slot id instead of a bare
3338
- identifier.
3339
-
3340
- New module is Sonor-only auth (`SONOR_API_KEY`) — 3.0 code does not implement the
3341
- deprecated `UPTRADE_*` fallbacks. The server side is BUILT: sonor-api `SlotsModule`
3342
- (`POST /api/public/slots/resolve` + portal CRUD with `checkProjectAccess` tenancy
3343
- asserts), the `managed_slots` table (applied 2026-06-11), and the dashboard editor
3344
- (Website module → Text Slots). The June 2026 security remediation landed first;
3345
- this endpoint follows the hardened pattern.
3346
-
3347
- Also: vitest now includes `.test.tsx` files (`vitest.config.ts` include pattern).
3348
-
3349
- ### Forms: visitor identity on submissions (Pillar 5 — identity graph)
3350
-
3351
- `submitForm` now sends the anonymous visitor id (`_sk_vid`, the same id Analytics
3352
- and Signal stamp on page views) with each submission, so Sonor can bind the
3353
- resulting CRM lead to its browsing session — closing the page → lead attribution
3354
- loop. Additive and silent: no change to the `submitForm` signature or to callers
3355
- (`<ManagedForm>` / `useForm`). Pairs with the sonor-api side that stores
3356
- `form_submissions.visitor_id` and stamps `contacts.visitor_id` on routing.
3357
-
3358
- ### Middleware: opt-in edge identity + visitor segment (the "one edge pass")
3359
-
3360
- `createMiddleware({ identity: true })` adds an edge identity pass to the existing
3361
- redirects + security + discovery chain. When enabled it ensures a first-party
3362
- `_sk_vid` visitor cookie (server-authoritative id) and resolves a coarse visitor
3363
- segment — new-vs-returning + marketing source (paid / organic / social /
3364
- referral / direct) + campaign — into a `_sk_seg` cookie, for segment-aware slots
3365
- and personalization. **Purely additive: it only writes cookies, never changes
3366
- the rendered HTML, so it stays LCP/static-safe. Default OFF** — existing sites
3367
- are unaffected until they opt in. New exports from `@sonordev/site-kit/middleware`:
3368
- `resolveVisitorSegment`, `encodeSegment`, `decodeSegment`, and the `VisitorSegment`
3369
- / `SegmentSource` types.
3370
-
3371
- ### Upgrading from 2.x
3372
-
3373
- This release is additive for every shipped module — a drop-in upgrade. Notes:
3374
-
3375
- - The long-deprecated `SiteKitProvider` is **not** part of the public exports;
3376
- use `SiteKitLayout` (RSC-safe), which has been the recommended pattern since 2.x.
3377
- - 3.0 modules (`slots`, the edge `identity` pass) are **Sonor-only** auth
3378
- (`SONOR_API_KEY`). The deprecated `UPTRADE_*` fallbacks still work for the
3379
- older modules so pre-rebrand sites keep building.
3380
-
3381
- ## 2.9.0
3382
-
3383
- ### /llms.txt now prerenders statically — no more "Dynamic server usage" build errors
3384
-
3385
- Every consumer site's `next build` logged a scary (but non-fatal) error:
3386
-
3387
- ```
3388
- @sonordev/llms: Error generating llms.txt: Error: Dynamic server usage:
3389
- Route /llms.txt couldn't be rendered statically because it used `request.headers`.
3390
- ```
3391
-
3392
- and the route was demoted to dynamic (`ƒ`), served as a serverless function
3393
- with no static/CDN caching — for what is effectively a static text file.
3394
-
3395
- **Cause.** `createLLMsTxtHandler` / `createLLMsFullTxtHandler` read
3396
- `request.headers.get('if-none-match')` to serve 304s themselves. During
3397
- prerendering, ANY access to the incoming Request's headers throws
3398
- `DynamicServerError` and flags the route dynamic — and because the access sat
3399
- inside the handlers' own `try/catch`, the error was also caught and logged on
3400
- every build.
3401
-
3402
- **Fix.** The returned GET handlers no longer touch the incoming `Request` at
3403
- all (signature is now `(request?: Request) => Promise<Response>`). In-handler
3404
- `If-None-Match`/304 matching is removed; the weak `ETag` is still emitted and
3405
- conditional requests are answered by the Next static layer / CDN, which
3406
- already does this for prerendered responses. With the route opted into
3407
- prerendering (Next 15+: `export const revalidate = 3600` or
3408
- `dynamic = 'force-static'` in the route file), `/llms.txt` now builds as
3409
- static (`○`) and the build-log error is gone. Verified on a Next 16.1.2
3410
- consumer site: `ƒ /llms.txt` + error before, `○ /llms.txt` + clean log after.
3411
-
3412
- ### New `baseUrl` option for llms.txt link resolution
3413
-
3414
- `GenerateLLMSTxtOptions` (and therefore both handler factories and
3415
- `generateLLMsTxt` / `generateLLMsFullTxt`) accepts an explicit `baseUrl`,
3416
- matching the sitemap helper's convention. The link base resolves as:
3417
-
3418
- 1. explicit `baseUrl` option
3419
- 2. Portal/local `business.website` (existing behavior, unchanged default)
3420
- 3. `NEXT_PUBLIC_SITE_URL`, then `SITE_URL` env vars
3421
-
3422
- It is never derived from request headers, so the route stays statically
3423
- prerenderable. The resolved base is normalized (trailing slashes stripped —
3424
- this also fixes double-slash links like `https://x.com//about` when
3425
- `business.website` had a trailing slash), and sections that previously
3426
- required `business.website` (`## Optional`, the `linkToFullLlms` block, the
3427
- `**Website:**` header line) now also render when only an env base is
3428
- available.
3429
-
3430
- ### Breaking changes
3431
-
3432
- None for the documented usage (`export const GET = createLLMsTxtHandler()`).
3433
- Behavioral note: the handlers no longer answer conditional requests with 304
3434
- themselves — on dynamic deployments that's now handled by the CDN layer (or
3435
- clients simply get a full 200, which is valid HTTP).
3436
-
3437
- ### `@sonordev/site-kit/sync` now ships its TypeScript declarations
3438
-
3439
- The `./sync` subpath (`BookingWidget`) shipped runtime JS but **no `.d.ts`**
3440
- in 2.7.2 and 2.8.1. `package.json` pointed `exports["./sync"].types` at
3441
- `./dist/sync/index.d.ts`, but that file was never built — so consumer sites
3442
- on `moduleResolution: "bundler"` + `strict` hit:
3443
-
3444
- ```
3445
- error TS2307: Cannot find module '@sonordev/site-kit/sync'
3446
- ```
3447
-
3448
- and had to add an ambient module shim to compile.
3449
-
3450
- **Cause.** `tsup.config.ts` kept the DTS entry list as a hand-maintained
3451
- second copy of the main `entry` map, and the two drifted: `sync/index` was
3452
- in `entry` (so JS built) but missing from `dts.entry` (so no declarations).
3453
-
3454
- **Fix.** The DTS entry set is now *derived* from the single `entry` map
3455
- (every entry minus the CLI binaries), so a subpath can never again ship JS
3456
- without types. The `prepublishOnly` guard was also strengthened — it now
3457
- verifies **every** `exports[*].types` target exists on disk (via
3458
- `scripts/verify-dts.cjs`), not just the root `index.d.ts`, so a missing
3459
- subpath declaration fails the publish instead of slipping through.
3460
-
3461
- Consumer sites that added a `*/sync.d.ts` ambient shim can delete it once on
3462
- this version.
3463
-
3464
- ### Breaking changes
3465
-
3466
- None. Packaging-only fix — no runtime, API, or export-surface change.
3467
-
3468
- ## 2.8.1
3469
-
3470
- ### `@sonordev/site-kit/reputation/server` — RSC-safe entry
3471
-
3472
- The reputation module's API functions (`fetchReviews`, `fetchReviewStats`)
3473
- were previously bundled with `TestimonialSection`, which is a Client
3474
- Component. Because the shared chunk carried a `'use client'` directive,
3475
- calling the API functions from a React Server Component failed at build
3476
- time with *"Attempted to call fetchReviews() from the server but
3477
- fetchReviews is on the client"*.
3478
-
3479
- 2.9.0 adds a new `./reputation/server` entry point that exports only the
3480
- data fetchers and types — no client taint — so they can be called from
3481
- RSC, route handlers, `generateMetadata`, etc.
3482
-
3483
- ```ts
3484
- // React Server Component
3485
- import { fetchReviews, fetchReviewStats } from '@sonordev/site-kit/reputation/server'
3486
-
3487
- export default async function Page() {
3488
- const reviews = await fetchReviews({ limit: 6 })
3489
- // ...
3490
- }
3491
-
3492
- // Client Component — unchanged
3493
- import { TestimonialSection } from '@sonordev/site-kit/reputation'
3494
- ```
3495
-
3496
- This matches the existing split on `./seo/server`, `./blog/server`,
3497
- `./images/server`, and `./commerce/server`.
3498
-
3499
- ### Breaking changes
3500
-
3501
- None. The existing `./reputation` entry continues to export
3502
- `TestimonialSection`, `fetchReviews`, `fetchReviewStats`, and types for
3503
- backwards compatibility — only the recommended import path for RSC
3504
- contexts has changed.
3505
-
3506
- ---
3507
-
3508
- ## 2.8.0
3509
-
3510
- ### Multi-site projects — forms + sitemap
3511
-
3512
- `@sonordev/site-kit` 2.7.0 introduced the `analytics.site` dimension so one
3513
- Sonor project could host many sub-sites (e.g. the True Power Systems project
3514
- hosts truepowersystems.com + 16 state-themed microsites) with each event
3515
- tagged by its host. 2.8.0 extends the same dimension across two more
3516
- surfaces so the dashboard can scope by sub-site everywhere:
3517
-
3518
- - **`SitemapSync`** now sends the host (`__SITE_KIT_SITE__`) alongside each
3519
- registration, tagging every `seo_pages` row with its sub-site. The Sonor
3520
- dashboard's page-tree sidebar filters by this when the site picker is
3521
- set.
3522
- - **`submitForm`** includes `site` in submission metadata so leads are
3523
- attributed to the originating microsite even after the form definition
3524
- is moved or merged. Persisted on `form_submissions.site`.
3525
- - **`formsApi.sync` / `CreateFormInput`** gained an optional `site` field.
3526
- CLI scripts (`migrate-contact-form.ts`) should derive it from
3527
- `NEXT_PUBLIC_SITE_URL` host so one Sonor project can host one form per
3528
- microsite (`ohio-quote → ohiopowerstudies.com`,
3529
- `georgia-quote → georgiapowerstudies.com`, etc.).
3530
-
3531
- The Sonor API (`api.sonor.io`) accepts `?site=ohiopowerstudies.com` on
3532
- every analytics, SEO, and forms read endpoint to scope results. The
3533
- dashboard's site picker (introduced alongside this release) sets this
3534
- filter globally per project.
3535
-
3536
- ### Breaking changes
3537
-
3538
- None. All new fields are optional — single-site projects continue to work
3539
- unchanged, and older versions of site-kit can still submit forms / sync
3540
- sitemaps (the server stores `site = NULL` for those events).
3541
-
3542
- ---
3543
-
3544
- ## 2.7.0
3545
-
3546
- ### Multi-site analytics
3547
-
3548
- - **`AnalyticsConfig.site`** — new sub-site identifier. One Sonor project
3549
- can now host many sites (e.g. TPS hosting truepowersystems.com + 16
3550
- microsites) with every page-view, event, session, scroll, web-vital, and
3551
- heatmap event tagged by host. Resolved with precedence: explicit
3552
- `analytics.site` > `NEXT_PUBLIC_SITE_URL` host > `window.location.host`.
3553
- - **`window.__SITE_KIT_SITE__`** — global set by SiteKitClientProviders,
3554
- read by every analytics/sitemap surface.
3555
-
3556
- ### Breaking changes
3557
-
3558
- None. Sites that don't opt in continue to behave exactly as before.
3559
-
3560
- ---
3561
-
3562
- ## 2.4.0
3563
-
3564
- ### GEO / AEO
3565
-
3566
- - **LLM GEO contract** (`src/llms/contract.ts`, `LLM_GEO_CONTRACT.md`) — versioned payload rules, `sanitizeLlmsPublicSummary`, `pickManagedLlmSchemaForJsonLd`.
3567
- - **llms.txt** — optional `optionalPagePaths`, `linkToFullLlms`; page list prefers `llms_public_summary`; `meta.last_updated` in header blockquote when API returns it.
3568
- - **Handlers** — `llmsResponseHeaders`, weak `ETag`, `stale-while-revalidate`, `If-None-Match` / 304; `GET` handlers receive `Request` (use `export const GET = createLLMsTxtHandler()`).
3569
- - **`buildAiDiscoveryHeaders`** — opt-in `Link: rel=describedby` for `/llms.txt`.
3570
- - **Sitemap** — `includeLlmsTxtInSitemap`, `includeLlmsFullTxtInSitemap` (default false).
3571
- - **`LLMSchema`** — filtered JSON-LD + `isPartOf` → `WebSite` when project `site_url` exists.
3572
- - **`createWebSiteOrganizationStub`** — optional grounding when Portal does not emit org/site nodes.
3573
- - **CLI `status`** — optional `llms.txt` smoke check when `NEXT_PUBLIC_SITE_URL` / `SITE_BASE_URL` set.
3574
- - **SiteKitLayout** — `showLlmsTxtFooterLink` (default false).
3575
-
3576
- ## 1.3.0
3577
-
3578
- ### Performance
3579
-
3580
- - **Suspense-wrapped all async server components** — `ManagedSchema`, `LLMSchema`, `ManagedFAQ`, `ManagedContent`, `ManagedInternalLinks`, `ManagedScripts`, `ManagedNoScripts`, and `LocationPageContent` now stream independently. API fetches no longer block page content from flushing, dramatically improving LCP on pages that use these components. Zero config — works automatically for all sites.
3581
- - **Parallelized ManagedSchema API calls** — `getSchemaMarkups`, `getSEOPageData`, and `getEntityEnhancedSchema` now run via `Promise.all` instead of sequentially, cutting schema fetch time to the slowest single call.
3582
- - **Deferred AnalyticsProvider by default** — `AnalyticsProvider` now lazy-loads internally (dynamic import, `ssr: false`) so analytics JS is excluded from the critical hydration path without sites needing `next/dynamic` wrappers.
3583
-
3584
- ### Breaking Changes
3585
-
3586
- - None. All changes are backwards-compatible. Sites that already wrap these components in `<Suspense>` will have a harmless double-wrap (no functional impact).
3587
-
3588
- ---
3589
-
3590
- ## 1.2.10
3591
-
3592
- ### Fixes
3593
-
3594
- - Deferred analytics loading pattern added to `AnalyticsProvider` export
3595
-
3596
- ---
3597
-
3598
- ## 1.2.9
3599
-
3600
- ### Changes
3601
-
3602
- - Package rename from `@uptrademedia/site-kit` to `@sonordev/site-kit`
3603
- - Updated all API endpoints from `api.uptrademedia.com` to `api.sonor.io`
3604
- - Updated CLI commands from `uptrade-*` to `sonor-*`
3605
-
3606
- ---
3607
-
3608
- ## 1.2.2 and earlier
3609
-
3610
- - Legacy versions published under `@uptrademedia/site-kit`