@sonordev/contracts 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0
4
+
5
+ A minor release: one new entry, `@sonordev/contracts/proposal-sitemap`. Every other entry is unchanged.
6
+
7
+ - **New entry `@sonordev/contracts/proposal-sitemap`**: how a website proposal's site plan counts its pages, so the app that writes a proposal, the API that checks it and the page that shows it state the same numbers.
8
+ - `countSitemapPlan(plan)` counts core pages, the plan's own pages, re-published articles, pages carried over and pages added, each address once.
9
+ - `sitemapTransformation(plan)` decides whether the plan's before-and-after comparison shows. It shows only when the build is bigger than today; a rebuild that keeps the same pages never reads as "N → 0".
10
+ - `normalizeSitemapLabels(labels)` keeps the words a plan may use for pages that aren't services sold to industries.
11
+ - `proposalPageCountIssues(sections)` and `describePageCountIssue(issue)` find page and URL counts stated in a proposal's headline, summary, pricing and plan copy that don't match the plan.
12
+
13
+ ## 2.1.1
14
+
15
+ - Docs only: the README names the Sonor API and the dashboard where it named internal repositories. No code changes.
16
+
3
17
  ## 2.1.0
4
18
 
5
19
  A minor release: one new entry, `@sonordev/contracts/voice`. Every other entry is unchanged.
@@ -69,7 +83,7 @@ A major for one entry, `slots`, plus three new entries; every other entry is unc
69
83
  `slots` re-exports all of it.
70
84
  - **New entry `@sonordev/contracts/site-cache`**: how Sonor refreshes a
71
85
  site's cached pages. `SITE_CACHE_TAGS` (the tag names site-kit's fetches
72
- carry and sonor-api expires), `SITE_REVALIDATE_PATH`, the webhook payload
86
+ carry and the Sonor API expires), `SITE_REVALIDATE_PATH`, the webhook payload
73
87
  and its `ping`, and `isSiteRevalidatePong`.
74
88
  - **New entry `@sonordev/contracts/site-edit`**: Sonor's Edit on page. The
75
89
  messages between a framed site and the dashboard, which origins may drive
@@ -80,5 +94,5 @@ A major for one entry, `slots`, plus three new entries; every other entry is unc
80
94
 
81
95
  First release. The rules site-kit 6.x published as `@sonordev/site-kit/*/contract`
82
96
  (website, color, sites, seo-pages, seo-meta, llms, forms, fleet, slots,
83
- portfolio), as a dependency-free package, so sonor-api, signal-api and the
84
- dashboard import them instead of keeping mirrors.
97
+ portfolio), as a dependency-free package, so the Sonor APIs and the
98
+ dashboard import them instead of keeping copies.
package/README.md CHANGED
@@ -9,19 +9,20 @@ pnpm add @sonordev/contracts
9
9
 
10
10
  | Entry | What it decides | Who reads it |
11
11
  |---|---|---|
12
- | `@sonordev/contracts/website` | A site's design tokens and what a popup is made of (blocks, limits, the text sanitizer) | site-kit renders them, sonor-api validates every write, the dashboard previews them, Signal's agents send them |
12
+ | `@sonordev/contracts/website` | A site's design tokens and what a popup is made of (blocks, limits, the text sanitizer) | site-kit renders them, the Sonor API validates every write, the dashboard previews them, Signal's agents send them |
13
13
  | `@sonordev/contracts/color` | Reading a CSS color, WCAG luminance and contrast | `website`, site-kit's brand extraction |
14
14
  | `@sonordev/contracts/sites` | The canonical shape of a site host (the multi-site `site` dimension) | every ingest and read that filters by site |
15
- | `@sonordev/contracts/seo-pages` | Which `seo_pages` row a path resolves to on a multi-site project | sonor-api, Signal, site-kit's sitemap sync |
15
+ | `@sonordev/contracts/seo-pages` | Which `seo_pages` row a path resolves to on a multi-site project | the Sonor API, Signal, site-kit's sitemap sync |
16
16
  | `@sonordev/contracts/seo-meta` | Whether a title or description is fit to ship | site-kit, Signal's SEO gate |
17
- | `@sonordev/contracts/llms` | llms.txt sanitizers and contract version | site-kit, sonor-api, Signal's schema validation |
18
- | `@sonordev/contracts/forms` | The honeypot policy | site-kit forms, sonor-api intake |
19
- | `@sonordev/contracts/fleet` | The fleet heartbeat's wire shape | site-kit, sonor-api |
20
- | `@sonordev/contracts/slots` | Slot payload signing, contract v2 (HMAC; Node only). Signs and verifies v1 for older site-kit releases | sonor-api, site-kit on the server |
21
- | `@sonordev/contracts/slot-content` | What a slot of each kind may hold (`normalizeSlotValue`), safe links, the rich-text parser. No `crypto`, so browsers can import it | sonor-api, site-kit, the dashboard editor |
22
- | `@sonordev/contracts/site-cache` | The cache tags a site's Sonor fetches carry and the revalidation route's path, payload and ping | site-kit tags and serves the route, sonor-api calls it |
17
+ | `@sonordev/contracts/llms` | llms.txt sanitizers and contract version | site-kit, the Sonor API, Signal's schema validation |
18
+ | `@sonordev/contracts/forms` | The honeypot policy | site-kit forms, the Sonor API's form intake |
19
+ | `@sonordev/contracts/fleet` | The fleet heartbeat's wire shape | site-kit, the Sonor API |
20
+ | `@sonordev/contracts/slots` | Slot payload signing, contract v2 (HMAC; Node only). Signs and verifies v1 for older site-kit releases | the Sonor API, site-kit on the server |
21
+ | `@sonordev/contracts/slot-content` | What a slot of each kind may hold (`normalizeSlotValue`), safe links, the rich-text parser. No `crypto`, so browsers can import it | the Sonor API, site-kit, the dashboard editor |
22
+ | `@sonordev/contracts/site-cache` | The cache tags a site's Sonor fetches carry and the revalidation route's path, payload and ping | site-kit tags and serves the route, the Sonor API calls it |
23
23
  | `@sonordev/contracts/site-edit` | Edit on page: the messages between a framed site and the dashboard, trusted origins, how a slot is found on the page | site-kit's overlay, the dashboard's visual editor |
24
- | `@sonordev/contracts/portfolio` | Where a portfolio number comes from and which may headline; the live-frame message | sonor-api, Signal, agency-site-kit |
24
+ | `@sonordev/contracts/portfolio` | Where a portfolio number comes from and which may headline; the live-frame message | the Sonor API, Signal, agency-site-kit |
25
+ | `@sonordev/contracts/proposal-sitemap` | How a proposal's site plan counts its pages, when its before-and-after comparison may show, and whether the page counts stated elsewhere in the proposal add up | the Sonor API, Signal's proposal writer, the dashboard's proposal pages |
25
26
  | `@sonordev/contracts/voice` | The house voice's contraction rule. `findUncontracted` and `contractText` find and apply full forms that should be contractions; `findContracted` finds the contractions a question-form heading should expand | anything that checks or rewrites copy: Sonor's APIs and dashboard |
26
27
 
27
28
  ## The voice contract
@@ -135,12 +136,59 @@ pinned by a test, so changing it later is a visible change.
135
136
  "*", "_" and masked text before it are skipped, so "Now: let us look at it."
136
137
  contracts.
137
138
 
