@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
package/dist/forms.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/contracts/forms.ts
|
|
2
|
+
var FORMS_HONEYPOT_CONTRACT_VERSION = 1;
|
|
3
|
+
var DEFAULT_HONEYPOT_FIELD = "_sk_hp_url";
|
|
4
|
+
var GENERIC_HONEYPOT_FIELDS = Object.freeze([
|
|
5
|
+
DEFAULT_HONEYPOT_FIELD,
|
|
6
|
+
"website",
|
|
7
|
+
"_hp_field",
|
|
8
|
+
"bot-field",
|
|
9
|
+
"bot_field"
|
|
10
|
+
]);
|
|
11
|
+
function resolveHoneypotFieldNames(opts) {
|
|
12
|
+
const names = /* @__PURE__ */ new Set();
|
|
13
|
+
if (!opts.enabled) return names;
|
|
14
|
+
const real = new Set(opts.realFieldSlugs ?? []);
|
|
15
|
+
for (const generic of GENERIC_HONEYPOT_FIELDS) {
|
|
16
|
+
if (!real.has(generic)) names.add(generic);
|
|
17
|
+
}
|
|
18
|
+
if (opts.formHoneypotField) names.add(opts.formHoneypotField);
|
|
19
|
+
return names;
|
|
20
|
+
}
|
|
21
|
+
function isTrapFilled(value) {
|
|
22
|
+
if (value == null) return false;
|
|
23
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
24
|
+
if (typeof value === "boolean") return value;
|
|
25
|
+
if (typeof value === "number") return Number.isFinite(value) && value !== 0;
|
|
26
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
function findTrippedTrap(data, fieldNames) {
|
|
30
|
+
for (const field of fieldNames) {
|
|
31
|
+
const value = data?.[field];
|
|
32
|
+
if (!isTrapFilled(value)) continue;
|
|
33
|
+
if (Array.isArray(value)) return { field, valueType: "array", length: value.length };
|
|
34
|
+
const valueType = typeof value;
|
|
35
|
+
return {
|
|
36
|
+
field,
|
|
37
|
+
valueType: valueType === "string" ? "string" : valueType,
|
|
38
|
+
length: typeof value === "string" ? value.length : null
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function isHoneypotTriggered(data, fieldNames) {
|
|
44
|
+
return findTrippedTrap(data, fieldNames) !== null;
|
|
45
|
+
}
|
|
46
|
+
var HONEYPOT_CONFIG_JSON_SCHEMA = {
|
|
47
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
48
|
+
$id: "https://sonor.io/schemas/honeypot-config.v1.json",
|
|
49
|
+
title: "HoneypotConfig",
|
|
50
|
+
description: "The honeypot slice of a public managed-form config.",
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
honeypot_enabled: { type: "boolean" },
|
|
54
|
+
honeypot_field: {
|
|
55
|
+
description: `Trap field name; defaults to "${DEFAULT_HONEYPOT_FIELD}" when unset.`,
|
|
56
|
+
type: "string"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
additionalProperties: true
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export { DEFAULT_HONEYPOT_FIELD, FORMS_HONEYPOT_CONTRACT_VERSION, GENERIC_HONEYPOT_FIELDS, HONEYPOT_CONFIG_JSON_SCHEMA, findTrippedTrap, isHoneypotTriggered, resolveHoneypotFieldNames };
|
package/dist/llms.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GEO / AEO contract — shared shape for Sonor API, Signal API, and site-kit.
|
|
3
|
+
* Bump LLM_GEO_CONTRACT_VERSION when making breaking changes to public payloads.
|
|
4
|
+
*
|
|
5
|
+
* The APIs import it as `@sonordev/contracts/llms`; sites get the same code as
|
|
6
|
+
* `@sonordev/site-kit/seo/llms/contract`.
|
|
7
|
+
*
|
|
8
|
+
* @see LLM_GEO_CONTRACT.md in this folder
|
|
9
|
+
*/
|
|
10
|
+
/** Increment when public LLMs payload shape changes incompatibly. */
|
|
11
|
+
export declare const LLM_GEO_CONTRACT_VERSION: 1;
|
|
12
|
+
/** Max length for llms_public_summary (plain text, single line in llms.txt link notes). */
|
|
13
|
+
export declare const LLMS_PUBLIC_SUMMARY_MAX_LENGTH = 400;
|
|
14
|
+
/** Max length for optional llms.txt blockquote disclaimer line. */
|
|
15
|
+
export declare const LLMS_DISCLAIMER_MAX_LENGTH = 280;
|
|
16
|
+
/**
|
|
17
|
+
* Optional keys we allow inside managed_llm_schema JSON from Portal.
|
|
18
|
+
* Values outside this set should not be relied on by consumers; strip unknown keys when emitting JSON-LD.
|
|
19
|
+
*/
|
|
20
|
+
export declare const MANAGED_LLM_SCHEMA_KNOWN_KEYS: Set<string>;
|
|
21
|
+
export interface LLMsPayloadMeta {
|
|
22
|
+
/** Contract version for this payload (matches LLM_GEO_CONTRACT_VERSION when current). */
|
|
23
|
+
contract_version: number;
|
|
24
|
+
/** ISO 8601 — best-effort freshness for the project’s SEO data. */
|
|
25
|
+
last_updated?: string | null;
|
|
26
|
+
/** BCP 47 tag for primary public content (e.g. from project settings). */
|
|
27
|
+
primary_language?: string | null;
|
|
28
|
+
/** Optional single-line, public-safe note in the llms.txt blockquote (e.g. scope / not legal advice). */
|
|
29
|
+
llms_disclaimer?: string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Public-safe summary for llms.txt `[title](url): notes` — never CRM-only or internal copy.
|
|
33
|
+
*/
|
|
34
|
+
export declare function sanitizeLlmsPublicSummary(raw: unknown): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Single line for llms.txt blockquote disclaimer — shorter min length than link notes.
|
|
37
|
+
*/
|
|
38
|
+
export declare function sanitizeLlmsDisclaimerLine(raw: unknown): string | null;
|
|
39
|
+
/** Normalize BCP 47 tag for display (no quotes). */
|
|
40
|
+
export declare function sanitizePrimaryLanguageTag(raw: unknown): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Google requires `author` on every Review node and treats its absence as a
|
|
43
|
+
* CRITICAL error that suppresses review rich results for the whole page.
|
|
44
|
+
* Generated schema has shipped Review nodes carrying only `reviewBody` and
|
|
45
|
+
* `itemReviewed` — editorial prose about a client, not an attributed review —
|
|
46
|
+
* so they are invalid markup AND self-serving-review-policy risk.
|
|
47
|
+
*
|
|
48
|
+
* Single source of truth for "is this Review node safe to emit as JSON-LD".
|
|
49
|
+
* An author must resolve to a non-empty name; a bare `{"@type":"Review"}` and
|
|
50
|
+
* `author: {}` both fail.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isEmittableReviewNode(node: Record<string, unknown>): boolean;
|
|
53
|
+
/** Pick JSON-LD-safe subset of managed_llm_schema for script injection. */
|
|
54
|
+
export declare function pickManagedLlmSchemaForJsonLd(schema: Record<string, unknown> | null | undefined): Record<string, unknown> | null;
|
package/dist/llms.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/llms.ts
|
|
4
|
+
var LLM_GEO_CONTRACT_VERSION = 1;
|
|
5
|
+
var LLMS_PUBLIC_SUMMARY_MAX_LENGTH = 400;
|
|
6
|
+
var LLMS_DISCLAIMER_MAX_LENGTH = 280;
|
|
7
|
+
var MANAGED_LLM_SCHEMA_KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
8
|
+
"@context",
|
|
9
|
+
"@type",
|
|
10
|
+
"name",
|
|
11
|
+
"description",
|
|
12
|
+
"url",
|
|
13
|
+
"keywords",
|
|
14
|
+
"audience",
|
|
15
|
+
"about",
|
|
16
|
+
"mainEntity",
|
|
17
|
+
"isPartOf",
|
|
18
|
+
"speakable",
|
|
19
|
+
"additionalType"
|
|
20
|
+
]);
|
|
21
|
+
function sanitizeLlmsPublicSummary(raw) {
|
|
22
|
+
if (raw === null || raw === void 0) return null;
|
|
23
|
+
if (typeof raw !== "string") return null;
|
|
24
|
+
let s = raw.trim().replace(/\s+/g, " ");
|
|
25
|
+
s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
|
|
26
|
+
if (s.length > LLMS_PUBLIC_SUMMARY_MAX_LENGTH) {
|
|
27
|
+
s = `${s.slice(0, LLMS_PUBLIC_SUMMARY_MAX_LENGTH - 1).trimEnd()}\u2026`;
|
|
28
|
+
}
|
|
29
|
+
if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
|
|
30
|
+
if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
|
|
31
|
+
if (s.length < 8) return null;
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
function sanitizeLlmsDisclaimerLine(raw) {
|
|
35
|
+
if (raw === null || raw === void 0) return null;
|
|
36
|
+
if (typeof raw !== "string") return null;
|
|
37
|
+
let s = raw.trim().replace(/\s+/g, " ");
|
|
38
|
+
s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
|
|
39
|
+
if (s.length > LLMS_DISCLAIMER_MAX_LENGTH) {
|
|
40
|
+
s = `${s.slice(0, LLMS_DISCLAIMER_MAX_LENGTH - 1).trimEnd()}\u2026`;
|
|
41
|
+
}
|
|
42
|
+
if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
|
|
43
|
+
if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
|
|
44
|
+
if (s.length < 3) return null;
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
function sanitizePrimaryLanguageTag(raw) {
|
|
48
|
+
if (raw === null || raw === void 0) return null;
|
|
49
|
+
if (typeof raw !== "string") return null;
|
|
50
|
+
const s = raw.trim();
|
|
51
|
+
if (!/^[\w-]{2,20}$/.test(s)) return null;
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
function isEmittableReviewNode(node) {
|
|
55
|
+
const author = node.author;
|
|
56
|
+
if (typeof author === "string") return author.trim().length > 0;
|
|
57
|
+
if (Array.isArray(author)) {
|
|
58
|
+
return author.some((a) => isEmittableReviewNode({ author: a }));
|
|
59
|
+
}
|
|
60
|
+
if (author && typeof author === "object") {
|
|
61
|
+
const name = author.name;
|
|
62
|
+
return typeof name === "string" && name.trim().length > 0;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
var DROP = /* @__PURE__ */ Symbol("drop-invalid-review");
|
|
67
|
+
function stripInvalidReviewNodes(value) {
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
return value.map((v) => stripInvalidReviewNodes(v)).filter((v) => v !== DROP);
|
|
70
|
+
}
|
|
71
|
+
if (value && typeof value === "object") {
|
|
72
|
+
const src = value;
|
|
73
|
+
if (src["@type"] === "Review" && !isEmittableReviewNode(src)) return DROP;
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const key of Object.keys(src)) {
|
|
76
|
+
const child = stripInvalidReviewNodes(src[key]);
|
|
77
|
+
if (child === DROP) continue;
|
|
78
|
+
out[key] = child;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
function pickManagedLlmSchemaForJsonLd(schema) {
|
|
85
|
+
if (!schema || typeof schema !== "object") return null;
|
|
86
|
+
const out = {};
|
|
87
|
+
const omitFromJsonLd = /* @__PURE__ */ new Set(["llms_public_summary"]);
|
|
88
|
+
for (const key of Object.keys(schema)) {
|
|
89
|
+
if (omitFromJsonLd.has(key)) continue;
|
|
90
|
+
if (MANAGED_LLM_SCHEMA_KNOWN_KEYS.has(key)) {
|
|
91
|
+
const value = stripInvalidReviewNodes(schema[key]);
|
|
92
|
+
if (value === DROP) continue;
|
|
93
|
+
out[key] = value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return Object.keys(out).length ? out : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
exports.LLMS_DISCLAIMER_MAX_LENGTH = LLMS_DISCLAIMER_MAX_LENGTH;
|
|
100
|
+
exports.LLMS_PUBLIC_SUMMARY_MAX_LENGTH = LLMS_PUBLIC_SUMMARY_MAX_LENGTH;
|
|
101
|
+
exports.LLM_GEO_CONTRACT_VERSION = LLM_GEO_CONTRACT_VERSION;
|
|
102
|
+
exports.MANAGED_LLM_SCHEMA_KNOWN_KEYS = MANAGED_LLM_SCHEMA_KNOWN_KEYS;
|
|
103
|
+
exports.isEmittableReviewNode = isEmittableReviewNode;
|
|
104
|
+
exports.pickManagedLlmSchemaForJsonLd = pickManagedLlmSchemaForJsonLd;
|
|
105
|
+
exports.sanitizeLlmsDisclaimerLine = sanitizeLlmsDisclaimerLine;
|
|
106
|
+
exports.sanitizeLlmsPublicSummary = sanitizeLlmsPublicSummary;
|
|
107
|
+
exports.sanitizePrimaryLanguageTag = sanitizePrimaryLanguageTag;
|
package/dist/llms.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// src/contracts/llms.ts
|
|
2
|
+
var LLM_GEO_CONTRACT_VERSION = 1;
|
|
3
|
+
var LLMS_PUBLIC_SUMMARY_MAX_LENGTH = 400;
|
|
4
|
+
var LLMS_DISCLAIMER_MAX_LENGTH = 280;
|
|
5
|
+
var MANAGED_LLM_SCHEMA_KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
6
|
+
"@context",
|
|
7
|
+
"@type",
|
|
8
|
+
"name",
|
|
9
|
+
"description",
|
|
10
|
+
"url",
|
|
11
|
+
"keywords",
|
|
12
|
+
"audience",
|
|
13
|
+
"about",
|
|
14
|
+
"mainEntity",
|
|
15
|
+
"isPartOf",
|
|
16
|
+
"speakable",
|
|
17
|
+
"additionalType"
|
|
18
|
+
]);
|
|
19
|
+
function sanitizeLlmsPublicSummary(raw) {
|
|
20
|
+
if (raw === null || raw === void 0) return null;
|
|
21
|
+
if (typeof raw !== "string") return null;
|
|
22
|
+
let s = raw.trim().replace(/\s+/g, " ");
|
|
23
|
+
s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
|
|
24
|
+
if (s.length > LLMS_PUBLIC_SUMMARY_MAX_LENGTH) {
|
|
25
|
+
s = `${s.slice(0, LLMS_PUBLIC_SUMMARY_MAX_LENGTH - 1).trimEnd()}\u2026`;
|
|
26
|
+
}
|
|
27
|
+
if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
|
|
28
|
+
if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
|
|
29
|
+
if (s.length < 8) return null;
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
function sanitizeLlmsDisclaimerLine(raw) {
|
|
33
|
+
if (raw === null || raw === void 0) return null;
|
|
34
|
+
if (typeof raw !== "string") return null;
|
|
35
|
+
let s = raw.trim().replace(/\s+/g, " ");
|
|
36
|
+
s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
|
|
37
|
+
if (s.length > LLMS_DISCLAIMER_MAX_LENGTH) {
|
|
38
|
+
s = `${s.slice(0, LLMS_DISCLAIMER_MAX_LENGTH - 1).trimEnd()}\u2026`;
|
|
39
|
+
}
|
|
40
|
+
if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
|
|
41
|
+
if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
|
|
42
|
+
if (s.length < 3) return null;
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
function sanitizePrimaryLanguageTag(raw) {
|
|
46
|
+
if (raw === null || raw === void 0) return null;
|
|
47
|
+
if (typeof raw !== "string") return null;
|
|
48
|
+
const s = raw.trim();
|
|
49
|
+
if (!/^[\w-]{2,20}$/.test(s)) return null;
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
function isEmittableReviewNode(node) {
|
|
53
|
+
const author = node.author;
|
|
54
|
+
if (typeof author === "string") return author.trim().length > 0;
|
|
55
|
+
if (Array.isArray(author)) {
|
|
56
|
+
return author.some((a) => isEmittableReviewNode({ author: a }));
|
|
57
|
+
}
|
|
58
|
+
if (author && typeof author === "object") {
|
|
59
|
+
const name = author.name;
|
|
60
|
+
return typeof name === "string" && name.trim().length > 0;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
var DROP = /* @__PURE__ */ Symbol("drop-invalid-review");
|
|
65
|
+
function stripInvalidReviewNodes(value) {
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
return value.map((v) => stripInvalidReviewNodes(v)).filter((v) => v !== DROP);
|
|
68
|
+
}
|
|
69
|
+
if (value && typeof value === "object") {
|
|
70
|
+
const src = value;
|
|
71
|
+
if (src["@type"] === "Review" && !isEmittableReviewNode(src)) return DROP;
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const key of Object.keys(src)) {
|
|
74
|
+
const child = stripInvalidReviewNodes(src[key]);
|
|
75
|
+
if (child === DROP) continue;
|
|
76
|
+
out[key] = child;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
function pickManagedLlmSchemaForJsonLd(schema) {
|
|
83
|
+
if (!schema || typeof schema !== "object") return null;
|
|
84
|
+
const out = {};
|
|
85
|
+
const omitFromJsonLd = /* @__PURE__ */ new Set(["llms_public_summary"]);
|
|
86
|
+
for (const key of Object.keys(schema)) {
|
|
87
|
+
if (omitFromJsonLd.has(key)) continue;
|
|
88
|
+
if (MANAGED_LLM_SCHEMA_KNOWN_KEYS.has(key)) {
|
|
89
|
+
const value = stripInvalidReviewNodes(schema[key]);
|
|
90
|
+
if (value === DROP) continue;
|
|
91
|
+
out[key] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Object.keys(out).length ? out : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export { LLMS_DISCLAIMER_MAX_LENGTH, LLMS_PUBLIC_SUMMARY_MAX_LENGTH, LLM_GEO_CONTRACT_VERSION, MANAGED_LLM_SCHEMA_KNOWN_KEYS, isEmittableReviewNode, pickManagedLlmSchemaForJsonLd, sanitizeLlmsDisclaimerLine, sanitizeLlmsPublicSummary, sanitizePrimaryLanguageTag };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sonordev/contracts/portfolio — portfolio proof display policy (v1)
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the Lighthouse-KPI collapse policy shared by the
|
|
5
|
+
* content side (signal-api generation) and the render side (agency-site-kit
|
|
6
|
+
* display):
|
|
7
|
+
* - agency-site-kit imports it (`@sonordev/site-kit/portfolio/contract`)
|
|
8
|
+
* from `src/portfolio/curate-proof.ts`, which keeps the render-only
|
|
9
|
+
* primitives that bind to its section types.
|
|
10
|
+
* - signal-api imports it through
|
|
11
|
+
* `src/modules/skills/portfolio/portfolio-kpi-policy.util.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Why it exists: generated case studies arrived with four "100/100 Lighthouse X"
|
|
14
|
+
* KPIs repeated across hero, results cards, and a Performance section. One
|
|
15
|
+
* differentiator repeated a dozen times stops differentiating — collapse the
|
|
16
|
+
* per-category Lighthouse tiles to at most one summary, business outcomes first.
|
|
17
|
+
*
|
|
18
|
+
* It is also the single source of truth for METRIC PROVENANCE (below): the
|
|
19
|
+
* three sources a portfolio number can have, what a client-reported number
|
|
20
|
+
* must carry, and which numbers may headline. sonor-api imports those
|
|
21
|
+
* through `src/modules/portfolio/portfolio-metric-source.util.ts`.
|
|
22
|
+
*/
|
|
23
|
+
/** The message a cross-origin-framed site-kit page posts to its embedder once it has painted. */
|
|
24
|
+
export declare const FRAME_READY_MESSAGE: 'sonor:frame-ready';
|
|
25
|
+
export interface FrameReadyMessage {
|
|
26
|
+
type: typeof FRAME_READY_MESSAGE;
|
|
27
|
+
v: 1;
|
|
28
|
+
}
|
|
29
|
+
/** True for a {@link FrameReadyMessage}, for the embedder's message handler. */
|
|
30
|
+
export declare function isFrameReadyMessage(data: unknown): data is FrameReadyMessage;
|
|
31
|
+
/** Bump on breaking changes to the collapse policy. */
|
|
32
|
+
export declare const PORTFOLIO_CONTRACT_VERSION: 1;
|
|
33
|
+
/** Minimal KPI shape the policy reads — callers pass their own richer tiles. */
|
|
34
|
+
export interface KpiLike {
|
|
35
|
+
label: string;
|
|
36
|
+
value: number;
|
|
37
|
+
description?: string;
|
|
38
|
+
}
|
|
39
|
+
/** True when a KPI tile is a per-category Lighthouse score. */
|
|
40
|
+
export declare function isLighthouseKpi(kpi: Pick<KpiLike, 'label'>): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Collapse per-category Lighthouse KPIs to at most one summary tile, business
|
|
43
|
+
* outcomes first. When every category is present at 100 the summary says so;
|
|
44
|
+
* otherwise the Performance category (the one buyers know) represents the set.
|
|
45
|
+
* Fewer than two Lighthouse tiles → returned unchanged.
|
|
46
|
+
*/
|
|
47
|
+
export declare function curateHeroKpis<T extends KpiLike>(kpis: T[]): T[];
|
|
48
|
+
/**
|
|
49
|
+
* Agent-native contract: JSON Schema for the minimal KPI shape, so services and
|
|
50
|
+
* agents can validate tiles before applying the collapse policy.
|
|
51
|
+
*/
|
|
52
|
+
export declare const PORTFOLIO_KPI_JSON_SCHEMA: {
|
|
53
|
+
readonly $schema: 'http://json-schema.org/draft-07/schema#';
|
|
54
|
+
readonly $id: 'https://sonor.io/schemas/portfolio-kpi.v1.json';
|
|
55
|
+
readonly title: 'KpiLike';
|
|
56
|
+
readonly description: 'The subset of a portfolio KPI tile the Lighthouse-collapse policy reads.';
|
|
57
|
+
readonly type: 'object';
|
|
58
|
+
readonly required: readonly ['label', 'value'];
|
|
59
|
+
readonly properties: {
|
|
60
|
+
readonly label: {
|
|
61
|
+
readonly type: 'string';
|
|
62
|
+
};
|
|
63
|
+
readonly value: {
|
|
64
|
+
readonly type: 'number';
|
|
65
|
+
};
|
|
66
|
+
readonly description: {
|
|
67
|
+
readonly type: 'string';
|
|
68
|
+
};
|
|
69
|
+
readonly source: {
|
|
70
|
+
readonly enum: readonly ['measured', 'reported', 'estimated'];
|
|
71
|
+
};
|
|
72
|
+
readonly reportedBy: {
|
|
73
|
+
readonly type: 'object';
|
|
74
|
+
readonly required: readonly ['name', 'organization', 'date'];
|
|
75
|
+
readonly properties: {
|
|
76
|
+
readonly name: {
|
|
77
|
+
readonly type: 'string';
|
|
78
|
+
readonly minLength: 1;
|
|
79
|
+
};
|
|
80
|
+
readonly role: {
|
|
81
|
+
readonly type: 'string';
|
|
82
|
+
};
|
|
83
|
+
readonly organization: {
|
|
84
|
+
readonly type: 'string';
|
|
85
|
+
readonly minLength: 1;
|
|
86
|
+
};
|
|
87
|
+
readonly date: {
|
|
88
|
+
readonly type: 'string';
|
|
89
|
+
readonly pattern: '^\\d{4}-\\d{2}-\\d{2}$';
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
readonly if: {
|
|
95
|
+
readonly properties: {
|
|
96
|
+
readonly source: {
|
|
97
|
+
readonly const: 'reported';
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
readonly required: readonly ['source'];
|
|
101
|
+
};
|
|
102
|
+
readonly then: {
|
|
103
|
+
readonly required: readonly ['reportedBy'];
|
|
104
|
+
};
|
|
105
|
+
readonly additionalProperties: true;
|
|
106
|
+
};
|
|
107
|
+
/** Every source a portfolio number can have. */
|
|
108
|
+
export declare const METRIC_SOURCES: readonly ['measured', 'reported', 'estimated'];
|
|
109
|
+
export type MetricSource = (typeof METRIC_SOURCES)[number];
|
|
110
|
+
/** Who stands behind a client-reported number. */
|
|
111
|
+
export interface MetricAttribution {
|
|
112
|
+
/** The person, e.g. "Scott Mann". */
|
|
113
|
+
name: string;
|
|
114
|
+
/** Their title, e.g. "Director of Business Development". */
|
|
115
|
+
role?: string;
|
|
116
|
+
/** Where they work: almost always the client, e.g. "True Power Systems". */
|
|
117
|
+
organization: string;
|
|
118
|
+
/** The day they reported it, YYYY-MM-DD. */
|
|
119
|
+
date: string;
|
|
120
|
+
}
|
|
121
|
+
/** The provenance fields a portfolio number (a KPI tile, a results card) carries. */
|
|
122
|
+
export interface SourcedMetric {
|
|
123
|
+
source?: string | null;
|
|
124
|
+
reportedBy?: unknown;
|
|
125
|
+
}
|
|
126
|
+
export declare function isMetricSource(value: unknown): value is MetricSource;
|
|
127
|
+
/**
|
|
128
|
+
* What's wrong with a number's provenance, or null when nothing is. A missing
|
|
129
|
+
* source is legacy data and passes (displays show it unlabelled, and it can't
|
|
130
|
+
* headline); an unknown source, or a reported number nobody is named behind,
|
|
131
|
+
* does not.
|
|
132
|
+
*/
|
|
133
|
+
export declare function metricSourceProblem(metric: SourcedMetric): string | null;
|
|
134
|
+
/**
|
|
135
|
+
* May this number lead: a hero tile, a gallery card, a carousel, a social
|
|
136
|
+
* post? Measured, or reported with someone named behind it. Never an
|
|
137
|
+
* estimate, and never an unlabelled legacy number.
|
|
138
|
+
*/
|
|
139
|
+
export declare function isHeadlineMetric(metric: SourcedMetric): boolean;
|
|
140
|
+
/** The badge a display shows, or null for a missing or unknown source (show nothing rather than guess). */
|
|
141
|
+
export declare function metricSourceLabel(source: unknown): string | null;
|
|
142
|
+
/**
|
|
143
|
+
* The full credit: "Reported by Scott Mann, Director of Business Development,
|
|
144
|
+
* True Power Systems, September 25, 2026". The date is read as a plain day,
|
|
145
|
+
* never through a time zone, so it can't slip to the day before.
|
|
146
|
+
*/
|
|
147
|
+
export declare function formatAttribution(by: MetricAttribution): string;
|
|
148
|
+
/** The credit for a tight space (a card, a carousel tile): "per True Power Systems". */
|
|
149
|
+
export declare function shortAttribution(by: Pick<MetricAttribution, 'organization'>): string;
|
|
150
|
+
/**
|
|
151
|
+
* Carry a person's reported numbers through a regeneration. The generator can
|
|
152
|
+
* only write measured or estimated numbers (it can't see a client's books, and
|
|
153
|
+
* any 'reported' tag it writes is dropped here), so without this a rewrite of
|
|
154
|
+
* the hero or the results would silently erase what a client told us.
|
|
155
|
+
* Reported entries from `previous` lead, in their order; `next` fills in
|
|
156
|
+
* behind them, minus anything sharing a key with a reported entry.
|
|
157
|
+
*/
|
|
158
|
+
export declare function carryReportedMetrics<T extends SourcedMetric>(previous: readonly T[] | null | undefined, next: readonly T[] | null | undefined, keyOf: (metric: T) => string): T[];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/portfolio.ts
|
|
4
|
+
var FRAME_READY_MESSAGE = "sonor:frame-ready";
|
|
5
|
+
function isFrameReadyMessage(data) {
|
|
6
|
+
return !!data && typeof data === "object" && data.type === FRAME_READY_MESSAGE && data.v === 1;
|
|
7
|
+
}
|
|
8
|
+
var PORTFOLIO_CONTRACT_VERSION = 1;
|
|
9
|
+
var LIGHTHOUSE_RE = /lighthouse/i;
|
|
10
|
+
function isLighthouseKpi(kpi) {
|
|
11
|
+
return LIGHTHOUSE_RE.test(kpi.label || "");
|
|
12
|
+
}
|
|
13
|
+
function curateHeroKpis(kpis) {
|
|
14
|
+
const business = kpis.filter((k) => !isLighthouseKpi(k));
|
|
15
|
+
const lighthouse = kpis.filter(isLighthouseKpi);
|
|
16
|
+
if (lighthouse.length <= 1) return kpis;
|
|
17
|
+
const allPerfect = lighthouse.length >= 4 && lighthouse.every((k) => k.value === 100);
|
|
18
|
+
const summary = allPerfect ? {
|
|
19
|
+
...lighthouse[0],
|
|
20
|
+
label: "Lighthouse, all 4 categories",
|
|
21
|
+
description: "Perfect scores across performance, SEO, accessibility, and best practices."
|
|
22
|
+
} : lighthouse.find((k) => /performance/i.test(k.label)) ?? lighthouse[0];
|
|
23
|
+
return [...business, summary];
|
|
24
|
+
}
|
|
25
|
+
var PORTFOLIO_KPI_JSON_SCHEMA = {
|
|
26
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
27
|
+
$id: "https://sonor.io/schemas/portfolio-kpi.v1.json",
|
|
28
|
+
title: "KpiLike",
|
|
29
|
+
description: "The subset of a portfolio KPI tile the Lighthouse-collapse policy reads.",
|
|
30
|
+
type: "object",
|
|
31
|
+
required: ["label", "value"],
|
|
32
|
+
properties: {
|
|
33
|
+
label: { type: "string" },
|
|
34
|
+
value: { type: "number" },
|
|
35
|
+
description: { type: "string" },
|
|
36
|
+
source: { enum: ["measured", "reported", "estimated"] },
|
|
37
|
+
reportedBy: {
|
|
38
|
+
type: "object",
|
|
39
|
+
required: ["name", "organization", "date"],
|
|
40
|
+
properties: {
|
|
41
|
+
name: { type: "string", minLength: 1 },
|
|
42
|
+
role: { type: "string" },
|
|
43
|
+
organization: { type: "string", minLength: 1 },
|
|
44
|
+
date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
// A reported number is only valid with someone named behind it.
|
|
49
|
+
if: { properties: { source: { const: "reported" } }, required: ["source"] },
|
|
50
|
+
then: { required: ["reportedBy"] },
|
|
51
|
+
additionalProperties: true
|
|
52
|
+
};
|
|
53
|
+
var METRIC_SOURCES = ["measured", "reported", "estimated"];
|
|
54
|
+
var ISO_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
55
|
+
var MONTHS = [
|
|
56
|
+
"January",
|
|
57
|
+
"February",
|
|
58
|
+
"March",
|
|
59
|
+
"April",
|
|
60
|
+
"May",
|
|
61
|
+
"June",
|
|
62
|
+
"July",
|
|
63
|
+
"August",
|
|
64
|
+
"September",
|
|
65
|
+
"October",
|
|
66
|
+
"November",
|
|
67
|
+
"December"
|
|
68
|
+
];
|
|
69
|
+
function isMetricSource(value) {
|
|
70
|
+
return typeof value === "string" && METRIC_SOURCES.includes(value);
|
|
71
|
+
}
|
|
72
|
+
function metricSourceProblem(metric) {
|
|
73
|
+
if (metric.source === void 0 || metric.source === null) return null;
|
|
74
|
+
if (!isMetricSource(metric.source)) return `source must be one of ${METRIC_SOURCES.join(", ")}`;
|
|
75
|
+
if (metric.source !== "reported") return null;
|
|
76
|
+
const by = metric.reportedBy;
|
|
77
|
+
if (!by || typeof by !== "object") {
|
|
78
|
+
return "a reported number needs reportedBy: who reported it, where they work, and when";
|
|
79
|
+
}
|
|
80
|
+
if (typeof by.name !== "string" || !by.name.trim()) return "reportedBy.name is required";
|
|
81
|
+
if (typeof by.organization !== "string" || !by.organization.trim()) {
|
|
82
|
+
return "reportedBy.organization is required";
|
|
83
|
+
}
|
|
84
|
+
if (typeof by.date !== "string" || !ISO_DAY_RE.test(by.date) || Number.isNaN(Date.parse(by.date))) {
|
|
85
|
+
return "reportedBy.date must be a real day, YYYY-MM-DD";
|
|
86
|
+
}
|
|
87
|
+
if (by.role !== void 0 && typeof by.role !== "string") return "reportedBy.role must be text";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function isHeadlineMetric(metric) {
|
|
91
|
+
if (metric.source === "measured") return true;
|
|
92
|
+
return metric.source === "reported" && metricSourceProblem(metric) === null;
|
|
93
|
+
}
|
|
94
|
+
function metricSourceLabel(source) {
|
|
95
|
+
if (source === "measured") return "Measured";
|
|
96
|
+
if (source === "reported") return "Client-reported";
|
|
97
|
+
if (source === "estimated") return "Estimated";
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function formatAttribution(by) {
|
|
101
|
+
const [year, month, day] = by.date.split("-").map(Number);
|
|
102
|
+
const when = MONTHS[month - 1] ? `${MONTHS[month - 1]} ${day}, ${year}` : by.date;
|
|
103
|
+
return [`Reported by ${by.name.trim()}`, by.role?.trim(), by.organization.trim(), when].filter(Boolean).join(", ");
|
|
104
|
+
}
|
|
105
|
+
function shortAttribution(by) {
|
|
106
|
+
return `per ${by.organization.trim()}`;
|
|
107
|
+
}
|
|
108
|
+
function carryReportedMetrics(previous, next, keyOf) {
|
|
109
|
+
const norm = (metric) => String(keyOf(metric) ?? "").trim().toLowerCase();
|
|
110
|
+
const reported = (previous ?? []).filter((m) => m?.source === "reported");
|
|
111
|
+
const taken = new Set(reported.map(norm));
|
|
112
|
+
const generated = (next ?? []).filter((m) => m && m.source !== "reported" && !taken.has(norm(m)));
|
|
113
|
+
return [...reported, ...generated];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
exports.FRAME_READY_MESSAGE = FRAME_READY_MESSAGE;
|
|
117
|
+
exports.METRIC_SOURCES = METRIC_SOURCES;
|
|
118
|
+
exports.PORTFOLIO_CONTRACT_VERSION = PORTFOLIO_CONTRACT_VERSION;
|
|
119
|
+
exports.PORTFOLIO_KPI_JSON_SCHEMA = PORTFOLIO_KPI_JSON_SCHEMA;
|
|
120
|
+
exports.carryReportedMetrics = carryReportedMetrics;
|
|
121
|
+
exports.curateHeroKpis = curateHeroKpis;
|
|
122
|
+
exports.formatAttribution = formatAttribution;
|
|
123
|
+
exports.isFrameReadyMessage = isFrameReadyMessage;
|
|
124
|
+
exports.isHeadlineMetric = isHeadlineMetric;
|
|
125
|
+
exports.isLighthouseKpi = isLighthouseKpi;
|
|
126
|
+
exports.isMetricSource = isMetricSource;
|
|
127
|
+
exports.metricSourceLabel = metricSourceLabel;
|
|
128
|
+
exports.metricSourceProblem = metricSourceProblem;
|
|
129
|
+
exports.shortAttribution = shortAttribution;
|