crawlforge-mcp-server 6.23.2 → 6.25.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.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/server.js +21 -11
  3. package/src/core/ActionExecutor.js +14 -0
  4. package/src/core/pageCache.js +249 -0
  5. package/src/schemas/toolOutputSchemas.js +18 -1
  6. package/src/server/inlineThreshold.js +12 -5
  7. package/src/server/requestContext.js +40 -0
  8. package/src/server/withAuth.js +84 -9
  9. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  10. package/src/skills/agent-skills/crawlforge-brand-style-guide/SKILL.md +107 -0
  11. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  12. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  13. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  14. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -1
  15. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
  16. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  17. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  18. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  19. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -0
  20. package/src/tools/basic/_fetch.js +35 -4
  21. package/src/tools/basic/extractLinks.js +3 -2
  22. package/src/tools/basic/extractMetadata.js +3 -2
  23. package/src/tools/basic/extractText.js +3 -2
  24. package/src/tools/basic/fetchUrl.js +3 -2
  25. package/src/tools/basic/scrapeStructured.js +3 -2
  26. package/src/tools/extract/_fetchAndParse.js +47 -18
  27. package/src/tools/extract/extractEmbeddedState.js +58 -7
  28. package/src/tools/extract/extractStructured.js +4 -1
  29. package/src/tools/extract/extractWithLlm.js +3 -0
  30. package/src/tools/scrape/_brandingColor.js +185 -0
  31. package/src/tools/scrape/_brandingComputed.js +262 -0
  32. package/src/tools/scrape/_brandingExtractor.js +62 -90
  33. package/src/tools/scrape/_brandingTokens.js +350 -0
  34. package/src/tools/scrape/unifiedScrape.js +61 -17
  35. package/src/utils/canonicalUrl.js +29 -0
  36. package/src/utils/fetchLadder.js +3 -3
  37. package/src/utils/maxAgeParam.js +18 -0
  38. package/src/utils/robotsGate.js +24 -5
@@ -16,6 +16,7 @@ import { safeFetch } from '../../utils/ssrfGuard.js';
16
16
  import { config } from '../../constants/config.js';
17
17
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
18
18
  import { preflightFetch } from '../../utils/robotsGate.js';
19
+ import { pageFromResponse, pageHeader, throughPageCache } from '../../core/pageCache.js';
19
20
 
20
21
  const DEFAULT_TIMEOUT_MS = 15000;
21
22
 
@@ -108,16 +109,60 @@ export { flattenText as flattenBodyText };
108
109
  * @param {number} [options.timeoutMs]
109
110
  * @param {string[]} [options.stripTags] — additional tags to strip (default: script, style, noscript, iframe, svg)
110
111
  * @param {boolean} [options.errorDocuments] — return a non-2xx HTML/text body with its status instead of throwing
112
+ * @param {number} [options.maxAge] — page cache: reuse a copy at most this many ms old (default 10 min; 0 = fetch fresh)
113
+ * @param {boolean} [options.noCache] — page cache: neither read nor store
111
114
  * @returns {Promise<{ html: string, $: import('cheerio').CheerioAPI, textContent: string, finalUrl: string, warnings: string[], status: number }>}
112
115
  */