139
+ ## The proposal-sitemap contract
140
+
141
+ A website proposal's site plan lists the pages a build delivers: core pages
142
+ (Home, About, Contact), top-level pages, and the pages under them. Its numbers
143
+ are what a buyer reads first, so they're computed here and never taken from
144
+ prose.
145
+
146
+ - **`countSitemapPlan(plan)`** returns `core`, `architecture` (the plan's own
147
+ pages), `articles` (existing posts re-published with the build), `full`
148
+ (all three), `existing` (pages carried over) and `added` (new pages). Each
149
+ address counts once: core pages first, then each top-level page and the
150
+ pages under it. A row without a slug always counts.
151
+ - **`sitemapTransformation(plan)`** returns the before-and-after comparison
152
+ to show (`{ before, after }`), or `null`. It shows only when the plan states
153
+ today's count and the build is bigger. A rebuild that keeps the same pages
154
+ isn't a before and after, so an after-count of zero, or one no bigger than
155
+ today's, hides it.
156
+ - **`normalizeSitemapLabels(labels)`** keeps the words a plan may use for its
157
+ own pages when they aren't services sold to industries: `architecture` (the
158
+ heading), `pillar` (a top-level page) and `child` (a page under one). Each is
159
+ one line of 1 to 40 characters.
160
+ - **`proposalPageCountIssues(sections)`** lists the page and URL counts a
161
+ proposal states in its headline, summary, pricing and plan copy that match
162
+ nothing the plan or its measured evidence adds up to.
163
+ `describePageCountIssue(issue)` puts one in a sentence for the person
164
+ reviewing it.
165
+
166
+ ```ts
167
+ import { countSitemapPlan, proposalPageCountIssues } from '@sonordev/contracts/proposal-sitemap'
168
+
169
+ countSitemapPlan({
170
+ corePages: [{ slug: '/' }, { slug: '/about/' }, { slug: '/locations/' }],
171
+ pages: [{ slug: '/locations/', children: [{ name: 'Springfield' }, { name: 'Shelbyville' }] }],
172
+ }).full
173
+ // 5: the top-level /locations/ page is the core one, counted once
174
+
175
+ proposalPageCountIssues([
176
+ { type: 'GlassHero', props: { stats: [{ value: '12', label: 'pages rebuilt' }] } },
177
+ { type: 'SitemapPlan', props: { pages: [{ slug: '/' }, { slug: '/about/' }] } },
178
+ ])
179
+ // [{ section: 'GlassHero', field: 'stats[0]', claimed: 12, unit: 'page', text: '12 pages', planned: 2 }]
180
+ ```
181
+
182
+ It reads "N pages", "an N-page site", "N existing URLs" and up to three words
183
+ between the number and "page" or "URL". A count written in words ("five
184
+ pages") isn't read, and a count of pages that aren't in the plan (say, a
185
+ competitor's) is reported like any other; the issues are for a person to look
186
+ at, not edits to apply.
187
+
138
188
  ## Why it exists
139
189
 
140
- Before this package, each API kept a byte-identical copy of the rules it
141
- shared with site-kit, plus a drift test to catch the copies diverging. Ten
142
- parallel copies of a rule is how two of them end up disagreeing. Now there's
143
- one: the source is `src/contracts/` in the site-kit repo, site-kit bundles it
190
+ A rule that lives in several copies is how two of them end up disagreeing.
191
+ This package means there's one: the source is `src/contracts/` in the site-kit repo, site-kit bundles it
144
192
  into its own `*/contract` entries, and this package publishes the same files
145
193
  for everyone else.
146
194
 
package/dist/fleet.d.ts CHANGED
@@ -2,13 +2,13 @@
2
2
  * @sonordev/contracts/fleet — Fleet heartbeat wire contract (v1)
3
3
  *
4
4
  * Single source of truth for the heartbeat payload shape, shared by site-kit
5
- * (send side, `@sonordev/site-kit/fleet/contract`) and sonor-api (ingest
5
+ * (send side, `@sonordev/site-kit/fleet/contract`) and the Sonor API (ingest
6
6
  * side: its DTO is type-checked against FleetHeartbeatPayload).
7
7
  *
8
- * Why it exists: ~24+ client sites run site-kit at wildly drifted versions and
9
- * the platform could not answer "which version / modules / key state is each
10
- * site running" without shelling into every local repo. The heartbeat answers
11
- * that from live traffic. It is fire-and-forget, deferred to idle (never in the
8
+ * Why it exists: sites run whatever site-kit version they last deployed, and
9
+ * Sonor should be able to answer "which version / modules / key state is each
10
+ * site running" without anyone opening each repo. The heartbeat answers that
11
+ * from live traffic. It is fire-and-forget, deferred to idle (never in the
12
12
  * hydration path), and carries NO visitor data — only the site's own build
13
13
  * fingerprint. Project identity is resolved server-side from the x-api-key
14
14
  * header, exactly like every other /api/public/* endpoint; sites never send
@@ -56,7 +56,7 @@ export interface FleetHeartbeatPayload {
56
56
  next_version?: string | null;
57
57
  /** Client-observable active modules — deduped, validated, canonical order. */
58
58
  modules: FleetModule[];
59
- /** Normalized lowercase host (multi-site sub-dimension; see CLAUDE.md). */
59
+ /** Normalized lowercase host (multi-site sub-dimension). */
60
60
  site?: string | null;
61
61
  /** 'client' (runtime boot) or 'build' (CI/build step). Default 'client'. */
62
62
  source?: FleetHeartbeatSource;
@@ -77,9 +77,9 @@ export declare function normalizeFleetModules(input: unknown): FleetModule[];
77
77
  export declare function sanitizeFleetVersion(raw: unknown): string | null;
78
78
  /**
79
79
  * Light client-side site normalization to the canonical host shape (lowercase,
80
- * no protocol/path/port). sonor-api's IngestService.normalizeSite is the
81
- * AUTHORITATIVE normalizer and re-runs on ingest — this just avoids sending
82
- * obvious noise. Keep the two shapes aligned (see CLAUDE.md "site" convention).
80
+ * no protocol/path/port). The Sonor API's normalizer is AUTHORITATIVE and
81
+ * re-runs on ingest; this just avoids sending obvious noise. Keep the two
82
+ * shapes aligned.
83
83
  */
84
84
  export declare function normalizeFleetSite(raw: unknown): string | null;
85
85
  /**
package/dist/forms.d.ts CHANGED
@@ -1,17 +1,11 @@
1
1
  /**
2
2
  * @sonordev/contracts/forms — managed-form honeypot policy (v1)
3
3
  *
4
- * Single source of truth for the honeypot trap-field naming + server-side
5
- * detection policy, shared by the client (site-kit emits the trap field) and
6
- * the server (sonor-api names the trap in the form config it returns AND
7
- * detects hits on intake). Both import this: site-kit as
8
- * `@sonordev/site-kit/forms/contract`, sonor-api through
9
- * `src/modules/forms/honeypot-contract.ts`.
4
+ * Single source of truth for the honeypot field's naming, shared by site-kit
5
+ * (which renders the field, as `@sonordev/site-kit/forms/contract`) and Sonor.
10
6
  *
11
- * The default field name (`_sk_hp_url`) is deliberately obscure: a common name
12
- * like "website" collides with real form fields and flags genuine submissions
13
- * as spam, so generic names are only trusted when they DON'T match a real
14
- * field of the form (see `resolveHoneypotFieldNames`).
7
+ * The default field name is deliberately unlike a real field's, so it never
8
+ * collides with a form's own fields (see `resolveHoneypotFieldNames`).
15
9
  */
16
10
  /** Bump on breaking changes to the honeypot policy. */
17
11
  export declare const FORMS_HONEYPOT_CONTRACT_VERSION: 1;
package/dist/llms.d.ts CHANGED
@@ -14,7 +14,7 @@ export declare const LLMS_PUBLIC_SUMMARY_MAX_LENGTH = 400;
14
14
  /** Max length for optional llms.txt blockquote disclaimer line. */
15
15
  export declare const LLMS_DISCLAIMER_MAX_LENGTH = 280;
16
16
  /**
17
- * Optional keys we allow inside managed_llm_schema JSON from Portal.
17
+ * Optional keys we allow inside managed_llm_schema JSON from Sonor.
18
18
  * Values outside this set should not be relied on by consumers; strip unknown keys when emitting JSON-LD.
19
19
  */
20
20
  export declare const MANAGED_LLM_SCHEMA_KNOWN_KEYS: Set<string>;
@@ -2,13 +2,9 @@
2
2
  * @sonordev/contracts/portfolio — portfolio proof display policy (v1)
3
3
  *
4
4
  * Single source of truth for the Lighthouse-KPI collapse policy shared by the
5
- * content side (signal-api generation) and the render side (agency-site-kit
6
- * display):
7
- * - agency-site-kit imports it (`@sonordev/site-kit/portfolio/contract`)
8
- * from `src/portfolio/curate-proof.ts`, which keeps the render-only
9
- * primitives that bind to its section types.
10
- * - signal-api imports it through
11
- * `src/modules/skills/portfolio/portfolio-kpi-policy.util.ts`.
5
+ * content side (Signal's case-study generation) and the render side
6
+ * (agency-site-kit, which imports it as `@sonordev/site-kit/portfolio/contract`
7
+ * and keeps the render-only primitives that bind to its section types).
12
8
  *
13
9
  * Why it exists: generated case studies arrived with four "100/100 Lighthouse X"
14
10
  * KPIs repeated across hero, results cards, and a Performance section. One
@@ -17,8 +13,8 @@
17
13
  *
18
14
  * It is also the single source of truth for METRIC PROVENANCE (below): the
19
15
  * three sources a portfolio number can have, what a client-reported number
20
- * must carry, and which numbers may headline. sonor-api imports those
21
- * through `src/modules/portfolio/portfolio-metric-source.util.ts`.
16
+ * must carry, and which numbers may headline. The Sonor API applies the same
17
+ * rules.
22
18
  */
23
19
  /** The message a cross-origin-framed site-kit page posts to its embedder once it has painted. */
24
20
  export declare const FRAME_READY_MESSAGE: 'sonor:frame-ready';
@@ -109,11 +105,11 @@ export declare const METRIC_SOURCES: readonly ['measured', 'reported', 'estimate
109
105
  export type MetricSource = (typeof METRIC_SOURCES)[number];
110
106
  /** Who stands behind a client-reported number. */
111
107
  export interface MetricAttribution {
112
- /** The person, e.g. "Scott Mann". */
108
+ /** The person, e.g. "Dana Reyes". */
113
109
  name: string;
114
- /** Their title, e.g. "Director of Business Development". */
110
+ /** Their title, e.g. "Director of Operations". */
115
111
  role?: string;
116
- /** Where they work: almost always the client, e.g. "True Power Systems". */
112
+ /** Where they work: almost always the client, e.g. "Northwind Engineering". */
117
113
  organization: string;
118
114
  /** The day they reported it, YYYY-MM-DD. */
119
115
  date: string;
@@ -140,12 +136,12 @@ export declare function isHeadlineMetric(metric: SourcedMetric): boolean;
140
136
  /** The badge a display shows, or null for a missing or unknown source (show nothing rather than guess). */
141
137
  export declare function metricSourceLabel(source: unknown): string | null;
142
138
  /**
143
- * The full credit: "Reported by Scott Mann, Director of Business Development,
144
- * True Power Systems, September 25, 2026". The date is read as a plain day,
139
+ * The full credit: "Reported by Dana Reyes, Director of Operations, Northwind
140
+ * Engineering, September 25, 2026". The date is read as a plain day,
145
141
  * never through a time zone, so it can't slip to the day before.
146
142
  */
147
143
  export declare function formatAttribution(by: MetricAttribution): string;
148
- /** The credit for a tight space (a card, a carousel tile): "per True Power Systems". */
144
+ /** The credit for a tight space (a card, a carousel tile): "per Northwind Engineering". */
149
145
  export declare function shortAttribution(by: Pick<MetricAttribution, 'organization'>): string;
150
146
  /**
151
147
  * Carry a person's reported numbers through a regeneration. The generator can
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @sonordev/contracts/proposal-sitemap — how a proposal's site plan is counted (v1)
3
+ *
4
+ * Single source of truth for the numbers a website proposal's site plan
5
+ * (the `SitemapPlan` block) states: how many pages the build has, when the
6
+ * "before → after" comparison may show, which custom labels the block may
7
+ * carry, and whether page counts written elsewhere in the proposal agree with
8
+ * the plan. The proposal generator stamps them, the Sonor API checks them and
9
+ * the dashboard renders them, so the headline, the plan and the price can't
10
+ * each state a different number.
11
+ *
12
+ * Why it exists: the numbers in a plan are what a buyer reads first, and a
13
+ * count written by a language model can't be trusted to add up. A rebuild
14
+ * that keeps every page must never read as a loss ("24 → 0 pages"), one
15
+ * address listed twice must not count twice, and the headline must not state
16
+ * a number the plan doesn't. Counts are computed here, never taken from prose.
17
+ */
18
+ /** Bump on breaking changes to how a plan is counted. */
19
+ export declare const PROPOSAL_SITEMAP_CONTRACT_VERSION: 1;
20
+ /**
21
+ * A count the plan states: a non-negative whole number, as a number or a
22
+ * numeric string. Anything else is no count at all (undefined), never 0.
23
+ */
24
+ export declare function sitemapCount(v: unknown): number | undefined;
25
+ /**
26
+ * The address a plan row stands for, in one shape: lowercase, no scheme or
27
+ * host, no query or fragment, a leading slash and no trailing one ("/" for the
28
+ * home page). Undefined for a row without a slug; such a row always counts,
29
+ * since nothing proves it repeats another.
30
+ */
31
+ export declare function sitemapPageKey(slug: unknown): string | undefined;
32
+ export interface SitemapPlanCounts {
33
+ /** Core pages (Home, About, Contact and the like), each address once. */
34
+ core: number;
35
+ /**
36
+ * The plan's own pages: top-level pages plus the pages under them, minus
37
+ * any address already counted as a core page.
38
+ */
39
+ architecture: number;
40
+ /** Existing articles re-published with the build (the plan's `blog.count`). */
41
+ articles: number;
42
+ /** Everything the client gets: core + architecture + articles. */
43
+ full: number;
44
+ /**
45
+ * Pages that exist today and carry over (status rebuild, optimize or
46
+ * migrate, plus the re-published articles).
47
+ */
48
+ existing: number;
49
+ /** Pages the build adds (status new, or no status). */
50
+ added: number;
51
+ }
52
+ /**
53
+ * Count a site plan's pages, each address once. Core pages are counted
54
+ * first, then each top-level page and the pages under it; a row whose
55
+ * address was already counted is skipped. Rows without a slug always count.
56
+ */
57
+ export declare function countSitemapPlan(props: unknown): SitemapPlanCounts;
58
+ export interface SitemapTransformationCounts {
59
+ /** Today's count, as the plan states it. */
60
+ before: number;
61
+ /** The build's count. */
62
+ after: number;
63
+ }
64
+ /**
65
+ * The before → after comparison a plan may show, or null when it must not
66
+ * show. It shows only when the plan states today's count and the build is
67
+ * bigger. A missing after-count falls back to the plan's own pages (then its
68
+ * `totalPages`); an after-count of zero, or one no bigger than today's, hides
69
+ * the comparison. A rebuild that keeps the same pages isn't a before and after.
70
+ */
71
+ export declare function sitemapTransformation(props: unknown): SitemapTransformationCounts | null;
72
+ /**
73
+ * Words a plan uses for its own pages, for a business whose pages aren't
74
+ * services sold to industries (communities and towns, practice areas,
75
+ * locations). Each replaces one default: `architecture` the heading
76
+ * ("Service Architecture"), `pillar` a top-level page ("Service pillar"),
77
+ * `child` a page under it ("Industry page"). Singular, title case.
78
+ */
79
+ export interface SitemapPlanLabels {
80
+ architecture?: string;
81
+ pillar?: string;
82
+ child?: string;
83
+ }
84
+ /** The longest a label may be. */
85
+ export declare const SITEMAP_LABEL_MAX = 40;
86
+ /**
87
+ * Keep only usable labels: strings of 1 to {@link SITEMAP_LABEL_MAX}
88
+ * characters on one line, whitespace collapsed, no angle brackets. Undefined
89
+ * when none survive, so a plan without labels reads the defaults.
90
+ */
91
+ export declare function normalizeSitemapLabels(raw: unknown): SitemapPlanLabels | undefined;
92
+ export type PageCountUnit = 'page' | 'url';
93
+ export interface PageCountClaim {
94
+ /** The number stated. */
95
+ value: number;
96
+ /** Whether it counts pages or URLs. */
97
+ unit: PageCountUnit;
98
+ /** The words that make the claim, e.g. "27 existing URLs". */
99
+ text: string;
100
+ /** Where the claim starts in the text. */
101
+ index: number;
102
+ }
103
+ /**
104
+ * Every page or URL count stated in a piece of text. A number that's part of
105
+ * a name ("Troy 7 Apartments get pages of their own") isn't a count.
106
+ */
107
+ export declare function findPageCountClaims(text: unknown): PageCountClaim[];
108
+ export interface PageCountIssue {
109
+ /** The block the claim is in, e.g. "GlassHero". */
110
+ section: string;
111
+ /** Where in the block, e.g. "stats[0]" or "tiers[0].features[1]". */
112
+ field: string;
113
+ /** The number stated. */
114
+ claimed: number;
115
+ unit: PageCountUnit;
116
+ /** The words that make the claim. */
117
+ text: string;
118
+ /** The page count the plan adds up to. */
119
+ planned: number;
120
+ }
121
+ /**
122
+ * Page and URL counts a proposal states that match nothing its site plan or
123
+ * its measured evidence adds up to. A stated count passes when it equals any
124
+ * of: the plan's full, core, own, article, carried-over or added page count;
125
+ * the plan's stated before-count; or the measured URL and page counts the
126
+ * evidence blocks carry. Empty when the proposal has no site plan.
127
+ */
128
+ export declare function proposalPageCountIssues(sections: unknown): PageCountIssue[];
129
+ /** One plain sentence for a person reviewing the proposal before it's sent. */
130
+ export declare function describePageCountIssue(issue: PageCountIssue): string;
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/proposal-sitemap.ts
4
+ var PROPOSAL_SITEMAP_CONTRACT_VERSION = 1;
5
+ function isRecord(v) {
6
+ return !!v && typeof v === "object" && !Array.isArray(v);
7
+ }
8
+ function list(v) {
9
+ return Array.isArray(v) ? v : [];
10
+ }
11
+ function sitemapCount(v) {
12
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
13
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : void 0;
14
+ }
15
+ function sitemapPageKey(slug) {
16
+ if (typeof slug !== "string") return void 0;
17
+ let s = slug.trim().toLowerCase();
18
+ if (!s) return void 0;
19
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/, "");
20
+ s = s.split(/[?#]/)[0];
21
+ if (!s.startsWith("/")) s = `/${s}`;
22
+ s = s.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
23
+ return s || "/";
24
+ }
25
+ var EXISTING_STATUSES = /* @__PURE__ */ new Set(["rebuild", "optimize", "migrate"]);
26
+ function countSitemapPlan(props) {
27
+ const p = isRecord(props) ? props : {};
28
+ const seen = /* @__PURE__ */ new Set();
29
+ let existing = 0;
30
+ let added = 0;
31
+ const take = (node) => {
32
+ if (!isRecord(node)) return false;
33
+ const key = sitemapPageKey(node.slug);
34
+ if (key !== void 0) {
35
+ if (seen.has(key)) return false;
36
+ seen.add(key);
37
+ }
38
+ if (typeof node.status === "string" && EXISTING_STATUSES.has(node.status)) existing++;
39
+ else added++;
40
+ return true;
41
+ };
42
+ let core = 0;
43
+ for (const node of list(p.corePages)) if (take(node)) core++;
44
+ let architecture = 0;
45
+ for (const pillar of list(p.pages)) {
46
+ if (take(pillar)) architecture++;
47
+ if (!isRecord(pillar)) continue;
48
+ for (const child of list(pillar.children)) if (take(child)) architecture++;
49
+ }
50
+ const articles = isRecord(p.blog) ? sitemapCount(p.blog.count) ?? 0 : 0;
51
+ return {
52
+ core,
53
+ architecture,
54
+ articles,
55
+ full: core + architecture + articles,
56
+ existing: existing + articles,
57
+ added
58
+ };
59
+ }
60
+ function sitemapTransformation(props) {
61
+ const p = isRecord(props) ? props : {};
62
+ const t = p.transformation;
63
+ if (!isRecord(t)) return null;
64
+ const before = sitemapCount(isRecord(t.before) ? t.before.count : void 0);
65
+ if (before === void 0) return null;
66
+ const planned = countSitemapPlan(p).architecture;
67
+ const after = sitemapCount(isRecord(t.after) ? t.after.count : void 0) ?? (planned > 0 ? planned : sitemapCount(p.totalPages) ?? 0);
68
+ if (after <= 0 || after <= before) return null;
69
+ return { before, after };
70
+ }
71
+ var SITEMAP_LABEL_MAX = 40;
72
+ var LABEL_KEYS = ["architecture", "pillar", "child"];
73
+ function normalizeSitemapLabels(raw) {
74
+ if (!isRecord(raw)) return void 0;
75
+ const out = {};
76
+ for (const key of LABEL_KEYS) {
77
+ const v = raw[key];
78
+ if (typeof v !== "string") continue;
79
+ if (/[\u0000-\u0008\u000a-\u001f\u007f<>]/.test(v)) continue;
80
+ const s = v.replace(/\s+/g, " ").trim();
81
+ if (s && s.length <= SITEMAP_LABEL_MAX) out[key] = s;
82
+ }
83
+ return Object.keys(out).length ? out : void 0;
84
+ }
85
+ var CLAIM_RE = /(?<![\d$.,/])(\d{1,5})(?![\d.,/%])(?:[\s-]+(?:[a-z'’&]+[\s-]+){0,3}?)(pages?|urls?)\b/gi;
86
+ var CAPITALIZED_BEFORE_RE = /(?<![\w'’])[A-Z][\w'’.&-]*\s+$/;
87
+ function numberInName(before) {
88
+ const m = before.match(CAPITALIZED_BEFORE_RE);
89
+ if (!m) return false;
90
+ const lead = before.slice(0, m.index);
91
+ if (/\n\s*$/.test(lead)) return false;
92
+ const trimmed = lead.replace(/\s+$/, "");
93
+ return trimmed !== "" && !/[.!?:]$/.test(trimmed);
94
+ }
95
+ function findPageCountClaims(text) {
96
+ if (typeof text !== "string" || !text) return [];
97
+ const claims = [];
98
+ for (const m of text.matchAll(CLAIM_RE)) {
99
+ if (numberInName(text.slice(0, m.index ?? 0))) continue;
100
+ claims.push({
101
+ value: Number(m[1]),
102
+ unit: m[2].toLowerCase().startsWith("url") ? "url" : "page",
103
+ text: m[0],
104
+ index: m.index ?? 0
105
+ });
106
+ }
107
+ return claims;
108
+ }
109
+ function claimFields(section) {
110
+ const props = isRecord(section.props) ? section.props : {};
111
+ switch (section.type) {
112
+ case "GlassHero": {
113
+ const fields = [["title", props.title], ["subtitle", props.subtitle]];
114
+ list(props.stats).forEach((st, i) => {
115
+ if (!isRecord(st)) return;
116
+ const value = typeof st.value === "number" || typeof st.value === "string" ? String(st.value) : "";
117
+ fields.push([`stats[${i}]`, `${value} ${typeof st.label === "string" ? st.label : ""}`]);
118
+ });
119
+ return fields;
120
+ }
121
+ case "ExecutiveSummary":
122
+ return [["content", props.content]];
123
+ case "GlassPricing": {
124
+ const fields = [["note", props.note]];
125
+ list(props.tiers).forEach((tier, i) => {
126
+ if (!isRecord(tier)) return;
127
+ fields.push([`tiers[${i}].name`, tier.name], [`tiers[${i}].description`, tier.description]);
128
+ list(tier.features).forEach((f, j) => fields.push([`tiers[${i}].features[${j}]`, f]));
129
+ });
130
+ return fields;
131
+ }
132
+ case "SitemapPlan": {
133
+ const strategy = isRecord(props.strategy) ? props.strategy : {};
134
+ return [
135
+ ["title", props.title],
136
+ ["subtitle", props.subtitle],
137
+ ["strategy.headline", strategy.headline],
138
+ ["strategy.lead", strategy.lead]
139
+ ];
140
+ }
141
+ default:
142
+ return [];
143
+ }
144
+ }
145
+ function proposalPageCountIssues(sections) {
146
+ const secs = list(sections).filter(isRecord);
147
+ const plan = secs.find((s) => s.type === "SitemapPlan");
148
+ if (!plan) return [];
149
+ const planProps = isRecord(plan.props) ? plan.props : {};
150
+ const counts = countSitemapPlan(planProps);
151
+ const known = /* @__PURE__ */ new Set([
152
+ counts.full,
153
+ counts.core,
154
+ counts.architecture,
155
+ counts.articles,
156
+ counts.existing,
157
+ counts.added,
158
+ counts.core + counts.architecture
159
+ ]);
160
+ const transformation = isRecord(planProps.transformation) ? planProps.transformation : {};
161
+ const before = sitemapCount(isRecord(transformation.before) ? transformation.before.count : void 0);
162
+ if (before !== void 0) known.add(before);
163
+ for (const s of secs) {
164
+ const props = isRecord(s.props) ? s.props : {};
165
+ if (s.type === "MigrationSafetyPlan") {
166
+ const urls = sitemapCount(props.urlCount);
167
+ if (urls !== void 0) known.add(urls);
168
+ }
169
+ if (s.type === "ContentDepth" && isRecord(props.ledger)) {
170
+ const measured = sitemapCount(props.ledger.beforePages);
171
+ if (measured !== void 0) known.add(measured);
172
+ }
173
+ }
174
+ const issues = [];
175
+ for (const s of secs) {
176
+ for (const [field, text] of claimFields(s)) {
177
+ for (const claim of findPageCountClaims(text)) {
178
+ if (known.has(claim.value)) continue;
179
+ issues.push({
180
+ section: String(s.type),
181
+ field,
182
+ claimed: claim.value,
183
+ unit: claim.unit,
184
+ text: claim.text,
185
+ planned: counts.full
186
+ });
187
+ }
188
+ }
189
+ }
190
+ return issues;
191
+ }
192
+ var SECTION_NAMES = {
193
+ GlassHero: "The headline",
194
+ ExecutiveSummary: "The summary",
195
+ GlassPricing: "The pricing",
196
+ SitemapPlan: "The site plan"
197
+ };
198
+ function describePageCountIssue(issue) {
199
+ const where = SECTION_NAMES[issue.section] ?? `The ${issue.section} section`;
200
+ return `${where} says "${issue.text}", but the site plan adds up to ${issue.planned} ${issue.planned === 1 ? "page" : "pages"}.`;
201
+ }
202
+
203
+ exports.PROPOSAL_SITEMAP_CONTRACT_VERSION = PROPOSAL_SITEMAP_CONTRACT_VERSION;
204
+ exports.SITEMAP_LABEL_MAX = SITEMAP_LABEL_MAX;
205
+ exports.countSitemapPlan = countSitemapPlan;
206
+ exports.describePageCountIssue = describePageCountIssue;
207
+ exports.findPageCountClaims = findPageCountClaims;
208
+ exports.normalizeSitemapLabels = normalizeSitemapLabels;
209
+ exports.proposalPageCountIssues = proposalPageCountIssues;
210
+ exports.sitemapCount = sitemapCount;
211
+ exports.sitemapPageKey = sitemapPageKey;
212
+ exports.sitemapTransformation = sitemapTransformation;
@@ -0,0 +1,201 @@
1
+ // src/contracts/proposal-sitemap.ts
2
+ var PROPOSAL_SITEMAP_CONTRACT_VERSION = 1;
3
+ function isRecord(v) {
4
+ return !!v && typeof v === "object" && !Array.isArray(v);
5
+ }
6
+ function list(v) {
7
+ return Array.isArray(v) ? v : [];
8
+ }
9
+ function sitemapCount(v) {
10
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
11
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : void 0;
12
+ }
13
+ function sitemapPageKey(slug) {
14
+ if (typeof slug !== "string") return void 0;
15
+ let s = slug.trim().toLowerCase();
16
+ if (!s) return void 0;
17
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/, "");
18
+ s = s.split(/[?#]/)[0];
19
+ if (!s.startsWith("/")) s = `/${s}`;
20
+ s = s.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
21
+ return s || "/";
22
+ }
23
+ var EXISTING_STATUSES = /* @__PURE__ */ new Set(["rebuild", "optimize", "migrate"]);
24
+ function countSitemapPlan(props) {
25
+ const p = isRecord(props) ? props : {};
26
+ const seen = /* @__PURE__ */ new Set();
27
+ let existing = 0;
28
+ let added = 0;
29
+ const take = (node) => {
30
+ if (!isRecord(node)) return false;
31
+ const key = sitemapPageKey(node.slug);
32
+ if (key !== void 0) {
33
+ if (seen.has(key)) return false;
34
+ seen.add(key);
35
+ }
36
+ if (typeof node.status === "string" && EXISTING_STATUSES.has(node.status)) existing++;
37
+ else added++;
38
+ return true;
39
+ };
40
+ let core = 0;
41
+ for (const node of list(p.corePages)) if (take(node)) core++;
42
+ let architecture = 0;
43
+ for (const pillar of list(p.pages)) {
44
+ if (take(pillar)) architecture++;
45
+ if (!isRecord(pillar)) continue;
46
+ for (const child of list(pillar.children)) if (take(child)) architecture++;
47
+ }
48
+ const articles = isRecord(p.blog) ? sitemapCount(p.blog.count) ?? 0 : 0;
49
+ return {
50
+ core,
51
+ architecture,
52
+ articles,
53
+ full: core + architecture + articles,
54
+ existing: existing + articles,
55
+ added
56
+ };
57
+ }
58
+ function sitemapTransformation(props) {
59
+ const p = isRecord(props) ? props : {};
60
+ const t = p.transformation;
61
+ if (!isRecord(t)) return null;
62
+ const before = sitemapCount(isRecord(t.before) ? t.before.count : void 0);
63
+ if (before === void 0) return null;
64
+ const planned = countSitemapPlan(p).architecture;
65
+ const after = sitemapCount(isRecord(t.after) ? t.after.count : void 0) ?? (planned > 0 ? planned : sitemapCount(p.totalPages) ?? 0);
66
+ if (after <= 0 || after <= before) return null;
67
+ return { before, after };
68
+ }
69
+ var SITEMAP_LABEL_MAX = 40;
70
+ var LABEL_KEYS = ["architecture", "pillar", "child"];
71
+ function normalizeSitemapLabels(raw) {
72
+ if (!isRecord(raw)) return void 0;
73
+ const out = {};
74
+ for (const key of LABEL_KEYS) {
75
+ const v = raw[key];
76
+ if (typeof v !== "string") continue;
77
+ if (/[\u0000-\u0008\u000a-\u001f\u007f<>]/.test(v)) continue;
78
+ const s = v.replace(/\s+/g, " ").trim();
79
+ if (s && s.length <= SITEMAP_LABEL_MAX) out[key] = s;
80
+ }
81
+ return Object.keys(out).length ? out : void 0;
82
+ }
83
+ var CLAIM_RE = /(?<![\d$.,/])(\d{1,5})(?![\d.,/%])(?:[\s-]+(?:[a-z'’&]+[\s-]+){0,3}?)(pages?|urls?)\b/gi;
84
+ var CAPITALIZED_BEFORE_RE = /(?<![\w'’])[A-Z][\w'’.&-]*\s+$/;
85
+ function numberInName(before) {
86
+ const m = before.match(CAPITALIZED_BEFORE_RE);
87
+ if (!m) return false;
88
+ const lead = before.slice(0, m.index);
89
+ if (/\n\s*$/.test(lead)) return false;
90
+ const trimmed = lead.replace(/\s+$/, "");
91
+ return trimmed !== "" && !/[.!?:]$/.test(trimmed);
92
+ }
93
+ function findPageCountClaims(text) {
94
+ if (typeof text !== "string" || !text) return [];
95
+ const claims = [];
96
+ for (const m of text.matchAll(CLAIM_RE)) {
97
+ if (numberInName(text.slice(0, m.index ?? 0))) continue;
98
+ claims.push({
99
+ value: Number(m[1]),
100
+ unit: m[2].toLowerCase().startsWith("url") ? "url" : "page",
101
+ text: m[0],
102
+ index: m.index ?? 0
103
+ });
104
+ }
105
+ return claims;
106
+ }
107
+ function claimFields(section) {
108
+ const props = isRecord(section.props) ? section.props : {};
109
+ switch (section.type) {
110
+ case "GlassHero": {
111
+ const fields = [["title", props.title], ["subtitle", props.subtitle]];
112
+ list(props.stats).forEach((st, i) => {
113
+ if (!isRecord(st)) return;
114
+ const value = typeof st.value === "number" || typeof st.value === "string" ? String(st.value) : "";
115
+ fields.push([`stats[${i}]`, `${value} ${typeof st.label === "string" ? st.label : ""}`]);
116
+ });
117
+ return fields;
118
+ }
119
+ case "ExecutiveSummary":
120
+ return [["content", props.content]];
121
+ case "GlassPricing": {
122
+ const fields = [["note", props.note]];
123
+ list(props.tiers).forEach((tier, i) => {
124
+ if (!isRecord(tier)) return;
125
+ fields.push([`tiers[${i}].name`, tier.name], [`tiers[${i}].description`, tier.description]);
126
+ list(tier.features).forEach((f, j) => fields.push([`tiers[${i}].features[${j}]`, f]));
127
+ });
128
+ return fields;
129
+ }
130
+ case "SitemapPlan": {
131
+ const strategy = isRecord(props.strategy) ? props.strategy : {};
132
+ return [
133
+ ["title", props.title],
134
+ ["subtitle", props.subtitle],
135
+ ["strategy.headline", strategy.headline],
136
+ ["strategy.lead", strategy.lead]
137
+ ];
138
+ }
139
+ default:
140
+ return [];
141
+ }
142
+ }
143
+ function proposalPageCountIssues(sections) {
144
+ const secs = list(sections).filter(isRecord);
145
+ const plan = secs.find((s) => s.type === "SitemapPlan");
146
+ if (!plan) return [];
147
+ const planProps = isRecord(plan.props) ? plan.props : {};
148
+ const counts = countSitemapPlan(planProps);
149
+ const known = /* @__PURE__ */ new Set([
150
+ counts.full,
151
+ counts.core,
152
+ counts.architecture,
153
+ counts.articles,
154
+ counts.existing,
155
+ counts.added,
156
+ counts.core + counts.architecture
157
+ ]);
158
+ const transformation = isRecord(planProps.transformation) ? planProps.transformation : {};
159
+ const before = sitemapCount(isRecord(transformation.before) ? transformation.before.count : void 0);
160
+ if (before !== void 0) known.add(before);
161
+ for (const s of secs) {
162
+ const props = isRecord(s.props) ? s.props : {};
163
+ if (s.type === "MigrationSafetyPlan") {
164
+ const urls = sitemapCount(props.urlCount);
165
+ if (urls !== void 0) known.add(urls);
166
+ }
167
+ if (s.type === "ContentDepth" && isRecord(props.ledger)) {
168
+ const measured = sitemapCount(props.ledger.beforePages);
169
+ if (measured !== void 0) known.add(measured);
170
+ }
171
+ }
172
+ const issues = [];
173
+ for (const s of secs) {
174
+ for (const [field, text] of claimFields(s)) {
175
+ for (const claim of findPageCountClaims(text)) {
176
+ if (known.has(claim.value)) continue;
177
+ issues.push({
178
+ section: String(s.type),
179
+ field,
180
+ claimed: claim.value,
181
+ unit: claim.unit,
182
+ text: claim.text,
183
+ planned: counts.full
184
+ });
185
+ }
186
+ }
187
+ }
188
+ return issues;
189
+ }
190
+ var SECTION_NAMES = {
191
+ GlassHero: "The headline",
192
+ ExecutiveSummary: "The summary",
193
+ GlassPricing: "The pricing",
194
+ SitemapPlan: "The site plan"
195
+ };
196
+ function describePageCountIssue(issue) {
197
+ const where = SECTION_NAMES[issue.section] ?? `The ${issue.section} section`;
198
+ return `${where} says "${issue.text}", but the site plan adds up to ${issue.planned} ${issue.planned === 1 ? "page" : "pages"}.`;
199
+ }
200
+
201
+ export { PROPOSAL_SITEMAP_CONTRACT_VERSION, SITEMAP_LABEL_MAX, countSitemapPlan, describePageCountIssue, findPageCountClaims, normalizeSitemapLabels, proposalPageCountIssues, sitemapCount, sitemapPageKey, sitemapTransformation };
@@ -4,17 +4,16 @@
4
4
  * Single source of truth for "is this machine-generated seo_pages.managed_title
5
5
  * / managed_meta_description fit to ship?". site-kit applies it at render time
6
6
  * (getManagedMetadata falls back to the page's own title/description), and
7
- * signal-api imports it (`src/shared/utils/seo-meta-quality.util.ts`) to gate
8
- * every generated title before it's written, so the two can't disagree.
7
+ * Signal applies it to every generated title before it's written, so the two
8
+ * can't disagree.
9
9
  *
10
- * Why it exists (2026-09-15): Signal's meta generator used the raw domain as
11
- * the brand whenever it could not resolve one, so ~500 managed titles across
12
- * 20+ projects shipped as "Apartments for Rent | abbeyglenapts.com", were
13
- * hard-cut at 60 chars ("… | reinhartheatingandcooli"), lost the suffix to a
14
- * word-boundary cut ("Contact Reinhart Heating and Cooling |"), or had the
15
- * model rebuild a brand out of the domain ("About SSO Shkosh Apartments | SSO
16
- * Shkosh" for ssoshkoshapts.com — the brand is SS Oshkosh Apartments). Sites
17
- * rendered them verbatim.
10
+ * Why it exists: a meta generator that uses the raw domain as the brand when it
11
+ * can't resolve one ships titles like "Apartments for Rent |
12
+ * maplecourtapts.com", hard-cuts them at 60 chars ("… |
13
+ * brightsideheatingandcooli"), loses the suffix to a word-boundary cut
14
+ * ("Contact Brightside Heating and Cooling |"), or rebuilds a brand out of the
15
+ * domain ("About BBE Llis Apartments | BBE Llis" for bbellisapts.com, when the
16
+ * brand is BB Ellis Apartments). Sites render them verbatim.
18
17
  *
19
18
  * Every rule derives from the site's own identity (its hosts and brand), so
20
19
  * there is no list of bad strings to maintain. Pure string logic — no I/O, no
@@ -24,7 +23,7 @@
24
23
  export declare const SEO_META_CONTRACT_VERSION: 1;
25
24
  /** Who the site is. Hosts come from the project/site config, the brand from the resolved business name — never from the domain. */
26
25
  export interface MetaIdentity {
27
- /** The site's brand, e.g. "SS Oshkosh Apartments". */
26
+ /** The site's brand, e.g. "BB Ellis Apartments". */
28
27
  brand?: string | null;
29
28
  /** Other correct spellings of the brand. */
30
29
  brandAliases?: Array<string | null | undefined>;
@@ -34,7 +33,7 @@ export interface MetaIdentity {
34
33
  export type TitleProblem = 'empty' | 'placeholder' | 'dangling-separator' | 'hostname' | 'mangled-brand';
35
34
  export type DescriptionProblem = 'empty' | 'hostname' | 'mangled-brand' | 'truncated';
36
35
  export type SuffixProblem = 'hostname' | 'mangled-brand';
37
- /** "https://www.AbbeyGlenApts.com:443/x" → "abbeyglenapts.com". Null for anything that isn't a host (no dot, or words). */
36
+ /** "https://www.MapleCourtApts.com:443/x" → "maplecourtapts.com". Null for anything that isn't a host (no dot, or words). */
38
37
  export declare function metaHost(value: string | null | undefined): string | null;
39
38
  /** True when the text names the brand (an alias, or either one's core) word for word. */
40
39
  export declare function namesBrand(text: string, id: MetaIdentity): boolean;
@@ -3,13 +3,10 @@
3
3
  *
4
4
  * Single source of truth for choosing ONE seo_pages row when a (project_id,
5
5
  * path/url) lookup matches several, and for scoping a project's rows to one
6
- * host. site-kit (`@sonordev/site-kit/seo/pages/contract`), sonor-api
7
- * (`src/modules/seo/seo-page-resolve.util.ts`) and signal-api
8
- * (`src/shared/utils/seo-page-pick.util.ts`) all import this; none keeps a
9
- * copy.
6
+ * host. site-kit (`@sonordev/site-kit/seo/pages/contract`) and both Sonor
7
+ * APIs import this; none keeps a copy.
10
8
  *
11
- * seo_pages identity became (project_id, site, url) in sonor-api migration
12
- * 20260702150000_seo_pages_per_site_identity.sql: a multi-site project (one
9
+ * seo_pages identity is (project_id, site, url): a multi-site project (one
13
10
  * Sonor project hosting many domains) keeps one row PER SUB-SITE HOST for
14
11
  * shared paths like `/` or `/contact`. Any path/url lookup can therefore match
15
12
  * several rows — `.single()`/`.maybeSingle()` error on them (PGRST116) — so
@@ -26,11 +23,9 @@ export declare const SEO_PAGES_CONTRACT_VERSION: 1;
26
23
  *
27
24
  * Retired pages are kept as tombstones (status='removed') so their history and
28
25
  * redirects survive, which means every public read has to opt out of them
29
- * explicitly. Three reads in llms-content.service.ts never did, and the sitemap
30
- * sync's removal flag was write-only for as long as the code existed: on
31
- * 2026-09-20 upforge.io's llms.txt — the file handed straight to AI crawlers —
32
- * was 32% dead URLs, advertising service areas in Atlanta, Detroit and Tampa
33
- * that the business has never served.
26
+ * explicitly. A read that forgets serves dead URLs: an llms.txt, the file
27
+ * handed straight to AI crawlers, ends up advertising pages the business
28
+ * retired long ago.
34
29
  *
35
30
  * Null-safe by design: `status` is nullable with an 'active' default, so a row
36
31
  * whose status was explicitly nulled stays live rather than vanishing from the
@@ -80,13 +75,12 @@ export declare function matchSitemapEntryRow<T extends SeoPageRowLike>(candidate
80
75
  * that belongs to `site`, keeping at most one row per path.
81
76
  *
82
77
  * Why this exists: a multi-site project (one Sonor project hosting many
83
- * domains — bd-aec.com plus 48 sibling domains, truepowersystems.com plus its
84
- * state microsites) holds one row PER HOST for shared paths like `/` and
85
- * `/terms`. Any project-wide read that dedupes by path alone therefore keeps an
86
- * ARBITRARY host's row. Observed 2026-08-25: bd-aec.com's llms.txt described
87
- * `/` with soqwriters.com's homepage copy and `/terms` with bd-charlotte.com's,
88
- * and which sibling won changed every time a neighbour rebuilt, because the tie
89
- * among same-path rows is broken by whatever order Postgres returns.
78
+ * domains, e.g. example.com plus dozens of city microsites) holds one row PER
79
+ * HOST for shared paths like `/` and `/terms`. Any project-wide read that
80
+ * dedupes by path alone therefore keeps an ARBITRARY host's row: example.com's
81
+ * llms.txt can describe `/` with a sibling's homepage copy, and which sibling
82
+ * wins changes every time a neighbour rebuilds, because the tie among
83
+ * same-path rows is broken by whatever order Postgres returns.
90
84
  *
91
85
  * Rules, per path, via `matchSitemapEntryRow`:
92
86
  * - this host's row wins outright
@@ -2,11 +2,11 @@
2
2
  * How Sonor refreshes a site's cached pages when content changes.
3
3
  *
4
4
  * A site serves cached pages and caches every fetch from Sonor. When someone
5
- * edits content in Sonor, sonor-api POSTs the paths and cache tags that
5
+ * edits content in Sonor, Sonor POSTs the paths and cache tags that
6
6
  * changed to the site's revalidation route, authenticated with the project's
7
7
  * API key, and the site regenerates just those. site-kit tags its fetches with
8
8
  * these names and serves the route (`@sonordev/site-kit/revalidate`);
9
- * sonor-api sends them. Single source of truth for the names on both sides:
9
+ * Sonor sends them. Single source of truth for the names on both sides:
10
10
  * never spell a tag as a string literal in either.
11
11
  */
12
12
  /** Where the revalidation route lives on every site. */
@@ -29,7 +29,7 @@ export declare const SITE_CACHE_TAGS: {
29
29
  readonly portfolio: 'portfolio';
30
30
  };
31
31
  export type SiteCacheTag = (typeof SITE_CACHE_TAGS)[keyof typeof SITE_CACHE_TAGS];
32
- /** The body sonor-api POSTs to the revalidation route. */
32
+ /** The body Sonor POSTs to the revalidation route. */
33
33
  export interface SiteRevalidatePayload {
34
34
  /** Local paths to regenerate, e.g. `['/services/roofing']`. */
35
35
  paths?: string[];
@@ -39,7 +39,7 @@ export interface SiteRevalidatePayload {
39
39
  revalidateAll?: boolean;
40
40
  /**
41
41
  * Only check that the route is installed and the key matches; nothing is
42
- * regenerated. sonor-api pings after it registers a site's route, so it
42
+ * regenerated. Sonor pings after it registers a site's route, so it
43
43
  * knows whether an edit goes live in seconds or waits for the cache to age out.
44
44
  */
45
45
  ping?: boolean;
@@ -14,9 +14,7 @@
14
14
  * pushed those entries over their bundle budgets.
15
15
  *
16
16
  * `./sites` re-exports this, so `@sonordev/contracts/sites` (and site-kit's
17
- * `sites/contract`) carry it too. The Nest APIs import it from there
18
- * (sonor-api `src/common/utils/site-host.ts`, signal-api
19
- * `src/shared/utils/seo-page-pick.util.ts`).
17
+ * `sites/contract`) carry it too. The Sonor APIs import it from there.
20
18
  *
21
19
  * Pure string logic only (no Node built-ins) so it is safe in the browser.
22
20
  */
@@ -24,7 +22,7 @@
24
22
  * normalizeSiteHost — canonical normalizer for a sub-site host value before it
25
23
  * is persisted or used as a filter.
26
24
  *
27
- * `https://OhioPowerStudies.com:3000/path` → `ohiopowerstudies.com`
25
+ * `https://Ohio.Example.com:3000/path` → `ohio.example.com`
28
26
  *
29
27
  * Lowercases, strips protocol, keeps host only (drops path + query), strips
30
28
  * port (rollups span dev/prod) and the FQDN root dot. Returns null for
package/dist/sites.d.ts CHANGED
@@ -2,10 +2,8 @@
2
2
  * @sonordev/contracts/sites — Multi-site "site" dimension host contract (v1)
3
3
  *
4
4
  * Single source of truth for normalizing and comparing sub-site host values
5
- * (the multi-site `site` dimension, see CLAUDE.md). site-kit, sonor-api
6
- * (`src/common/utils/site-host.ts`) and signal-api
7
- * (`src/shared/utils/seo-page-pick.util.ts`) all import it, so there's no
8
- * copy to drift.
5
+ * (the multi-site `site` dimension). site-kit and both Sonor APIs import it,
6
+ * so there's no copy to drift.
9
7
  *
10
8
  * Pure string logic only (no Node built-ins) so it is safe in the browser:
11
9
  * site-kit itself normalizes `__SITE_KIT_SITE__` with `normalizeSiteHost`.
@@ -28,7 +26,7 @@ export declare function stripWww(host: string): string;
28
26
  */
29
27
  export declare function sameSite(a: string | null | undefined, b: string | null | undefined): boolean;
30
28
  /**
31
- * Host of a full URL ("https://ohiopowerstudies.com/x" → "ohiopowerstudies.com"),
29
+ * Host of a full URL ("https://ohio.example.com/x" → "ohio.example.com"),
32
30
  * null for site-relative paths ("/services") and unparseable input.
33
31
  */
34
32
  export declare function hostFromUrl(value: string | null | undefined): string | null;
@@ -4,7 +4,7 @@
4
4
  * The pure half of the slots contract: content types, value validation
5
5
  * (normalizeSlotValue), safe links, and the rich-text subset parser. No
6
6
  * crypto, so the dashboard can import it in the browser to validate and
7
- * preview exactly what sonor-api will accept and site-kit will render.
7
+ * preview exactly what the Sonor API will accept and site-kit will render.
8
8
  * `@sonordev/contracts/slots` re-exports all of it next to the signing.
9
9
  */
10
10
  /**
@@ -66,7 +66,7 @@ export type SlotValueResult = {
66
66
  /**
67
67
  * Validate and normalize what an editor (or Signal) submits for a slot of
68
68
  * `type`, returning the canonical `content` + `value` to store and sign.
69
- * The single rule for "what a valid slot of this type is": sonor-api runs it
69
+ * The single rule for "what a valid slot of this type is": the Sonor API runs it
70
70
  * on every write and verifySlotContent re-runs it on every read.
71
71
  */
72
72
  export declare function normalizeSlotValue(type: SlotContentType, input: {
package/dist/slots.d.ts CHANGED
@@ -3,23 +3,23 @@
3
3
  *
4
4
  * Single source of truth for the slot payload shape, value validation, the
5
5
  * rich-text subset, and content signing, shared by site-kit (verify + render),
6
- * sonor-api (validate + sign) and the dashboard (preview). Node only: it signs
6
+ * the Sonor API (validate + sign) and the dashboard (preview). Node only: it signs
7
7
  * with `crypto`.
8
8
  *
9
9
  * Security model: every SlotContent is HMAC-signed with the project API key.
10
10
  * site-kit verifies before rendering and falls back to the developer's static
11
11
  * content on any mismatch, so platform-originated content carries provenance
12
- * end to end. See docs/SITE-KIT-3.0-SPEC-SLOTS.md and docs/WEBSITE-CMS.md.
12
+ * end to end.
13
13
  *
14
14
  * v1 (site-kit ≤ 7.0) is plain text only. v2 adds rich text, links and lists,
15
- * and signs the structured value too. sonor-api serves both: a v1 request gets
15
+ * and signs the structured value too. Sonor serves both: a v1 request gets
16
16
  * text slots signed the v1 way and null for everything else.
17
17
  */
18
18
  import { type SlotContentType, type SlotValue } from './slot-content';
19
19
  export * from './slot-content';
20
20
  /** The version this package speaks. Bump on breaking payload changes. */
21
21
  export declare const SLOTS_CONTRACT_VERSION = 2;
22
- /** Versions sonor-api still serves. */
22
+ /** Versions Sonor still serves. */
23
23
  export declare const SUPPORTED_SLOTS_CONTRACT_VERSIONS: readonly number[];
24
24
  export interface SlotContent {
25
25
  slot_id: string;
@@ -63,7 +63,7 @@ export interface SlotResolveRequestEntry {
63
63
  export interface SlotsResolveRequest {
64
64
  contract_version: number;
65
65
  slots: SlotResolveRequestEntry[];
66
- /** Multi-site sub-dimension: normalized lowercase host (see CLAUDE.md "site" convention). */
66
+ /** Multi-site sub-dimension: normalized lowercase host. */
67
67
  site?: string;
68
68
  }
69
69
  export interface SlotsResolveResponse {
@@ -83,7 +83,7 @@ type Signable = Pick<SlotContent, 'slot_id' | 'type' | 'content'> & {
83
83
  */
84
84
  export declare function slotSigningPayload(content: Signable, version?: number): string;
85
85
  /**
86
- * Sign slot content with the project API key (sonor-api side).
86
+ * Sign slot content with the project API key (Sonor's side).
87
87
  * Returns the hex HMAC-SHA256 signature.
88
88
  */
89
89
  export declare function signSlotContent(content: Signable, apiKey: string, version?: number): string;
package/dist/website.d.ts CHANGED
@@ -5,14 +5,14 @@
5
5
  * SINGLE SOURCE OF TRUTH shared by every side of the Website module's popups:
6
6
  * - the site (site-kit measures its own rendered design and reports it,
7
7
  * publishes it as `--sk-site-*` CSS variables, and renders popup blocks),
8
- * - sonor-api (validates the design report and every popup write with these
8
+ * - the Sonor API (validates the design report and every popup write with these
9
9
  * normalizers, so a popup can't be saved in a shape the site can't draw),
10
10
  * - the dashboard (the builder's preview reads the same tokens through the
11
11
  * same CSS-variable mapping, so it looks like the site),
12
12
  * - Signal's agents (the block shapes an MCP `propose_site_popup` sends).
13
13
  *
14
14
  * All four import it: site-kit as `@sonordev/site-kit/website/contract`,
15
- * sonor-api and the dashboard as `@sonordev/contracts/website`.
15
+ * the API and the dashboard as `@sonordev/contracts/website`.
16
16
  *
17
17
  * Why popups carry blocks and the site carries tokens: a popup should look
18
18
  * like the site it's on. It says WHAT (a heading, some formatted text, an
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sonordev/contracts",
3
- "version": "2.1.0",
4
- "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics and the house voice's contraction rule. Pure functions, no dependencies.",
3
+ "version": "2.2.0",
4
+ "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule and how a proposal's site plan counts its pages. Pure functions, no dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -85,6 +85,11 @@
85
85
  "import": "./dist/voice.mjs",
86
86
  "require": "./dist/voice.js"
87
87
  },
88
+ "./proposal-sitemap": {
89
+ "types": "./dist/proposal-sitemap.d.ts",
90
+ "import": "./dist/proposal-sitemap.mjs",
91
+ "require": "./dist/proposal-sitemap.js"
92
+ },
88
93
  "./package.json": "./package.json"
89
94
  },
90
95
  "typesVersions": {