@sonordev/contracts 2.7.1 → 2.9.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 +34 -0
- package/README.md +315 -0
- package/dist/broadcast-layouts.d.ts +133 -0
- package/dist/broadcast-layouts.js +83 -0
- package/dist/broadcast-layouts.mjs +56 -0
- package/dist/business-claims.d.ts +456 -0
- package/dist/business-claims.js +1378 -0
- package/dist/business-claims.mjs +1340 -0
- package/dist/outreach.d.ts +267 -0
- package/dist/outreach.js +351 -0
- package/dist/outreach.mjs +328 -0
- package/dist/page-titles.d.ts +80 -0
- package/dist/page-titles.js +124 -0
- package/dist/page-titles.mjs +120 -0
- package/dist/publication-branding.d.ts +143 -0
- package/dist/publication-branding.js +350 -0
- package/dist/publication-branding.mjs +328 -0
- package/dist/publication-charter.d.ts +14 -0
- package/package.json +28 -2
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// src/contracts/business-claims.ts
|
|
2
|
+
function fnv1a(text) {
|
|
3
|
+
let hash = 2166136261;
|
|
4
|
+
for (let i = 0; i < text.length; i++) {
|
|
5
|
+
hash ^= text.charCodeAt(i);
|
|
6
|
+
hash = Math.imul(hash, 16777619);
|
|
7
|
+
}
|
|
8
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/contracts/outreach.ts
|
|
12
|
+
var OUTREACH_CONTRACT_VERSION = 1;
|
|
13
|
+
var OUTREACH_WRITER_BADGE = "signal.outreach.writer";
|
|
14
|
+
var OUTREACH_EMAIL_KIND = "outreach_email";
|
|
15
|
+
var GRADUATION_BAR = { minMeasured: 5, minWinRate: 0.7, maxUnrevertedDegradations: 0 };
|
|
16
|
+
var SEGMENT_STATES = ["ungranted", "granted", "paused", "lapsed", "revoked"];
|
|
17
|
+
var UNSPECIFIED_INDUSTRY = "unspecified";
|
|
18
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
+
var isUuid = (value) => typeof value === "string" && UUID.test(value);
|
|
20
|
+
var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
var nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
|
|
22
|
+
function prospectIndustry(prospect) {
|
|
23
|
+
for (const value of [prospect?.vertical, prospect?.industry, prospect?.company_industry]) {
|
|
24
|
+
if (nonEmpty(value)) return value.trim();
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
function normalizeIndustry(value) {
|
|
29
|
+
if (typeof value !== "string") return UNSPECIFIED_INDUSTRY;
|
|
30
|
+
const slug = value.normalize("NFKC").normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
|
|
31
|
+
return slug || UNSPECIFIED_INDUSTRY;
|
|
32
|
+
}
|
|
33
|
+
var NARRATIVE_PITCH_FIELDS = [
|
|
34
|
+
"positioning_statement",
|
|
35
|
+
"target_icp_description",
|
|
36
|
+
"tone_profile",
|
|
37
|
+
"hook_types",
|
|
38
|
+
"example_angles",
|
|
39
|
+
"value_prop_bullets"
|
|
40
|
+
];
|
|
41
|
+
function canonical(value) {
|
|
42
|
+
if (value === null || value === void 0) return "null";
|
|
43
|
+
if (typeof value === "string") return JSON.stringify(value.normalize("NFKC").replace(/\s+/g, " ").trim());
|
|
44
|
+
if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
|
|
45
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
46
|
+
if (isRecord(value)) {
|
|
47
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
48
|
+
}
|
|
49
|
+
return "null";
|
|
50
|
+
}
|
|
51
|
+
function narrativeFingerprint(narrative) {
|
|
52
|
+
const source = isRecord(narrative) ? narrative : {};
|
|
53
|
+
const pitch = {};
|
|
54
|
+
for (const field of NARRATIVE_PITCH_FIELDS) pitch[field] = source[field] ?? null;
|
|
55
|
+
return fnv1a(canonical(pitch));
|
|
56
|
+
}
|
|
57
|
+
function outreachSegment(input) {
|
|
58
|
+
const vertical = normalizeIndustry(input?.industry);
|
|
59
|
+
const label = typeof input?.label === "string" ? input.label : "";
|
|
60
|
+
const angle_fp = typeof input?.angleFp === "string" && input.angleFp ? input.angleFp : null;
|
|
61
|
+
if (input?.source === "drip") {
|
|
62
|
+
if (!isUuid(input.narrativeId) || !isUuid(input.mailboxId)) return null;
|
|
63
|
+
const narrative_id = input.narrativeId.toLowerCase();
|
|
64
|
+
const mailbox_id = input.mailboxId.toLowerCase();
|
|
65
|
+
return { key: `drip:${narrative_id}@${mailbox_id}|${vertical}`, source: "drip", narrative_id, mailbox_id, vertical, label, angle_fp };
|
|
66
|
+
}
|
|
67
|
+
if (input?.source === "mcp") {
|
|
68
|
+
const mailbox_id = isUuid(input.mailboxId) ? input.mailboxId.toLowerCase() : null;
|
|
69
|
+
return { key: `mcp:-|${vertical}`, source: "mcp", narrative_id: null, mailbox_id, vertical, label, angle_fp };
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function outreachSegmentLabel(input) {
|
|
74
|
+
const name = nonEmpty(input?.narrativeName) ? input.narrativeName.trim() : "Outreach";
|
|
75
|
+
const industry = nonEmpty(input?.industry) ? input.industry.trim() : "leads with no industry";
|
|
76
|
+
const from = nonEmpty(input?.mailboxAddress) ? `, from ${input.mailboxAddress.trim()}` : "";
|
|
77
|
+
return `${name} to ${industry}${from}`;
|
|
78
|
+
}
|
|
79
|
+
var DRIP_KEY = /^drip:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})@([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\|([a-z0-9-]{1,48})$/;
|
|
80
|
+
var MCP_KEY = /^mcp:-\|([a-z0-9-]{1,48})$/;
|
|
81
|
+
function parseOutreachSegmentKey(key) {
|
|
82
|
+
if (typeof key !== "string" || key.length > 140) return null;
|
|
83
|
+
const drip = DRIP_KEY.exec(key);
|
|
84
|
+
if (drip) return { source: "drip", narrative_id: drip[1], mailbox_id: drip[2], vertical: drip[3] };
|
|
85
|
+
const mcp = MCP_KEY.exec(key);
|
|
86
|
+
if (mcp) return { source: "mcp", vertical: mcp[1] };
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function isGrantableSegmentKey(key) {
|
|
90
|
+
const parsed = parseOutreachSegmentKey(key);
|
|
91
|
+
return parsed?.source === "drip" && parsed.vertical !== UNSPECIFIED_INDUSTRY;
|
|
92
|
+
}
|
|
93
|
+
var isDate = (value) => typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
94
|
+
function readGrant(value) {
|
|
95
|
+
if (!isRecord(value)) return null;
|
|
96
|
+
const { state, source, by, via, at, ref, receipts, mailbox_id, label, angle_fp, paused, resumed, revoked } = value;
|
|
97
|
+
if (state !== "granted" && state !== "revoked") return null;
|
|
98
|
+
if (source !== "first_email" && source !== "earned") return null;
|
|
99
|
+
if (via !== "dashboard" && via !== "drafts") return null;
|
|
100
|
+
if (!nonEmpty(by) || !isDate(at) || !nonEmpty(ref) || !nonEmpty(mailbox_id) || !nonEmpty(angle_fp) || typeof label !== "string") return null;
|
|
101
|
+
const grant = { state, source, by, via, at, ref, mailbox_id, label, angle_fp };
|
|
102
|
+
if (receipts !== void 0) {
|
|
103
|
+
if (!Array.isArray(receipts) || !receipts.every(nonEmpty)) return null;
|
|
104
|
+
grant.receipts = [...receipts];
|
|
105
|
+
}
|
|
106
|
+
if (paused !== void 0 && paused !== null) {
|
|
107
|
+
if (!isRecord(paused) || !isDate(paused.at) || typeof paused.reason !== "string") return null;
|
|
108
|
+
grant.paused = { at: paused.at, reason: paused.reason, loss_ref: nonEmpty(paused.loss_ref) ? paused.loss_ref : null };
|
|
109
|
+
}
|
|
110
|
+
if (resumed !== void 0 && resumed !== null) {
|
|
111
|
+
if (!isRecord(resumed) || !nonEmpty(resumed.by) || !isDate(resumed.at)) return null;
|
|
112
|
+
grant.resumed = { by: resumed.by, at: resumed.at };
|
|
113
|
+
}
|
|
114
|
+
if (revoked !== void 0 && revoked !== null) {
|
|
115
|
+
if (!isRecord(revoked) || !nonEmpty(revoked.by) || !isDate(revoked.at) || typeof revoked.reason !== "string") return null;
|
|
116
|
+
grant.revoked = { by: revoked.by, at: revoked.at, reason: revoked.reason };
|
|
117
|
+
}
|
|
118
|
+
return grant;
|
|
119
|
+
}
|
|
120
|
+
function readSegmentGrants(source) {
|
|
121
|
+
const autonomy = isRecord(source) && isRecord(source.autonomy) ? source.autonomy : source;
|
|
122
|
+
const segments = isRecord(autonomy) && isRecord(autonomy.segments) ? autonomy.segments[OUTREACH_EMAIL_KIND] : null;
|
|
123
|
+
const out = {};
|
|
124
|
+
if (!isRecord(segments)) return out;
|
|
125
|
+
for (const key of Object.keys(segments)) {
|
|
126
|
+
if (!parseOutreachSegmentKey(key)) continue;
|
|
127
|
+
const grant = readGrant(segments[key]);
|
|
128
|
+
if (grant) out[key] = grant;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function activeSegmentGrant(source, key) {
|
|
133
|
+
if (!isGrantableSegmentKey(key)) return null;
|
|
134
|
+
const grant = readSegmentGrants(source)[key];
|
|
135
|
+
return grant && grant.state === "granted" && !grant.paused ? grant : null;
|
|
136
|
+
}
|
|
137
|
+
function segmentGrantCovers(grant, row) {
|
|
138
|
+
if (!grant) return { covers: false, code: "not_granted", reason: "Nobody's turned this segment on." };
|
|
139
|
+
if (grant.state === "revoked") return { covers: false, code: "revoked", reason: "Someone stopped this segment." };
|
|
140
|
+
if (grant.paused) return { covers: false, code: "paused", reason: `This segment stopped itself: ${grant.paused.reason}` };
|
|
141
|
+
if (grant.state !== "granted") return { covers: false, code: "not_granted", reason: "Nobody's turned this segment on." };
|
|
142
|
+
if (row.by !== void 0 && row.by !== grant.by) return { covers: false, code: "other_person", reason: "This row names someone other than the person who turned the segment on." };
|
|
143
|
+
const created = Date.parse(row.createdAt ?? "");
|
|
144
|
+
const granted = Date.parse(grant.at);
|
|
145
|
+
if (!Number.isFinite(created) || !Number.isFinite(granted) || created < granted) {
|
|
146
|
+
return { covers: false, code: "granted_after", reason: "This email was written before the segment was turned on." };
|
|
147
|
+
}
|
|
148
|
+
if (!row.angleFp || row.angleFp !== grant.angle_fp) {
|
|
149
|
+
return { covers: false, code: "angle_changed", reason: "The pitch changed since this segment was turned on, so its next email needs a look." };
|
|
150
|
+
}
|
|
151
|
+
if (!row.mailboxId || row.mailboxId.toLowerCase() !== grant.mailbox_id.toLowerCase()) {
|
|
152
|
+
return { covers: false, code: "other_mailbox", reason: "This email sends from a different mailbox than the one the segment was turned on for." };
|
|
153
|
+
}
|
|
154
|
+
return { covers: true };
|
|
155
|
+
}
|
|
156
|
+
function segmentOf(row) {
|
|
157
|
+
return isRecord(row.target_ref) && isRecord(row.target_ref.segment) ? row.target_ref.segment : null;
|
|
158
|
+
}
|
|
159
|
+
function payloadOf(row) {
|
|
160
|
+
return isRecord(row.payload) ? row.payload : {};
|
|
161
|
+
}
|
|
162
|
+
function viaOf(row) {
|
|
163
|
+
const approval = payloadOf(row)._approval;
|
|
164
|
+
return isRecord(approval) ? approval.via : void 0;
|
|
165
|
+
}
|
|
166
|
+
function wasEdited(row) {
|
|
167
|
+
const edited = payloadOf(row).edited;
|
|
168
|
+
return isRecord(edited) && typeof edited.count === "number" && edited.count > 0;
|
|
169
|
+
}
|
|
170
|
+
function readBackStatus(row) {
|
|
171
|
+
const execution = payloadOf(row)._execution;
|
|
172
|
+
return isRecord(execution) && isRecord(execution.read_back) ? execution.read_back.status : void 0;
|
|
173
|
+
}
|
|
174
|
+
function evidenceRowRefusal(row, grant, scope, people) {
|
|
175
|
+
if (!row) return "isn't on record";
|
|
176
|
+
if (row.kind !== OUTREACH_EMAIL_KIND || row.created_by !== OUTREACH_WRITER_BADGE) return "isn't an email Signal's outreach writer wrote";
|
|
177
|
+
if (scope.projectId && row.project_id && row.project_id !== scope.projectId) return "belongs to another project";
|
|
178
|
+
if (row.status !== "applied") return "wasn't approved and sent";
|
|
179
|
+
const segment = segmentOf(row);
|
|
180
|
+
if (!segment || segment.key !== scope.key) return "is in a different segment";
|
|
181
|
+
if (typeof segment.mailbox_id !== "string" || segment.mailbox_id.toLowerCase() !== grant.mailbox_id.toLowerCase()) return "went out from a different mailbox";
|
|
182
|
+
if (typeof segment.angle_fp === "string" && segment.angle_fp !== grant.angle_fp) return "was written for an earlier version of the pitch";
|
|
183
|
+
if (!nonEmpty(row.reviewed_by) || row.reviewed_by.startsWith("agent:")) return "wasn't approved by a person";
|
|
184
|
+
if (people && !people.has(row.reviewed_by)) return "wasn't approved by a person";
|
|
185
|
+
const reviewed = Date.parse(row.reviewed_at ?? "");
|
|
186
|
+
if (!Number.isFinite(reviewed) || reviewed > Date.parse(grant.at)) return "was approved after the segment was turned on";
|
|
187
|
+
const via = viaOf(row);
|
|
188
|
+
if (via !== "dashboard" && via !== "drafts") return via === "mcp" ? "was approved through MCP, not in Sonor" : "doesn't say where it was approved";
|
|
189
|
+
if (wasEdited(row)) return "was edited before it was approved";
|
|
190
|
+
if (readBackStatus(row) !== "verified") return "wasn't confirmed to have arrived intact";
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
function grantEvidenceRefusal(grant, refRows, scope) {
|
|
194
|
+
if (!grant) return "There's no grant.";
|
|
195
|
+
if (!isGrantableSegmentKey(scope?.key)) return "This segment can't be turned on.";
|
|
196
|
+
const rows = /* @__PURE__ */ new Map();
|
|
197
|
+
for (const row of refRows ?? []) if (row && typeof row.id === "string") rows.set(row.id, row);
|
|
198
|
+
const people = scope.people ? new Set(scope.people) : null;
|
|
199
|
+
if (grant.source === "first_email") {
|
|
200
|
+
const row = rows.get(grant.ref);
|
|
201
|
+
const refusal = evidenceRowRefusal(row, grant, scope, people);
|
|
202
|
+
if (refusal) return `The email this segment was turned on from ${refusal}.`;
|
|
203
|
+
if (row.reviewed_by !== grant.by) return "The email this segment was turned on from was approved by someone else.";
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const receipts = Array.from(new Set(grant.receipts ?? []));
|
|
207
|
+
const needed = Math.max(1, Math.ceil(Number(scope.minMeasured) || 0));
|
|
208
|
+
if (receipts.length < needed) {
|
|
209
|
+
return `Only ${receipts.length} of the ${needed} approvals this segment needs to earn its own sending are on record.`;
|
|
210
|
+
}
|
|
211
|
+
for (const id of receipts) {
|
|
212
|
+
const refusal = evidenceRowRefusal(rows.get(id), grant, scope, people);
|
|
213
|
+
if (refusal) return `One of the approvals this segment earned its sending with ${refusal}.`;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
var GRANTER_ROLES = /* @__PURE__ */ new Set(["owner", "admin"]);
|
|
218
|
+
function segmentGranterEligible(roles) {
|
|
219
|
+
if (!roles) return false;
|
|
220
|
+
if (typeof roles.contact_type === "string" && roles.contact_type.trim().toLowerCase() === "agent") return false;
|
|
221
|
+
if (roles.is_platform_admin === true) return true;
|
|
222
|
+
return [roles.org_role, roles.project_role, roles.managing_org_role].some(
|
|
223
|
+
(role) => typeof role === "string" && GRANTER_ROLES.has(role.trim().toLowerCase())
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
var PUBLIC_MAILBOX_DOMAINS = [
|
|
227
|
+
"gmail.com",
|
|
228
|
+
"googlemail.com",
|
|
229
|
+
"yahoo.com",
|
|
230
|
+
"ymail.com",
|
|
231
|
+
"rocketmail.com",
|
|
232
|
+
"hotmail.com",
|
|
233
|
+
"outlook.com",
|
|
234
|
+
"live.com",
|
|
235
|
+
"msn.com",
|
|
236
|
+
"aol.com",
|
|
237
|
+
"aim.com",
|
|
238
|
+
"icloud.com",
|
|
239
|
+
"me.com",
|
|
240
|
+
"mac.com",
|
|
241
|
+
"protonmail.com",
|
|
242
|
+
"proton.me",
|
|
243
|
+
"pm.me",
|
|
244
|
+
"gmx.com",
|
|
245
|
+
"gmx.net",
|
|
246
|
+
"mail.com",
|
|
247
|
+
"zoho.com",
|
|
248
|
+
"zohomail.com",
|
|
249
|
+
"yandex.com",
|
|
250
|
+
"fastmail.com",
|
|
251
|
+
"hey.com",
|
|
252
|
+
"tutanota.com",
|
|
253
|
+
"tuta.io",
|
|
254
|
+
"att.net",
|
|
255
|
+
"sbcglobal.net",
|
|
256
|
+
"bellsouth.net",
|
|
257
|
+
"comcast.net",
|
|
258
|
+
"verizon.net",
|
|
259
|
+
"cox.net",
|
|
260
|
+
"charter.net",
|
|
261
|
+
"earthlink.net",
|
|
262
|
+
"frontier.com",
|
|
263
|
+
"windstream.net",
|
|
264
|
+
"centurylink.net",
|
|
265
|
+
"optonline.net",
|
|
266
|
+
"juno.com",
|
|
267
|
+
"netzero.net",
|
|
268
|
+
"roadrunner.com",
|
|
269
|
+
"rr.com",
|
|
270
|
+
"qq.com",
|
|
271
|
+
"163.com",
|
|
272
|
+
"126.com",
|
|
273
|
+
"mail.ru",
|
|
274
|
+
"web.de",
|
|
275
|
+
"t-online.de",
|
|
276
|
+
"orange.fr",
|
|
277
|
+
"free.fr",
|
|
278
|
+
"btinternet.com",
|
|
279
|
+
"sky.com",
|
|
280
|
+
"rogers.com",
|
|
281
|
+
"shaw.ca",
|
|
282
|
+
"sympatico.ca",
|
|
283
|
+
"bigpond.com"
|
|
284
|
+
];
|
|
285
|
+
var PUBLIC_DOMAIN_SET = new Set(PUBLIC_MAILBOX_DOMAINS);
|
|
286
|
+
var PUBLIC_DOMAIN_FAMILY = /^(?:yahoo|ymail|hotmail|live|outlook|msn|aol|gmx|yandex)\.(?:[a-z]{2,3}|co\.[a-z]{2}|com\.[a-z]{2})$/;
|
|
287
|
+
var PUBLIC_DOMAIN_SUFFIX = [".rr.com"];
|
|
288
|
+
function isPublicMailboxDomain(value) {
|
|
289
|
+
if (typeof value !== "string") return false;
|
|
290
|
+
let domain = value.trim().toLowerCase().replace(/^mailto:/, "");
|
|
291
|
+
const at = domain.lastIndexOf("@");
|
|
292
|
+
if (at >= 0) domain = domain.slice(at + 1);
|
|
293
|
+
domain = domain.replace(/\.+$/, "").replace(/^www\./, "");
|
|
294
|
+
if (!domain) return false;
|
|
295
|
+
return PUBLIC_DOMAIN_SET.has(domain) || PUBLIC_DOMAIN_FAMILY.test(domain) || PUBLIC_DOMAIN_SUFFIX.some((suffix) => domain.endsWith(suffix));
|
|
296
|
+
}
|
|
297
|
+
function dripRowDisposition(row) {
|
|
298
|
+
const payload = isRecord(row?.payload) ? row.payload : {};
|
|
299
|
+
const execution = isRecord(payload._execution) ? payload._execution : {};
|
|
300
|
+
switch (row?.status) {
|
|
301
|
+
case "proposed":
|
|
302
|
+
case "approved":
|
|
303
|
+
return "live";
|
|
304
|
+
case "applied":
|
|
305
|
+
case "auto_applied":
|
|
306
|
+
case "executed":
|
|
307
|
+
case "reverted":
|
|
308
|
+
return "sent";
|
|
309
|
+
case "failed":
|
|
310
|
+
return execution.sent === true ? "sent" : "refile";
|
|
311
|
+
case "rejected": {
|
|
312
|
+
const decision = isRecord(row?.autonomy_decision) ? row.autonomy_decision : {};
|
|
313
|
+
if (decision.allowed === "off" && !row?.reviewed_by) return "refile";
|
|
314
|
+
const reason = row?.review_reason;
|
|
315
|
+
if (reason === "wrong_recipient") return "retire";
|
|
316
|
+
if (reason === "wrong_timing" || reason === "withdrawn") return "refile";
|
|
317
|
+
return "declined";
|
|
318
|
+
}
|
|
319
|
+
case "expired":
|
|
320
|
+
case "superseded":
|
|
321
|
+
case "discarded":
|
|
322
|
+
return "refile";
|
|
323
|
+
default:
|
|
324
|
+
return "live";
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export { GRADUATION_BAR, NARRATIVE_PITCH_FIELDS, OUTREACH_CONTRACT_VERSION, OUTREACH_EMAIL_KIND, OUTREACH_WRITER_BADGE, PUBLIC_MAILBOX_DOMAINS, SEGMENT_STATES, UNSPECIFIED_INDUSTRY, activeSegmentGrant, dripRowDisposition, grantEvidenceRefusal, isGrantableSegmentKey, isPublicMailboxDomain, narrativeFingerprint, normalizeIndustry, outreachSegment, outreachSegmentLabel, parseOutreachSegmentKey, prospectIndustry, readSegmentGrants, segmentGrantCovers, segmentGranterEligible };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-titles contract: what a page is called when a list, a sitemap or a
|
|
3
|
+
* case study needs its own name and not its whole <title>. The Sonor APIs and
|
|
4
|
+
* the agency site kit import it as `@sonordev/contracts/page-titles`.
|
|
5
|
+
*
|
|
6
|
+
* Why it exists: three places each turned a stored title or a URL slug into a
|
|
7
|
+
* label with their own logic, and they disagreed. "ac-replacement" became "Ac
|
|
8
|
+
* Replacement", "faq" became "Faq", "smith-and-smith" became "Smith And
|
|
9
|
+
* Smith", and a title with a stray second pipe shipped as "Loans | | Brand".
|
|
10
|
+
* There's one rule now:
|
|
11
|
+
*
|
|
12
|
+
* - `titleHead` is the page's own name: the title without its trailing
|
|
13
|
+
* " | Brand" segment. Prefer it. A page's title is the label it was
|
|
14
|
+
* written with, so it beats anything guessed from a URL.
|
|
15
|
+
* - `slugLabel` is for the rare case a label has to come from a slug, a file
|
|
16
|
+
* name or a path, because the page has no title at all.
|
|
17
|
+
* - `isRoutePlaceholder` says whether a value is a framework route
|
|
18
|
+
* placeholder (":slug", "[slug]", "[...rest]") and not a page's name.
|
|
19
|
+
*
|
|
20
|
+
* Pure string logic: no I/O, no Node built-ins. The brand split is
|
|
21
|
+
* seo-meta's `stripTitleSuffix`, imported, never copied: only the LAST
|
|
22
|
+
* " | " segment of a title is its brand.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The page's own name: the title without its trailing " | Brand" segment.
|
|
26
|
+
*
|
|
27
|
+
* Whitespace is collapsed first. Stray pipes are repaired before the brand is
|
|
28
|
+
* cut, so "Loans | | Brand" reads as "Loans | Brand" (a run of pipes with
|
|
29
|
+
* only whitespace between them is one separator) and a leading or trailing
|
|
30
|
+
* pipe is dropped ("Pricing |" is "Pricing"). Then the last " | " segment is
|
|
31
|
+
* the brand and goes; anything before it stays, pipes and all:
|
|
32
|
+
* "1 Bedroom | 1BR 1BA | Example Apartments" is "1 Bedroom | 1BR 1BA".
|
|
33
|
+
*
|
|
34
|
+
* Apply it once to a stored title. A title with two or more separators
|
|
35
|
+
* loses one segment per call, so its own output isn't a fixed point.
|
|
36
|
+
*
|
|
37
|
+
* A title with no separator comes back as written (trimmed): this can't tell
|
|
38
|
+
* a brand from a name, so a title that's only a brand stays that brand.
|
|
39
|
+
* Empty, whitespace-only, non-string, null and undefined input give "".
|
|
40
|
+
*/
|
|
41
|
+
export declare function titleHead(title: string | null | undefined): string;
|
|
42
|
+
/**
|
|
43
|
+
* A readable label for a slug, a file name or a path: "ac-replacement" is
|
|
44
|
+
* "AC Replacement", "/services/hvac-repair/" is "HVAC Repair".
|
|
45
|
+
*
|
|
46
|
+
* Reach for it only when the page has no title (`titleHead` is the page's own
|
|
47
|
+
* name). It reads the last non-empty path segment of the input, after
|
|
48
|
+
* dropping a "https://host" origin, a query or a hash, and decodes %-escapes
|
|
49
|
+
* without ever throwing. A trailing .html, .htm, .php or .aspx is dropped. The
|
|
50
|
+
* words split on "-", "_", "+" and whitespace.
|
|
51
|
+
*
|
|
52
|
+
* Each word is cased by the first rule that fits:
|
|
53
|
+
* - an acronym is upper case ("ac", "ai", "cms", "faq", "hvac", "seo",
|
|
54
|
+
* "llc", "mls", ...), and "faqs" is "FAQs";
|
|
55
|
+
* - a two-letter US state or DC code is upper case ("ky" is "KY"), apart
|
|
56
|
+
* from the codes that are ordinary words as often as not ("in", "or",
|
|
57
|
+
* "me", "hi", "id", "la", "ma", "pa", "al", "de", "co"). "mt" goes by its
|
|
58
|
+
* position: last, it's Montana ("billings-mt" is "Billings MT"); with a
|
|
59
|
+
* word after it, it's Mount ("mt-adams" is "Mt Adams"). "us" isn't a
|
|
60
|
+
* state: "about-us" is "About Us";
|
|
61
|
+
* - a word with a digit keeps its case and only gains a leading capital
|
|
62
|
+
* when it starts with a letter ("c1" is "C1", "075ct" stays);
|
|
63
|
+
* - a small word ("a", "an", "and", "at", "by", "for", "in", "of", "on",
|
|
64
|
+
* "or", "the", "to", "vs", "with") is lower case, unless it's first;
|
|
65
|
+
* - anything else is capitalized with the rest lower case.
|
|
66
|
+
*
|
|
67
|
+
* Nothing left to label ("", "/", "?x=1") gives "". The home page is the
|
|
68
|
+
* caller's to name. A label isn't a title: "index" isn't special-cased,
|
|
69
|
+
* and a brand's own capitalization ("DeWalt") isn't recoverable from a slug.
|
|
70
|
+
*/
|
|
71
|
+
export declare function slugLabel(input: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* True when the value, or any "/"-separated segment of it, is a framework
|
|
74
|
+
* route placeholder: a page's pattern leaked into its name ("/floor-plans/:slug",
|
|
75
|
+
* ":Slug", "[...rest]"). A placeholder is one token with no spaces, so
|
|
76
|
+
* ordinary text that contains a colon or a bracket isn't one ("Update: new
|
|
77
|
+
* hours", "FAQ / [draft] notes"). Nullish, empty and non-string values are
|
|
78
|
+
* false.
|
|
79
|
+
*/
|
|
80
|
+
export declare function isRoutePlaceholder(value: string | null | undefined): boolean;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/seo-meta.ts
|
|
4
|
+
var TITLE_SEPARATOR = " | ";
|
|
5
|
+
function stripTitleSuffix(title) {
|
|
6
|
+
const t = title.trim();
|
|
7
|
+
const i = t.lastIndexOf(TITLE_SEPARATOR);
|
|
8
|
+
return i > 0 ? t.slice(0, i).trim() : t;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/contracts/page-titles.ts
|
|
12
|
+
var PIPE_RUN = /\|(?:\s*\|)+/g;
|
|
13
|
+
var EDGE_PIPES = /^[\s|]+|[\s|]+$/g;
|
|
14
|
+
function titleHead(title) {
|
|
15
|
+
if (typeof title !== "string") return "";
|
|
16
|
+
const tidy = title.replace(/\s+/g, " ").replace(PIPE_RUN, " | ").replace(EDGE_PIPES, "").replace(/\s+/g, " ");
|
|
17
|
+
return tidy ? stripTitleSuffix(tidy) : "";
|
|
18
|
+
}
|
|
19
|
+
var ACRONYMS = /* @__PURE__ */ new Map([
|
|
20
|
+
["ac", "AC"],
|
|
21
|
+
["ai", "AI"],
|
|
22
|
+
["aeo", "AEO"],
|
|
23
|
+
["api", "API"],
|
|
24
|
+
["cms", "CMS"],
|
|
25
|
+
["crm", "CRM"],
|
|
26
|
+
["faq", "FAQ"],
|
|
27
|
+
["faqs", "FAQs"],
|
|
28
|
+
["fsbo", "FSBO"],
|
|
29
|
+
["hvac", "HVAC"],
|
|
30
|
+
["jl", "JL"],
|
|
31
|
+
["llc", "LLC"],
|
|
32
|
+
["llp", "LLP"],
|
|
33
|
+
["mls", "MLS"],
|
|
34
|
+
["pdf", "PDF"],
|
|
35
|
+
["pllc", "PLLC"],
|
|
36
|
+
["ppc", "PPC"],
|
|
37
|
+
["roi", "ROI"],
|
|
38
|
+
["seo", "SEO"],
|
|
39
|
+
["ui", "UI"],
|
|
40
|
+
["usa", "USA"],
|
|
41
|
+
["ux", "UX"]
|
|
42
|
+
]);
|
|
43
|
+
var STATE_CODES = new Set(
|
|
44
|
+
"al ak az ar ca co ct de dc fl ga hi id il in ia ks ky la me md ma mi mn ms mo mt ne nv nh nj nm ny nc nd oh ok or pa ri sc sd tn tx ut vt va wa wv wi wy".split(" ")
|
|
45
|
+
);
|
|
46
|
+
var WORD_STATE_CODES = /* @__PURE__ */ new Set([
|
|
47
|
+
"in",
|
|
48
|
+
"or",
|
|
49
|
+
"me",
|
|
50
|
+
"hi",
|
|
51
|
+
"id",
|
|
52
|
+
"la",
|
|
53
|
+
"ma",
|
|
54
|
+
"pa",
|
|
55
|
+
"al",
|
|
56
|
+
"de",
|
|
57
|
+
"co"
|
|
58
|
+
]);
|
|
59
|
+
var TRAILING_STATE_CODES = /* @__PURE__ */ new Set(["mt"]);
|
|
60
|
+
function isStateCode(lower, isLast) {
|
|
61
|
+
if (!STATE_CODES.has(lower) || WORD_STATE_CODES.has(lower)) return false;
|
|
62
|
+
return TRAILING_STATE_CODES.has(lower) ? isLast : true;
|
|
63
|
+
}
|
|
64
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set([
|
|
65
|
+
"a",
|
|
66
|
+
"an",
|
|
67
|
+
"and",
|
|
68
|
+
"at",
|
|
69
|
+
"by",
|
|
70
|
+
"for",
|
|
71
|
+
"in",
|
|
72
|
+
"of",
|
|
73
|
+
"on",
|
|
74
|
+
"or",
|
|
75
|
+
"the",
|
|
76
|
+
"to",
|
|
77
|
+
"vs",
|
|
78
|
+
"with"
|
|
79
|
+
]);
|
|
80
|
+
var PAGE_EXTENSION = /\.(?:html?|php|aspx)$/i;
|
|
81
|
+
var URL_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/\\]*/i;
|
|
82
|
+
var PERCENT_RUN = /(?:%[0-9a-f]{2})+/gi;
|
|
83
|
+
function decodeEscapes(value) {
|
|
84
|
+
return value.replace(PERCENT_RUN, (run) => {
|
|
85
|
+
try {
|
|
86
|
+
return decodeURIComponent(run);
|
|
87
|
+
} catch {
|
|
88
|
+
return run;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function capitalizeFirst(word) {
|
|
93
|
+
const code = word.codePointAt(0);
|
|
94
|
+
if (code === void 0) return word;
|
|
95
|
+
const first = String.fromCodePoint(code);
|
|
96
|
+
return first.toUpperCase() + word.slice(first.length);
|
|
97
|
+
}
|
|
98
|
+
function labelWord(token, isFirst, isLast) {
|
|
99
|
+
const lower = token.toLowerCase();
|
|
100
|
+
const acronym = ACRONYMS.get(lower);
|
|
101
|
+
if (acronym) return acronym;
|
|
102
|
+
if (isStateCode(lower, isLast)) return lower.toUpperCase();
|
|
103
|
+
if (/\p{N}/u.test(token)) return /^\p{L}/u.test(token) ? capitalizeFirst(token) : token;
|
|
104
|
+
if (!isFirst && SMALL_WORDS.has(lower)) return lower;
|
|
105
|
+
return capitalizeFirst(lower);
|
|
106
|
+
}
|
|
107
|
+
function slugLabel(input) {
|
|
108
|
+
if (typeof input !== "string") return "";
|
|
109
|
+
const path = input.split(/[?#]/, 1)[0].replace(URL_ORIGIN, "");
|
|
110
|
+
const segment = path.split(/[/\\]/).filter((s) => s.trim() !== "").pop();
|
|
111
|
+
if (!segment) return "";
|
|
112
|
+
const name = decodeEscapes(segment).replace(PAGE_EXTENSION, "");
|
|
113
|
+
const tokens = name.split(/[-_+\s]+/).filter(Boolean);
|
|
114
|
+
return tokens.map((token, i) => labelWord(token, i === 0, i === tokens.length - 1)).join(" ");
|
|
115
|
+
}
|
|
116
|
+
var PLACEHOLDER_TOKEN = /^(?::[A-Za-z_][\w-]*[?*+]?|\[\[\.\.\.[A-Za-z_][\w-]*\]\]|\[(?:\.\.\.)?[A-Za-z_][\w-]*\])$/;
|
|
117
|
+
function isRoutePlaceholder(value) {
|
|
118
|
+
if (typeof value !== "string") return false;
|
|
119
|
+
return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
exports.isRoutePlaceholder = isRoutePlaceholder;
|
|
123
|
+
exports.slugLabel = slugLabel;
|
|
124
|
+
exports.titleHead = titleHead;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// src/contracts/seo-meta.ts
|
|
2
|
+
var TITLE_SEPARATOR = " | ";
|
|
3
|
+
function stripTitleSuffix(title) {
|
|
4
|
+
const t = title.trim();
|
|
5
|
+
const i = t.lastIndexOf(TITLE_SEPARATOR);
|
|
6
|
+
return i > 0 ? t.slice(0, i).trim() : t;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/contracts/page-titles.ts
|
|
10
|
+
var PIPE_RUN = /\|(?:\s*\|)+/g;
|
|
11
|
+
var EDGE_PIPES = /^[\s|]+|[\s|]+$/g;
|
|
12
|
+
function titleHead(title) {
|
|
13
|
+
if (typeof title !== "string") return "";
|
|
14
|
+
const tidy = title.replace(/\s+/g, " ").replace(PIPE_RUN, " | ").replace(EDGE_PIPES, "").replace(/\s+/g, " ");
|
|
15
|
+
return tidy ? stripTitleSuffix(tidy) : "";
|
|
16
|
+
}
|
|
17
|
+
var ACRONYMS = /* @__PURE__ */ new Map([
|
|
18
|
+
["ac", "AC"],
|
|
19
|
+
["ai", "AI"],
|
|
20
|
+
["aeo", "AEO"],
|
|
21
|
+
["api", "API"],
|
|
22
|
+
["cms", "CMS"],
|
|
23
|
+
["crm", "CRM"],
|
|
24
|
+
["faq", "FAQ"],
|
|
25
|
+
["faqs", "FAQs"],
|
|
26
|
+
["fsbo", "FSBO"],
|
|
27
|
+
["hvac", "HVAC"],
|
|
28
|
+
["jl", "JL"],
|
|
29
|
+
["llc", "LLC"],
|
|
30
|
+
["llp", "LLP"],
|
|
31
|
+
["mls", "MLS"],
|
|
32
|
+
["pdf", "PDF"],
|
|
33
|
+
["pllc", "PLLC"],
|
|
34
|
+
["ppc", "PPC"],
|
|
35
|
+
["roi", "ROI"],
|
|
36
|
+
["seo", "SEO"],
|
|
37
|
+
["ui", "UI"],
|
|
38
|
+
["usa", "USA"],
|
|
39
|
+
["ux", "UX"]
|
|
40
|
+
]);
|
|
41
|
+
var STATE_CODES = new Set(
|
|
42
|
+
"al ak az ar ca co ct de dc fl ga hi id il in ia ks ky la me md ma mi mn ms mo mt ne nv nh nj nm ny nc nd oh ok or pa ri sc sd tn tx ut vt va wa wv wi wy".split(" ")
|
|
43
|
+
);
|
|
44
|
+
var WORD_STATE_CODES = /* @__PURE__ */ new Set([
|
|
45
|
+
"in",
|
|
46
|
+
"or",
|
|
47
|
+
"me",
|
|
48
|
+
"hi",
|
|
49
|
+
"id",
|
|
50
|
+
"la",
|
|
51
|
+
"ma",
|
|
52
|
+
"pa",
|
|
53
|
+
"al",
|
|
54
|
+
"de",
|
|
55
|
+
"co"
|
|
56
|
+
]);
|
|
57
|
+
var TRAILING_STATE_CODES = /* @__PURE__ */ new Set(["mt"]);
|
|
58
|
+
function isStateCode(lower, isLast) {
|
|
59
|
+
if (!STATE_CODES.has(lower) || WORD_STATE_CODES.has(lower)) return false;
|
|
60
|
+
return TRAILING_STATE_CODES.has(lower) ? isLast : true;
|
|
61
|
+
}
|
|
62
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set([
|
|
63
|
+
"a",
|
|
64
|
+
"an",
|
|
65
|
+
"and",
|
|
66
|
+
"at",
|
|
67
|
+
"by",
|
|
68
|
+
"for",
|
|
69
|
+
"in",
|
|
70
|
+
"of",
|
|
71
|
+
"on",
|
|
72
|
+
"or",
|
|
73
|
+
"the",
|
|
74
|
+
"to",
|
|
75
|
+
"vs",
|
|
76
|
+
"with"
|
|
77
|
+
]);
|
|
78
|
+
var PAGE_EXTENSION = /\.(?:html?|php|aspx)$/i;
|
|
79
|
+
var URL_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/\\]*/i;
|
|
80
|
+
var PERCENT_RUN = /(?:%[0-9a-f]{2})+/gi;
|
|
81
|
+
function decodeEscapes(value) {
|
|
82
|
+
return value.replace(PERCENT_RUN, (run) => {
|
|
83
|
+
try {
|
|
84
|
+
return decodeURIComponent(run);
|
|
85
|
+
} catch {
|
|
86
|
+
return run;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function capitalizeFirst(word) {
|
|
91
|
+
const code = word.codePointAt(0);
|
|
92
|
+
if (code === void 0) return word;
|
|
93
|
+
const first = String.fromCodePoint(code);
|
|
94
|
+
return first.toUpperCase() + word.slice(first.length);
|
|
95
|
+
}
|
|
96
|
+
function labelWord(token, isFirst, isLast) {
|
|
97
|
+
const lower = token.toLowerCase();
|
|
98
|
+
const acronym = ACRONYMS.get(lower);
|
|
99
|
+
if (acronym) return acronym;
|
|
100
|
+
if (isStateCode(lower, isLast)) return lower.toUpperCase();
|
|
101
|
+
if (/\p{N}/u.test(token)) return /^\p{L}/u.test(token) ? capitalizeFirst(token) : token;
|
|
102
|
+
if (!isFirst && SMALL_WORDS.has(lower)) return lower;
|
|
103
|
+
return capitalizeFirst(lower);
|
|
104
|
+
}
|
|
105
|
+
function slugLabel(input) {
|
|
106
|
+
if (typeof input !== "string") return "";
|
|
107
|
+
const path = input.split(/[?#]/, 1)[0].replace(URL_ORIGIN, "");
|
|
108
|
+
const segment = path.split(/[/\\]/).filter((s) => s.trim() !== "").pop();
|
|
109
|
+
if (!segment) return "";
|
|
110
|
+
const name = decodeEscapes(segment).replace(PAGE_EXTENSION, "");
|
|
111
|
+
const tokens = name.split(/[-_+\s]+/).filter(Boolean);
|
|
112
|
+
return tokens.map((token, i) => labelWord(token, i === 0, i === tokens.length - 1)).join(" ");
|
|
113
|
+
}
|
|
114
|
+
var PLACEHOLDER_TOKEN = /^(?::[A-Za-z_][\w-]*[?*+]?|\[\[\.\.\.[A-Za-z_][\w-]*\]\]|\[(?:\.\.\.)?[A-Za-z_][\w-]*\])$/;
|
|
115
|
+
function isRoutePlaceholder(value) {
|
|
116
|
+
if (typeof value !== "string") return false;
|
|
117
|
+
return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { isRoutePlaceholder, slugLabel, titleHead };
|