@sonordev/contracts 1.0.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,114 @@
1
+ // src/contracts/portfolio.ts
2
+ var FRAME_READY_MESSAGE = "sonor:frame-ready";
3
+ function isFrameReadyMessage(data) {
4
+ return !!data && typeof data === "object" && data.type === FRAME_READY_MESSAGE && data.v === 1;
5
+ }
6
+ var PORTFOLIO_CONTRACT_VERSION = 1;
7
+ var LIGHTHOUSE_RE = /lighthouse/i;
8
+ function isLighthouseKpi(kpi) {
9
+ return LIGHTHOUSE_RE.test(kpi.label || "");
10
+ }
11
+ function curateHeroKpis(kpis) {
12
+ const business = kpis.filter((k) => !isLighthouseKpi(k));
13
+ const lighthouse = kpis.filter(isLighthouseKpi);
14
+ if (lighthouse.length <= 1) return kpis;
15
+ const allPerfect = lighthouse.length >= 4 && lighthouse.every((k) => k.value === 100);
16
+ const summary = allPerfect ? {
17
+ ...lighthouse[0],
18
+ label: "Lighthouse, all 4 categories",
19
+ description: "Perfect scores across performance, SEO, accessibility, and best practices."
20
+ } : lighthouse.find((k) => /performance/i.test(k.label)) ?? lighthouse[0];
21
+ return [...business, summary];
22
+ }
23
+ var PORTFOLIO_KPI_JSON_SCHEMA = {
24
+ $schema: "http://json-schema.org/draft-07/schema#",
25
+ $id: "https://sonor.io/schemas/portfolio-kpi.v1.json",
26
+ title: "KpiLike",
27
+ description: "The subset of a portfolio KPI tile the Lighthouse-collapse policy reads.",
28
+ type: "object",
29
+ required: ["label", "value"],
30
+ properties: {
31
+ label: { type: "string" },
32
+ value: { type: "number" },
33
+ description: { type: "string" },
34
+ source: { enum: ["measured", "reported", "estimated"] },
35
+ reportedBy: {
36
+ type: "object",
37
+ required: ["name", "organization", "date"],
38
+ properties: {
39
+ name: { type: "string", minLength: 1 },
40
+ role: { type: "string" },
41
+ organization: { type: "string", minLength: 1 },
42
+ date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
43
+ }
44
+ }
45
+ },
46
+ // A reported number is only valid with someone named behind it.
47
+ if: { properties: { source: { const: "reported" } }, required: ["source"] },
48
+ then: { required: ["reportedBy"] },
49
+ additionalProperties: true
50
+ };
51
+ var METRIC_SOURCES = ["measured", "reported", "estimated"];
52
+ var ISO_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
53
+ var MONTHS = [
54
+ "January",
55
+ "February",
56
+ "March",
57
+ "April",
58
+ "May",
59
+ "June",
60
+ "July",
61
+ "August",
62
+ "September",
63
+ "October",
64
+ "November",
65
+ "December"
66
+ ];
67
+ function isMetricSource(value) {
68
+ return typeof value === "string" && METRIC_SOURCES.includes(value);
69
+ }
70
+ function metricSourceProblem(metric) {
71
+ if (metric.source === void 0 || metric.source === null) return null;
72
+ if (!isMetricSource(metric.source)) return `source must be one of ${METRIC_SOURCES.join(", ")}`;
73
+ if (metric.source !== "reported") return null;
74
+ const by = metric.reportedBy;
75
+ if (!by || typeof by !== "object") {
76
+ return "a reported number needs reportedBy: who reported it, where they work, and when";
77
+ }
78
+ if (typeof by.name !== "string" || !by.name.trim()) return "reportedBy.name is required";
79
+ if (typeof by.organization !== "string" || !by.organization.trim()) {
80
+ return "reportedBy.organization is required";
81
+ }
82
+ if (typeof by.date !== "string" || !ISO_DAY_RE.test(by.date) || Number.isNaN(Date.parse(by.date))) {
83
+ return "reportedBy.date must be a real day, YYYY-MM-DD";
84
+ }
85
+ if (by.role !== void 0 && typeof by.role !== "string") return "reportedBy.role must be text";
86
+ return null;
87
+ }
88
+ function isHeadlineMetric(metric) {
89
+ if (metric.source === "measured") return true;
90
+ return metric.source === "reported" && metricSourceProblem(metric) === null;
91
+ }
92
+ function metricSourceLabel(source) {
93
+ if (source === "measured") return "Measured";
94
+ if (source === "reported") return "Client-reported";
95
+ if (source === "estimated") return "Estimated";
96
+ return null;
97
+ }
98
+ function formatAttribution(by) {
99
+ const [year, month, day] = by.date.split("-").map(Number);
100
+ const when = MONTHS[month - 1] ? `${MONTHS[month - 1]} ${day}, ${year}` : by.date;
101
+ return [`Reported by ${by.name.trim()}`, by.role?.trim(), by.organization.trim(), when].filter(Boolean).join(", ");
102
+ }
103
+ function shortAttribution(by) {
104
+ return `per ${by.organization.trim()}`;
105
+ }
106
+ function carryReportedMetrics(previous, next, keyOf) {
107
+ const norm = (metric) => String(keyOf(metric) ?? "").trim().toLowerCase();
108
+ const reported = (previous ?? []).filter((m) => m?.source === "reported");
109
+ const taken = new Set(reported.map(norm));
110
+ const generated = (next ?? []).filter((m) => m && m.source !== "reported" && !taken.has(norm(m)));
111
+ return [...reported, ...generated];
112
+ }
113
+
114
+ export { FRAME_READY_MESSAGE, METRIC_SOURCES, PORTFOLIO_CONTRACT_VERSION, PORTFOLIO_KPI_JSON_SCHEMA, carryReportedMetrics, curateHeroKpis, formatAttribution, isFrameReadyMessage, isHeadlineMetric, isLighthouseKpi, isMetricSource, metricSourceLabel, metricSourceProblem, shortAttribution };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @sonordev/contracts/seo-meta — generated title/description quality contract (v1)
3
+ *
4
+ * Single source of truth for "is this machine-generated seo_pages.managed_title
5
+ * / managed_meta_description fit to ship?". site-kit applies it at render time
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.
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.
18
+ *
19
+ * Every rule derives from the site's own identity (its hosts and brand), so
20
+ * there is no list of bad strings to maintain. Pure string logic — no I/O, no
21
+ * Node built-ins.
22
+ */
23
+ /** Bump on breaking changes to what counts as a problem. */
24
+ export declare const SEO_META_CONTRACT_VERSION: 1;
25
+ /** Who the site is. Hosts come from the project/site config, the brand from the resolved business name — never from the domain. */
26
+ export interface MetaIdentity {
27
+ /** The site's brand, e.g. "SS Oshkosh Apartments". */
28
+ brand?: string | null;
29
+ /** Other correct spellings of the brand. */
30
+ brandAliases?: Array<string | null | undefined>;
31
+ /** Hosts the site answers on, in any spelling (URL, www, port). */
32
+ hosts?: Array<string | null | undefined>;
33
+ }
34
+ export type TitleProblem = 'empty' | 'placeholder' | 'dangling-separator' | 'hostname' | 'mangled-brand';
35
+ export type DescriptionProblem = 'empty' | 'hostname' | 'mangled-brand' | 'truncated';
36
+ 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). */
38
+ export declare function metaHost(value: string | null | undefined): string | null;
39
+ /** True when the text names the brand (an alias, or either one's core) word for word. */
40
+ export declare function namesBrand(text: string, id: MetaIdentity): boolean;
41
+ /** Everything after the last " | ", or null when the title has no suffix. */
42
+ export declare function titleSuffix(title: string): string | null;
43
+ /** Everything before the last " | ". */
44
+ export declare function stripTitleSuffix(title: string): string;
45
+ /**
46
+ * Why a title's brand suffix (the part after the last " | ") can't ship, or
47
+ * null when it can. Exposed so a generator can replace a bad suffix with the
48
+ * real brand instead of discarding the whole title.
49
+ */
50
+ export declare function suffixProblem(suffix: string, id: MetaIdentity): SuffixProblem | null;
51
+ /** Why a managed title can't ship, or null when it can. */
52
+ export declare function titleProblem(title: string | null | undefined, id: MetaIdentity, opts?: {
53
+ path?: string | null;
54
+ }): TitleProblem | null;
55
+ /** Why a managed meta description can't ship, or null when it can. */
56
+ export declare function descriptionProblem(description: string | null | undefined, id: MetaIdentity): DescriptionProblem | null;
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/seo-meta.ts
4
+ var SEO_META_CONTRACT_VERSION = 1;
5
+ var TITLE_SEPARATOR = " | ";
6
+ var DANGLING_TAIL = /[|,\-–—:;·•]\s*$/;
7
+ var TRAILING_FUNCTION_WORD = /\s(?:for|of|and|or|in|on|at|to|with|from|by|a|an|the|your|our|that|&)$/i;
8
+ var MIN_HOST_FRAGMENT = 8;
9
+ var MIN_NAME_LETTERS = 6;
10
+ var lettersOf = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "");
11
+ var wordsOf = (s) => ` ${s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()} `;
12
+ function metaHost(value) {
13
+ if (!value || typeof value !== "string" || /\s/.test(value.trim())) return null;
14
+ const s = value.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "").split(/[/?#]/)[0].split(":")[0].replace(/\.+$/, "").replace(/^www\./, "");
15
+ return s.includes(".") ? s : null;
16
+ }
17
+ function hostsOf(id) {
18
+ const out = /* @__PURE__ */ new Set();
19
+ for (const h of id.hosts ?? []) {
20
+ const host = metaHost(h);
21
+ if (host) out.add(host);
22
+ }
23
+ return [...out];
24
+ }
25
+ function labelsOf(id) {
26
+ return hostsOf(id).map((h) => lettersOf(h.split(".")[0])).filter((l) => l.length >= MIN_NAME_LETTERS);
27
+ }
28
+ function brandCore(name) {
29
+ let s = name.replace(/^the\s+/i, "").trim();
30
+ let prev;
31
+ do {
32
+ prev = s;
33
+ s = s.replace(/[\s,]+(?:apartments|apts|llc|inc|co|corp|ltd|company)\.?$/i, "").trim();
34
+ } while (s !== prev);
35
+ return s;
36
+ }
37
+ function brandVariants(id) {
38
+ const out = [];
39
+ for (const raw of [id.brand, ...id.brandAliases ?? []]) {
40
+ const name = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
41
+ if (!name) continue;
42
+ out.push(name);
43
+ const core = brandCore(name);
44
+ if (core && core !== name) out.push(core);
45
+ }
46
+ return out;
47
+ }
48
+ function nameVariants(id) {
49
+ return brandVariants(id).filter((v) => metaHost(v) === null);
50
+ }
51
+ function namesBrand(text, id) {
52
+ const words = wordsOf(text);
53
+ return brandVariants(id).some((v) => wordsOf(v).trim() !== "" && words.includes(wordsOf(v)));
54
+ }
55
+ function titleSuffix(title) {
56
+ const t = title.trim();
57
+ const i = t.lastIndexOf(TITLE_SEPARATOR);
58
+ return i > 0 ? t.slice(i + TITLE_SEPARATOR.length).trim() : null;
59
+ }
60
+ function stripTitleSuffix(title) {
61
+ const t = title.trim();
62
+ const i = t.lastIndexOf(TITLE_SEPARATOR);
63
+ return i > 0 ? t.slice(0, i).trim() : t;
64
+ }
65
+ function namesHost(text, id) {
66
+ const lower = text.toLowerCase();
67
+ const brands = brandVariants(id).map((b) => b.toLowerCase());
68
+ return hostsOf(id).some((host) => lower.includes(host) && !brands.some((b) => b.includes(host)));
69
+ }
70
+ function endsOnTruncatedHost(text, id) {
71
+ const tokens = text.trim().split(/[\s|,()[\]]+/);
72
+ const last = (tokens[tokens.length - 1] ?? "").replace(/^[^a-z0-9]+/i, "");
73
+ if (!last || last !== last.toLowerCase()) return false;
74
+ const stem = lettersOf(last.split(".")[0]);
75
+ return stem.length >= MIN_HOST_FRAGMENT && labelsOf(id).some((l) => l.startsWith(stem));
76
+ }
77
+ function occurrences(hay, needle, step = needle.length) {
78
+ let n = 0;
79
+ for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + step)) n++;
80
+ return n;
81
+ }
82
+ function respellsBrand(text, id) {
83
+ const letters = lettersOf(text);
84
+ const words = wordsOf(text);
85
+ return nameVariants(id).some((v) => {
86
+ const vl = lettersOf(v);
87
+ if (vl.length < MIN_NAME_LETTERS) return false;
88
+ const vw = wordsOf(v);
89
+ return occurrences(letters, vl) > occurrences(words, vw, vw.length - 1);
90
+ });
91
+ }
92
+ function gluesHostLabel(text, id) {
93
+ const variants = nameVariants(id);
94
+ if (!variants.length) return false;
95
+ const labels = labelsOf(id);
96
+ return text.split(/[\s|,()[\]:;]+/).some((token) => {
97
+ const tl = lettersOf(token);
98
+ return tl.length >= MIN_NAME_LETTERS && labels.includes(tl) && !variants.some((v) => wordsOf(v).includes(wordsOf(token)));
99
+ });
100
+ }
101
+ function suffixProblem(suffix, id) {
102
+ const s = suffix.trim();
103
+ if (!s) return null;
104
+ if (namesHost(s, id) || endsOnTruncatedHost(s, id)) return "hostname";
105
+ const variants = nameVariants(id);
106
+ if (!variants.length) return null;
107
+ if (respellsBrand(s, id) || gluesHostLabel(s, id)) return "mangled-brand";
108
+ if (/\s/.test(s)) return null;
109
+ const sl = lettersOf(s);
110
+ const fromLabel = sl.length >= MIN_NAME_LETTERS && labelsOf(id).some((l) => l.startsWith(sl));
111
+ const sw = wordsOf(brandCore(s));
112
+ const isBrand = variants.some((v) => {
113
+ const vw = wordsOf(brandCore(v));
114
+ return vw.startsWith(sw) || sw.startsWith(vw);
115
+ });
116
+ return fromLabel && !isBrand ? "mangled-brand" : null;
117
+ }
118
+ function isPlaceholder(title, path) {
119
+ const t = title.trim();
120
+ if (!path) return false;
121
+ const strip = (s) => s.replace(/\/+$/, "") || "/";
122
+ if (t.startsWith("/") && strip(t.toLowerCase()) === strip(path.trim().toLowerCase())) return true;
123
+ return strip(path.trim()) === "/" && /^(home|index|untitled)$/i.test(t);
124
+ }
125
+ function titleProblem(title, id, opts = {}) {
126
+ const t = (title ?? "").trim();
127
+ if (!t) return "empty";
128
+ if (isPlaceholder(t, opts.path)) return "placeholder";
129
+ if (DANGLING_TAIL.test(t)) return "dangling-separator";
130
+ if (namesHost(t, id) || endsOnTruncatedHost(t, id)) return "hostname";
131
+ const suffix = titleSuffix(t);
132
+ if (suffix !== null) {
133
+ const problem = suffixProblem(suffix, id);
134
+ if (problem) return problem;
135
+ }
136
+ if (respellsBrand(t, id) || gluesHostLabel(t, id)) return "mangled-brand";
137
+ return null;
138
+ }
139
+ function descriptionProblem(description, id) {
140
+ const d = (description ?? "").trim();
141
+ if (!d) return "empty";
142
+ if (namesHost(d, id)) return "hostname";
143
+ if (respellsBrand(d, id) || gluesHostLabel(d, id)) return "mangled-brand";
144
+ if (DANGLING_TAIL.test(d) || TRAILING_FUNCTION_WORD.test(d)) return "truncated";
145
+ return null;
146
+ }
147
+
148
+ exports.SEO_META_CONTRACT_VERSION = SEO_META_CONTRACT_VERSION;
149
+ exports.descriptionProblem = descriptionProblem;
150
+ exports.metaHost = metaHost;
151
+ exports.namesBrand = namesBrand;
152
+ exports.stripTitleSuffix = stripTitleSuffix;
153
+ exports.suffixProblem = suffixProblem;
154
+ exports.titleProblem = titleProblem;
155
+ exports.titleSuffix = titleSuffix;
@@ -0,0 +1,146 @@
1
+ // src/contracts/seo-meta.ts
2
+ var SEO_META_CONTRACT_VERSION = 1;
3
+ var TITLE_SEPARATOR = " | ";
4
+ var DANGLING_TAIL = /[|,\-–—:;·•]\s*$/;
5
+ var TRAILING_FUNCTION_WORD = /\s(?:for|of|and|or|in|on|at|to|with|from|by|a|an|the|your|our|that|&)$/i;
6
+ var MIN_HOST_FRAGMENT = 8;
7
+ var MIN_NAME_LETTERS = 6;
8
+ var lettersOf = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "");
9
+ var wordsOf = (s) => ` ${s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()} `;
10
+ function metaHost(value) {
11
+ if (!value || typeof value !== "string" || /\s/.test(value.trim())) return null;
12
+ const s = value.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "").split(/[/?#]/)[0].split(":")[0].replace(/\.+$/, "").replace(/^www\./, "");
13
+ return s.includes(".") ? s : null;
14
+ }
15
+ function hostsOf(id) {
16
+ const out = /* @__PURE__ */ new Set();
17
+ for (const h of id.hosts ?? []) {
18
+ const host = metaHost(h);
19
+ if (host) out.add(host);
20
+ }
21
+ return [...out];
22
+ }
23
+ function labelsOf(id) {
24
+ return hostsOf(id).map((h) => lettersOf(h.split(".")[0])).filter((l) => l.length >= MIN_NAME_LETTERS);
25
+ }
26
+ function brandCore(name) {
27
+ let s = name.replace(/^the\s+/i, "").trim();
28
+ let prev;
29
+ do {
30
+ prev = s;
31
+ s = s.replace(/[\s,]+(?:apartments|apts|llc|inc|co|corp|ltd|company)\.?$/i, "").trim();
32
+ } while (s !== prev);
33
+ return s;
34
+ }
35
+ function brandVariants(id) {
36
+ const out = [];
37
+ for (const raw of [id.brand, ...id.brandAliases ?? []]) {
38
+ const name = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
39
+ if (!name) continue;
40
+ out.push(name);
41
+ const core = brandCore(name);
42
+ if (core && core !== name) out.push(core);
43
+ }
44
+ return out;
45
+ }
46
+ function nameVariants(id) {
47
+ return brandVariants(id).filter((v) => metaHost(v) === null);
48
+ }
49
+ function namesBrand(text, id) {
50
+ const words = wordsOf(text);
51
+ return brandVariants(id).some((v) => wordsOf(v).trim() !== "" && words.includes(wordsOf(v)));
52
+ }
53
+ function titleSuffix(title) {
54
+ const t = title.trim();
55
+ const i = t.lastIndexOf(TITLE_SEPARATOR);
56
+ return i > 0 ? t.slice(i + TITLE_SEPARATOR.length).trim() : null;
57
+ }
58
+ function stripTitleSuffix(title) {
59
+ const t = title.trim();
60
+ const i = t.lastIndexOf(TITLE_SEPARATOR);
61
+ return i > 0 ? t.slice(0, i).trim() : t;
62
+ }
63
+ function namesHost(text, id) {
64
+ const lower = text.toLowerCase();
65
+ const brands = brandVariants(id).map((b) => b.toLowerCase());
66
+ return hostsOf(id).some((host) => lower.includes(host) && !brands.some((b) => b.includes(host)));
67
+ }
68
+ function endsOnTruncatedHost(text, id) {
69
+ const tokens = text.trim().split(/[\s|,()[\]]+/);
70
+ const last = (tokens[tokens.length - 1] ?? "").replace(/^[^a-z0-9]+/i, "");
71
+ if (!last || last !== last.toLowerCase()) return false;
72
+ const stem = lettersOf(last.split(".")[0]);
73
+ return stem.length >= MIN_HOST_FRAGMENT && labelsOf(id).some((l) => l.startsWith(stem));
74
+ }
75
+ function occurrences(hay, needle, step = needle.length) {
76
+ let n = 0;
77
+ for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + step)) n++;
78
+ return n;
79
+ }
80
+ function respellsBrand(text, id) {
81
+ const letters = lettersOf(text);
82
+ const words = wordsOf(text);
83
+ return nameVariants(id).some((v) => {
84
+ const vl = lettersOf(v);
85
+ if (vl.length < MIN_NAME_LETTERS) return false;
86
+ const vw = wordsOf(v);
87
+ return occurrences(letters, vl) > occurrences(words, vw, vw.length - 1);
88
+ });
89
+ }
90
+ function gluesHostLabel(text, id) {
91
+ const variants = nameVariants(id);
92
+ if (!variants.length) return false;
93
+ const labels = labelsOf(id);
94
+ return text.split(/[\s|,()[\]:;]+/).some((token) => {
95
+ const tl = lettersOf(token);
96
+ return tl.length >= MIN_NAME_LETTERS && labels.includes(tl) && !variants.some((v) => wordsOf(v).includes(wordsOf(token)));
97
+ });
98
+ }
99
+ function suffixProblem(suffix, id) {
100
+ const s = suffix.trim();
101
+ if (!s) return null;
102
+ if (namesHost(s, id) || endsOnTruncatedHost(s, id)) return "hostname";
103
+ const variants = nameVariants(id);
104
+ if (!variants.length) return null;
105
+ if (respellsBrand(s, id) || gluesHostLabel(s, id)) return "mangled-brand";
106
+ if (/\s/.test(s)) return null;
107
+ const sl = lettersOf(s);
108
+ const fromLabel = sl.length >= MIN_NAME_LETTERS && labelsOf(id).some((l) => l.startsWith(sl));
109
+ const sw = wordsOf(brandCore(s));
110
+ const isBrand = variants.some((v) => {
111
+ const vw = wordsOf(brandCore(v));
112
+ return vw.startsWith(sw) || sw.startsWith(vw);
113
+ });
114
+ return fromLabel && !isBrand ? "mangled-brand" : null;
115
+ }
116
+ function isPlaceholder(title, path) {
117
+ const t = title.trim();
118
+ if (!path) return false;
119
+ const strip = (s) => s.replace(/\/+$/, "") || "/";
120
+ if (t.startsWith("/") && strip(t.toLowerCase()) === strip(path.trim().toLowerCase())) return true;
121
+ return strip(path.trim()) === "/" && /^(home|index|untitled)$/i.test(t);
122
+ }
123
+ function titleProblem(title, id, opts = {}) {
124
+ const t = (title ?? "").trim();
125
+ if (!t) return "empty";
126
+ if (isPlaceholder(t, opts.path)) return "placeholder";
127
+ if (DANGLING_TAIL.test(t)) return "dangling-separator";
128
+ if (namesHost(t, id) || endsOnTruncatedHost(t, id)) return "hostname";
129
+ const suffix = titleSuffix(t);
130
+ if (suffix !== null) {
131
+ const problem = suffixProblem(suffix, id);
132
+ if (problem) return problem;
133
+ }
134
+ if (respellsBrand(t, id) || gluesHostLabel(t, id)) return "mangled-brand";
135
+ return null;
136
+ }
137
+ function descriptionProblem(description, id) {
138
+ const d = (description ?? "").trim();
139
+ if (!d) return "empty";
140
+ if (namesHost(d, id)) return "hostname";
141
+ if (respellsBrand(d, id) || gluesHostLabel(d, id)) return "mangled-brand";
142
+ if (DANGLING_TAIL.test(d) || TRAILING_FUNCTION_WORD.test(d)) return "truncated";
143
+ return null;
144
+ }
145
+
146
+ export { SEO_META_CONTRACT_VERSION, descriptionProblem, metaHost, namesBrand, stripTitleSuffix, suffixProblem, titleProblem, titleSuffix };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @sonordev/contracts/seo-pages — seo_pages row resolution contract (v1)
3
+ *
4
+ * Single source of truth for choosing ONE seo_pages row when a (project_id,
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.
10
+ *
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
13
+ * Sonor project hosting many domains) keeps one row PER SUB-SITE HOST for
14
+ * shared paths like `/` or `/contact`. Any path/url lookup can therefore match
15
+ * several rows — `.single()`/`.maybeSingle()` error on them (PGRST116) — so
16
+ * read one deterministically via `pickSeoPageRow` instead.
17
+ *
18
+ * Pure data logic over row shapes (no I/O, no Node built-ins).
19
+ */
20
+ /** Bump on breaking changes to the row-pick preference order. */
21
+ export declare const SEO_PAGES_CONTRACT_VERSION: 1;
22
+ /**
23
+ * Single source of truth for "this seo_pages row is still live" — apply as
24
+ * `.or(LIVE_SEO_PAGE_FILTER)` (PostgREST, in either API) on any read that
25
+ * represents the site's CURRENT content to the outside world.
26
+ *
27
+ * Retired pages are kept as tombstones (status='removed') so their history and
28
+ * 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.
34
+ *
35
+ * Null-safe by design: `status` is nullable with an 'active' default, so a row
36
+ * whose status was explicitly nulled stays live rather than vanishing from the
37
+ * index. A bare `.eq('status','active')` would drop it, and `.neq(...,'removed')`
38
+ * would too, because PostgREST evaluates NULL comparisons as NULL.
39
+ */
40
+ export declare const LIVE_SEO_PAGE_FILTER = "status.is.null,status.eq.active";
41
+ /** Minimal row shape `pickSeoPageRow` reads — callers pass their own richer rows. */
42
+ export interface SeoPageRowLike {
43
+ site?: string | null;
44
+ updated_at?: string | null;
45
+ }
46
+ /**
47
+ * pickSeoPageRow — choose ONE row among per-site seo_pages rows sharing a
48
+ * path/url. Preference order:
49
+ * 1. exact `site` match (www-insensitive) when the caller knows the host
50
+ * 2. unattributed legacy row (site IS NULL)
51
+ * 3. the project's primary-domain row
52
+ * 4. most recently updated
53
+ */
54
+ export declare function pickSeoPageRow<T extends SeoPageRowLike>(rows: T[], opts?: {
55
+ site?: string | null;
56
+ primaryHost?: string | null;
57
+ }): T | null;
58
+ /**
59
+ * matchSitemapEntryRow — choose the existing row a given host's write or read
60
+ * reconciles into. Lives here beside `pickSeoPageRow` because it is the same
61
+ * decision from the other direction: `pickSeoPageRow` answers "which row do I
62
+ * show when I don't have to care about the host", this answers "which row
63
+ * BELONGS to this host".
64
+ *
65
+ * - site-tagged: the same host's row; else CLAIM an unattributed (NULL-site)
66
+ * legacy row — a write stamps it with this host. Never matches another
67
+ * host's row (this was the site flip-flop bug: shared paths collapsed into
68
+ * one row whose site tag swung to the last syncer). `null` means the path
69
+ * is not this host's, which is a real answer, not a miss.
70
+ * - site-less: deterministic pick via pickSeoPageRow (NULL-site → primary
71
+ * domain → most recent).
72
+ *
73
+ * The single matcher for every by-path caller — sitemap registration, GSC
74
+ * metrics sync, runtime page registration, and the llms.txt page index
75
+ * (`scopeSeoPagesToSite`). Do not fork this rule.
76
+ */
77
+ export declare function matchSitemapEntryRow<T extends SeoPageRowLike>(candidates: T[], site: string | null, primaryHost: string | null): T | null;
78
+ /**
79
+ * scopeSeoPagesToSite — reduce a project's seo_pages rows to the ONE page set
80
+ * that belongs to `site`, keeping at most one row per path.
81
+ *
82
+ * 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.
90
+ *
91
+ * Rules, per path, via `matchSitemapEntryRow`:
92
+ * - this host's row wins outright
93
+ * - an unattributed (NULL-site) row stands in when the host has none — legacy
94
+ * rows from site-less syncs are genuinely ambiguous, and dropping them would
95
+ * silently delete real pages from the one file AI crawlers read
96
+ * - another host's row is never substituted; that path is simply not ours
97
+ *
98
+ * A site-less call is the legacy project-wide read: dedupe only, no filtering.
99
+ */
100
+ export declare function scopeSeoPagesToSite<T extends SeoPageRowLike & {
101
+ path: string;
102
+ }>(rows: T[], site: string | null | undefined, primaryHost: string | null | undefined): T[];
103
+ /**
104
+ * Agent-native contract: JSON Schema for the minimal row shape, so services and
105
+ * agents can validate candidate rows before handing them to `pickSeoPageRow`.
106
+ */
107
+ export declare const SEO_PAGE_ROW_JSON_SCHEMA: {
108
+ readonly $schema: 'http://json-schema.org/draft-07/schema#';
109
+ readonly $id: 'https://sonor.io/schemas/seo-page-row.v1.json';
110
+ readonly title: 'SeoPageRowLike';
111
+ readonly description: 'The subset of an seo_pages row that determines per-site resolution.';
112
+ readonly type: 'object';
113
+ readonly properties: {
114
+ readonly site: {
115
+ readonly description: 'Normalized sub-site host, or null for a project-wide row.';
116
+ readonly type: readonly ['string', 'null'];
117
+ };
118
+ readonly updated_at: {
119
+ readonly description: 'ISO 8601 timestamp; ties in resolution break toward the most recent.';
120
+ readonly type: readonly ['string', 'null'];
121
+ };
122
+ };
123
+ readonly additionalProperties: true;
124
+ };