113
116
  export async function fetchAndParse(url, options = {}) {
117
+ const { stripTags = ['script', 'style', 'noscript', 'iframe', 'svg'] } = options;
118
+
119
+ // The page itself may come from the process page cache (src/core/pageCache.js);
120
+ // the parse below is always this call's own, so no two calls share a `$`.
121
+ const page = await throughPageCache(
122
+ url,
123
+ options,
124
+ 'html',
125
+ async () => {
126
+ const fetched = await fetchPage(url, options);
127
+ return { result: fetched, page: fetched };
128
+ },
129
+ (entry, warnings) => ({ ...entry, warnings })
130
+ );
131
+
132
+ const html = page.body;
133
+ const { warnings, status } = page;
134
+ const finalUrl = page.url;
135
+
136
+ // text/plain and application/json aren't markup: running them through the
137
+ // HTML parser risks misinterpreting substrings (e.g. a "<script>" value
138
+ // inside a JSON string) as real tags and stripping/mangling content.
139
+ if (classifyContentType(pageHeader(page, 'content-type')) === 'text') {
140
+ return { html, $: load(''), textContent: html.trim(), finalUrl, warnings, status };
141
+ }
142
+
143
+ const $ = load(html);
144
+
145
+ if (stripTags.length > 0) {
146
+ $(stripTags.join(', ')).remove();
147
+ }
148
+
149
+ const textContent = flattenText($);
150
+
151
+ return { html, $, textContent, finalUrl, warnings, status };
152
+ }
153
+
154
+ /**
155
+ * The network fetch behind fetchAndParse: the gate, the request, the status
156
+ * and content-type checks and the capped body read.
157
+ * @returns {Promise<object>} the page as pageFromResponse gives it, plus `warnings`
158
+ */
159
+ async function fetchPage(url, options) {
114
160
  const {
115
161
  userAgent,
116
162
  respectRobots,
117
163
  tool,
118
164
  apiKey,
119
165
  timeoutMs = DEFAULT_TIMEOUT_MS,
120
- stripTags = ['script', 'style', 'noscript', 'iframe', 'svg'],
121
166
  errorDocuments = false
122
167
  } = options;
123
168
 
@@ -157,21 +202,5 @@ export async function fetchAndParse(url, options = {}) {
157
202
  }
158
203
 
159
204
  const html = await readTextWithSizeCap(response);
160
-
161
- // text/plain and application/json aren't markup: running them through the
162
- // HTML parser risks misinterpreting substrings (e.g. a "<script>" value
163
- // inside a JSON string) as real tags and stripping/mangling content.
164
- if (classification === 'text') {
165
- return { html, $: load(''), textContent: html.trim(), finalUrl: response.url, warnings, status: response.status };
166
- }
167
-
168
- const $ = load(html);
169
-
170
- if (stripTags.length > 0) {
171
- $(stripTags.join(', ')).remove();
172
- }
173
-
174
- const textContent = flattenText($);
175
-
176
- return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
205
+ return { ...pageFromResponse(response, html), warnings };
177
206
  }
@@ -21,7 +21,7 @@ import { z } from 'zod';
21
21
  import { fetchAndParse } from './_fetchAndParse.js';
22
22
  // Both live in crawlforge-extractors so the REST API's extract_embedded_state
23
23
  // runs this exact reader — one RSC flight-stream parser, not two.
24
- import { extractEmbeddedState, selectJsonPath, findJsonPaths } from 'crawlforge-extractors';
24
+ import { extractEmbeddedState, selectJsonPath, findJsonPaths, parseJsonPath } from 'crawlforge-extractors';
25
25
  import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
26
26
  import { pageTitle } from '../../utils/pageTitle.js';
27
27
  import { setActualCost, markPreflightRefusal } from '../../server/requestContext.js';
@@ -38,6 +38,12 @@ const EMBEDDED_STATE_BASE_CREDITS = 2;
38
38
  // `find` ask for less up front.
39
39
  const LARGE_RESULT_BYTES = 256_000;
40
40
 
41
+ // A path miss carries the key tree where the path stopped, so the next path is
42
+ // aimed rather than guessed (repeat-call plan 2.1). Capped per object and in
43
+ // characters; over the character cap it drops to one level.
44
+ const MISS_TREE_KEYS = 30;
45
+ const MISS_TREE_CHARS = 2000;
46
+
41
47
  // A wall, as opposed to a missing page or a broken server (G4: a 404 or a 5xx
42
48
  // never escalates — see aBrowserMightPass).
43
49
  const WALL_STATUSES = new Set([403, 429, 444]);
