@sonordev/contracts 2.1.1 → 2.3.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.
@@ -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.1.1",
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.3.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, how a proposal's site plan counts its pages, and what separates an error page from a page's content. Pure functions, no dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -85,6 +85,16 @@
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
+ },
93
+ "./error-page": {
94
+ "types": "./dist/error-page.d.ts",
95
+ "import": "./dist/error-page.mjs",
96
+ "require": "./dist/error-page.js"
97
+ },
88
98
  "./package.json": "./package.json"
89
99
  },
90
100
  "typesVersions": {