@sonordev/site-kit 8.5.2 → 8.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/agent-manifest.json +1 -1
  3. package/dist/{AnalyticsProvider-HSJMHYBA.js → AnalyticsProvider-3UIQMIRW.js} +6 -6
  4. package/dist/{ArticleViewTracker-XEA6ZY67.js → ArticleViewTracker-KNXP2DXL.js} +5 -5
  5. package/dist/{BlocksPopup-DUTOENMQ.js → BlocksPopup-DFE32GPX.js} +9 -8
  6. package/dist/{ChatWidget-PMVQTSEU.js → ChatWidget-L74L4YAD.js} +6 -6
  7. package/dist/FileField-P3PLEAON.js +237 -0
  8. package/dist/{FormSpotlight-RTPJPLQ3.js → FormSpotlight-DNBSBRIE.js} +2 -2
  9. package/dist/{FormStage-UANGD66Z.js → FormStage-I4JGLT57.js} +2 -2
  10. package/dist/{ManagedForm-4AACRHEY.js → ManagedForm-GJRGBM6W.js} +8 -8
  11. package/dist/{ManagedNewsletterForm-RWKFWZ72.js → ManagedNewsletterForm-AUZ6YB7G.js} +11 -11
  12. package/dist/{SignalCore-XULLPKPX.js → SignalCore-UV7K7PT4.js} +5 -5
  13. package/dist/SiteChat-6UWE545R.js +6 -0
  14. package/dist/SiteDesignReporter-ZSU76DZJ.js +13 -0
  15. package/dist/{SitePopups-US6VQN7X.js → SitePopups-XHWU7GNF.js} +6 -6
  16. package/dist/SitemapSync-ELWV6W7Y.js +9 -0
  17. package/dist/_client/booking-widget.js +5 -5
  18. package/dist/affiliates/index.js +3 -3
  19. package/dist/analytics/content-text.d.ts +17 -0
  20. package/dist/analytics/index.js +6 -6
  21. package/dist/{article-styles-VASFBYA3.js → article-styles-D7VCDXMG.js} +2 -1
  22. package/dist/article-tables-W45BE353.js +3 -0
  23. package/dist/articles/index.js +7 -20
  24. package/dist/articles/server-ui.js +29 -55
  25. package/dist/articles/server.js +6 -5
  26. package/dist/chat/index.js +9 -9
  27. package/dist/{chunk-3RDJKIYO.js → chunk-2WDWQI7K.js} +1 -1
  28. package/dist/{chunk-DLTLXYOV.js → chunk-36ZMNBXZ.js} +4 -9
  29. package/dist/chunk-3MJNBOUO.js +8 -0
  30. package/dist/{chunk-IXA7ZB6U.js → chunk-3MLWIBHX.js} +1 -1
  31. package/dist/{chunk-BF7TZYC3.js → chunk-52KO4LKD.js} +1 -1
  32. package/dist/{chunk-ZDUTXHM6.js → chunk-5WHBYQJ4.js} +3 -3
  33. package/dist/{chunk-5D7ZISUP.js → chunk-7DYMM6SS.js} +11 -6
  34. package/dist/{chunk-HNYECNCU.js → chunk-7YB3QA4P.js} +15 -7
  35. package/dist/{chunk-N4ZCNYWF.js → chunk-A2MDVP47.js} +1 -1
  36. package/dist/{chunk-E4PTMORJ.js → chunk-AL5UCWLI.js} +23 -8
  37. package/dist/{chunk-ANVHRT35.js → chunk-BAJUXQTH.js} +2 -2
  38. package/dist/{chunk-ITQND6IB.js → chunk-BJYX6SFI.js} +2 -2
  39. package/dist/{chunk-TYAVHRWH.js → chunk-CVADNHJ4.js} +8 -2
  40. package/dist/{chunk-5BRHTBPO.js → chunk-DJIGFX33.js} +1 -1
  41. package/dist/{chunk-ZSU7KTIE.js → chunk-DPYGC7WE.js} +2 -2
  42. package/dist/{chunk-XA7Q2VQA.js → chunk-EK7P62DT.js} +1 -1
  43. package/dist/chunk-FXR5PJDL.js +420 -0
  44. package/dist/{chunk-GLLFIE2R.js → chunk-G5FD7UXK.js} +2 -2
  45. package/dist/{chunk-OFQ3BU52.js → chunk-HM7CDWFK.js} +1 -1
  46. package/dist/{chunk-Q4Y3WIHE.js → chunk-HWQPCZOX.js} +1 -1
  47. package/dist/{chunk-6KU7STM3.js → chunk-HY7ZEU2C.js} +3 -1
  48. package/dist/chunk-IBHBEUZV.js +27 -0
  49. package/dist/{chunk-7HRYSLY2.js → chunk-IXZBOOAD.js} +4 -4
  50. package/dist/chunk-JESLPEST.js +14 -0
  51. package/dist/{chunk-BC7JMY4Z.js → chunk-JG53YJLM.js} +58 -1
  52. package/dist/{chunk-FVXCQC7W.js → chunk-JWCCY72G.js} +1 -1
  53. package/dist/{chunk-RI2N2EBI.js → chunk-JYITKUUB.js} +13 -5
  54. package/dist/chunk-KIYACQW3.js +1 -0
  55. package/dist/{chunk-YW3LNSBM.js → chunk-KLIDCNML.js} +1 -1
  56. package/dist/{chunk-3PT6SO65.js → chunk-L2KSHJS2.js} +5 -5
  57. package/dist/{chunk-27LYZVPP.js → chunk-LGWN6KCN.js} +2 -2
  58. package/dist/{chunk-GTVOB5RN.js → chunk-MJW6YIDJ.js} +1 -1
  59. package/dist/{chunk-CFSIGEUW.js → chunk-MNORERMF.js} +1 -1
  60. package/dist/{chunk-Y35BWW2C.js → chunk-ODOV76VW.js} +1 -1
  61. package/dist/{chunk-EQHGPB3J.js → chunk-OHLCE4GC.js} +2 -2
  62. package/dist/{chunk-OEA7U4HJ.js → chunk-P5PRR2BB.js} +1 -1
  63. package/dist/{chunk-JJ4JS3LD.js → chunk-PAYCK7FC.js} +1 -1
  64. package/dist/{chunk-DLM4FFNH.js → chunk-QTBAKOCZ.js} +1 -1
  65. package/dist/{chunk-2QFIYYMP.js → chunk-RHT7OZNF.js} +1 -1
  66. package/dist/{chunk-UGZ3B5AK.js → chunk-RWWSOYJO.js} +9 -9
  67. package/dist/{chunk-T6U6J3QK.js → chunk-T6TWPKBL.js} +1 -1
  68. package/dist/{chunk-5SQK3D53.js → chunk-TYSIAWC3.js} +1 -0
  69. package/dist/{chunk-24NM64IV.js → chunk-U4OQJQK6.js} +3 -3
  70. package/dist/{chunk-GDQKDL3E.js → chunk-UYOE3HA4.js} +2 -2
  71. package/dist/chunk-VEJD5EJ2.js +344 -0
  72. package/dist/{chunk-LNSF6BJC.js → chunk-VPZJ7ZTL.js} +2 -2
  73. package/dist/chunk-XAZC2TUD.js +1119 -0
  74. package/dist/{chunk-A7RZQI6H.js → chunk-XC2TWYDV.js} +3 -3
  75. package/dist/{chunk-URJN75ZG.js → chunk-XIDWNXZN.js} +3 -3
  76. package/dist/chunk-XLAHRN4B.js +1 -0
  77. package/dist/{chunk-URZTBPZO.js → chunk-XZFOQ2V4.js} +113 -93
  78. package/dist/{chunk-I4LMZNBN.js → chunk-YYPYLYZM.js} +1 -1
  79. package/dist/client/index.js +4 -4
  80. package/dist/contracts/broadcast-layouts.d.ts +133 -0
  81. package/dist/contracts/business-claims.d.ts +456 -0
  82. package/dist/contracts/campaign-normalize.d.ts +2 -0
  83. package/dist/contracts/entries.d.ts +1 -1
  84. package/dist/contracts/funnels.d.ts +576 -0
  85. package/dist/contracts/inbox.d.ts +42 -0
  86. package/dist/contracts/llms.d.ts +200 -0
  87. package/dist/contracts/outreach.d.ts +267 -0
  88. package/dist/contracts/page-titles.d.ts +140 -0
  89. package/dist/contracts/portfolio.d.ts +170 -0
  90. package/dist/contracts/public-url-host.d.ts +10 -0
  91. package/dist/contracts/publication-branding.d.ts +151 -0
  92. package/dist/contracts/publication-charter.d.ts +14 -0
  93. package/dist/contracts/site-cache.d.ts +2 -0
  94. package/dist/fleet/index.js +6 -6
  95. package/dist/forms/FileField.d.ts +3 -1
  96. package/dist/forms/PrivateFileField.d.ts +3 -0
  97. package/dist/forms/index.js +11 -11
  98. package/dist/forms/private-file-rules.d.ts +8 -0
  99. package/dist/forms/server.js +5 -5
  100. package/dist/forms/static.js +3 -3
  101. package/dist/forms/styles.css +17 -1
  102. package/dist/forms/types.d.ts +4 -0
  103. package/dist/images/index.js +4 -4
  104. package/dist/index.js +1 -1
  105. package/dist/landing/contract.d.ts +2 -13
  106. package/dist/landing/contract.js +2 -0
  107. package/dist/landing/index.js +4 -2
  108. package/dist/landing/managed-server.d.ts +21 -0
  109. package/dist/landing/managed.d.ts +11 -0
  110. package/dist/landing/metadata.js +2 -0
  111. package/dist/landing/server.d.ts +5 -0
  112. package/dist/landing/server.js +19 -2
  113. package/dist/layout/client.js +9 -9
  114. package/dist/layout/index.js +10 -10
  115. package/dist/llms/contract.js +2 -1
  116. package/dist/llms/index.js +9 -7
  117. package/dist/llms/merge.d.ts +29 -0
  118. package/dist/maps/index.js +3 -3
  119. package/dist/mcp/sonor.js +12 -11
  120. package/dist/portfolio/contract.js +1 -1
  121. package/dist/press-kit/articles/article-body.d.ts +13 -0
  122. package/dist/press-kit/articles/article-heading-ids.d.ts +7 -0
  123. package/dist/press-kit/articles/article-headings.d.ts +61 -0
  124. package/dist/press-kit/articles/html-tags.d.ts +46 -0
  125. package/dist/press-kit/articles/types.d.ts +2 -0
  126. package/dist/revalidate/index.js +3 -3
  127. package/dist/runtime/index.js +3 -3
  128. package/dist/seo/client.js +6 -6
  129. package/dist/seo/index.js +14 -13
  130. package/dist/seo/llms/contract.js +2 -1
  131. package/dist/seo/llms.js +9 -7
  132. package/dist/seo/meta/contract.js +2 -1
  133. package/dist/seo/register-sitemap-cli.js +1 -1
  134. package/dist/seo/server.js +5 -4
  135. package/dist/seo/sitemap.js +7 -5
  136. package/dist/seo-meta/contract.js +2 -1
  137. package/dist/server/index.js +3 -3
  138. package/dist/{server-api-EGOKC7Q3.js → server-api-XIKDWSNO.js} +2 -2
  139. package/dist/shared/build-entries.d.ts +3 -1
  140. package/dist/shared/markdown.d.ts +7 -6
  141. package/dist/shared/version.d.ts +1 -1
  142. package/dist/signal/index.js +2 -2
  143. package/dist/sitemap/index.js +7 -5
  144. package/dist/slots/index.js +2 -2
  145. package/dist/sync/index.js +5 -5
  146. package/dist/website/contract.js +2 -1
  147. package/dist/website/images.js +4 -4
  148. package/dist/website/index.js +10 -9
  149. package/dist/website/landing/server.js +19 -2
  150. package/dist/website/landing.js +4 -2
  151. package/dist/website/popups.js +9 -8
  152. package/dist/website/slots.js +2 -2
  153. package/dist/{writeLLMsTxt-BAK5XMRR.js → writeLLMsTxt-QN2HIVT4.js} +6 -4
  154. package/docs.json +5 -0
  155. package/package.json +1 -1
  156. package/src/analytics/README.md +1 -1
  157. package/src/forms/README.md +22 -0
  158. package/src/landing/README.md +64 -0
  159. package/src/llms/README.md +52 -20
  160. package/dist/FileField-F7BG4XAZ.js +0 -83
  161. package/dist/SiteChat-P4CRBRJZ.js +0 -6
  162. package/dist/SiteDesignReporter-QDVP4WMK.js +0 -12
  163. package/dist/SitemapSync-5FBRDGHG.js +0 -9
  164. package/dist/article-tables-6XHVULPU.js +0 -2
  165. package/dist/chunk-EKBEOXTH.js +0 -114
  166. package/dist/chunk-NZ4RXN3G.js +0 -145
  167. package/dist/chunk-OSYCOC3K.js +0 -21
  168. /package/dist/{chunk-7ZWO5HDQ.js → chunk-BTHEQDN4.js} +0 -0
  169. /package/dist/{chunk-BQR4IPBW.js → chunk-ISRBSTB7.js} +0 -0
  170. /package/dist/{chunk-IHG36STL.js → chunk-XHYE24HU.js} +0 -0
