@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,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/sites-normalize.ts
|
|
4
|
+
function normalizeSiteHost(site) {
|
|
5
|
+
if (!site || typeof site !== "string") return null;
|
|
6
|
+
let s = site.trim().toLowerCase();
|
|
7
|
+
if (!s) return null;
|
|
8
|
+
s = s.replace(/^https?:\/\//, "");
|
|
9
|
+
const slash = s.indexOf("/");
|
|
10
|
+
if (slash >= 0) s = s.slice(0, slash);
|
|
11
|
+
const colon = s.indexOf(":");
|
|
12
|
+
if (colon >= 0) s = s.slice(0, colon);
|
|
13
|
+
s = s.replace(/\.+$/, "");
|
|
14
|
+
return s || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/contracts/sites.ts
|
|
18
|
+
function stripWww(host) {
|
|
19
|
+
return host.startsWith("www.") ? host.slice(4) : host;
|
|
20
|
+
}
|
|
21
|
+
function sameSite(a, b) {
|
|
22
|
+
if (!a || !b) return false;
|
|
23
|
+
return stripWww(a) === stripWww(b);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/contracts/seo-pages.ts
|
|
27
|
+
var SEO_PAGES_CONTRACT_VERSION = 1;
|
|
28
|
+
var LIVE_SEO_PAGE_FILTER = "status.is.null,status.eq.active";
|
|
29
|
+
function pickSeoPageRow(rows, opts = {}) {
|
|
30
|
+
if (!rows.length) return null;
|
|
31
|
+
if (rows.length === 1) return rows[0];
|
|
32
|
+
const wanted = normalizeSiteHost(opts.site);
|
|
33
|
+
if (wanted) {
|
|
34
|
+
const exact = rows.find((r) => sameSite(r.site, wanted));
|
|
35
|
+
if (exact) return exact;
|
|
36
|
+
}
|
|
37
|
+
const unattributed = rows.find((r) => !r.site);
|
|
38
|
+
if (unattributed) return unattributed;
|
|
39
|
+
const primary = normalizeSiteHost(opts.primaryHost);
|
|
40
|
+
if (primary) {
|
|
41
|
+
const main = rows.find((r) => sameSite(r.site, primary));
|
|
42
|
+
if (main) return main;
|
|
43
|
+
}
|
|
44
|
+
return [...rows].sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))[0];
|
|
45
|
+
}
|
|
46
|
+
function matchSitemapEntryRow(candidates, site, primaryHost) {
|
|
47
|
+
if (!candidates.length) return null;
|
|
48
|
+
if (site) {
|
|
49
|
+
return candidates.find((r) => sameSite(r.site, site)) ?? candidates.find((r) => !r.site) ?? null;
|
|
50
|
+
}
|
|
51
|
+
return pickSeoPageRow(candidates, { primaryHost });
|
|
52
|
+
}
|
|
53
|
+
function scopeSeoPagesToSite(rows, site, primaryHost) {
|
|
54
|
+
const wanted = normalizeSiteHost(site);
|
|
55
|
+
const host = normalizeSiteHost(primaryHost);
|
|
56
|
+
const groups = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const path = row.path ?? "";
|
|
59
|
+
const key = path.replace(/\/$/, "") || path;
|
|
60
|
+
const bucket = groups.get(key);
|
|
61
|
+
if (bucket) bucket.push(row);
|
|
62
|
+
else groups.set(key, [row]);
|
|
63
|
+
}
|
|
64
|
+
const scoped = [];
|
|
65
|
+
for (const candidates of groups.values()) {
|
|
66
|
+
const picked = matchSitemapEntryRow(candidates, wanted, host);
|
|
67
|
+
if (picked) scoped.push(picked);
|
|
68
|
+
}
|
|
69
|
+
return scoped;
|
|
70
|
+
}
|
|
71
|
+
var SEO_PAGE_ROW_JSON_SCHEMA = {
|
|
72
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
73
|
+
$id: "https://sonor.io/schemas/seo-page-row.v1.json",
|
|
74
|
+
title: "SeoPageRowLike",
|
|
75
|
+
description: "The subset of an seo_pages row that determines per-site resolution.",
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
site: {
|
|
79
|
+
description: "Normalized sub-site host, or null for a project-wide row.",
|
|
80
|
+
type: ["string", "null"]
|
|
81
|
+
},
|
|
82
|
+
updated_at: {
|
|
83
|
+
description: "ISO 8601 timestamp; ties in resolution break toward the most recent.",
|
|
84
|
+
type: ["string", "null"]
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
additionalProperties: true
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
exports.LIVE_SEO_PAGE_FILTER = LIVE_SEO_PAGE_FILTER;
|
|
91
|
+
exports.SEO_PAGES_CONTRACT_VERSION = SEO_PAGES_CONTRACT_VERSION;
|
|
92
|
+
exports.SEO_PAGE_ROW_JSON_SCHEMA = SEO_PAGE_ROW_JSON_SCHEMA;
|
|
93
|
+
exports.matchSitemapEntryRow = matchSitemapEntryRow;
|
|
94
|
+
exports.pickSeoPageRow = pickSeoPageRow;
|
|
95
|
+
exports.scopeSeoPagesToSite = scopeSeoPagesToSite;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/contracts/sites-normalize.ts
|
|
2
|
+
function normalizeSiteHost(site) {
|
|
3
|
+
if (!site || typeof site !== "string") return null;
|
|
4
|
+
let s = site.trim().toLowerCase();
|
|
5
|
+
if (!s) return null;
|
|
6
|
+
s = s.replace(/^https?:\/\//, "");
|
|
7
|
+
const slash = s.indexOf("/");
|
|
8
|
+
if (slash >= 0) s = s.slice(0, slash);
|
|
9
|
+
const colon = s.indexOf(":");
|
|
10
|
+
if (colon >= 0) s = s.slice(0, colon);
|
|
11
|
+
s = s.replace(/\.+$/, "");
|
|
12
|
+
return s || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/contracts/sites.ts
|
|
16
|
+
function stripWww(host) {
|
|
17
|
+
return host.startsWith("www.") ? host.slice(4) : host;
|
|
18
|
+
}
|
|
19
|
+
function sameSite(a, b) {
|
|
20
|
+
if (!a || !b) return false;
|
|
21
|
+
return stripWww(a) === stripWww(b);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/contracts/seo-pages.ts
|
|
25
|
+
var SEO_PAGES_CONTRACT_VERSION = 1;
|
|
26
|
+
var LIVE_SEO_PAGE_FILTER = "status.is.null,status.eq.active";
|
|
27
|
+
function pickSeoPageRow(rows, opts = {}) {
|
|
28
|
+
if (!rows.length) return null;
|
|
29
|
+
if (rows.length === 1) return rows[0];
|
|
30
|
+
const wanted = normalizeSiteHost(opts.site);
|
|
31
|
+
if (wanted) {
|
|
32
|
+
const exact = rows.find((r) => sameSite(r.site, wanted));
|
|
33
|
+
if (exact) return exact;
|
|
34
|
+
}
|
|
35
|
+
const unattributed = rows.find((r) => !r.site);
|
|
36
|
+
if (unattributed) return unattributed;
|
|
37
|
+
const primary = normalizeSiteHost(opts.primaryHost);
|
|
38
|
+
if (primary) {
|
|
39
|
+
const main = rows.find((r) => sameSite(r.site, primary));
|
|
40
|
+
if (main) return main;
|
|
41
|
+
}
|
|
42
|
+
return [...rows].sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))[0];
|
|
43
|
+
}
|
|
44
|
+
function matchSitemapEntryRow(candidates, site, primaryHost) {
|
|
45
|
+
if (!candidates.length) return null;
|
|
46
|
+
if (site) {
|
|
47
|
+
return candidates.find((r) => sameSite(r.site, site)) ?? candidates.find((r) => !r.site) ?? null;
|
|
48
|
+
}
|
|
49
|
+
return pickSeoPageRow(candidates, { primaryHost });
|
|
50
|
+
}
|
|
51
|
+
function scopeSeoPagesToSite(rows, site, primaryHost) {
|
|
52
|
+
const wanted = normalizeSiteHost(site);
|
|
53
|
+
const host = normalizeSiteHost(primaryHost);
|
|
54
|
+
const groups = /* @__PURE__ */ new Map();
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
const path = row.path ?? "";
|
|
57
|
+
const key = path.replace(/\/$/, "") || path;
|
|
58
|
+
const bucket = groups.get(key);
|
|
59
|
+
if (bucket) bucket.push(row);
|
|
60
|
+
else groups.set(key, [row]);
|
|
61
|
+
}
|
|
62
|
+
const scoped = [];
|
|
63
|
+
for (const candidates of groups.values()) {
|
|
64
|
+
const picked = matchSitemapEntryRow(candidates, wanted, host);
|
|
65
|
+
if (picked) scoped.push(picked);
|
|
66
|
+
}
|
|
67
|
+
return scoped;
|
|
68
|
+
}
|
|
69
|
+
var SEO_PAGE_ROW_JSON_SCHEMA = {
|
|
70
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
71
|
+
$id: "https://sonor.io/schemas/seo-page-row.v1.json",
|
|
72
|
+
title: "SeoPageRowLike",
|
|
73
|
+
description: "The subset of an seo_pages row that determines per-site resolution.",
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
site: {
|
|
77
|
+
description: "Normalized sub-site host, or null for a project-wide row.",
|
|
78
|
+
type: ["string", "null"]
|
|
79
|
+
},
|
|
80
|
+
updated_at: {
|
|
81
|
+
description: "ISO 8601 timestamp; ties in resolution break toward the most recent.",
|
|
82
|
+
type: ["string", "null"]
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
additionalProperties: true
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export { LIVE_SEO_PAGE_FILTER, SEO_PAGES_CONTRACT_VERSION, SEO_PAGE_ROW_JSON_SCHEMA, matchSitemapEntryRow, pickSeoPageRow, scopeSeoPagesToSite };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sonordev/contracts/sites — normalizeSiteHost (leaf module)
|
|
3
|
+
*
|
|
4
|
+
* Deliberately its own file, not part of `./sites`, so browser code can pull
|
|
5
|
+
* in the normalizer WITHOUT the rest of the contract surface.
|
|
6
|
+
*
|
|
7
|
+
* `publishCredential` (which every client entry that publishes a credential
|
|
8
|
+
* imports) needs exactly this one function. When it lived in `./contract`, the
|
|
9
|
+
* bundler emitted that whole module as one shared chunk — `SITE_HOST_JSON_SCHEMA`,
|
|
10
|
+
* `SITE_HOST_CONTRACT_VERSION`, `stripWww`, `sameSite`, `hostFromUrl`,
|
|
11
|
+
* `isNormalizedSiteHost` and all — into `client`, `layout/client` and every
|
|
12
|
+
* other entry that touched it. That was ~750B gzipped of agent-facing contract
|
|
13
|
+
* metadata shipped to browsers for a 14-line string normalizer, and it is what
|
|
14
|
+
* pushed those entries over their bundle budgets.
|
|
15
|
+
*
|
|
16
|
+
* `./sites` re-exports this, so `@sonordev/contracts/sites` (and site-kit's
|
|
17
|
+
* `sites/contract`) carry it too. The Nest APIs import it from there
|
|
18
|
+
* (sonor-api `src/common/utils/site-host.ts`, signal-api
|
|
19
|
+
* `src/shared/utils/seo-page-pick.util.ts`).
|
|
20
|
+
*
|
|
21
|
+
* Pure string logic only (no Node built-ins) so it is safe in the browser.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* normalizeSiteHost — canonical normalizer for a sub-site host value before it
|
|
25
|
+
* is persisted or used as a filter.
|
|
26
|
+
*
|
|
27
|
+
* `https://OhioPowerStudies.com:3000/path` → `ohiopowerstudies.com`
|
|
28
|
+
*
|
|
29
|
+
* Lowercases, strips protocol, keeps host only (drops path + query), strips
|
|
30
|
+
* port (rollups span dev/prod) and the FQDN root dot. Returns null for
|
|
31
|
+
* empty/non-string input.
|
|
32
|
+
* Note: `www.` is intentionally preserved here — use `sameSite`/`stripWww`
|
|
33
|
+
* for www-insensitive equality.
|
|
34
|
+
*/
|
|
35
|
+
export declare function normalizeSiteHost(site?: string | null): string | null;
|
package/dist/sites.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sonordev/contracts/sites — Multi-site "site" dimension host contract (v1)
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for normalizing and comparing sub-site host values
|
|
5
|
+
* (the multi-site `site` dimension, see CLAUDE.md). site-kit, sonor-api
|
|
6
|
+
* (`src/common/utils/site-host.ts`) and signal-api
|
|
7
|
+
* (`src/shared/utils/seo-page-pick.util.ts`) all import it, so there's no
|
|
8
|
+
* copy to drift.
|
|
9
|
+
*
|
|
10
|
+
* Pure string logic only (no Node built-ins) so it is safe in the browser:
|
|
11
|
+
* site-kit itself normalizes `__SITE_KIT_SITE__` with `normalizeSiteHost`.
|
|
12
|
+
*/
|
|
13
|
+
/** Bump on breaking changes to the normalized host shape. */
|
|
14
|
+
export declare const SITE_HOST_CONTRACT_VERSION: 1;
|
|
15
|
+
/**
|
|
16
|
+
* The normalizer itself lives in `./sites-normalize`, a leaf module, so browser code
|
|
17
|
+
* (`shared/publishCredential`) can import it without dragging this whole module
|
|
18
|
+
* — JSON schema included — into client bundles. Re-exported here so the public
|
|
19
|
+
* contract surface is unchanged.
|
|
20
|
+
*/
|
|
21
|
+
export { normalizeSiteHost } from './sites-normalize';
|
|
22
|
+
/** Strip a leading `www.` from a host ("www.tps.com" → "tps.com"). */
|
|
23
|
+
export declare function stripWww(host: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* www-insensitive sub-site host equality — `www.a.com` and `a.com` are the
|
|
26
|
+
* same site. Operands are expected already-normalized (lowercase host);
|
|
27
|
+
* returns false if either side is missing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sameSite(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Host of a full URL ("https://ohiopowerstudies.com/x" → "ohiopowerstudies.com"),
|
|
32
|
+
* null for site-relative paths ("/services") and unparseable input.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hostFromUrl(value: string | null | undefined): string | null;
|
|
35
|
+
/** True when `value` is a non-empty normalized host (lowercase, no protocol/path/port). */
|
|
36
|
+
export declare function isNormalizedSiteHost(value: unknown): value is string;
|
|
37
|
+
/**
|
|
38
|
+
* Agent-native contract: JSON Schema for a normalized `site` value, so services
|
|
39
|
+
* and agents can validate the dimension mechanically without importing the code.
|
|
40
|
+
*/
|
|
41
|
+
export declare const SITE_HOST_JSON_SCHEMA: {
|
|
42
|
+
readonly $schema: 'http://json-schema.org/draft-07/schema#';
|
|
43
|
+
readonly $id: 'https://sonor.io/schemas/site-host.v1.json';
|
|
44
|
+
readonly title: 'NormalizedSiteHost';
|
|
45
|
+
readonly description: 'A normalized sub-site host (the multi-site `site` dimension): lowercase, no protocol, no path, no port. null denotes a project-wide (unattributed) row.';
|
|
46
|
+
readonly type: readonly ['string', 'null'];
|
|
47
|
+
readonly pattern: '^[a-z0-9.-]+$';
|
|
48
|
+
};
|
package/dist/sites.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/sites-normalize.ts
|
|
4
|
+
function normalizeSiteHost(site) {
|
|
5
|
+
if (!site || typeof site !== "string") return null;
|
|
6
|
+
let s = site.trim().toLowerCase();
|
|
7
|
+
if (!s) return null;
|
|
8
|
+
s = s.replace(/^https?:\/\//, "");
|
|
9
|
+
const slash = s.indexOf("/");
|
|
10
|
+
if (slash >= 0) s = s.slice(0, slash);
|
|
11
|
+
const colon = s.indexOf(":");
|
|
12
|
+
if (colon >= 0) s = s.slice(0, colon);
|
|
13
|
+
s = s.replace(/\.+$/, "");
|
|
14
|
+
return s || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/contracts/sites.ts
|
|
18
|
+
var SITE_HOST_CONTRACT_VERSION = 1;
|
|
19
|
+
function stripWww(host) {
|
|
20
|
+
return host.startsWith("www.") ? host.slice(4) : host;
|
|
21
|
+
}
|
|
22
|
+
function sameSite(a, b) {
|
|
23
|
+
if (!a || !b) return false;
|
|
24
|
+
return stripWww(a) === stripWww(b);
|
|
25
|
+
}
|
|
26
|
+
function hostFromUrl(value) {
|
|
27
|
+
if (!value || !value.startsWith("http://") && !value.startsWith("https://")) return null;
|
|
28
|
+
try {
|
|
29
|
+
return normalizeSiteHost(new URL(value).host);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isNormalizedSiteHost(value) {
|
|
35
|
+
return typeof value === "string" && value.length > 0 && normalizeSiteHost(value) === value;
|
|
36
|
+
}
|
|
37
|
+
var SITE_HOST_JSON_SCHEMA = {
|
|
38
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
39
|
+
$id: "https://sonor.io/schemas/site-host.v1.json",
|
|
40
|
+
title: "NormalizedSiteHost",
|
|
41
|
+
description: "A normalized sub-site host (the multi-site `site` dimension): lowercase, no protocol, no path, no port. null denotes a project-wide (unattributed) row.",
|
|
42
|
+
type: ["string", "null"],
|
|
43
|
+
pattern: "^[a-z0-9.-]+$"
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
exports.SITE_HOST_CONTRACT_VERSION = SITE_HOST_CONTRACT_VERSION;
|
|
47
|
+
exports.SITE_HOST_JSON_SCHEMA = SITE_HOST_JSON_SCHEMA;
|
|
48
|
+
exports.hostFromUrl = hostFromUrl;
|
|
49
|
+
exports.isNormalizedSiteHost = isNormalizedSiteHost;
|
|
50
|
+
exports.normalizeSiteHost = normalizeSiteHost;
|
|
51
|
+
exports.sameSite = sameSite;
|
|
52
|
+
exports.stripWww = stripWww;
|
package/dist/sites.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/contracts/sites-normalize.ts
|
|
2
|
+
function normalizeSiteHost(site) {
|
|
3
|
+
if (!site || typeof site !== "string") return null;
|
|
4
|
+
let s = site.trim().toLowerCase();
|
|
5
|
+
if (!s) return null;
|
|
6
|
+
s = s.replace(/^https?:\/\//, "");
|
|
7
|
+
const slash = s.indexOf("/");
|
|
8
|
+
if (slash >= 0) s = s.slice(0, slash);
|
|
9
|
+
const colon = s.indexOf(":");
|
|
10
|
+
if (colon >= 0) s = s.slice(0, colon);
|
|
11
|
+
s = s.replace(/\.+$/, "");
|
|
12
|
+
return s || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/contracts/sites.ts
|
|
16
|
+
var SITE_HOST_CONTRACT_VERSION = 1;
|
|
17
|
+
function stripWww(host) {
|
|
18
|
+
return host.startsWith("www.") ? host.slice(4) : host;
|
|
19
|
+
}
|
|
20
|
+
function sameSite(a, b) {
|
|
21
|
+
if (!a || !b) return false;
|
|
22
|
+
return stripWww(a) === stripWww(b);
|
|
23
|
+
}
|
|
24
|
+
function hostFromUrl(value) {
|
|
25
|
+
if (!value || !value.startsWith("http://") && !value.startsWith("https://")) return null;
|
|
26
|
+
try {
|
|
27
|
+
return normalizeSiteHost(new URL(value).host);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isNormalizedSiteHost(value) {
|
|
33
|
+
return typeof value === "string" && value.length > 0 && normalizeSiteHost(value) === value;
|
|
34
|
+
}
|
|
35
|
+
var SITE_HOST_JSON_SCHEMA = {
|
|
36
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
37
|
+
$id: "https://sonor.io/schemas/site-host.v1.json",
|
|
38
|
+
title: "NormalizedSiteHost",
|
|
39
|
+
description: "A normalized sub-site host (the multi-site `site` dimension): lowercase, no protocol, no path, no port. null denotes a project-wide (unattributed) row.",
|
|
40
|
+
type: ["string", "null"],
|
|
41
|
+
pattern: "^[a-z0-9.-]+$"
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export { SITE_HOST_CONTRACT_VERSION, SITE_HOST_JSON_SCHEMA, hostFromUrl, isNormalizedSiteHost, normalizeSiteHost, sameSite, stripWww };
|
package/dist/slots.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sonordev/contracts/slots — Managed Slots wire contract (v1)
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the slot payload shape and content signing,
|
|
5
|
+
* shared by site-kit (verify side) and sonor-api (sign side). Node only: it
|
|
6
|
+
* signs with `crypto`.
|
|
7
|
+
*
|
|
8
|
+
* Security model: every SlotContent is HMAC-signed with the project API key.
|
|
9
|
+
* site-kit verifies before rendering and falls back to the developer's static
|
|
10
|
+
* content on any mismatch, so platform-originated content carries provenance
|
|
11
|
+
* end to end. See docs/SITE-KIT-3.0-SPEC-SLOTS.md.
|
|
12
|
+
*/
|
|
13
|
+
/** Bump on breaking payload changes. */
|
|
14
|
+
export declare const SLOTS_CONTRACT_VERSION = 1;
|
|
15
|
+
/**
|
|
16
|
+
* v1 supports plain text only — rendered as React text (escaped), never
|
|
17
|
+
* dangerouslySetInnerHTML. 'markdown' / 'rich' are reserved for a future
|
|
18
|
+
* contract version behind a real sanitizer decision.
|
|
19
|
+
*/
|
|
20
|
+
export type SlotContentType = 'text';
|
|
21
|
+
export declare const SLOT_CONTENT_TYPES: readonly SlotContentType[];
|
|
22
|
+
/** Hard cap on managed content length; longer payloads are rejected → fallback. */
|
|
23
|
+
export declare const SLOT_CONTENT_MAX_LENGTH = 10000;
|
|
24
|
+
/** Slot ids: lowercase, page-scoped by convention, e.g. "home-hero-headline". */
|
|
25
|
+
export declare const SLOT_ID_PATTERN: RegExp;
|
|
26
|
+
export interface SlotContent {
|
|
27
|
+
slot_id: string;
|
|
28
|
+
type: SlotContentType;
|
|
29
|
+
/** The managed text. Rendered escaped — never as HTML. */
|
|
30
|
+
content: string;
|
|
31
|
+
/** Forward-compat: experiment/personalization variant that produced this content. */
|
|
32
|
+
variant_id?: string | null;
|
|
33
|
+
experiment_id?: string | null;
|
|
34
|
+
/** ISO timestamp of the last content change. */
|
|
35
|
+
updated_at?: string | null;
|
|
36
|
+
/** Hex HMAC-SHA256 over the canonical payload, keyed with the project API key. */
|
|
37
|
+
signature: string;
|
|
38
|
+
}
|
|
39
|
+
export interface SlotResolveRequestEntry {
|
|
40
|
+
id: string;
|
|
41
|
+
/**
|
|
42
|
+
* The developer's current static text for this slot (when statically
|
|
43
|
+
* extractable). Stored as `fallback_text` on registration so the dashboard
|
|
44
|
+
* editor shows the live copy instead of a bare slot id. Never rendered by
|
|
45
|
+
* the API anywhere — display + drafting context only.
|
|
46
|
+
*/
|
|
47
|
+
fallback?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The route this slot renders on (e.g. "/" or "/pricing"). A static literal,
|
|
50
|
+
* never derived from the request — it feeds the Copy Evidence Loop's
|
|
51
|
+
* slot↔page mapping so Signal can judge the slot against its page's metrics.
|
|
52
|
+
*/
|
|
53
|
+
page?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface SlotsResolveRequest {
|
|
56
|
+
contract_version: number;
|
|
57
|
+
slots: SlotResolveRequestEntry[];
|
|
58
|
+
/** Multi-site sub-dimension: normalized lowercase host (see CLAUDE.md "site" convention). */
|
|
59
|
+
site?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface SlotsResolveResponse {
|
|
62
|
+
contract_version: number;
|
|
63
|
+
slots: Record<string, SlotContent | null>;
|
|
64
|
+
}
|
|
65
|
+
export declare function isValidSlotId(id: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Canonical string both sides sign/verify. Exported separately from the HMAC
|
|
68
|
+
* helpers so an edge-runtime resolver can reproduce it with WebCrypto.
|
|
69
|
+
*/
|
|
70
|
+
export declare function slotSigningPayload(content: Pick<SlotContent, 'slot_id' | 'type' | 'content'>): string;
|
|
71
|
+
/**
|
|
72
|
+
* Sign slot content with the project API key (sonor-api side).
|
|
73
|
+
* Returns the hex HMAC-SHA256 signature.
|
|
74
|
+
*/
|
|
75
|
+
export declare function signSlotContent(content: Pick<SlotContent, 'slot_id' | 'type' | 'content'>, apiKey: string): string;
|
|
76
|
+
/**
|
|
77
|
+
* Verify a slot payload's shape + signature (site-kit side).
|
|
78
|
+
* Returns true only when the payload is well-formed, within limits, and the
|
|
79
|
+
* signature matches the project API key. Constant-time comparison.
|
|
80
|
+
*/
|
|
81
|
+
export declare function verifySlotContent(content: unknown, apiKey: string): content is SlotContent;
|
package/dist/slots.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/contracts/slots.ts
|
|
6
|
+
var SLOTS_CONTRACT_VERSION = 1;
|
|
7
|
+
var SLOT_CONTENT_TYPES = ["text"];
|
|
8
|
+
var SLOT_CONTENT_MAX_LENGTH = 1e4;
|
|
9
|
+
var SLOT_ID_PATTERN = /^[a-z0-9][a-z0-9-_.:]{0,127}$/;
|
|
10
|
+
function isValidSlotId(id) {
|
|
11
|
+
return SLOT_ID_PATTERN.test(id);
|
|
12
|
+
}
|
|
13
|
+
function slotSigningPayload(content) {
|
|
14
|
+
return `sonor-slot.v${SLOTS_CONTRACT_VERSION}
|
|
15
|
+
${content.slot_id}
|
|
16
|
+
${content.type}
|
|
17
|
+
${content.content}`;
|
|
18
|
+
}
|
|
19
|
+
function signSlotContent(content, apiKey) {
|
|
20
|
+
return crypto.createHmac("sha256", apiKey).update(slotSigningPayload(content), "utf8").digest("hex");
|
|
21
|
+
}
|
|
22
|
+
function verifySlotContent(content, apiKey) {
|
|
23
|
+
if (!content || typeof content !== "object") return false;
|
|
24
|
+
const c = content;
|
|
25
|
+
if (typeof c.slot_id !== "string" || !isValidSlotId(c.slot_id) || typeof c.content !== "string" || c.content.length === 0 || c.content.length > SLOT_CONTENT_MAX_LENGTH || typeof c.signature !== "string" || !SLOT_CONTENT_TYPES.includes(c.type)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const expected = signSlotContent(
|
|
29
|
+
{ slot_id: c.slot_id, type: c.type, content: c.content },
|
|
30
|
+
apiKey
|
|
31
|
+
);
|
|
32
|
+
const a = Buffer.from(expected, "utf8");
|
|
33
|
+
const b = Buffer.from(c.signature, "utf8");
|
|
34
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
exports.SLOTS_CONTRACT_VERSION = SLOTS_CONTRACT_VERSION;
|
|
38
|
+
exports.SLOT_CONTENT_MAX_LENGTH = SLOT_CONTENT_MAX_LENGTH;
|
|
39
|
+
exports.SLOT_CONTENT_TYPES = SLOT_CONTENT_TYPES;
|
|
40
|
+
exports.SLOT_ID_PATTERN = SLOT_ID_PATTERN;
|
|
41
|
+
exports.isValidSlotId = isValidSlotId;
|
|
42
|
+
exports.signSlotContent = signSlotContent;
|
|
43
|
+
exports.slotSigningPayload = slotSigningPayload;
|
|
44
|
+
exports.verifySlotContent = verifySlotContent;
|
package/dist/slots.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/contracts/slots.ts
|
|
4
|
+
var SLOTS_CONTRACT_VERSION = 1;
|
|
5
|
+
var SLOT_CONTENT_TYPES = ["text"];
|
|
6
|
+
var SLOT_CONTENT_MAX_LENGTH = 1e4;
|
|
7
|
+
var SLOT_ID_PATTERN = /^[a-z0-9][a-z0-9-_.:]{0,127}$/;
|
|
8
|
+
function isValidSlotId(id) {
|
|
9
|
+
return SLOT_ID_PATTERN.test(id);
|
|
10
|
+
}
|
|
11
|
+
function slotSigningPayload(content) {
|
|
12
|
+
return `sonor-slot.v${SLOTS_CONTRACT_VERSION}
|
|
13
|
+
${content.slot_id}
|
|
14
|
+
${content.type}
|
|
15
|
+
${content.content}`;
|
|
16
|
+
}
|
|
17
|
+
function signSlotContent(content, apiKey) {
|
|
18
|
+
return createHmac("sha256", apiKey).update(slotSigningPayload(content), "utf8").digest("hex");
|
|
19
|
+
}
|
|
20
|
+
function verifySlotContent(content, apiKey) {
|
|
21
|
+
if (!content || typeof content !== "object") return false;
|
|
22
|
+
const c = content;
|
|
23
|
+
if (typeof c.slot_id !== "string" || !isValidSlotId(c.slot_id) || typeof c.content !== "string" || c.content.length === 0 || c.content.length > SLOT_CONTENT_MAX_LENGTH || typeof c.signature !== "string" || !SLOT_CONTENT_TYPES.includes(c.type)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const expected = signSlotContent(
|
|
27
|
+
{ slot_id: c.slot_id, type: c.type, content: c.content },
|
|
28
|
+
apiKey
|
|
29
|
+
);
|
|
30
|
+
const a = Buffer.from(expected, "utf8");
|
|
31
|
+
const b = Buffer.from(c.signature, "utf8");
|
|
32
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { SLOTS_CONTRACT_VERSION, SLOT_CONTENT_MAX_LENGTH, SLOT_CONTENT_TYPES, SLOT_ID_PATTERN, isValidSlotId, signSlotContent, slotSigningPayload, verifySlotContent };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sonordev/contracts/website — what a site's design is, and what a
|
|
3
|
+
* popup is made of. Pure: no DOM, no network, no React.
|
|
4
|
+
*
|
|
5
|
+
* SINGLE SOURCE OF TRUTH shared by every side of the Website module's popups:
|
|
6
|
+
* - the site (site-kit measures its own rendered design and reports it,
|
|
7
|
+
* publishes it as `--sk-site-*` CSS variables, and renders popup blocks),
|
|
8
|
+
* - sonor-api (validates the design report and every popup write with these
|
|
9
|
+
* normalizers, so a popup can't be saved in a shape the site can't draw),
|
|
10
|
+
* - the dashboard (the builder's preview reads the same tokens through the
|
|
11
|
+
* same CSS-variable mapping, so it looks like the site),
|
|
12
|
+
* - Signal's agents (the block shapes an MCP `propose_site_popup` sends).
|
|
13
|
+
*
|
|
14
|
+
* All four import it: site-kit as `@sonordev/site-kit/website/contract`,
|
|
15
|
+
* sonor-api and the dashboard as `@sonordev/contracts/website`.
|
|
16
|
+
*
|
|
17
|
+
* Why popups carry blocks and the site carries tokens: a popup should look
|
|
18
|
+
* like the site it's on. It says WHAT (a heading, some formatted text, an
|
|
19
|
+
* image, a button); the site's measured design says HOW (colors, fonts,
|
|
20
|
+
* radius). Nothing in a block chooses a color or a font.
|
|
21
|
+
*/
|
|
22
|
+
/** Bumped when the token shape changes; sent with every report. */
|
|
23
|
+
export declare const DESIGN_TOKENS_VERSION = 1;
|
|
24
|
+
export interface SiteDesignTokens {
|
|
25
|
+
version: typeof DESIGN_TOKENS_VERSION;
|
|
26
|
+
colors: {
|
|
27
|
+
/** The page background. */
|
|
28
|
+
background: string;
|
|
29
|
+
/** A card or popup surface (the page background when the site has none). */
|
|
30
|
+
surface: string;
|
|
31
|
+
/** Body text. */
|
|
32
|
+
text: string;
|
|
33
|
+
/** Secondary text. */
|
|
34
|
+
muted: string;
|
|
35
|
+
/** The accent: buttons and links. */
|
|
36
|
+
primary: string;
|
|
37
|
+
/** Text on the accent, chosen for contrast when the site doesn't say. */
|
|
38
|
+
onPrimary: string;
|
|
39
|
+
/** Hairlines and card borders. */
|
|
40
|
+
border: string;
|
|
41
|
+
};
|
|
42
|
+
fonts: {
|
|
43
|
+
/** CSS font-family stacks, as the site declares them. */
|
|
44
|
+
body: string;
|
|
45
|
+
heading: string;
|
|
46
|
+
};
|
|
47
|
+
radius: {
|
|
48
|
+
/** Buttons and inputs. */
|
|
49
|
+
control: string;
|
|
50
|
+
/** Cards and popups. */
|
|
51
|
+
card: string;
|
|
52
|
+
};
|
|
53
|
+
/** How the site's buttons set text. */
|
|
54
|
+
button: {
|
|
55
|
+
textTransform: 'none' | 'uppercase';
|
|
56
|
+
fontWeight: string;
|
|
57
|
+
};
|
|
58
|
+
/** Light or dark, from the background's luminance. */
|
|
59
|
+
scheme: 'light' | 'dark';
|
|
60
|
+
/** Where the tokens came from: declared on SiteKitLayout, measured from the page, or both. */
|
|
61
|
+
source: 'declared' | 'measured' | 'mixed';
|
|
62
|
+
}
|
|
63
|
+
/** A CSS color (hex, rgb(), hsl()) as `#rrggbb`, or null for anything else or transparent. */
|
|
64
|
+
export declare function toHexColor(value: unknown): string | null;
|
|
65
|
+
/** `a` moved `t` (0-1) of the way toward `b`, both `#rrggbb`. */
|
|
66
|
+
export declare function mixHex(a: string, b: string, t: number): string;
|
|
67
|
+
/** '#ffffff' or '#111827', whichever reads better on `hex` (WCAG contrast). */
|
|
68
|
+
export declare function readableOn(hex: string): '#ffffff' | '#111827';
|
|
69
|
+
/**
|
|
70
|
+
* A design report (from the site, or anything claiming to be one) as
|
|
71
|
+
* SiteDesignTokens, or null when there isn't enough to draw with (no usable
|
|
72
|
+
* background, text or primary color). Missing pieces fall back to
|
|
73
|
+
* reasonable values derived from the ones present.
|
|
74
|
+
*/
|
|
75
|
+
export declare function normalizeDesignTokens(input: unknown): SiteDesignTokens | null;
|
|
76
|
+
/** A stable hash of the tokens (FNV-1a over sorted JSON), to report only on change. */
|
|
77
|
+
export declare function designTokensHash(tokens: SiteDesignTokens): string;
|
|
78
|
+
/**
|
|
79
|
+
* The tokens as CSS custom properties. SINGLE SOURCE OF TRUTH for the names:
|
|
80
|
+
* the site sets them on :root (so popups render in its design) and the
|
|
81
|
+
* dashboard sets them on the preview frame (so the preview matches).
|
|
82
|
+
*/
|
|
83
|
+
export declare function designTokenCssVars(tokens: SiteDesignTokens): Record<string, string>;
|
|
84
|
+
export type PopupBlock = {
|
|
85
|
+
type: 'heading';
|
|
86
|
+
text: string;
|
|
87
|
+
} | {
|
|
88
|
+
type: 'text';
|
|
89
|
+
html: string;
|
|
90
|
+
} | {
|
|
91
|
+
type: 'image';
|
|
92
|
+
url: string;
|
|
93
|
+
alt: string;
|
|
94
|
+
} | {
|
|
95
|
+
type: 'button';
|
|
96
|
+
text: string;
|
|
97
|
+
url: string;
|
|
98
|
+
variant: 'primary' | 'secondary';
|
|
99
|
+
} | {
|
|
100
|
+
type: 'divider';
|
|
101
|
+
};
|
|
102
|
+
export declare const POPUP_BLOCK_TYPES: readonly ['heading', 'text', 'image', 'button', 'divider'];
|
|
103
|
+
export declare const POPUP_BLOCK_LIMITS: {
|
|
104
|
+
readonly blocks: 12;
|
|
105
|
+
readonly heading: 120;
|
|
106
|
+
/** Sanitized HTML length for one text block. */
|
|
107
|
+
readonly textHtml: 4000;
|
|
108
|
+
/** Visible characters across one text block. */
|
|
109
|
+
readonly textPlain: 1200;
|
|
110
|
+
readonly buttonText: 40;
|
|
111
|
+
readonly imageAlt: 200;
|
|
112
|
+
readonly buttons: 2;
|
|
113
|
+
readonly images: 3;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* A link or image target a popup may use. Links: a path on the site (`/sale`,
|
|
117
|
+
* never `//host` or `/\host`), an anchor (`#offer`), http(s), mailto: or
|
|
118
|
+
* tel:. Images: https or a path on the site (an http image is blocked on an
|
|
119
|
+
* https page). Anything carrying a space or control character is refused,
|
|
120
|
+
* since browsers strip those before parsing ("java\tscript:" runs).
|
|
121
|
+
*/
|
|
122
|
+
export declare function isSafePopupUrl(value: unknown, kind?: 'link' | 'image'): boolean;
|
|
123
|
+
/**
|
|
124
|
+
* A popup text block's HTML, reduced to the formatting a popup allows:
|
|
125
|
+
* paragraphs, line breaks, bold, italic, underline, strikethrough, lists and
|
|
126
|
+
* links (safe targets only; external links open in a new tab with
|
|
127
|
+
* rel="noopener noreferrer"). Every other tag is removed (its text kept,
|
|
128
|
+
* except script/style and friends, which go with their content); every
|
|
129
|
+
* attribute except a link's href is removed. Server-safe: no DOM.
|
|
130
|
+
*/
|
|
131
|
+
export declare function sanitizePopupHtml(html: unknown): string;
|
|
132
|
+
/** The visible text of sanitized popup HTML (tags removed, common entities decoded). */
|
|
133
|
+
export declare function popupHtmlText(html: string): string;
|
|
134
|
+
/**
|
|
135
|
+
* Popup blocks from any input (the editor, an agent, a stored row), sanitized
|
|
136
|
+
* and checked against POPUP_BLOCK_LIMITS. `errors` says, in words a person
|
|
137
|
+
* can act on, what was wrong; a caller saving a popup refuses on any error,
|
|
138
|
+
* a renderer just draws `blocks`.
|
|
139
|
+
*/
|
|
140
|
+
export declare function normalizePopupBlocks(input: unknown): {
|
|
141
|
+
blocks: PopupBlock[];
|
|
142
|
+
errors: string[];
|
|
143
|
+
};
|
|
144
|
+
/** A popup's words in reading order: the accessible name source and the legacy plain-text fallback. */
|
|
145
|
+
export declare function popupBlocksText(blocks: PopupBlock[]): {
|
|
146
|
+
heading: string;
|
|
147
|
+
body: string;
|
|
148
|
+
};
|