@sonordev/contracts 2.1.1 → 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 +10 -0
- package/README.md +50 -0
- package/dist/proposal-sitemap.d.ts +130 -0
- package/dist/proposal-sitemap.js +212 -0
- package/dist/proposal-sitemap.mjs +201 -0
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 2.1.1
|
|
4
14
|
|
|
5
15
|
- Docs only: the README names the Sonor API and the dashboard where it named internal repositories. No code changes.
|
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ pnpm add @sonordev/contracts
|
|
|
22
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
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,6 +136,55 @@ 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
190
|
A rule that lives in several copies is how two of them end up disagreeing.
|
|
@@ -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 };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sonordev/contracts",
|
|
3
|
-
"version": "2.
|
|
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
|
|
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": {
|