@@ -6,6 +6,13 @@
6
6
  * `@sonordev/site-kit/seo/llms/contract`.
7
7
  *
8
8
  * @see LLM_GEO_CONTRACT.md in this folder
9
+ *
10
+ * It is also where llms.txt's page notes, page titles and page order are
11
+ * decided, so the Sonor API's index, its fallback file and site-kit's local
12
+ * generator can't disagree: `pickLlmsPageDescription` (summary, curated meta,
13
+ * the page's own meta, the schema description, then a cleaned scrape),
14
+ * `llmsPageTitle`, `isLlmsListablePage`, `selectLlmsPages`, `groupLlmsPages`,
15
+ * `llmsPageListNote` and `llmsContactLines`.
9
16
  */
10
17
  /** Increment when public LLMs payload shape changes incompatibly. */
11
18
  export declare const LLM_GEO_CONTRACT_VERSION: 1;
@@ -29,8 +36,27 @@ export interface LLMsPayloadMeta {
29
36
  llms_disclaimer?: string | null;
30
37
  }
31
38
  export declare function clipAtWordBoundary(text: string, max: number): string;
39
+ /**
40
+ * The text with its phone numbers and email addresses taken out. The business's
41
+ * Contact Information block already carries them, and a page's note is no place
42
+ * for either.
43
+ *
44
+ * Works a sentence at a time: a sentence with a detail in it keeps what comes
45
+ * before the detail (and before a "call" or "email us at" in front of it) when
46
+ * that is still a sentence of four words or more, and is dropped otherwise.
47
+ * "Family-owned HVAC in Cincinnati, call 513-555-1234 today." becomes
48
+ * "Family-owned HVAC in Cincinnati."; "Call 513-555-1234 for a quote." goes.
49
+ * Cutting out only the number would leave "for a quote." behind.
50
+ */
51
+ export declare function stripContactDetails(text: string): string;
52
+ export declare function stripPageFiller(note: string): string;
32
53
  /**
33
54
  * Public-safe summary for llms.txt `[title](url): notes` — never CRM-only or internal copy.
55
+ *
56
+ * A summary that mentions a phone number or an email address loses that part
57
+ * (`stripContactDetails`) and is still used. It used to be thrown away whole,
58
+ * and a page with no summary is described by its scrape, so a well-meant
59
+ * "...call 555-1234" summary made the page's note worse, not safer.
34
60
  */