@@ -75,24 +81,49 @@ function label(value) {
75
81
  * Shared shape with the REST route — keep the two in step.
76
82
  * @param {unknown} value
77
83
  * @param {number} depth
84
+ * @param {number} [maxKeys] per object; the rest are counted under "…"
78
85
  */
79
- export function describeKeys(value, depth) {
86
+ export function describeKeys(value, depth, maxKeys = Infinity) {
80
87
  if (Array.isArray(value)) {
81
88
  if (depth <= 1) return label(value);
82
89
  return value.length > 0
83
- ? { length: value.length, first: describeKeys(value[0], depth - 1) }
90
+ ? { length: value.length, first: describeKeys(value[0], depth - 1, maxKeys) }
84
91
  : { length: 0 };
85
92
  }
86
93
  if (value !== null && typeof value === 'object') {
87
94
  const keys = {};
88
- for (const [key, child] of Object.entries(value)) {
89
- keys[key] = depth > 1 ? describeKeys(child, depth - 1) : label(child);
95
+ const entries = Object.entries(value);
96
+ for (const [key, child] of entries.slice(0, maxKeys)) {
97
+ keys[key] = depth > 1 ? describeKeys(child, depth - 1, maxKeys) : label(child);
90
98
  }
99
+ if (entries.length > maxKeys) keys['…'] = `${entries.length - maxKeys} more keys`;
91
100
  return keys;
92
101
  }
93
102
  return label(value);
94
103
  }
95
104
 
105
+ /**
106
+ * The key tree where `path` stops resolving: `null` when the value there has
107
+ * no keys (the path ran into a string or number).
108
+ * @param {unknown} root
109
+ * @param {string} path
110
+ * @returns {{ at: string, tree: string } | null}
111
+ */
112
+ function missKeyTree(root, path) {
113
+ let current = root;
114
+ const walked = [];
115
+ for (const segment of parseJsonPath(path)) {
116
+ const key = Array.isArray(current) ? Number(segment) : segment;
117
+ if (current === null || typeof current !== 'object' || !Object.hasOwn(current, key)) break;
118
+ current = current[key];
119
+ walked.push(segment);
120
+ }
121
+ if (current === null || typeof current !== 'object') return null;
122
+ let tree = JSON.stringify(describeKeys(current, 2, MISS_TREE_KEYS));
123
+ if (tree.length > MISS_TREE_CHARS) tree = JSON.stringify(describeKeys(current, 1, MISS_TREE_KEYS));
124
+ return { at: walked.join('.'), tree };
125
+ }
126
+
96
127
  function httpError(status) {
97
128
  return new Error(`HTTP ${status}: ${STATUS_CODES[status] ?? ''}`.trimEnd());
98
129
  }
@@ -120,7 +151,7 @@ function failed(verdict, status, gotState) {
120
151
  */
121
152
  export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
122
153
  return async function extractEmbeddedStateHandler({
123
- url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots
154
+ url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots, maxAge
124
155
  }) {
125
156
  // Each replaces `data` with something else, so asking for both has no
126
157
  // answer. Nothing was fetched, so - like a robots refusal - it costs nothing.
@@ -149,6 +180,7 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
149
180
  const fetched = await fetchAndParse(url, {
150
181
  userAgent: user_agent,
151
182
  respectRobots: respect_robots,
183
+ maxAge,
152
184
  tool: 'extract_embedded_state',
153
185
  errorDocuments: true
154
186
  });
@@ -283,7 +315,26 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
283
315
  }
284
316
  }
285
317
 
286
- const data = effectivePath ? selectJsonPath(state.data, effectivePath) : state.data;
318
+ let data = state.data;
319
+ if (effectivePath) {
320
+ try {
321
+ data = selectJsonPath(state.data, effectivePath);
322
+ } catch (error) {
323
+ // A path miss: the key tree where it stopped, and a next step that
324
+ // stays on this tool (the fallback hint would say to drop `path`).
325
+ reportCost();
326
+ const miss = missKeyTree(state.data, effectivePath);
327
+ return {
328
+ content: [{
329
+ type: 'text',
330
+ text: `Failed to extract embedded state: ${error.message}` +
331
+ (miss ? `\nKeys at ${miss.at ? `"${miss.at}"` : 'the top level'}: ${miss.tree}` : '') +
332
+ '\nNext step: Call again with a path built from the keys above, or with find:"<key>" to get the paths where a field of that name lives.'
333
+ }],
334
+ isError: true
335
+ };
336
+ }
337
+ }
287
338
  const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
288
339
 
289
340
  if (!path && !keys_only && find === undefined && bytes > LARGE_RESULT_BYTES) {
@@ -9,6 +9,7 @@ import { ElicitationHelper } from '../../core/ElicitationHelper.js'; // D1.4
9
9
  import { load } from 'cheerio';
10
10
  import { LLMManager } from '../../core/llm/LLMManager.js';
11
11
  import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
12
+ import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
12
13
  import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
13
14
  import { fetchErrorMessage } from '../../utils/fetchError.js';
14
15
  import { extractMainContent } from '../scrape/_mainContent.js';
@@ -267,6 +268,7 @@ const ExtractStructuredSchema = z.object({
267
268
  selectorHints: z.record(z.string()).optional(),
268
269
  respect_robots: z.boolean().optional(),
269
270
  user_agent: z.string().optional(),
271
+ ...MAX_AGE_PARAM,
270
272
  verify_numbers: z.boolean().optional().default(true)
271
273
  });
272
274
 
@@ -324,7 +326,7 @@ export class ExtractStructuredTool {
324
326
 
325
327
  try {
326
328
  const validated = ExtractStructuredSchema.parse(params);
327
- const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, verify_numbers } = validated;
329
+ const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, maxAge, verify_numbers } = validated;
328
330
  // `type` is optional on input, and the tool's own example omits it. The
329
331
  // validator reads a typeless schema as "anything", so the CSS fallback
330
332
  // reported valid:true for "2 reviews" in an integer field (R24). A schema
@@ -387,6 +389,7 @@ export class ExtractStructuredTool {
387
389
  const { html, $, textContent, warnings } = await fetchAndParse(url, {
388
390
  userAgent: user_agent || this.userAgent,
389
391
  respectRobots: respect_robots,
392
+ maxAge,
390
393
  tool: 'extract_structured'
391
394
  });
392
395
 
@@ -440,6 +440,7 @@ export class ExtractWithLlm {
440
440
  * @param {number} [params.maxTokens] - Max output tokens (default 4096)
441
441
  * @param {boolean} [params.respect_robots] - Per-request robots.txt override
442
442
  * @param {string} [params.user_agent] - Per-request identity override
443
+ * @param {number} [params.maxAge] - Page-cache age limit in ms, passed to fetchAndParse
443
444
  * @param {boolean} [params.verify_numbers] - Numeric provenance guard (default true)
444
445
  * @param {AbortSignal} [params.signal] - Aborts the model request(s); an aborted
445
446
  * call skips the retry and the sampling fallback
@@ -456,6 +457,7 @@ export class ExtractWithLlm {
456
457
  maxTokens = 4096,
457
458
  respect_robots,
458
459
  user_agent,
460
+ maxAge,
459
461
  verify_numbers = true,
460
462
  signal
461
463
  } = params;
@@ -501,6 +503,7 @@ export class ExtractWithLlm {
501
503
  const { html, textContent, warnings } = await fetchAndParse(url, {
502
504
  respectRobots: respect_robots,
503
505
  userAgent: user_agent,
506
+ maxAge,
504
507
  tool: 'extract_with_llm'
505
508
  });
506
509
  text = textContent;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * _brandingColor.js — CSS colour parsing for the branding format.
3
+ *
4
+ * parseColor turns hex, rgb(), hsl(), oklch(), oklab(), lab(), lch() and the
5
+ * basic named colours into { r, g, b, a } (channels 0-255, alpha 0-1). The
6
+ * wide-gamut forms matter because Tailwind v4 writes its palette in oklch and
7
+ * its build (Lightning CSS) ships it as lab(). The helpers below it
8
+ * compare colours: how saturated one is, how light, how far apart two are.
9
+ */
10
+
11
+ const NAMED_COLORS = {
12
+ black: '#000000', white: '#ffffff', red: '#ff0000', green: '#008000',
13
+ blue: '#0000ff', yellow: '#ffff00', orange: '#ffa500', purple: '#800080',
14
+ gray: '#808080', grey: '#808080', silver: '#c0c0c0', navy: '#000080',
15
+ teal: '#008080', maroon: '#800000', olive: '#808000', lime: '#00ff00',
16
+ aqua: '#00ffff', fuchsia: '#ff00ff',
17
+ };
18
+
19
+ function clamp(n, lo, hi) {
20
+ return Math.max(lo, Math.min(hi, n));
21
+ }
22
+
23
+ function toHex2(n) {
24
+ return clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0');
25
+ }
26
+
27
+ // A channel or alpha argument: `none` is 0, a percentage scales to `full`.
28
+ function num(part, full = 1) {
29
+ if (part === 'none') return 0;
30
+ if (part.endsWith('%')) return (parseFloat(part) / 100) * full;
31
+ return parseFloat(part);
32
+ }
33
+
34
+ function alphaOf(part) {
35
+ if (part == null) return 1;
36
+ const a = num(part);
37
+ return Number.isNaN(a) ? 1 : clamp(a, 0, 1);
38
+ }
39
+
40
+ function hslToRgb(h, s, l) {
41
+ const c = (1 - Math.abs(2 * l - 1)) * s;
42
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
43
+ const mm = l - c / 2;
44
+ let r = 0, g = 0, b = 0;
45
+ const hh = ((h % 360) + 360) % 360;
46
+ if (hh < 60) [r, g, b] = [c, x, 0];
47
+ else if (hh < 120) [r, g, b] = [x, c, 0];
48
+ else if (hh < 180) [r, g, b] = [0, c, x];
49
+ else if (hh < 240) [r, g, b] = [0, x, c];
50
+ else if (hh < 300) [r, g, b] = [x, 0, c];
51
+ else [r, g, b] = [c, 0, x];
52
+ return [(r + mm) * 255, (g + mm) * 255, (b + mm) * 255];
53
+ }
54
+
55
+ // Linear-light channels -> gamma-encoded sRGB 0-255, clipped to the gamut.
56
+ function encode(lin) {
57
+ return lin.map((x) => {
58
+ const v = clamp(x, 0, 1);
59
+ return (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055) * 255;
60
+ });
61
+ }
62
+
63
+ // CIE Lab (D50) -> XYZ D50 -> Bradford to D65 -> linear sRGB (CSS Color 4 matrices).
64
+ function labToRgb(L, a, b) {
65
+ const e = 216 / 24389;
66
+ const k = 24389 / 27;
67
+ const fy = (L + 16) / 116;
68
+ const fx = fy + a / 500;
69
+ const fz = fy - b / 200;
70
+ const x = (fx ** 3 > e ? fx ** 3 : (116 * fx - 16) / k) * 0.3457 / 0.3585;
71
+ const y = L > k * e ? fy ** 3 : L / k;
72
+ const z = (fz ** 3 > e ? fz ** 3 : (116 * fz - 16) / k) * (1 - 0.3457 - 0.3585) / 0.3585;
73
+ const X = 0.9554734527042182 * x - 0.023098536874261423 * y + 0.0632593086610217 * z;
74
+ const Y = -0.028369706963208136 * x + 1.0099954580058226 * y + 0.021041398966943008 * z;
75
+ const Z = 0.012314001688319899 * x - 0.020507696433477912 * y + 1.3303659366080753 * z;
76
+ return encode([
77
+ 3.2409699419045226 * X - 1.537383177570094 * Y - 0.4986107602930034 * Z,
78
+ -0.9692436362808796 * X + 1.8759675015077202 * Y + 0.04155505740717559 * Z,
79
+ 0.05563007969699366 * X - 0.20397695888897652 * Y + 1.0569715142428786 * Z,
80
+ ]);
81
+ }
82
+
83
+ // OKLab -> sRGB (Björn Ottosson's reference matrices), clipped to the gamut.
84
+ function oklabToRgb(L, a, b) {
85
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
86
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
87
+ const s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3;
88
+ return encode([
89
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
90
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
91
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
92
+ ]);
93
+ }
94
+
95
+ /**
96
+ * Parse a CSS colour into { r, g, b, a }. Returns null for anything that is
97
+ * not a literal colour (gradients, currentColor, var(), transparent).
98
+ */
99
+ export function parseColor(raw) {
100
+ if (!raw) return null;
101
+ const v = String(raw).trim().toLowerCase();
102
+
103
+ const named = NAMED_COLORS[v];
104
+ if (named) return parseColor(named);
105
+
106
+ let m = v.match(/^#([0-9a-f]{3,8})$/);
107
+ if (m) {
108
+ let h = m[1];
109
+ if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
110
+ if (h.length !== 6 && h.length !== 8) return null;
111
+ return {
112
+ r: parseInt(h.slice(0, 2), 16),
113
+ g: parseInt(h.slice(2, 4), 16),
114
+ b: parseInt(h.slice(4, 6), 16),
115
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
116
+ };
117
+ }
118
+
119
+ m = v.match(/^(rgba?|hsla?|oklch|oklab|lch|lab)\(([^()]+)\)$/);
120
+ if (!m) return null;
121
+ const parts = m[2].split(/[,/\s]+/).filter(Boolean);
122
+ if (parts.length < 3) return null;
123
+ const a = alphaOf(parts[3]);
124
+ let rgb;
125
+ switch (m[1]) {
126
+ case 'rgb':
127
+ case 'rgba':
128
+ rgb = parts.slice(0, 3).map((p) => num(p, 255));
129
+ break;
130
+ case 'hsl':
131
+ case 'hsla':
132
+ rgb = hslToRgb(parseFloat(parts[0]), num(parts[1].endsWith('%') ? parts[1] : parts[1] + '%'), num(parts[2].endsWith('%') ? parts[2] : parts[2] + '%'));
133
+ break;
134
+ case 'oklch': {
135
+ const L = num(parts[0]);
136
+ const C = num(parts[1], 0.4);
137
+ const H = (num(parts[2]) * Math.PI) / 180;
138
+ rgb = oklabToRgb(L, C * Math.cos(H), C * Math.sin(H));
139
+ break;
140
+ }
141
+ case 'oklab':
142
+ rgb = oklabToRgb(num(parts[0]), num(parts[1], 0.4), num(parts[2], 0.4));
143
+ break;
144
+ case 'lch': {
145
+ const C = num(parts[1], 150);
146
+ const H = (num(parts[2]) * Math.PI) / 180;
147
+ rgb = labToRgb(num(parts[0], 100), C * Math.cos(H), C * Math.sin(H));
148
+ break;
149
+ }
150
+ case 'lab':
151
+ rgb = labToRgb(num(parts[0], 100), num(parts[1], 125), num(parts[2], 125));
152
+ break;
153
+ }
154
+ if (rgb.some((x) => Number.isNaN(x))) return null;
155
+ return { r: rgb[0], g: rgb[1], b: rgb[2], a };
156
+ }
157
+
158
+ /** '#rrggbb', alpha dropped. */
159
+ export function toHex({ r, g, b }) {
160
+ return '#' + toHex2(r) + toHex2(g) + toHex2(b);
161
+ }
162
+
163
+ /** '#rrggbb', or '#rrggbbaa' when the colour is translucent. */
164
+ export function toHexAlpha(c) {
165
+ return c.a < 1 ? toHex(c) + toHex2(c.a * 255) : toHex(c);
166
+ }
167
+
168
+ /** 0 for a grey, 1 for a fully saturated colour. */
169
+ export function chroma({ r, g, b }) {
170
+ return (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
171
+ }
172
+
173
+ /** WCAG relative luminance, 0 (black) to 1 (white). */
174
+ export function luminance({ r, g, b }) {
175
+ const lin = (x) => {
176
+ const v = x / 255;
177
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
178
+ };
179
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
180
+ }
181
+
182
+ /** Euclidean distance in RGB, 0 to ~441. */
183
+ export function distance(x, y) {
184
+ return Math.hypot(x.r - y.r, x.g - y.g, x.b - y.b);
185
+ }