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