35
61
  export declare function sanitizeLlmsPublicSummary(raw: unknown): string | null;
36
62
  /**
@@ -53,3 +79,177 @@ export declare function sanitizePrimaryLanguageTag(raw: unknown): string | null;
53
79
  export declare function isEmittableReviewNode(node: Record<string, unknown>): boolean;
54
80
  /** Pick JSON-LD-safe subset of managed_llm_schema for script injection. */
55
81
  export declare function pickManagedLlmSchemaForJsonLd(schema: Record<string, unknown> | null | undefined): Record<string, unknown> | null;
82
+ /** The longest note a page line carries. */
83
+ export declare const LLMS_DESCRIPTION_MAX_LENGTH = 500;
84
+ /**
85
+ * A page's scraped text (seo_pages.content_text) made fit to describe it.
86
+ *
87
+ * The DOM scrape joins elements with nothing between them: a heading, its
88
+ * tagline and the first paragraph arrive as "Getting paid for the
89
+ * workIncome, spending, budgets.Track expenses by vendor", a contact page as
90
+ * "First Name*Last Name*Email*PhoneMessage*Send Message". This puts the
91
+ * whitespace back, drops what is not about the page (form labels, menu items,
92
+ * skip links, cookie banners, "Eyebrow · Section" lines) and returns what is
93
+ * left. It may return '' when nothing worth a note is.
94
+ *
95
+ * It does not cut to a length: the caller clips (`clipAtWordBoundary`). It
96
+ * does not strip phone numbers: that is `stripContactDetails`. `knownTerms`
97
+ * are words written with a capital inside them that must stay whole.
98
+ */
99
+ export declare function cleanScrapedPageText(raw: string | null | undefined, options?: {
100
+ knownTerms?: readonly string[];
101
+ }): string;
102
+ /** Where a page's note came from, best source first. */
103
+ export type LlmsDescriptionSource = 'summary' | 'managed_meta' | 'meta' | 'schema' | 'scrape';
104
+ export interface LlmsDescriptionInput {
105
+ /** The page's own name: a note that only repeats it says nothing. */
106
+ title?: string | null;
107
+ /** seo_pages.llms_public_summary: written for this file. */
108
+ llms_public_summary?: string | null;
109
+ /** seo_pages.managed_meta_description: the curated meta description. */
110
+ managed_meta_description?: string | null;
111
+ /** seo_pages.meta_description: the meta description the page serves. */
112
+ meta_description?: string | null;
113
+ /** The `description` of the page's managed LLM schema. */
114
+ llm_schema_description?: string | null;
115
+ /** seo_pages.content_text: the DOM scrape. */
116
+ content_text?: string | null;
117
+ /**
118
+ * True when the page's managed copy says the page itself is missing
119
+ * (describesErrorPage). That copy, the schema and the page's own meta
120
+ * description are then not used.
121
+ */
122
+ copyWithheld?: boolean;
123
+ }
124
+ /**
125
+ * The note a page's line in llms.txt carries, and where it came from.
126
+ *
127
+ * One rule for every llms.txt Sonor builds, best source first:
128
+ *
129
+ * 1. `llms_public_summary`, written for this file, with a phone number or an
130
+ * email address taken out rather than the whole summary thrown away;
131
+ * 2. `managed_meta_description`, the curated meta description;
132
+ * 3. `meta_description`, the one the page serves;
133
+ * 4. the managed schema's description;
134
+ * 5. the cleaned scrape (`cleanScrapedPageText`), last, because it is the
135
+ * least written of the five.
136
+ *
137
+ * Until 2026-10 the scrape was FIRST: llms.txt, the one file handed straight
138
+ * to AI crawlers, described a contact page as "First Name*Last Name*Email*".
139
+ * Every note is cut at a sentence or word boundary at 500 characters. A scrape
140
+ * that is an error page is not used (`usablePageText`). Returns null when
141
+ * nothing says anything about the page.
142
+ */
143
+ export declare function pickLlmsPageDescription(input: LlmsDescriptionInput): {
144
+ text: string;
145
+ source: LlmsDescriptionSource;
146
+ } | null;
147
+ /** `pickLlmsPageDescription`'s text. */
148
+ export declare function resolveLlmsPageDescription(input: LlmsDescriptionInput): string | null;
149
+ /**
150
+ * The note on a page's line, from what the payload carries: its
151
+ * `llms_public_summary`, then its `description`. With `publicSummaryOnly`
152
+ * (a site that wants strict notes) only the summary counts. Single source of
153
+ * truth for the Sonor API's fallback file and site-kit's local generator, which
154
+ * each had their own copy.
155
+ */
156
+ export declare function llmsPageListNote(page: {
157
+ description?: string | null;
158
+ llms_public_summary?: string | null;
159
+ }, publicSummaryOnly?: boolean): string | undefined;
160
+ export interface LlmsListablePage {
161
+ path: string;
162
+ http_status?: number | null;
163
+ has_noindex?: boolean | null;
164
+ robots_meta?: string | null;
165
+ managed_robots_meta?: string | null;
166
+ }
167
+ /**
168
+ * Whether a live seo_pages row belongs in llms.txt. A page the last visit saw
169
+ * answer with an error (404, 5xx), a page that asks search engines to leave it
170
+ * out, and a utility page (a thank-you page, a cart, a login) are not listed:
171
+ * llms.txt is the index of what a site is, and every dead or unwanted link in
172
+ * it is one a model may follow and cite. Retired pages never get here
173
+ * (LIVE_SEO_PAGE_FILTER).
174
+ */
175
+ export declare function isLlmsListablePage(page: LlmsListablePage): boolean;
176
+ /**
177
+ * What a page is called in llms.txt: its curated title, else the title it
178
+ * serves, else its h1, each with the brand suffix off and only when it is a
179
+ * name and not an address; the home page is `homeName`; else the last segment
180
+ * of its path as a label. Never the path itself: "[/features/accounting]" was
181
+ * what a page with no title got.
182
+ */
183
+ export declare function llmsPageTitle(page: {
184
+ path: string;
185
+ managed_title?: string | null;
186
+ title?: string | null;
187
+ h1?: string | null;
188
+ }, homeName?: string | null): string;
189
+ /**
190
+ * Choose which pages survive a page cap.
191
+ *
192
+ * A round-robin across top-level sections, not a truncation: cutting an
193
+ * alphabetical list amputates its tail. On a 191-page site a cap of 50 kept 37
194
+ * `/industries/*` pages and dropped `/work`, `/pricing`, `/services`,
195
+ * `/web-design` and `/resources` entirely. Every section is represented at any
196
+ * cap, and the survivors keep their original order, because the round-robin
197
+ * decides membership, not presentation. Sections are taken most valuable first
198
+ * (the order `groupLlmsPages` lists them in, legal pages last), so a cap smaller
199
+ * than the number of sections drops the least valuable one.
200
+ *
201
+ * Single source of truth: the Sonor API caps its index with it, and site-kit's
202
+ * local generator caps with it instead of `slice(0, max)`.
203
+ */
204
+ export declare function selectLlmsPages<T extends {
205
+ path: string;
206
+ }>(pages: T[], cap: number): T[];
207
+ export interface LlmsPageGroup<T> {
208
+ /** null for the one list of a small site. */
209
+ heading: string | null;
210
+ pages: T[];
211
+ }
212
+ /**
213
+ * The page index in the order and groups an answer engine reads best: the
214
+ * home page, then the site's top-level pages by value (what it does, where,
215
+ * the proof, who, the cost, contact), then one group per section of the site
216
+ * (`/services/*` is "Services"), biggest unlisted sections last, and the legal
217
+ * pages at the very end. Nothing is dropped or renamed; a list of 12 pages or
218
+ * fewer is one ungrouped list in the same order.
219
+ *
220
+ * Single source of truth for the Sonor API's fallback file and site-kit's
221
+ * local generator.
222
+ */
223
+ export declare function groupLlmsPages<T extends {
224
+ path: string;
225
+ }>(pages: readonly T[]): Array<LlmsPageGroup<T>>;
226
+ /**
227
+ * The one-line summary under the business name: its tagline, else the first
228
+ * sentence of its description, else its name. The first sentence comes from
229
+ * `splitSentences`, not `description.split('.')[0]`, which ends "Smith &
230
+ * Sons, Inc. is a roofing company" at "Inc".
231
+ */
232
+ export declare function llmsBlockquoteSummary(business: {
233
+ name?: string | null;
234
+ tagline?: string | null;
235
+ description?: string | null;
236
+ }): string;
237
+ export interface LlmsContactLike {
238
+ phone?: string | null;
239
+ email?: string | null;
240
+ address?: string | null;
241
+ city?: string | null;
242
+ state?: string | null;
243
+ postal_code?: string | null;
244
+ country?: string | null;
245
+ hours?: unknown;
246
+ }
247
+ /**
248
+ * The bullets of the Contact Information block, none for a field that is
249
+ * empty. An address reads "1 Main St, Springfield, OH 45501", and a
250
+ * default "US" is not printed. Empty array when there is nothing to show, so a
251
+ * caller leaves the whole section out rather than print a bare heading.
252
+ */
253
+ export declare function llmsContactLines(contact: LlmsContactLike | null | undefined): string[];
254
+ /** A page or service link as an absolute URL on the site, so a crawler never resolves it against llms.txt's own address. */
255
+ export declare function llmsAbsoluteUrl(baseUrl: string | null | undefined, pathOrUrl: string): string;
@@ -0,0 +1,267 @@
1
+ /** Bumped when a stored shape here (SegmentGrant, the segment key) changes incompatibly. */
2
+ export declare const OUTREACH_CONTRACT_VERSION = 1;
3
+ /** The badge Signal's outreach writer files and acts under. */
4
+ export declare const OUTREACH_WRITER_BADGE = "signal.outreach.writer";
5
+ /** The ledger kind segments belong to, and its key under settings.autonomy.segments. */
6
+ export declare const OUTREACH_EMAIL_KIND = "outreach_email";
7
+ /**
8
+ * Signal's one graduation bar: a track record earns sending without a click
9
+ * per item once at least minMeasured are measured, at least minWinRate of
10
+ * them won, and none degraded without being undone. Single source of truth
11
+ * for both APIs: Signal measures a kind or a segment against it, and an
12
+ * earned segment grant needs minMeasured receipts wherever
13
+ * grantEvidenceRefusal checks one.
14
+ */
15
+ export declare const GRADUATION_BAR: {
16
+ readonly minMeasured: 5;
17
+ readonly minWinRate: 0.7;
18
+ readonly maxUnrevertedDegradations: 0;
19
+ };
20
+ /**
21
+ * A segment's standing, one vocabulary everywhere:
22
+ * - ungranted: nobody turned it on (its next email is a first email);
23
+ * - granted: a person turned it on and it's sending without a per-email click;
24
+ * - paused: it stopped itself (a quality loss or a list signal) until a person resumes it;
25
+ * - lapsed: the pitch changed since it was turned on, so its next email is a first email again;
26
+ * - revoked: a person stopped it.
27
+ * "Awaiting first approval" is a drip check's answer, never a stored state.
28
+ */
29
+ export declare const SEGMENT_STATES: readonly ['ungranted', 'granted', 'paused', 'lapsed', 'revoked'];
30
+ export type SegmentState = (typeof SEGMENT_STATES)[number];
31
+ export type OutreachSegmentSource = 'drip' | 'mcp';
32
+ /** The vertical of a lead with no industry. Never grantable. */
33
+ export declare const UNSPECIFIED_INDUSTRY = "unspecified";
34
+ /**
35
+ * A prospect's industry as written, in the one order every caller uses: the
36
+ * target company's vertical, then its industry, then the lead's
37
+ * company_industry. Null when none is set.
38
+ */
39
+ export declare function prospectIndustry(prospect: {
40
+ vertical?: unknown;
41
+ industry?: unknown;
42
+ company_industry?: unknown;
43
+ } | null | undefined): string | null;
44
+ /**
45
+ * An industry as it appears in a segment key: NFKC, accents folded, lowercase,
46
+ * "&" read as "and", every run of anything that isn't a-z or 0-9 a single
47
+ * "-", trimmed, at most 48 characters. Empty is 'unspecified'. There's no
48
+ * synonym table: "Roofing" and "Roofers" are two segments.
49
+ */
50
+ export declare function normalizeIndustry(value: unknown): string;
51
+ /** The fields of an outreach_narratives row that are its pitch. Its name, its switch and its forbidden phrases aren't. */
52
+ export declare const NARRATIVE_PITCH_FIELDS: readonly ['positioning_statement', 'target_icp_description', 'tone_profile', 'hook_types', 'example_angles', 'value_prop_bullets'];
53
+ /** A narrative row, or any object carrying its pitch fields; every other column (name, enabled, forbidden_phrases, ...) is read past. */
54
+ export type NarrativePitch = {
55
+ [K in (typeof NARRATIVE_PITCH_FIELDS)[number]]?: unknown;
56
+ } & Record<string, unknown>;
57
+ /**
58
+ * The narrative's pitch, fingerprinted: FNV-1a of canonical JSON of
59
+ * NARRATIVE_PITCH_FIELDS only. Rewriting the pitch changes it, and that
60
+ * lapses a segment's grant. Renaming the narrative, switching it on or off,
61
+ * or tightening its forbidden phrases or avoid_tone doesn't: those take
62
+ * effect at once through the checker and the prompt.
63
+ */
64
+ export declare function narrativeFingerprint(narrative: NarrativePitch | null | undefined): string;
65
+ /** target_ref.segment on an outreach_email row: stamped once, at filing. */
66
+ export interface OutreachSegment {
67
+ key: string;
68
+ source: OutreachSegmentSource;
69
+ narrative_id: string | null;
70
+ mailbox_id: string | null;
71
+ vertical: string;
72
+ label: string;
73
+ angle_fp: string | null;
74
+ }
75
+ /**
76
+ * A segment: which pitch, from which mailbox, to which kind of business.
77
+ * - drip: `drip:<narrative uuid>@<mailbox uuid>|<vertical>`. Null when either id isn't a UUID.
78
+ * - mcp: `mcp:-|<vertical>`. For metrics only; never granted.
79
+ */
80
+ export declare function outreachSegment(input: {
81
+ source: OutreachSegmentSource;
82
+ narrativeId?: string | null;
83
+ mailboxId?: string | null;
84
+ industry?: unknown;
85
+ label?: string | null;
86
+ angleFp?: string | null;
87
+ }): OutreachSegment | null;
88
+ /** A segment's label: "<narrative name> to <industry as written>, from <mailbox address>". */
89
+ export declare function outreachSegmentLabel(input: {
90
+ narrativeName?: unknown;
91
+ industry?: unknown;
92
+ mailboxAddress?: unknown;
93
+ }): string;
94
+ export type ParsedOutreachSegmentKey = {
95
+ source: 'drip';
96
+ narrative_id: string;
97
+ mailbox_id: string;
98
+ vertical: string;
99
+ } | {
100
+ source: 'mcp';
101
+ vertical: string;
102
+ };
103
+ /**
104
+ * A segment key, parsed strictly, or null. Nothing else is ever used as a key
105
+ * in projects.settings, so a caller-chosen key ("__proto__", a long string)
106
+ * is refused before any read or write.
107
+ */
108
+ export declare function parseOutreachSegmentKey(key: unknown): ParsedOutreachSegmentKey | null;
109
+ /**
110
+ * Whether a segment can ever be turned on: a drip segment with a known
111
+ * industry. An mcp segment, or a lead with no industry, always waits for a
112
+ * person. (A row whose recipient Sonor already knows waits too; that's the
113
+ * row's relationship, checked separately.)
114
+ */
115
+ export declare function isGrantableSegmentKey(key: unknown): boolean;
116
+ /**
117
+ * A person's standing approval of one segment, at
118
+ * projects.settings.autonomy.segments.outreach_email[key]. Written only by
119
+ * Signal's AutonomyPolicyService.setSegmentGrant, refused on every generic
120
+ * settings writer, and only ever trusted after grantEvidenceRefusal passes.
121
+ */
122
+ export interface SegmentGrant {
123
+ state: 'granted' | 'revoked';
124
+ /** first_email: a person approved the segment's first email unedited with "Send the rest like this". earned: they accepted Signal's offer once the bar was met. */
125
+ source: 'first_email' | 'earned';
126
+ /** contacts.id of the person who turned it on. */
127
+ by: string;
128
+ /** Where they did it. Never MCP. */
129
+ via: 'dashboard' | 'drafts';
130
+ at: string;
131
+ /** first_email: the ledger id of that email. earned: the offer's dedup key. */
132
+ ref: string;
133
+ /** earned: the ledger ids of the wins that earned it. */
134
+ receipts?: string[];
135
+ /** The mailbox it sends from. A grant never covers another mailbox. */
136
+ mailbox_id: string;
137
+ label: string;
138
+ /** narrativeFingerprint when it was turned on. A rewritten pitch lapses it. */
139
+ angle_fp: string;
140
+ /** Stopped itself. Cleared only by a person's resume. */
141
+ paused?: {
142
+ at: string;
143
+ reason: string;
144
+ loss_ref: string | null;
145
+ };
146
+ resumed?: {
147
+ by: string;
148
+ at: string;
149
+ };
150
+ revoked?: {
151
+ by: string;
152
+ at: string;
153
+ reason: string;
154
+ };
155
+ }
156
+ /**
157
+ * Every well-formed grant, by segment key. Pass projects.settings or its
158
+ * autonomy document. A key that doesn't parse strictly, or a grant that isn't
159
+ * the SegmentGrant shape, is left out: an unreadable grant counts as no grant.
160
+ */
161
+ export declare function readSegmentGrants(source: unknown): Record<string, SegmentGrant>;
162
+ /** The grant for a segment when it's live (granted, not paused, on a grantable key), else null. Evidence is checked separately. */
163
+ export declare function activeSegmentGrant(source: unknown, key: string): SegmentGrant | null;
164
+ export type SegmentGrantCoverage = {
165
+ covers: true;
166
+ } | {
167
+ covers: false;
168
+ code: 'not_granted' | 'revoked' | 'paused' | 'other_person' | 'granted_after' | 'angle_changed' | 'other_mailbox';
169
+ reason: string;
170
+ };
171
+ /**
172
+ * Whether a live grant covers one row: refused when there's no grant, it was
173
+ * revoked or paused, the row names another approver, the row was filed
174
+ * before the grant, the pitch changed since (a different fingerprint), or the
175
+ * row sends from another mailbox.
176
+ */
177
+ export declare function segmentGrantCovers(grant: SegmentGrant | null | undefined, row: {
178
+ by?: string | null;
179
+ createdAt: string | null | undefined;
180
+ angleFp: string | null | undefined;
181
+ mailboxId: string | null | undefined;
182
+ }): SegmentGrantCoverage;
183
+ /** The columns of a signal_proposals row grantEvidenceRefusal reads. */
184
+ export interface GrantEvidenceRow {
185
+ id: string;
186
+ kind: string;
187
+ status: string;
188
+ project_id?: string | null;
189
+ created_by?: string | null;
190
+ reviewed_by?: string | null;
191
+ reviewed_at?: string | null;
192
+ target_ref?: unknown;
193
+ payload?: unknown;
194
+ }
195
+ export interface GrantEvidenceScope {
196
+ /** The segment key the grant is stored under. */
197
+ key: string;
198
+ /** GRADUATION_BAR.minMeasured: how many receipts an earned grant needs. */
199
+ minMeasured: number;
200
+ /** The project the grant is stored on. Rows from another project never count. */
201
+ projectId?: string | null;
202
+ /** contacts.id of reviewers known to be people (not agents). When given, every reviewer must be in it. */
203
+ people?: ReadonlySet<string> | ReadonlyArray<string> | null;
204
+ }
205
+ /**
206
+ * Why a grant can't be trusted, or null when the ledger rows it cites stand
207
+ * behind it. A grant that fails counts as no grant, so a grant forged into
208
+ * settings never sends anything.
209
+ *
210
+ * - first_email: grant.ref is an applied outreach_email the writer filed in
211
+ * this project and segment, from the grant's mailbox, approved by grant.by
212
+ * at or before grant.at, in Sonor (via dashboard or drafts, never MCP),
213
+ * unedited, and read back verified.
214
+ * - earned: at least scope.minMeasured distinct receipts, each an applied
215
+ * writer row in this segment and mailbox, for the same pitch, approved by a
216
+ * person in Sonor at or before grant.at, unedited and read back verified.
217
+ */
218
+ export declare function grantEvidenceRefusal(grant: SegmentGrant | null | undefined, refRows: ReadonlyArray<GrantEvidenceRow> | null | undefined, scope: GrantEvidenceScope): string | null;
219
+ /** The live roles of the person asking, read by the caller. */
220
+ export interface SegmentGranterRoles {
221
+ contact_type?: string | null;
222
+ is_platform_admin?: boolean | null;
223
+ /** Their role in the project's org. */
224
+ org_role?: string | null;
225
+ /** Their role on the project itself. */
226
+ project_role?: string | null;
227
+ /** Their role in the agency that manages the project's org. */
228
+ managing_org_role?: string | null;
229
+ }
230
+ /**
231
+ * Whether this person may turn a segment on (or resume it): never an agent
232
+ * contact; otherwise a platform admin, or an owner or admin of the project,
233
+ * its org, or the agency that manages that org.
234
+ */
235
+ export declare function segmentGranterEligible(roles: SegmentGranterRoles | null | undefined): boolean;
236
+ /**
237
+ * Domains anyone can sign up for. A matching address says nothing about the
238
+ * company someone works for, so a relationship check never matches on one.
239
+ */
240
+ export declare const PUBLIC_MAILBOX_DOMAINS: ReadonlyArray<string>;
241
+ /** Whether an address or a domain is a public mailbox provider's. Case and a leading "mailto:" or "@" don't matter. */
242
+ export declare function isPublicMailboxDomain(value: unknown): boolean;
243
+ /**
244
+ * - live: still waiting on a person or a held send. Leave the lead reserved.
245
+ * - sent: the email went out. The lead moves on.
246
+ * - retire: a person said it shouldn't go to this person. Drop the lead.
247
+ * - declined: a person turned this email down for another reason. The lead goes back in line.
248
+ * - refile: nothing was decided about the lead (wrong timing, expired,
249
+ * superseded, the kind switched off, a send that never went out). It may be
250
+ * filed again after its cooldown.
251
+ */
252
+ export type DripRowDisposition = 'live' | 'sent' | 'retire' | 'declined' | 'refile';
253
+ /** The columns of a signal_proposals row dripRowDisposition reads. */
254
+ export interface DripLedgerRow {
255
+ status: string;
256
+ review_reason?: string | null;
257
+ reviewed_by?: string | null;
258
+ autonomy_decision?: unknown;
259
+ payload?: unknown;
260
+ }
261
+ /**
262
+ * What a drip's ledger row means for its lead. Signal's filing dedup and the
263
+ * Sonor API's reservation release both read it, so "decided" means one thing.
264
+ * A status it doesn't know reads as live: a lead stays reserved rather than
265
+ * being written to twice.
266
+ */
267
+ export declare function dripRowDisposition(row: DripLedgerRow | null | undefined): DripRowDisposition;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Page-titles contract: what a page is called when a list, a sitemap or a
3
+ * case study needs its own name and not its whole <title>. The Sonor APIs and
4
+ * the agency site kit import it as `@sonordev/contracts/page-titles`.
5
+ *
6
+ * Why it exists: three places each turned a stored title or a URL slug into a
7
+ * label with their own logic, and they disagreed. "ac-replacement" became "Ac
8
+ * Replacement", "faq" became "Faq", "smith-and-smith" became "Smith And
9
+ * Smith", and a title with a stray second pipe shipped as "Loans | | Brand".
10
+ * There's one rule now:
11
+ *
12
+ * - `titleHead` is the page's own name: the title without its trailing
13
+ * " | Brand" segment. Prefer it. A page's title is the label it was
14
+ * written with, so it beats anything guessed from a URL.
15
+ * - `slugLabel` is for the rare case a label has to come from a slug, a file
16
+ * name or a path, because the page has no title at all.
17
+ * - `isRoutePlaceholder` says whether a value is a framework route
18
+ * placeholder (":slug", "[slug]", "[...rest]") and not a page's name.
19
+ * - `isPageAddress` says whether a value is a page's address (a path, a
20
+ * URL, a route pattern, or the page's own path without its slashes) and
21
+ * not its name. It's the one "is this title really a path" rule, so the
22
+ * writer of a page list and the reader that prints it can't disagree.
23
+ * - `isPageName` says whether a stored title is a name: something is left
24
+ * once the pipes are set aside, and no piece between them is an address.
25
+ * It's the one rule for a title that gets printed whole, so the writer
26
+ * that stores a name and the reader that prints it can't disagree about
27
+ * a path, a URL or a placeholder behind a pipe.
28
+ *
29
+ * Pure string logic: no I/O, no Node built-ins. The brand split is
30
+ * seo-meta's `stripTitleSuffix`, imported, never copied: only the LAST
31
+ * " | " segment of a title is its brand.
32
+ */
33
+ /**
34
+ * The page's own name: the title without its trailing " | Brand" segment.
35
+ *
36
+ * Whitespace is collapsed first. Stray pipes are repaired before the brand is
37
+ * cut, so "Loans | | Brand" reads as "Loans | Brand" (a run of pipes with
38
+ * only whitespace between them is one separator) and a leading or trailing
39
+ * pipe is dropped ("Pricing |" is "Pricing"). Then the last " | " segment is
40
+ * the brand and goes; anything before it stays, pipes and all:
41
+ * "1 Bedroom | 1BR 1BA | Example Apartments" is "1 Bedroom | 1BR 1BA".
42
+ *
43
+ * Apply it once to a stored title. A title with two or more separators
44
+ * loses one segment per call, so its own output isn't a fixed point.
45
+ *
46
+ * A title with no separator comes back as written (trimmed): this can't tell
47
+ * a brand from a name, so a title that's only a brand stays that brand.
48
+ * Empty, whitespace-only, non-string, null and undefined input give "".
49
+ */
50
+ export declare function titleHead(title: string | null | undefined): string;
51
+ /**
52
+ * A readable label for a slug, a file name or a path: "ac-replacement" is
53
+ * "AC Replacement", "/services/hvac-repair/" is "HVAC Repair".
54
+ *
55
+ * Reach for it only when the page has no title (`titleHead` is the page's own
56
+ * name). It reads the last non-empty path segment of the input, after
57
+ * dropping a "https://host" origin, a query or a hash, and decodes %-escapes
58
+ * without ever throwing. A trailing .html, .htm, .php or .aspx is dropped. The
59
+ * words split on "-", "_", "+" and whitespace.
60
+ *
61
+ * Each word is cased by the first rule that fits:
62
+ * - an acronym is upper case ("ac", "ai", "cms", "faq", "hvac", "seo",
63
+ * "llc", "mls", ...), and "faqs" is "FAQs";
64
+ * - a two-letter US state or DC code is upper case ("ky" is "KY"), apart
65
+ * from the codes that are ordinary words as often as not ("in", "or",
66
+ * "me", "hi", "id", "la", "ma", "pa", "al", "de", "co"). "mt" goes by its
67
+ * position: last, it's Montana ("billings-mt" is "Billings MT"); with a
68
+ * word after it, it's Mount ("mt-adams" is "Mt Adams"). "us" isn't a
69
+ * state: "about-us" is "About Us";
70
+ * - a word with a digit keeps its case and only gains a leading capital
71
+ * when it starts with a letter ("c1" is "C1", "075ct" stays);
72
+ * - a small word ("a", "an", "and", "at", "by", "for", "in", "of", "on",
73
+ * "or", "the", "to", "vs", "with") is lower case, unless it's first;
74
+ * - anything else is capitalized with the rest lower case.
75
+ *
76
+ * Nothing left to label ("", "/", "?x=1") gives "". The home page is the
77
+ * caller's to name. A label isn't a title: "index" isn't special-cased,
78
+ * and a brand's own capitalization ("DeWalt") isn't recoverable from a slug.
79
+ */
80
+ export declare function slugLabel(input: string): string;
81
+ /**
82
+ * True when the value, or any "/"-separated segment of it, is a framework
83
+ * route placeholder: a page's pattern leaked into its name ("/floor-plans/:slug",
84
+ * ":Slug", "[...rest]"). A placeholder is one token with no spaces, so
85
+ * ordinary text that contains a colon or a bracket isn't one ("Update: new
86
+ * hours", "FAQ / [draft] notes"). Nullish, empty and non-string values are
87
+ * false.
88
+ */
89
+ export declare function isRoutePlaceholder(value: string | null | undefined): boolean;
90
+ /**
91
+ * Whether `value` is a page's address and not its name: what a page's title
92
+ * becomes when nothing better was stored (the old sitemap writers saved the
93
+ * path there), or a pattern that leaked out of the router. True for:
94
+ *
95
+ * - a path, or anything starting with a slash ("/services/ac-repair",
96
+ * "//example.com/services");
97
+ * - an absolute URL ("https://example.com/services");
98
+ * - a host and a path with no scheme ("example.com/services");
99
+ * - a route placeholder, as `isRoutePlaceholder` reads it ("/floor-plans/:slug",
100
+ * ":Slug", "[slug]");
101
+ * - the page's own path written without its slashes. Given `path`, the
102
+ * comparison drops leading and trailing slashes from both sides, so for
103
+ * "/services/ac-repair" the values "services/ac-repair" and
104
+ * "services/ac-repair/" are the address, and for "/faq" the value "faq" is.
105
+ * It compares the whole path and it's case-sensitive: "ac-repair" isn't
106
+ * the address of "/services/ac-repair", and "Pricing" for "/pricing" is a
107
+ * name where "pricing" is the path.
108
+ *
109
+ * The value is trimmed first. Empty, whitespace-only, nullish and non-string
110
+ * values give false: they aren't an address, they're nothing, and the caller
111
+ * decides what an empty title means. `path` is optional; without it only the
112
+ * checks that don't need the page's own path run.
113
+ *
114
+ * It judges one string. A title with a " | Brand" tail is `titleHead`'s to
115
+ * cut, and the caller asks about the head: `isPageAddress(titleHead(title), path)`.
116
+ * Pure string logic, linear in the length of the input.
117
+ */
118
+ export declare function isPageAddress(value: string | null | undefined, path?: string | null): boolean;
119
+ /**
120
+ * Whether a stored title is a real name for the page at `path`: something is
121
+ * left once the pipes are set aside, and none of its pieces is a page's
122
+ * address (`isPageAddress`, given the page's path). It's the one rule for a
123
+ * title that gets printed whole, so whoever stores a name and whoever prints
124
+ * it can't disagree.
125
+ *
126
+ * Every piece is judged, the first and the last included, because a title
127
+ * with a pipe in it prints with its pipes: ":slug | Example Apartments" (a
128
+ * placeholder in front of a brand tail) and "Floor Plans | /floor-plans/x" (a
129
+ * path behind the name) are both refused, and so is the unspaced
130
+ * "Floor Plans|/floor-plans/x". A name with a pipe in it stays a name while
131
+ * every piece is text ("1 Bedroom | 1BR 1BA"). A title that's only pipes has
132
+ * no piece and isn't a name. Empty, whitespace-only, nullish and non-string
133
+ * values aren't names either.
134
+ *
135
+ * It judges the title it is given, brand tail and all. A writer cuts the tail
136
+ * first and asks about what it stores: `isPageName(titleHead(title), path)`.
137
+ * `path` is optional, as it is for `isPageAddress`. Pure string logic, linear
138
+ * in the length of the input.
139
+ */
140
+ export declare function isPageName(title: string | null | undefined, path?: string | null): boolean;