@sonordev/contracts 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ // src/contracts/color.ts
2
+ var HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
3
+ function channel(v) {
4
+ const c = v / 255;
5
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
6
+ }
7
+ function relativeLuminance({ r, g, b }) {
8
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
9
+ }
10
+ function hexToRgb(hex) {
11
+ if (!HEX_COLOR.test(hex)) return null;
12
+ let c = hex.slice(1);
13
+ if (c.length <= 4) c = c.split("").map((ch) => ch + ch).join("");
14
+ if (c.length === 8 && c.slice(6) === "00") return null;
15
+ return {
16
+ r: parseInt(c.slice(0, 2), 16),
17
+ g: parseInt(c.slice(2, 4), 16),
18
+ b: parseInt(c.slice(4, 6), 16)
19
+ };
20
+ }
21
+ function hslToRgb(h, s, l) {
22
+ const k = (n) => (n + h / 30) % 12;
23
+ const a = s * Math.min(l, 1 - l);
24
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
25
+ return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255) };
26
+ }
27
+ function colorArgs(inner) {
28
+ const [main, alpha] = inner.split("/");
29
+ const parts = main.split(/[\s,]+/).filter(Boolean);
30
+ if (alpha === void 0 && parts.length === 4) return { parts: parts.slice(0, 3), alpha: parts[3] };
31
+ return { parts, alpha: alpha?.trim() };
32
+ }
33
+ function isTransparentAlpha(alpha) {
34
+ if (!alpha) return false;
35
+ const n = alpha.endsWith("%") ? parseFloat(alpha) / 100 : parseFloat(alpha);
36
+ return n === 0;
37
+ }
38
+ function colorFunction(v) {
39
+ const fn = v.match(/^([a-z]+)\((.*)\)$/);
40
+ if (!fn) return null;
41
+ const { parts, alpha } = colorArgs(fn[2]);
42
+ if (isTransparentAlpha(alpha) || parts.length < 3) return null;
43
+ return { name: fn[1], parts };
44
+ }
45
+ var num = (p, scale) => p.endsWith("%") ? parseFloat(p) / 100 * scale : parseFloat(p);
46
+ function parseColor(value) {
47
+ const v = value.trim().toLowerCase();
48
+ if (!v || v === "transparent") return null;
49
+ if (v === "white") return { r: 255, g: 255, b: 255 };
50
+ if (v === "black") return { r: 0, g: 0, b: 0 };
51
+ if (v.startsWith("#")) return hexToRgb(v);
52
+ const fn = colorFunction(v);
53
+ if (fn) {
54
+ const { name, parts } = fn;
55
+ if (name === "rgb" || name === "rgba") {
56
+ const [r, g, b] = parts.map((p) => num(p, 255));
57
+ return [r, g, b].some(Number.isNaN) ? null : { r, g, b };
58
+ }
59
+ if (name === "hsl" || name === "hsla") {
60
+ const h = parseFloat(parts[0]);
61
+ const s = num(parts[1], 1);
62
+ const l = num(parts[2], 1);
63
+ return [h, s, l].some(Number.isNaN) ? null : hslToRgb(h, s, l);
64
+ }
65
+ return null;
66
+ }
67
+ const triple = v.match(/^(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%$/);
68
+ if (triple) return hslToRgb(parseFloat(triple[1]), parseFloat(triple[2]) / 100, parseFloat(triple[3]) / 100);
69
+ return null;
70
+ }
71
+
72
+ // src/contracts/website.ts
73
+ var DESIGN_TOKENS_VERSION = 1;
74
+ var HEX = (rgb) => `#${[rgb.r, rgb.g, rgb.b].map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")).join("")}`;
75
+ function toHexColor(value) {
76
+ if (typeof value !== "string") return null;
77
+ const v = value.trim();
78
+ const inner = v.match(/^(?:rgba?|hsla?)\(([^)]*)\)$/i)?.[1];
79
+ if (inner) {
80
+ const alpha = inner.includes("/") ? inner.split("/")[1]?.trim() : inner.split(",")[3]?.trim();
81
+ if (alpha && (alpha.endsWith("%") ? parseFloat(alpha) / 100 : parseFloat(alpha)) < 0.5) return null;
82
+ }
83
+ if (/^#[0-9a-f]{8}$/i.test(v) && parseInt(v.slice(7, 9), 16) < 128) return null;
84
+ const rgb = parseColor(v);
85
+ return rgb ? HEX(rgb) : null;
86
+ }
87
+ var luminanceOf = (hex) => {
88
+ const rgb = parseColor(hex);
89
+ return rgb ? relativeLuminance(rgb) : 1;
90
+ };
91
+ function mixHex(a, b, t) {
92
+ const x = parseColor(a);
93
+ const y = parseColor(b);
94
+ if (!x || !y) return a;
95
+ return HEX({ r: x.r + (y.r - x.r) * t, g: x.g + (y.g - x.g) * t, b: x.b + (y.b - x.b) * t });
96
+ }
97
+ function mutedFor(text, background) {
98
+ const bg = luminanceOf(background);
99
+ for (const t of [0.4, 0.3, 0.2, 0.1]) {
100
+ const m = mixHex(text, background, t);
101
+ const l = luminanceOf(m);
102
+ if ((Math.max(l, bg) + 0.05) / (Math.min(l, bg) + 0.05) >= 4.5) return m;
103
+ }
104
+ return text;
105
+ }
106
+ function readableOn(hex) {
107
+ const lum = luminanceOf(hex);
108
+ const onWhite = 1.05 / (lum + 0.05);
109
+ const onDark = (lum + 0.05) / (luminanceOf("#111827") + 0.05);
110
+ return onWhite >= onDark ? "#ffffff" : "#111827";
111
+ }
112
+ function fontStack(value) {
113
+ if (typeof value !== "string") return null;
114
+ const clean = value.replace(/[^A-Za-z0-9 ,'"_-]/g, "").replace(/\s+/g, " ").trim().slice(0, 200);
115
+ return clean && /[A-Za-z]/.test(clean) ? clean : null;
116
+ }
117
+ function radiusLength(value) {
118
+ if (value === 0 || value === "0") return "0px";
119
+ if (typeof value !== "string") return null;
120
+ const m = value.trim().match(/^(\d+(?:\.\d+)?)(px|rem|em)$/);
121
+ if (!m) return null;
122
+ const n = Number(m[1]);
123
+ const max = m[2] === "px" ? 64 : 4;
124
+ return `${Math.min(n, max)}${m[2]}`;
125
+ }
126
+ var FALLBACK_FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
127
+ function normalizeDesignTokens(input) {
128
+ if (!input || typeof input !== "object") return null;
129
+ const raw = input;
130
+ const c = raw.colors ?? {};
131
+ const background = toHexColor(c.background);
132
+ const text = toHexColor(c.text);
133
+ const primary = toHexColor(c.primary);
134
+ if (!background || !text || !primary) return null;
135
+ const scheme = luminanceOf(background) < 0.179 ? "dark" : "light";
136
+ const fonts = raw.fonts ?? {};
137
+ const radius = raw.radius ?? {};
138
+ const button = raw.button ?? {};
139
+ const body = fontStack(fonts.body) ?? FALLBACK_FONT;
140
+ const control = radiusLength(radius.control) ?? "8px";
141
+ return {
142
+ version: DESIGN_TOKENS_VERSION,
143
+ colors: {
144
+ background,
145
+ surface: toHexColor(c.surface) ?? background,
146
+ text,
147
+ muted: toHexColor(c.muted) ?? mutedFor(text, background),
148
+ primary,
149
+ onPrimary: toHexColor(c.onPrimary) ?? readableOn(primary),
150
+ border: toHexColor(c.border) ?? (scheme === "dark" ? "#374151" : "#e5e7eb")
151
+ },
152
+ fonts: { body, heading: fontStack(fonts.heading) ?? body },
153
+ // A pill site's control radius would make a pill-shaped popup; a card it
154
+ // didn't name gets a gentler corner instead.
155
+ radius: { control, card: radiusLength(radius.card) ?? (parseFloat(control) * (control.endsWith("px") ? 1 : 16) > 24 ? "16px" : control) },
156
+ button: {
157
+ textTransform: button.textTransform === "uppercase" ? "uppercase" : "none",
158
+ fontWeight: /^[1-9]00$/.test(String(button.fontWeight ?? "")) ? String(button.fontWeight) : "600"
159
+ },
160
+ scheme,
161
+ source: raw.source === "declared" || raw.source === "mixed" ? raw.source : "measured"
162
+ };
163
+ }
164
+ function designTokensHash(tokens) {
165
+ const stable = (v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, stable(v[k])])) : v;
166
+ const text = JSON.stringify(stable(tokens));
167
+ let h = 2166136261;
168
+ for (let i = 0; i < text.length; i++) {
169
+ h ^= text.charCodeAt(i);
170
+ h = Math.imul(h, 16777619) >>> 0;
171
+ }
172
+ return h.toString(16).padStart(8, "0");
173
+ }
174
+ function designTokenCssVars(tokens) {
175
+ return {
176
+ "--sk-site-bg": tokens.colors.background,
177
+ "--sk-site-surface": tokens.colors.surface,
178
+ "--sk-site-text": tokens.colors.text,
179
+ "--sk-site-muted": tokens.colors.muted,
180
+ "--sk-site-primary": tokens.colors.primary,
181
+ "--sk-site-on-primary": tokens.colors.onPrimary,
182
+ "--sk-site-border": tokens.colors.border,
183
+ "--sk-site-font-body": tokens.fonts.body,
184
+ "--sk-site-font-heading": tokens.fonts.heading,
185
+ "--sk-site-radius-control": tokens.radius.control,
186
+ "--sk-site-radius-card": tokens.radius.card,
187
+ "--sk-site-button-transform": tokens.button.textTransform,
188
+ "--sk-site-button-weight": tokens.button.fontWeight
189
+ };
190
+ }
191
+ var POPUP_BLOCK_TYPES = ["heading", "text", "image", "button", "divider"];
192
+ var POPUP_BLOCK_LIMITS = {
193
+ blocks: 12,
194
+ heading: 120,
195
+ /** Sanitized HTML length for one text block. */
196
+ textHtml: 4e3,
197
+ /** Visible characters across one text block. */
198
+ textPlain: 1200,
199
+ buttonText: 40,
200
+ imageAlt: 200,
201
+ buttons: 2,
202
+ images: 3
203
+ };
204
+ function isSafePopupUrl(value, kind = "link") {
205
+ if (typeof value !== "string") return false;
206
+ const v = value.trim();
207
+ if (!v || v.length > 2048) return false;
208
+ for (let i = 0; i < v.length; i++) {
209
+ const code = v.charCodeAt(i);
210
+ if (code <= 32 || code >= 127 && code <= 159) return false;
211
+ }
212
+ if (v.startsWith("/")) return !v.startsWith("//") && !v.startsWith("/\\");
213
+ if (v.startsWith("#")) return kind === "link" && v.length > 1;
214
+ let url;
215
+ try {
216
+ url = new URL(v);
217
+ } catch {
218
+ return false;
219
+ }
220
+ if (kind === "image") return url.protocol === "https:";
221
+ return ["https:", "http:", "mailto:", "tel:"].includes(url.protocol);
222
+ }
223
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set(["p", "br", "strong", "b", "em", "i", "u", "s", "a", "ul", "ol", "li"]);
224
+ var DROPPED_WITH_CONTENT = /* @__PURE__ */ new Set(["script", "style", "iframe", "object", "embed", "template", "noscript", "svg", "math", "textarea", "select", "title", "head"]);
225
+ var VOID = /* @__PURE__ */ new Set(["br"]);
226
+ var escapeAttr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
227
+ function hrefOf(attrs) {
228
+ const m = attrs.match(/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i);
229
+ if (!m) return null;
230
+ const raw = (m[1] ?? m[2] ?? m[3] ?? "").replace(/&amp;/g, "&").trim();
231
+ return raw;
232
+ }
233
+ function sanitizePopupHtml(html) {
234
+ if (typeof html !== "string" || !html) return "";
235
+ const out = [];
236
+ const open = [];
237
+ let dropDepth = 0;
238
+ let dropTag = "";
239
+ const token = /<!--[\s\S]*?-->|<\/?([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>|<|[^<]+/g;
240
+ let m;
241
+ while (m = token.exec(html)) {
242
+ const piece = m[0];
243
+ if (piece.startsWith("<!--")) continue;
244
+ const name = m[1]?.toLowerCase();
245
+ if (!name) {
246
+ if (dropDepth) continue;
247
+ out.push(piece === "<" ? "&lt;" : piece.replace(/>/g, "&gt;"));
248
+ continue;
249
+ }
250
+ const closing = piece.startsWith("</");
251
+ if (dropDepth) {
252
+ if (name === dropTag) dropDepth += closing ? -1 : piece.endsWith("/>") ? 0 : 1;
253
+ continue;
254
+ }
255
+ if (DROPPED_WITH_CONTENT.has(name)) {
256
+ if (!closing && !piece.endsWith("/>")) {
257
+ dropDepth = 1;
258
+ dropTag = name;
259
+ }
260
+ continue;
261
+ }
262
+ if (!ALLOWED_TAGS.has(name)) continue;
263
+ const tag = name === "b" ? "strong" : name === "i" ? "em" : name;
264
+ if (closing) {
265
+ const at = open.lastIndexOf(tag);
266
+ if (at === -1) continue;
267
+ while (open.length > at) out.push(`</${open.pop()}>`);
268
+ continue;
269
+ }
270
+ if (VOID.has(tag)) {
271
+ out.push("<br>");
272
+ continue;
273
+ }
274
+ if (tag === "a") {
275
+ const href = hrefOf(m[2] ?? "");
276
+ if (!href || !isSafePopupUrl(href, "link")) continue;
277
+ const external = /^https?:/i.test(href);
278
+ out.push(`<a href="${escapeAttr(href)}"${external ? ' target="_blank" rel="noopener noreferrer"' : ""}>`);
279
+ open.push("a");
280
+ continue;
281
+ }
282
+ out.push(`<${tag}>`);
283
+ open.push(tag);
284
+ }
285
+ while (open.length) out.push(`</${open.pop()}>`);
286
+ return out.join("").trim();
287
+ }
288
+ function popupHtmlText(html) {
289
+ return html.replace(/<br>/g, "\n").replace(/<\/(p|li)>/g, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
290
+ }
291
+ var str = (v) => typeof v === "string" ? v.trim() : "";
292
+ function normalizePopupBlocks(input) {
293
+ const errors = [];
294
+ const blocks = [];
295
+ if (!Array.isArray(input)) return { blocks, errors: input == null ? [] : ["Blocks must be a list."] };
296
+ if (input.length > POPUP_BLOCK_LIMITS.blocks) errors.push(`A popup can have at most ${POPUP_BLOCK_LIMITS.blocks} blocks.`);
297
+ let buttons = 0;
298
+ let images = 0;
299
+ input.slice(0, POPUP_BLOCK_LIMITS.blocks).forEach((raw, i) => {
300
+ const where = `Block ${i + 1}`;
301
+ const b = raw && typeof raw === "object" ? raw : {};
302
+ switch (b.type) {
303
+ case "heading": {
304
+ const text = str(b.text);
305
+ if (!text) errors.push(`${where}: the heading is empty.`);
306
+ else if (text.length > POPUP_BLOCK_LIMITS.heading) errors.push(`${where}: the heading is over ${POPUP_BLOCK_LIMITS.heading} characters.`);
307
+ else blocks.push({ type: "heading", text });
308
+ return;
309
+ }
310
+ case "text": {
311
+ const html = sanitizePopupHtml(b.html);
312
+ const plain = popupHtmlText(html);
313
+ if (!plain) errors.push(`${where}: the text is empty.`);
314
+ else if (html.length > POPUP_BLOCK_LIMITS.textHtml || plain.length > POPUP_BLOCK_LIMITS.textPlain) {
315
+ errors.push(`${where}: the text is over ${POPUP_BLOCK_LIMITS.textPlain} characters.`);
316
+ } else blocks.push({ type: "text", html });
317
+ return;
318
+ }
319
+ case "image": {
320
+ const url = str(b.url);
321
+ const alt = str(b.alt);
322
+ if (!isSafePopupUrl(url, "image")) errors.push(`${where}: the image needs an https URL or a path on the site.`);
323
+ else if (!alt) errors.push(`${where}: describe the image for screen readers (alt text).`);
324
+ else if (alt.length > POPUP_BLOCK_LIMITS.imageAlt) errors.push(`${where}: the alt text is over ${POPUP_BLOCK_LIMITS.imageAlt} characters.`);
325
+ else if (++images > POPUP_BLOCK_LIMITS.images) errors.push(`A popup can have at most ${POPUP_BLOCK_LIMITS.images} images.`);
326
+ else blocks.push({ type: "image", url, alt });
327
+ return;
328
+ }
329
+ case "button": {
330
+ const text = str(b.text);
331
+ const url = str(b.url);
332
+ if (!text) errors.push(`${where}: the button needs text.`);
333
+ else if (text.length > POPUP_BLOCK_LIMITS.buttonText) errors.push(`${where}: the button text is over ${POPUP_BLOCK_LIMITS.buttonText} characters.`);
334
+ else if (!isSafePopupUrl(url, "link")) errors.push(`${where}: the button needs a link (a path on the site, https, mailto: or tel:).`);
335
+ else if (++buttons > POPUP_BLOCK_LIMITS.buttons) errors.push(`A popup can have at most ${POPUP_BLOCK_LIMITS.buttons} buttons.`);
336
+ else blocks.push({ type: "button", text, url, variant: b.variant === "secondary" ? "secondary" : "primary" });
337
+ return;
338
+ }
339
+ case "divider":
340
+ blocks.push({ type: "divider" });
341
+ return;
342
+ default:
343
+ errors.push(`${where}: unknown block type "${String(b.type ?? "")}".`);
344
+ }
345
+ });
346
+ if (!errors.length && !blocks.some((b) => b.type !== "divider")) errors.push("A popup needs at least one heading, text, image or button.");
347
+ return { blocks, errors };
348
+ }
349
+ function popupBlocksText(blocks) {
350
+ const heading = blocks.find((b) => b.type === "heading")?.text ?? "";
351
+ const body = blocks.filter((b) => b.type === "text").map((b) => popupHtmlText(b.html)).join("\n\n");
352
+ return { heading, body };
353
+ }
354
+
355
+ export { DESIGN_TOKENS_VERSION, POPUP_BLOCK_LIMITS, POPUP_BLOCK_TYPES, designTokenCssVars, designTokensHash, isSafePopupUrl, mixHex, normalizeDesignTokens, normalizePopupBlocks, popupBlocksText, popupHtmlText, readableOn, sanitizePopupHtml, toHexColor };
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@sonordev/contracts",
3
+ "version": "1.0.0",
4
+ "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot signing and portfolio metrics. Pure functions, no dependencies.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/upforge-dev/site-kit.git",
9
+ "directory": "packages/contracts"
10
+ },
11
+ "keywords": [
12
+ "sonor",
13
+ "site-kit",
14
+ "contracts"
15
+ ],
16
+ "sideEffects": false,
17
+ "exports": {
18
+ "./forms": {
19
+ "types": "./dist/forms.d.ts",
20
+ "import": "./dist/forms.mjs",
21
+ "require": "./dist/forms.js"
22
+ },
23
+ "./llms": {
24
+ "types": "./dist/llms.d.ts",
25
+ "import": "./dist/llms.mjs",
26
+ "require": "./dist/llms.js"
27
+ },
28
+ "./slots": {
29
+ "types": "./dist/slots.d.ts",
30
+ "import": "./dist/slots.mjs",
31
+ "require": "./dist/slots.js"
32
+ },
33
+ "./website": {
34
+ "types": "./dist/website.d.ts",
35
+ "import": "./dist/website.mjs",
36
+ "require": "./dist/website.js"
37
+ },
38
+ "./color": {
39
+ "types": "./dist/color.d.ts",
40
+ "import": "./dist/color.mjs",
41
+ "require": "./dist/color.js"
42
+ },
43
+ "./fleet": {
44
+ "types": "./dist/fleet.d.ts",
45
+ "import": "./dist/fleet.mjs",
46
+ "require": "./dist/fleet.js"
47
+ },
48
+ "./sites": {
49
+ "types": "./dist/sites.d.ts",
50
+ "import": "./dist/sites.mjs",
51
+ "require": "./dist/sites.js"
52
+ },
53
+ "./seo-pages": {
54
+ "types": "./dist/seo-pages.d.ts",
55
+ "import": "./dist/seo-pages.mjs",
56
+ "require": "./dist/seo-pages.js"
57
+ },
58
+ "./seo-meta": {
59
+ "types": "./dist/seo-meta.d.ts",
60
+ "import": "./dist/seo-meta.mjs",
61
+ "require": "./dist/seo-meta.js"
62
+ },
63
+ "./portfolio": {
64
+ "types": "./dist/portfolio.d.ts",
65
+ "import": "./dist/portfolio.mjs",
66
+ "require": "./dist/portfolio.js"
67
+ },
68
+ "./package.json": "./package.json"
69
+ },
70
+ "typesVersions": {
71
+ "*": {
72
+ "*": [
73
+ "dist/*.d.ts"
74
+ ]
75
+ }
76
+ },
77
+ "files": [
78
+ "dist",
79
+ "README.md",
80
+ "CHANGELOG.md"
81
+ ],
82
+ "engines": {
83
+ "node": ">=20"
84
+ },
85
+ "publishConfig": {
86
+ "access": "public"
87
+ },
88
+ "scripts": {
89
+ "build": "cd ../.. && pnpm build:contracts",
90
+ "test": "cd ../.. && pnpm test:contracts",
91
+ "prepublishOnly": "cd ../.. && pnpm test:contracts && pnpm build:contracts"
92
+ }
93
+ }