@tangle-network/agent-app 0.25.0 → 0.27.2

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.
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  DesignCanvas,
3
3
  DesignCanvas_default
4
- } from "./chunk-LPAVBGVY.js";
4
+ } from "./chunk-27LQXQSS.js";
5
5
  import "./chunk-JZAJE3JL.js";
6
6
  export {
7
7
  DesignCanvas,
8
8
  DesignCanvas_default as default
9
9
  };
10
- //# sourceMappingURL=DesignCanvas-MA5EH324.js.map
10
+ //# sourceMappingURL=DesignCanvas-73IUJJUP.js.map
@@ -0,0 +1,9 @@
1
+ import {
2
+ DesignCanvasEditor
3
+ } from "./chunk-HABVCJJT.js";
4
+ import "./chunk-27LQXQSS.js";
5
+ import "./chunk-JZAJE3JL.js";
6
+ export {
7
+ DesignCanvasEditor
8
+ };
9
+ //# sourceMappingURL=DesignCanvasEditor-6F2UTOSW.js.map
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Typed contract for a brand kit extracted from a website (+ optional shared
3
+ * source files). The extractor degrades gracefully: every field is best-effort,
4
+ * absence is represented explicitly (empty arrays, undefined), never faked.
5
+ *
6
+ * A consuming product (gtm, creative) maps this onto its own durable brand
7
+ * store — see the gtm `WorkspaceBrand`. This module owns only extraction; it
8
+ * does NOT persist, render, or judge confidence.
9
+ */
10
+ /** A logo / brand-mark candidate discovered on a page. */
11
+ interface BrandLogoCandidate {
12
+ /** Absolute URL to the asset. */
13
+ url: string;
14
+ /** Where it came from — drives ranking and lets callers cite provenance. */
15
+ source: 'favicon' | 'apple-touch-icon' | 'og:image' | 'img-logo' | 'svg-inline' | 'manifest';
16
+ /** Higher = more likely the canonical logo. 0..1. */
17
+ confidence: number;
18
+ /** Declared width in px when known (from <link sizes> or <img width>). */
19
+ width?: number;
20
+ /** Declared height in px when known. */
21
+ height?: number;
22
+ /** MIME type when inferable from the extension. */
23
+ mimeType?: string;
24
+ /** alt / title text when present — a "logo" alt is a strong signal. */
25
+ alt?: string;
26
+ }
27
+ /** A color extracted from the page, with where it was seen. */
28
+ interface BrandColor {
29
+ /** Normalized 6- or 8-digit `#rrggbb` / `#rrggbbaa` hex (lowercase). */
30
+ hex: string;
31
+ /** How many distinct declarations referenced this color (popularity proxy). */
32
+ occurrences: number;
33
+ /** True when sourced from a `:root` CSS custom property (highest fidelity). */
34
+ fromToken: boolean;
35
+ /** The custom-property name when fromToken (e.g. `--accent`). */
36
+ tokenName?: string;
37
+ }
38
+ /** A font-family discovered in CSS, with role inference. */
39
+ interface BrandFont {
40
+ /** Primary family name, unquoted (e.g. `Inter`, `Playfair Display`). */
41
+ family: string;
42
+ /** The full declared stack as written, in order. */
43
+ stack: string[];
44
+ /** Inferred role from the selector the declaration was attached to. */
45
+ role: 'display' | 'body' | 'unknown';
46
+ /** Declaration count — popularity proxy for picking the dominant family. */
47
+ occurrences: number;
48
+ }
49
+ /** A prominent product / hero image candidate (not a logo). */
50
+ interface BrandImage {
51
+ url: string;
52
+ source: 'og:image' | 'twitter:image' | 'img-hero' | 'img-content';
53
+ /** alt text when present. */
54
+ alt?: string;
55
+ width?: number;
56
+ height?: number;
57
+ }
58
+ /**
59
+ * The full extracted kit. The shape every consumer reads. Each list is ranked
60
+ * best-first; empty lists mean "found nothing", never a fabricated default.
61
+ */
62
+ interface BrandKit {
63
+ /** The site the kit was extracted from (normalized, absolute). */
64
+ sourceUrl: string;
65
+ /** Best-guess brand / product name from <title>, og:site_name, manifest. */
66
+ name?: string;
67
+ /** Tagline / description from meta description or og:description. */
68
+ description?: string;
69
+ /** Logo candidates, ranked best-first. */
70
+ logos: BrandLogoCandidate[];
71
+ /** Colors, ranked by token-first then popularity. */
72
+ palette: BrandColor[];
73
+ /** Fonts, ranked display-then-body then popularity. */
74
+ fonts: BrandFont[];
75
+ /** Prominent images (hero / og), ranked best-first. */
76
+ images: BrandImage[];
77
+ /** Absolute URLs / labels the kit was derived from — for provenance. */
78
+ extractedFrom: string[];
79
+ }
80
+ /**
81
+ * Typed outcome. Callers MUST inspect `succeeded` before reading `kit`.
82
+ * No silent empties: a fetch failure is `succeeded:false` with a real error,
83
+ * NOT an empty BrandKit that downstream code mistakes for "this brand has
84
+ * nothing".
85
+ */
86
+ type BrandExtractionResult = {
87
+ succeeded: true;
88
+ kit: BrandKit;
89
+ warnings: string[];
90
+ } | {
91
+ succeeded: false;
92
+ error: string;
93
+ stage: 'fetch' | 'parse' | 'input';
94
+ };
95
+ /** Fetch boundary, injectable so callers (Workers, Node, tests) supply their
96
+ * own fetch and so tests can run fully offline against fixture HTML. */
97
+ type FetchLike = (url: string, init?: {
98
+ signal?: AbortSignal;
99
+ headers?: Record<string, string>;
100
+ }) => Promise<{
101
+ ok: boolean;
102
+ status: number;
103
+ text(): Promise<string>;
104
+ headers?: {
105
+ get(name: string): string | null;
106
+ };
107
+ }>;
108
+ interface ExtractBrandKitOptions {
109
+ /** Raw HTML for the page. When supplied, no network fetch happens — used by
110
+ * tests and by callers that already hold the HTML. */
111
+ html?: string;
112
+ /** Fetch implementation. Required when `html` is omitted. */
113
+ fetchImpl?: FetchLike;
114
+ /** Per-request timeout in ms (default 15000). Applied to the page fetch. */
115
+ timeoutMs?: number;
116
+ /** Cap on each ranked list (default 12). Keeps payloads bounded. */
117
+ maxPerList?: number;
118
+ }
119
+
120
+ /**
121
+ * Brand-kit extraction engine.
122
+ *
123
+ * Given a website URL (or raw HTML), parse the page and pull a typed BrandKit:
124
+ * logo candidates, color palette, fonts, and prominent images. Parsing is
125
+ * regex-based on purpose — this runs on Cloudflare Workers and other edge
126
+ * runtimes where DOM libraries (jsdom/cheerio) are unavailable, and the page
127
+ * structures we read (link tags, meta tags, CSS custom properties, font-family
128
+ * declarations) are flat enough that a DOM buys nothing.
129
+ *
130
+ * Degrades gracefully: a missing favicon, no CSS tokens, or an image-only page
131
+ * each narrow the kit rather than failing it. The ONLY hard failures are a bad
132
+ * input (no html and no fetch) and a fetch error — both returned as a typed
133
+ * `{ succeeded: false }` outcome so callers never mistake an empty kit for a
134
+ * real one.
135
+ */
136
+
137
+ /** Normalize a user-supplied site URL: add https:// when scheme-less, validate. */
138
+ declare function normalizeSiteUrl(raw: string): string | null;
139
+ /** Normalize any CSS color literal to #rrggbb / #rrggbbaa hex, or null. */
140
+ declare function normalizeColor(raw: string): string | null;
141
+ /** Parse already-fetched HTML into a BrandKit. Pure — no network. Exposed so
142
+ * callers that hold the HTML (or want to combine multiple pages) can reuse the
143
+ * parsing without re-fetching. */
144
+ declare function parseBrandKit(html: string, sourceUrl: string, maxPerList?: number): BrandKit;
145
+ /**
146
+ * Fetch a website (or use supplied HTML) and extract its BrandKit.
147
+ *
148
+ * Returns a typed outcome — callers MUST check `succeeded`. A fetch failure or
149
+ * empty input is a real, surfaced error, never an empty kit masquerading as a
150
+ * result. Parsing itself never throws: a malformed page yields a sparse kit
151
+ * with warnings, which is information, not failure.
152
+ */
153
+ declare function extractBrandKit(url: string, options?: ExtractBrandKitOptions): Promise<BrandExtractionResult>;
154
+
155
+ /**
156
+ * Pure mapping helpers from an extracted BrandKit onto consumable shapes.
157
+ *
158
+ * Extraction yields ranked candidate lists; products need decided roles
159
+ * (background vs accent, display vs body). These helpers make the obvious,
160
+ * defensible default choice and expose the reasoning so a confirmation UI can
161
+ * show "we picked X — change it?". They NEVER invent a value: when a role can't
162
+ * be filled from the kit, it is omitted, and the caller decides the fallback.
163
+ */
164
+
165
+ /** Relative luminance (0..1) of an #rrggbb(aa) hex — for light/dark sorting. */
166
+ declare function luminance(hex: string): number;
167
+ interface DecidedPalette {
168
+ /** Lightest neutral — page background. */
169
+ background?: string;
170
+ /** A raised neutral one step from background. */
171
+ surface?: string;
172
+ /** Darkest readable color — primary text. */
173
+ textPrimary?: string;
174
+ /** Mid-tone neutral — secondary text. */
175
+ textSecondary?: string;
176
+ /** Most saturated / popular brand color — accent. */
177
+ accent?: string;
178
+ /** Readable text color to sit on the accent (black or white by contrast). */
179
+ accentText?: string;
180
+ }
181
+ /**
182
+ * Assign palette roles from the ranked colors. Heuristic, not authoritative —
183
+ * a confirmation step should let the user correct it. Roles only appear when
184
+ * the kit actually contained a color that fits.
185
+ */
186
+ declare function decidePalette(palette: BrandColor[]): DecidedPalette;
187
+ interface DecidedFonts {
188
+ display?: BrandFont;
189
+ body?: BrandFont;
190
+ }
191
+ /** Pick a display and body font from the ranked list. When only one usable
192
+ * font exists it fills both roles — a single-typeface brand is valid. */
193
+ declare function decideFonts(fonts: BrandFont[]): DecidedFonts;
194
+ /** Everything a confirmation step needs from a kit, with roles decided. */
195
+ interface DecidedBrandKit {
196
+ name?: string;
197
+ description?: string;
198
+ sourceUrl: string;
199
+ palette: DecidedPalette;
200
+ fonts: DecidedFonts;
201
+ /** Best logo URL, when any candidate was found. */
202
+ primaryLogoUrl?: string;
203
+ /** All logo URLs, ranked. */
204
+ logoUrls: string[];
205
+ /** Prominent image URLs, ranked. */
206
+ imageUrls: string[];
207
+ extractedFrom: string[];
208
+ }
209
+ /** Collapse a raw BrandKit into decided roles — the shape a product persists. */
210
+ declare function decideBrandKit(kit: BrandKit): DecidedBrandKit;
211
+
212
+ export { type BrandColor, type BrandExtractionResult, type BrandFont, type BrandImage, type BrandKit, type BrandLogoCandidate, type DecidedBrandKit, type DecidedFonts, type DecidedPalette, type ExtractBrandKitOptions, type FetchLike, decideBrandKit, decideFonts, decidePalette, extractBrandKit, luminance, normalizeColor, normalizeSiteUrl, parseBrandKit };
@@ -0,0 +1,446 @@
1
+ // src/brand-extraction/extract.ts
2
+ var DEFAULT_TIMEOUT_MS = 15e3;
3
+ var DEFAULT_MAX_PER_LIST = 12;
4
+ function normalizeSiteUrl(raw) {
5
+ const trimmed = raw.trim();
6
+ if (!trimmed) return null;
7
+ if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) && !/^https?:\/\//i.test(trimmed)) return null;
8
+ const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
9
+ try {
10
+ const u = new URL(withScheme);
11
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
12
+ return u.toString();
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+ function absolutize(href, base) {
18
+ const v = href.trim();
19
+ if (!v || v.startsWith("data:") || v.startsWith("javascript:")) return null;
20
+ try {
21
+ const u = new URL(v, base);
22
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
23
+ return u.toString();
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ function matchTags(html, tag) {
29
+ const re = new RegExp(`<${tag}\\b[^>]*>`, "gi");
30
+ return html.match(re) ?? [];
31
+ }
32
+ function attr(tag, name) {
33
+ const quoted = new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(tag);
34
+ if (quoted) return decodeEntities(quoted[1] ?? "");
35
+ const bare = new RegExp(`\\b${name}\\s*=\\s*([^\\s"'>]+)`, "i").exec(tag);
36
+ return bare ? decodeEntities(bare[1] ?? "") : void 0;
37
+ }
38
+ function decodeEntities(s) {
39
+ return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x2F;/gi, "/");
40
+ }
41
+ function extractName(html) {
42
+ for (const tag of matchTags(html, "meta")) {
43
+ const prop = (attr(tag, "property") ?? attr(tag, "name"))?.toLowerCase();
44
+ if (prop === "og:site_name") {
45
+ const c = attr(tag, "content")?.trim();
46
+ if (c) return c;
47
+ }
48
+ }
49
+ const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1];
50
+ if (title) {
51
+ const head = (decodeEntities(title).split(/\s+[|–—\-:·]\s+/)[0] ?? "").trim();
52
+ if (head) return head;
53
+ }
54
+ return void 0;
55
+ }
56
+ function extractDescription(html) {
57
+ for (const tag of matchTags(html, "meta")) {
58
+ const prop = (attr(tag, "property") ?? attr(tag, "name"))?.toLowerCase();
59
+ if (prop === "description" || prop === "og:description") {
60
+ const c = attr(tag, "content")?.trim();
61
+ if (c) return c;
62
+ }
63
+ }
64
+ return void 0;
65
+ }
66
+ var MIME_BY_EXT = {
67
+ png: "image/png",
68
+ jpg: "image/jpeg",
69
+ jpeg: "image/jpeg",
70
+ gif: "image/gif",
71
+ webp: "image/webp",
72
+ svg: "image/svg+xml",
73
+ ico: "image/x-icon"
74
+ };
75
+ function mimeFromUrl(url) {
76
+ const ext = new URL(url).pathname.split(".").pop()?.toLowerCase();
77
+ return ext ? MIME_BY_EXT[ext] : void 0;
78
+ }
79
+ function parseSizes(sizes) {
80
+ if (!sizes) return {};
81
+ const m = /(\d+)\s*[x×]\s*(\d+)/i.exec(sizes);
82
+ if (!m) return {};
83
+ return { width: Number(m[1]), height: Number(m[2]) };
84
+ }
85
+ function extractLogos(html, base) {
86
+ const out = [];
87
+ const seen = /* @__PURE__ */ new Set();
88
+ const push = (c) => {
89
+ if (seen.has(c.url)) return;
90
+ seen.add(c.url);
91
+ out.push(c);
92
+ };
93
+ for (const tag of matchTags(html, "link")) {
94
+ const rel = attr(tag, "rel")?.toLowerCase() ?? "";
95
+ const href = attr(tag, "href");
96
+ if (!href) continue;
97
+ const url = absolutize(href, base);
98
+ if (!url) continue;
99
+ const { width, height } = parseSizes(attr(tag, "sizes"));
100
+ if (rel.includes("apple-touch-icon")) {
101
+ push({ url, source: "apple-touch-icon", confidence: 0.7, width, height, mimeType: mimeFromUrl(url) });
102
+ } else if (rel.split(/\s+/).includes("icon") || rel.includes("shortcut icon")) {
103
+ push({ url, source: "favicon", confidence: 0.5, width, height, mimeType: mimeFromUrl(url) });
104
+ }
105
+ }
106
+ for (const tag of matchTags(html, "meta")) {
107
+ const prop = (attr(tag, "property") ?? attr(tag, "name"))?.toLowerCase();
108
+ if (prop === "og:image" || prop === "og:image:url") {
109
+ const href = attr(tag, "content");
110
+ const url = href ? absolutize(href, base) : null;
111
+ if (url) push({ url, source: "og:image", confidence: 0.55, mimeType: mimeFromUrl(url) });
112
+ }
113
+ }
114
+ for (const tag of matchTags(html, "img")) {
115
+ const src = attr(tag, "src") ?? attr(tag, "data-src");
116
+ if (!src) continue;
117
+ const alt = attr(tag, "alt");
118
+ const cls = attr(tag, "class") ?? "";
119
+ const id = attr(tag, "id") ?? "";
120
+ const looksLikeLogo = /logo|brand|wordmark/i.test(`${src} ${alt ?? ""} ${cls} ${id}`);
121
+ if (!looksLikeLogo) continue;
122
+ const url = absolutize(src, base);
123
+ if (!url) continue;
124
+ const w = attr(tag, "width");
125
+ const h = attr(tag, "height");
126
+ push({
127
+ url,
128
+ source: "img-logo",
129
+ confidence: 0.85,
130
+ alt,
131
+ width: w ? Number(w) || void 0 : void 0,
132
+ height: h ? Number(h) || void 0 : void 0,
133
+ mimeType: mimeFromUrl(url)
134
+ });
135
+ }
136
+ if (!out.some((l) => l.source === "favicon" || l.source === "apple-touch-icon")) {
137
+ const fav = absolutize("/favicon.ico", base);
138
+ if (fav) push({ url: fav, source: "favicon", confidence: 0.3, mimeType: "image/x-icon" });
139
+ }
140
+ return out.sort((a, b) => b.confidence - a.confidence);
141
+ }
142
+ function normalizeColor(raw) {
143
+ const v = raw.trim().toLowerCase();
144
+ const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(v);
145
+ if (hex) {
146
+ const h = hex[1] ?? "";
147
+ if (h.length === 3 || h.length === 4) {
148
+ return `#${h.split("").map((c) => c + c).join("")}`;
149
+ }
150
+ return `#${h}`;
151
+ }
152
+ const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.%]+))?\s*\)$/.exec(v);
153
+ if (rgb) {
154
+ const to255 = (n) => Math.max(0, Math.min(255, Math.round(Number(n))));
155
+ const r = to255(rgb[1]).toString(16).padStart(2, "0");
156
+ const g = to255(rgb[2]).toString(16).padStart(2, "0");
157
+ const b = to255(rgb[3]).toString(16).padStart(2, "0");
158
+ let a = "";
159
+ if (rgb[4] !== void 0) {
160
+ const av = rgb[4].endsWith("%") ? Number(rgb[4].slice(0, -1)) / 100 : Number(rgb[4]);
161
+ const ai = Math.max(0, Math.min(255, Math.round(av * 255)));
162
+ if (ai < 255) a = ai.toString(16).padStart(2, "0");
163
+ }
164
+ return `#${r}${g}${b}${a}`;
165
+ }
166
+ return null;
167
+ }
168
+ var COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g;
169
+ var ROOT_VAR_RE = /(--[a-z0-9-]*(?:color|colour|bg|background|accent|brand|primary|secondary|surface|text|fg|ink)[a-z0-9-]*)\s*:\s*([^;}{]+)/gi;
170
+ function extractPalette(html) {
171
+ const byHex = /* @__PURE__ */ new Map();
172
+ const bump = (hex, fromToken, tokenName) => {
173
+ const existing = byHex.get(hex);
174
+ if (existing) {
175
+ existing.occurrences += 1;
176
+ if (fromToken && !existing.fromToken) {
177
+ existing.fromToken = true;
178
+ existing.tokenName = tokenName;
179
+ }
180
+ } else {
181
+ byHex.set(hex, { hex, occurrences: 1, fromToken, ...tokenName ? { tokenName } : {} });
182
+ }
183
+ };
184
+ for (const m of html.matchAll(ROOT_VAR_RE)) {
185
+ const tokenName = m[1];
186
+ const value = m[2];
187
+ if (!value) continue;
188
+ const lit = value.match(COLOR_LITERAL_RE)?.[0];
189
+ if (!lit) continue;
190
+ const hex = normalizeColor(lit);
191
+ if (hex) bump(hex, true, tokenName);
192
+ }
193
+ for (const m of html.matchAll(COLOR_LITERAL_RE)) {
194
+ const hex = normalizeColor(m[0]);
195
+ if (hex) bump(hex, false);
196
+ }
197
+ const isNeutral = (h) => /^#(0{6}|f{6})(ff)?$/i.test(h);
198
+ return [...byHex.values()].sort((a, b) => {
199
+ if (a.fromToken !== b.fromToken) return a.fromToken ? -1 : 1;
200
+ const an = isNeutral(a.hex) ? 1 : 0;
201
+ const bn = isNeutral(b.hex) ? 1 : 0;
202
+ if (an !== bn) return an - bn;
203
+ return b.occurrences - a.occurrences;
204
+ });
205
+ }
206
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
207
+ "serif",
208
+ "sans-serif",
209
+ "monospace",
210
+ "cursive",
211
+ "fantasy",
212
+ "system-ui",
213
+ "ui-sans-serif",
214
+ "ui-serif",
215
+ "ui-monospace",
216
+ "ui-rounded",
217
+ "inherit",
218
+ "initial",
219
+ "unset"
220
+ ]);
221
+ var FONT_DECL_RE = /([^{}]*)\{[^{}]*font-family\s*:\s*([^;}{]+)[;}]/gi;
222
+ function parseFontStack(raw) {
223
+ if (!raw) return [];
224
+ return raw.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
225
+ }
226
+ function roleFromSelector(selector) {
227
+ const s = selector.toLowerCase();
228
+ if (/\b(h[1-3]|\.h[1-3]|heading|title|display|hero)\b/.test(s)) return "display";
229
+ if (/\b(body|p|html|\.text|\.content|root|:root|\*)\b/.test(s)) return "body";
230
+ return "unknown";
231
+ }
232
+ function extractFonts(html) {
233
+ const byFamily = /* @__PURE__ */ new Map();
234
+ const consider = (family, stack, role) => {
235
+ const key = family.toLowerCase();
236
+ if (!family || GENERIC_FAMILIES.has(key)) return;
237
+ const existing = byFamily.get(key);
238
+ if (existing) {
239
+ existing.occurrences += 1;
240
+ if (existing.role === "unknown" && role !== "unknown") existing.role = role;
241
+ } else {
242
+ byFamily.set(key, { family, stack, role, occurrences: 1 });
243
+ }
244
+ };
245
+ for (const m of html.matchAll(FONT_DECL_RE)) {
246
+ const selector = m[1] ?? "";
247
+ const stack = parseFontStack(m[2]);
248
+ const primary = stack[0];
249
+ if (!primary) continue;
250
+ consider(primary, stack, roleFromSelector(selector));
251
+ }
252
+ const BARE_RE = /font-family\s*:\s*([^;"'}{]+)/gi;
253
+ for (const m of html.matchAll(BARE_RE)) {
254
+ const stack = parseFontStack(m[1]);
255
+ const primary = stack[0];
256
+ if (!primary) continue;
257
+ consider(primary, stack, "unknown");
258
+ }
259
+ for (const tag of matchTags(html, "link")) {
260
+ const href = attr(tag, "href") ?? "";
261
+ if (!/fonts\.googleapis\.com/i.test(href)) continue;
262
+ for (const fam of href.matchAll(/family=([^:&]+)/gi)) {
263
+ const captured = fam[1];
264
+ if (!captured) continue;
265
+ const family = decodeURIComponent(captured.replace(/\+/g, " ")).trim();
266
+ consider(family, [family], "display");
267
+ }
268
+ }
269
+ return [...byFamily.values()].sort((a, b) => {
270
+ const rank = (r) => r === "display" ? 0 : r === "body" ? 1 : 2;
271
+ const rr = rank(a.role) - rank(b.role);
272
+ if (rr !== 0) return rr;
273
+ return b.occurrences - a.occurrences;
274
+ });
275
+ }
276
+ function extractImages(html, base) {
277
+ const out = [];
278
+ const seen = /* @__PURE__ */ new Set();
279
+ const push = (img) => {
280
+ if (seen.has(img.url)) return;
281
+ seen.add(img.url);
282
+ out.push(img);
283
+ };
284
+ for (const tag of matchTags(html, "meta")) {
285
+ const prop = (attr(tag, "property") ?? attr(tag, "name"))?.toLowerCase();
286
+ const href = attr(tag, "content");
287
+ const url = href ? absolutize(href, base) : null;
288
+ if (!url) continue;
289
+ if (prop === "og:image" || prop === "og:image:url") push({ url, source: "og:image" });
290
+ else if (prop === "twitter:image" || prop === "twitter:image:src") push({ url, source: "twitter:image" });
291
+ }
292
+ for (const tag of matchTags(html, "img")) {
293
+ const src = attr(tag, "src") ?? attr(tag, "data-src");
294
+ if (!src) continue;
295
+ const url = absolutize(src, base);
296
+ if (!url) continue;
297
+ const cls = `${attr(tag, "class") ?? ""} ${attr(tag, "id") ?? ""}`;
298
+ if (/logo|icon|favicon|wordmark/i.test(`${url} ${cls}`)) continue;
299
+ const isHero = /hero|banner|cover|feature|splash/i.test(cls);
300
+ const w = attr(tag, "width");
301
+ const h = attr(tag, "height");
302
+ push({
303
+ url,
304
+ source: isHero ? "img-hero" : "img-content",
305
+ alt: attr(tag, "alt"),
306
+ width: w ? Number(w) || void 0 : void 0,
307
+ height: h ? Number(h) || void 0 : void 0
308
+ });
309
+ }
310
+ const rank = (s) => s === "og:image" ? 0 : s === "twitter:image" ? 1 : s === "img-hero" ? 2 : 3;
311
+ return out.sort((a, b) => rank(a.source) - rank(b.source));
312
+ }
313
+ function parseBrandKit(html, sourceUrl, maxPerList = DEFAULT_MAX_PER_LIST) {
314
+ const logos = extractLogos(html, sourceUrl).slice(0, maxPerList);
315
+ const palette = extractPalette(html).slice(0, maxPerList);
316
+ const fonts = extractFonts(html).slice(0, maxPerList);
317
+ const images = extractImages(html, sourceUrl).slice(0, maxPerList);
318
+ const name = extractName(html);
319
+ const description = extractDescription(html);
320
+ return {
321
+ sourceUrl,
322
+ ...name ? { name } : {},
323
+ ...description ? { description } : {},
324
+ logos,
325
+ palette,
326
+ fonts,
327
+ images,
328
+ extractedFrom: [sourceUrl]
329
+ };
330
+ }
331
+ async function extractBrandKit(url, options = {}) {
332
+ const { html: providedHtml, fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS, maxPerList = DEFAULT_MAX_PER_LIST } = options;
333
+ const warnings = [];
334
+ const sourceUrl = normalizeSiteUrl(url);
335
+ if (!sourceUrl) {
336
+ return { succeeded: false, error: `Not a valid http(s) URL: ${JSON.stringify(url)}`, stage: "input" };
337
+ }
338
+ let html = providedHtml;
339
+ if (html === void 0) {
340
+ const doFetch = fetchImpl ?? (typeof globalThis.fetch === "function" ? (u, init) => globalThis.fetch(u, init) : void 0);
341
+ if (!doFetch) {
342
+ return { succeeded: false, error: "No html provided and no fetch implementation available", stage: "input" };
343
+ }
344
+ const controller = new AbortController();
345
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
346
+ try {
347
+ const res = await doFetch(sourceUrl, {
348
+ signal: controller.signal,
349
+ headers: { "user-agent": "TangleBrandExtractor/1.0 (+https://tangle.tools)" }
350
+ });
351
+ if (!res.ok) {
352
+ return { succeeded: false, error: `Fetch failed: HTTP ${res.status} for ${sourceUrl}`, stage: "fetch" };
353
+ }
354
+ html = await res.text();
355
+ } catch (err) {
356
+ const reason = err instanceof Error ? err.message : String(err);
357
+ return { succeeded: false, error: `Fetch error for ${sourceUrl}: ${reason}`, stage: "fetch" };
358
+ } finally {
359
+ clearTimeout(timer);
360
+ }
361
+ }
362
+ if (!html.trim()) {
363
+ return { succeeded: false, error: `Empty document for ${sourceUrl}`, stage: "parse" };
364
+ }
365
+ const kit = parseBrandKit(html, sourceUrl, maxPerList);
366
+ if (kit.logos.length === 0) warnings.push("No logo candidates found.");
367
+ if (kit.palette.length === 0) warnings.push("No colors extracted \u2014 page has no inline CSS or design tokens.");
368
+ if (kit.fonts.length === 0) warnings.push("No custom fonts found \u2014 site likely uses system defaults.");
369
+ if (kit.images.length === 0) warnings.push("No prominent images found.");
370
+ return { succeeded: true, kit, warnings };
371
+ }
372
+
373
+ // src/brand-extraction/map.ts
374
+ function luminance(hex) {
375
+ const h = hex.replace("#", "").slice(0, 6);
376
+ const r = parseInt(h.slice(0, 2), 16) / 255;
377
+ const g = parseInt(h.slice(2, 4), 16) / 255;
378
+ const b = parseInt(h.slice(4, 6), 16) / 255;
379
+ const lin = (c) => c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
380
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
381
+ }
382
+ function isGreyish(hex) {
383
+ const h = hex.replace("#", "").slice(0, 6);
384
+ const r = parseInt(h.slice(0, 2), 16);
385
+ const g = parseInt(h.slice(2, 4), 16);
386
+ const b = parseInt(h.slice(4, 6), 16);
387
+ const max = Math.max(r, g, b);
388
+ const min = Math.min(r, g, b);
389
+ return max - min < 18;
390
+ }
391
+ function decidePalette(palette) {
392
+ if (palette.length === 0) return {};
393
+ const hexes = palette.map((c) => c.hex);
394
+ const greys = hexes.filter(isGreyish).sort((a, b) => luminance(b) - luminance(a));
395
+ const colored = hexes.filter((h) => !isGreyish(h));
396
+ const accent = colored[0];
397
+ const sortedLight = [...hexes].sort((a, b) => luminance(b) - luminance(a));
398
+ const background = greys[0] ?? sortedLight[0];
399
+ const surface = greys[1] ?? sortedLight.find((h) => h !== background);
400
+ const textPrimary = [...hexes].sort((a, b) => luminance(a) - luminance(b))[0];
401
+ const textSecondary = greys.find((h) => h !== background && h !== surface && h !== textPrimary);
402
+ const result = {};
403
+ if (background) result.background = background;
404
+ if (surface) result.surface = surface;
405
+ if (textPrimary) result.textPrimary = textPrimary;
406
+ if (textSecondary) result.textSecondary = textSecondary;
407
+ if (accent) {
408
+ result.accent = accent;
409
+ result.accentText = luminance(accent) > 0.45 ? "#000000" : "#ffffff";
410
+ }
411
+ return result;
412
+ }
413
+ function decideFonts(fonts) {
414
+ if (fonts.length === 0) return {};
415
+ const display = fonts.find((f) => f.role === "display") ?? fonts[0];
416
+ const body = fonts.find((f) => f.role === "body") ?? fonts.find((f) => f !== display) ?? display;
417
+ return { display, body };
418
+ }
419
+ function decideBrandKit(kit) {
420
+ const palette = decidePalette(kit.palette);
421
+ const fonts = decideFonts(kit.fonts);
422
+ const logoUrls = kit.logos.map((l) => l.url);
423
+ const result = {
424
+ sourceUrl: kit.sourceUrl,
425
+ palette,
426
+ fonts,
427
+ logoUrls,
428
+ imageUrls: kit.images.map((i) => i.url),
429
+ extractedFrom: kit.extractedFrom
430
+ };
431
+ if (kit.name) result.name = kit.name;
432
+ if (kit.description) result.description = kit.description;
433
+ if (logoUrls[0]) result.primaryLogoUrl = logoUrls[0];
434
+ return result;
435
+ }
436
+ export {
437
+ decideBrandKit,
438
+ decideFonts,
439
+ decidePalette,
440
+ extractBrandKit,
441
+ luminance,
442
+ normalizeColor,
443
+ normalizeSiteUrl,
444
+ parseBrandKit
445
+ };
446
+ //# sourceMappingURL=index.js.map