@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.
- package/CHANGELOG.md +8 -0
- package/README.md +44 -0
- package/dist/color.d.ts +38 -0
- package/dist/color.js +101 -0
- package/dist/color.mjs +94 -0
- package/dist/fleet.d.ts +96 -0
- package/dist/fleet.js +67 -0
- package/dist/fleet.mjs +55 -0
- package/dist/forms.d.ts +89 -0
- package/dist/forms.js +70 -0
- package/dist/forms.mjs +62 -0
- package/dist/llms.d.ts +54 -0
- package/dist/llms.js +107 -0
- package/dist/llms.mjs +97 -0
- package/dist/portfolio.d.ts +158 -0
- package/dist/portfolio.js +129 -0
- package/dist/portfolio.mjs +114 -0
- package/dist/seo-meta.d.ts +56 -0
- package/dist/seo-meta.js +155 -0
- package/dist/seo-meta.mjs +146 -0
- package/dist/seo-pages.d.ts +124 -0
- package/dist/seo-pages.js +95 -0
- package/dist/seo-pages.mjs +88 -0
- package/dist/sites-normalize.d.ts +35 -0
- package/dist/sites.d.ts +48 -0
- package/dist/sites.js +52 -0
- package/dist/sites.mjs +44 -0
- package/dist/slots.d.ts +81 -0
- package/dist/slots.js +44 -0
- package/dist/slots.mjs +35 -0
- package/dist/website.d.ts +148 -0
- package/dist/website.js +370 -0
- package/dist/website.mjs +355 -0
- package/package.json +93 -0
|
@@ -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;
|
package/dist/seo-meta.js
ADDED
|
@@ -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
|
+
};
|