samey-cli 0.0.0-stage → 0.1.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 +43 -0
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/ai/local-model.d.ts +26 -0
- package/dist/ai/local-model.js +91 -0
- package/dist/audit.d.ts +18 -0
- package/dist/audit.js +250 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +166 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/render/crawl.d.ts +34 -0
- package/dist/render/crawl.js +337 -0
- package/dist/render/extract.d.ts +8 -0
- package/dist/render/extract.js +441 -0
- package/dist/render/interact.d.ts +8 -0
- package/dist/render/interact.js +76 -0
- package/dist/render/network.d.ts +33 -0
- package/dist/render/network.js +92 -0
- package/dist/render/robots.d.ts +8 -0
- package/dist/render/robots.js +61 -0
- package/dist/render/server.d.ts +15 -0
- package/dist/render/server.js +174 -0
- package/dist/report/html.d.ts +2 -0
- package/dist/report/html.js +368 -0
- package/dist/report/prompt.d.ts +12 -0
- package/dist/report/prompt.js +62 -0
- package/dist/report/terminal.d.ts +6 -0
- package/dist/report/terminal.js +73 -0
- package/dist/rules/content.d.ts +8 -0
- package/dist/rules/content.js +227 -0
- package/dist/rules/functional.d.ts +17 -0
- package/dist/rules/functional.js +467 -0
- package/dist/rules/helpers.d.ts +11 -0
- package/dist/rules/helpers.js +29 -0
- package/dist/rules/index.d.ts +13 -0
- package/dist/rules/index.js +100 -0
- package/dist/rules/normalize.d.ts +18 -0
- package/dist/rules/normalize.js +114 -0
- package/dist/rules/static.d.ts +9 -0
- package/dist/rules/static.js +314 -0
- package/dist/rules/types.d.ts +39 -0
- package/dist/rules/types.js +16 -0
- package/dist/rules/visual.d.ts +15 -0
- package/dist/rules/visual.js +463 -0
- package/dist/score.d.ts +31 -0
- package/dist/score.js +66 -0
- package/dist/security/fetch.d.ts +26 -0
- package/dist/security/fetch.js +86 -0
- package/dist/security/net-policy.d.ts +46 -0
- package/dist/security/net-policy.js +178 -0
- package/dist/static/scan.d.ts +15 -0
- package/dist/static/scan.js +191 -0
- package/dist/types.d.ts +316 -0
- package/dist/types.js +1 -0
- package/dist/util/color.d.ts +25 -0
- package/dist/util/color.js +180 -0
- package/dist/util/text.d.ts +22 -0
- package/dist/util/text.js +106 -0
- package/package.json +69 -4
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
2
|
+
export type Category = 'visual' | 'content' | 'functional' | 'accessibility' | 'technical';
|
|
3
|
+
export type FindingSource = 'rendered' | 'static' | 'model';
|
|
4
|
+
export type ViewportName = 'desktop' | 'mobile';
|
|
5
|
+
export interface Rect {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Evidence {
|
|
12
|
+
/** `dom` is an element on a rendered page; `network` and `console` extend the base set. */
|
|
13
|
+
type: 'screenshot' | 'dom' | 'source' | 'metric' | 'text' | 'network' | 'console';
|
|
14
|
+
route?: string;
|
|
15
|
+
/** Other routes where the identical evidence was seen (a shared component). */
|
|
16
|
+
alsoOn?: string[];
|
|
17
|
+
viewport?: ViewportName;
|
|
18
|
+
selector?: string;
|
|
19
|
+
rect?: Rect;
|
|
20
|
+
/** Relative to the project root, posix separators, validated to stay inside it. */
|
|
21
|
+
file?: string;
|
|
22
|
+
line?: number;
|
|
23
|
+
excerpt?: string;
|
|
24
|
+
/** Relative to the report directory. */
|
|
25
|
+
screenshotPath?: string;
|
|
26
|
+
metric?: Record<string, number | string>;
|
|
27
|
+
detail?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface Finding {
|
|
30
|
+
/** Stable for the same rule and evidence: `${ruleId}-${fingerprint prefix}`. */
|
|
31
|
+
id: string;
|
|
32
|
+
ruleId: string;
|
|
33
|
+
ruleVersion: number;
|
|
34
|
+
category: Category;
|
|
35
|
+
title: string;
|
|
36
|
+
description: string;
|
|
37
|
+
severity: Severity;
|
|
38
|
+
/** 0 to 1. How sure the rule is that the observation is real, separate from how much it matters. */
|
|
39
|
+
confidence: number;
|
|
40
|
+
evidence: Evidence[];
|
|
41
|
+
recommendation: string;
|
|
42
|
+
/** Hash of rule id and evidence identity (not routes), used for deduplication. */
|
|
43
|
+
fingerprint: string;
|
|
44
|
+
/** True for measured defects; false for design observations that need judgment. */
|
|
45
|
+
verified: boolean;
|
|
46
|
+
source: FindingSource;
|
|
47
|
+
routes?: string[];
|
|
48
|
+
/** Model findings are advisory candidates and never affect the score. */
|
|
49
|
+
advisory?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface ElementRef {
|
|
52
|
+
selector: string;
|
|
53
|
+
rect: Rect;
|
|
54
|
+
}
|
|
55
|
+
export interface GradientFact extends ElementRef {
|
|
56
|
+
value: string;
|
|
57
|
+
area: number;
|
|
58
|
+
isText: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface GlowFact extends ElementRef {
|
|
61
|
+
value: string;
|
|
62
|
+
kind: 'shadow' | 'blur-blob' | 'backdrop';
|
|
63
|
+
}
|
|
64
|
+
export interface SectionFact extends ElementRef {
|
|
65
|
+
index: number;
|
|
66
|
+
tag: string;
|
|
67
|
+
/** Coarse layout fingerprint used for sameness comparisons. */
|
|
68
|
+
signature: string;
|
|
69
|
+
heading?: string;
|
|
70
|
+
text: string;
|
|
71
|
+
centered: boolean;
|
|
72
|
+
columns: number;
|
|
73
|
+
cardCount: number;
|
|
74
|
+
cardSignature?: string;
|
|
75
|
+
buttonCount: number;
|
|
76
|
+
imageCount: number;
|
|
77
|
+
hasBlockquote: boolean;
|
|
78
|
+
hasDetails: boolean;
|
|
79
|
+
hasPrice: boolean;
|
|
80
|
+
isChrome: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface HeadingFact extends ElementRef {
|
|
83
|
+
level: number;
|
|
84
|
+
text: string;
|
|
85
|
+
}
|
|
86
|
+
export interface ControlFact extends ElementRef {
|
|
87
|
+
tag: string;
|
|
88
|
+
text: string;
|
|
89
|
+
name: string;
|
|
90
|
+
href?: string;
|
|
91
|
+
type?: string;
|
|
92
|
+
inForm: boolean;
|
|
93
|
+
}
|
|
94
|
+
export interface LinkFact extends ElementRef {
|
|
95
|
+
href: string;
|
|
96
|
+
text: string;
|
|
97
|
+
internal: boolean;
|
|
98
|
+
}
|
|
99
|
+
export interface ImageFact extends ElementRef {
|
|
100
|
+
src: string;
|
|
101
|
+
alt: string | null;
|
|
102
|
+
broken: boolean;
|
|
103
|
+
decorative: boolean;
|
|
104
|
+
}
|
|
105
|
+
export interface TextBlock extends ElementRef {
|
|
106
|
+
tag: string;
|
|
107
|
+
text: string;
|
|
108
|
+
}
|
|
109
|
+
export interface StyleCounts {
|
|
110
|
+
colors: Record<string, number>;
|
|
111
|
+
backgrounds: Record<string, number>;
|
|
112
|
+
fontSizes: Record<string, number>;
|
|
113
|
+
fontFamilies: Record<string, number>;
|
|
114
|
+
radii: Record<string, number>;
|
|
115
|
+
shadows: Record<string, number>;
|
|
116
|
+
spacing: Record<string, number>;
|
|
117
|
+
}
|
|
118
|
+
export interface ViewportFacts {
|
|
119
|
+
viewport: ViewportName;
|
|
120
|
+
width: number;
|
|
121
|
+
height: number;
|
|
122
|
+
docHeight: number;
|
|
123
|
+
scrollWidth: number;
|
|
124
|
+
screenshot?: string;
|
|
125
|
+
screenshotHeight?: number;
|
|
126
|
+
lang: string | null;
|
|
127
|
+
title: string;
|
|
128
|
+
metaDescription: string | null;
|
|
129
|
+
sections: SectionFact[];
|
|
130
|
+
headings: HeadingFact[];
|
|
131
|
+
controls: ControlFact[];
|
|
132
|
+
links: LinkFact[];
|
|
133
|
+
images: ImageFact[];
|
|
134
|
+
textBlocks: TextBlock[];
|
|
135
|
+
gradients: GradientFact[];
|
|
136
|
+
glows: GlowFact[];
|
|
137
|
+
styles: StyleCounts;
|
|
138
|
+
smallTargets: (ElementRef & {
|
|
139
|
+
text: string;
|
|
140
|
+
w: number;
|
|
141
|
+
h: number;
|
|
142
|
+
})[];
|
|
143
|
+
tinyText: (ElementRef & {
|
|
144
|
+
text: string;
|
|
145
|
+
size: number;
|
|
146
|
+
})[];
|
|
147
|
+
overflowing: (ElementRef & {
|
|
148
|
+
right: number;
|
|
149
|
+
})[];
|
|
150
|
+
unlabeledInputs: (ElementRef & {
|
|
151
|
+
type: string;
|
|
152
|
+
})[];
|
|
153
|
+
wordCount: number;
|
|
154
|
+
}
|
|
155
|
+
export interface InteractionResult extends ElementRef {
|
|
156
|
+
text: string;
|
|
157
|
+
outcome: 'navigated' | 'changed' | 'no-effect' | 'error' | 'skipped';
|
|
158
|
+
detail?: string;
|
|
159
|
+
}
|
|
160
|
+
export interface PageFacts {
|
|
161
|
+
url: string;
|
|
162
|
+
path: string;
|
|
163
|
+
status: number | null;
|
|
164
|
+
loadMs: number;
|
|
165
|
+
error?: string;
|
|
166
|
+
consoleErrors: string[];
|
|
167
|
+
failedRequests: {
|
|
168
|
+
url: string;
|
|
169
|
+
status?: number;
|
|
170
|
+
failure?: string;
|
|
171
|
+
resourceType: string;
|
|
172
|
+
}[];
|
|
173
|
+
desktop?: ViewportFacts;
|
|
174
|
+
mobile?: ViewportFacts;
|
|
175
|
+
interactions: InteractionResult[];
|
|
176
|
+
}
|
|
177
|
+
export interface CrawlResult {
|
|
178
|
+
baseUrl: string;
|
|
179
|
+
pages: PageFacts[];
|
|
180
|
+
skipped: {
|
|
181
|
+
url: string;
|
|
182
|
+
reason: string;
|
|
183
|
+
}[];
|
|
184
|
+
brokenLinks: {
|
|
185
|
+
from: string;
|
|
186
|
+
url: string;
|
|
187
|
+
status: number | null;
|
|
188
|
+
error?: string;
|
|
189
|
+
}[];
|
|
190
|
+
discovered: number;
|
|
191
|
+
limitHit: boolean;
|
|
192
|
+
network: {
|
|
193
|
+
blocked: {
|
|
194
|
+
url: string;
|
|
195
|
+
reason: string;
|
|
196
|
+
}[];
|
|
197
|
+
requests: number;
|
|
198
|
+
bytes: number;
|
|
199
|
+
budgetExceeded: boolean;
|
|
200
|
+
navRedirects?: Map<string, string>;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export interface SourceFile {
|
|
204
|
+
path: string;
|
|
205
|
+
ext: string;
|
|
206
|
+
content: string;
|
|
207
|
+
}
|
|
208
|
+
export interface RepoFacts {
|
|
209
|
+
root: string;
|
|
210
|
+
name?: string;
|
|
211
|
+
frameworks: string[];
|
|
212
|
+
packageManager?: string;
|
|
213
|
+
scripts: Record<string, string>;
|
|
214
|
+
files: SourceFile[];
|
|
215
|
+
fileCount: number;
|
|
216
|
+
truncated: boolean;
|
|
217
|
+
routeHints: string[];
|
|
218
|
+
staticRoot?: string;
|
|
219
|
+
}
|
|
220
|
+
export interface CategoryScore {
|
|
221
|
+
category: Category;
|
|
222
|
+
/** 0 = no slop indicators found, 100 = many or severe. null = not measured. */
|
|
223
|
+
score: number | null;
|
|
224
|
+
weight: number;
|
|
225
|
+
/** Sum of weighted penalties before saturation. */
|
|
226
|
+
penalty: number;
|
|
227
|
+
deductions: {
|
|
228
|
+
findingId: string;
|
|
229
|
+
ruleId: string;
|
|
230
|
+
penalty: number;
|
|
231
|
+
}[];
|
|
232
|
+
}
|
|
233
|
+
export interface Coverage {
|
|
234
|
+
staticAnalysis: boolean;
|
|
235
|
+
rendered: boolean;
|
|
236
|
+
/** A rendered target existed and the browser ran, whether or not any page loaded. */
|
|
237
|
+
renderAttempted: boolean;
|
|
238
|
+
renderedNote?: string;
|
|
239
|
+
pagesAudited: number;
|
|
240
|
+
pagesFailed: number;
|
|
241
|
+
routeLimitHit: boolean;
|
|
242
|
+
skipped: {
|
|
243
|
+
url: string;
|
|
244
|
+
reason: string;
|
|
245
|
+
}[];
|
|
246
|
+
interactions: boolean;
|
|
247
|
+
/** Requests the network policy refused (sample), e.g. a page referencing a private address. */
|
|
248
|
+
blockedRequests: {
|
|
249
|
+
url: string;
|
|
250
|
+
reason: string;
|
|
251
|
+
}[];
|
|
252
|
+
networkBudgetExceeded: boolean;
|
|
253
|
+
model: {
|
|
254
|
+
enabled: boolean;
|
|
255
|
+
name?: string;
|
|
256
|
+
note?: string;
|
|
257
|
+
};
|
|
258
|
+
/** Plain-language list of everything that limits how complete this scan is. */
|
|
259
|
+
limitations: string[];
|
|
260
|
+
}
|
|
261
|
+
export interface AuditReport {
|
|
262
|
+
schemaVersion: 1;
|
|
263
|
+
scanId: string;
|
|
264
|
+
tool: {
|
|
265
|
+
name: 'samey';
|
|
266
|
+
version: string;
|
|
267
|
+
};
|
|
268
|
+
generatedAt: string;
|
|
269
|
+
durationMs: number;
|
|
270
|
+
/** A short hash of the project path or URL origin, safe to share. */
|
|
271
|
+
project: {
|
|
272
|
+
id: string;
|
|
273
|
+
name?: string;
|
|
274
|
+
};
|
|
275
|
+
target: {
|
|
276
|
+
path?: string;
|
|
277
|
+
url?: string;
|
|
278
|
+
};
|
|
279
|
+
/** Overall slop score: 0 = few indicators, 100 = many or severe. null when nothing was measured. */
|
|
280
|
+
score: number | null;
|
|
281
|
+
categories: CategoryScore[];
|
|
282
|
+
findings: Finding[];
|
|
283
|
+
coverage: Coverage;
|
|
284
|
+
versions: {
|
|
285
|
+
scoring: string;
|
|
286
|
+
rules: Record<string, number>;
|
|
287
|
+
model?: string;
|
|
288
|
+
};
|
|
289
|
+
viewports: {
|
|
290
|
+
name: ViewportName;
|
|
291
|
+
width: number;
|
|
292
|
+
height: number;
|
|
293
|
+
}[];
|
|
294
|
+
failedRoutes: {
|
|
295
|
+
url: string;
|
|
296
|
+
reason: string;
|
|
297
|
+
}[];
|
|
298
|
+
repo?: {
|
|
299
|
+
name?: string;
|
|
300
|
+
frameworks: string[];
|
|
301
|
+
fileCount: number;
|
|
302
|
+
truncated: boolean;
|
|
303
|
+
};
|
|
304
|
+
pages: {
|
|
305
|
+
url: string;
|
|
306
|
+
path: string;
|
|
307
|
+
status: number | null;
|
|
308
|
+
error?: string;
|
|
309
|
+
title?: string;
|
|
310
|
+
screenshots: Partial<Record<ViewportName, {
|
|
311
|
+
file: string;
|
|
312
|
+
width: number;
|
|
313
|
+
height: number;
|
|
314
|
+
}>>;
|
|
315
|
+
}[];
|
|
316
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface RGB {
|
|
2
|
+
r: number;
|
|
3
|
+
g: number;
|
|
4
|
+
b: number;
|
|
5
|
+
a: number;
|
|
6
|
+
}
|
|
7
|
+
export interface HSL {
|
|
8
|
+
h: number;
|
|
9
|
+
s: number;
|
|
10
|
+
l: number;
|
|
11
|
+
}
|
|
12
|
+
/** Parses a single CSS color value. Returns null for keywords it does not know. */
|
|
13
|
+
export declare function parseColor(input: string): RGB | null;
|
|
14
|
+
export declare function rgbToHsl({ r, g, b }: RGB): HSL;
|
|
15
|
+
/** Pulls every color literal out of a CSS value such as a gradient or shadow. */
|
|
16
|
+
export declare function extractColors(value: string): RGB[];
|
|
17
|
+
export type HueFamily = 'neutral' | 'red' | 'orange' | 'yellow' | 'green' | 'teal' | 'cyan' | 'blue' | 'indigo' | 'violet' | 'fuchsia' | 'pink';
|
|
18
|
+
export declare function hueFamily(c: RGB): HueFamily;
|
|
19
|
+
/**
|
|
20
|
+
* The stock "AI landing page" gradient: two or more saturated stops drawn from the
|
|
21
|
+
* blue-indigo-violet-pink band, with at least one stop in indigo/violet/fuchsia.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isStockGradient(value: string): boolean;
|
|
24
|
+
export declare function isSaturated(c: RGB): boolean;
|
|
25
|
+
export declare function toHex(c: RGB): string;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
|
|
2
|
+
function num(token, scale = 1) {
|
|
3
|
+
const t = token.trim();
|
|
4
|
+
if (t === 'none')
|
|
5
|
+
return 0;
|
|
6
|
+
if (t.endsWith('%'))
|
|
7
|
+
return (parseFloat(t) / 100) * scale;
|
|
8
|
+
return parseFloat(t);
|
|
9
|
+
}
|
|
10
|
+
function splitArgs(inner) {
|
|
11
|
+
const [main, alpha] = inner.split('/');
|
|
12
|
+
const parts = main.replace(/,/g, ' ').trim().split(/\s+/).filter(Boolean);
|
|
13
|
+
return { parts, alpha: alpha?.trim() };
|
|
14
|
+
}
|
|
15
|
+
function linearToSrgb(c) {
|
|
16
|
+
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
17
|
+
return v * 255;
|
|
18
|
+
}
|
|
19
|
+
function oklabToRgb(L, a, b, alpha) {
|
|
20
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
21
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
22
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
23
|
+
const l = l_ ** 3;
|
|
24
|
+
const m = m_ ** 3;
|
|
25
|
+
const s = s_ ** 3;
|
|
26
|
+
return {
|
|
27
|
+
r: clamp255(linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)),
|
|
28
|
+
g: clamp255(linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)),
|
|
29
|
+
b: clamp255(linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)),
|
|
30
|
+
a: alpha,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function hslToRgb(h, s, l, a) {
|
|
34
|
+
const k = (n) => (n + h / 30) % 12;
|
|
35
|
+
const f = (n) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
|
|
36
|
+
return { r: clamp255(f(0) * 255), g: clamp255(f(8) * 255), b: clamp255(f(4) * 255), a };
|
|
37
|
+
}
|
|
38
|
+
/** Parses a single CSS color value. Returns null for keywords it does not know. */
|
|
39
|
+
export function parseColor(input) {
|
|
40
|
+
const s = input.trim().toLowerCase();
|
|
41
|
+
if (s === 'transparent')
|
|
42
|
+
return { r: 0, g: 0, b: 0, a: 0 };
|
|
43
|
+
if (s === 'white')
|
|
44
|
+
return { r: 255, g: 255, b: 255, a: 1 };
|
|
45
|
+
if (s === 'black')
|
|
46
|
+
return { r: 0, g: 0, b: 0, a: 1 };
|
|
47
|
+
const hex = s.match(/^#([0-9a-f]{3,8})$/);
|
|
48
|
+
if (hex) {
|
|
49
|
+
let h = hex[1];
|
|
50
|
+
if (h.length === 3 || h.length === 4)
|
|
51
|
+
h = [...h].map((c) => c + c).join('');
|
|
52
|
+
if (h.length !== 6 && h.length !== 8)
|
|
53
|
+
return null;
|
|
54
|
+
return {
|
|
55
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
56
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
57
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
58
|
+
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const fn = s.match(/^([a-z]+)\((.*)\)$/);
|
|
62
|
+
if (!fn)
|
|
63
|
+
return null;
|
|
64
|
+
const [, name, inner] = fn;
|
|
65
|
+
const { parts, alpha } = splitArgs(inner);
|
|
66
|
+
const a = alpha !== undefined ? num(alpha, 1) : parts.length === 4 ? num(parts[3], 1) : 1;
|
|
67
|
+
switch (name) {
|
|
68
|
+
case 'rgb':
|
|
69
|
+
case 'rgba':
|
|
70
|
+
if (parts.length < 3)
|
|
71
|
+
return null;
|
|
72
|
+
return { r: clamp255(num(parts[0], 255)), g: clamp255(num(parts[1], 255)), b: clamp255(num(parts[2], 255)), a };
|
|
73
|
+
case 'hsl':
|
|
74
|
+
case 'hsla':
|
|
75
|
+
if (parts.length < 3)
|
|
76
|
+
return null;
|
|
77
|
+
return hslToRgb(parseFloat(parts[0]), num(parts[1], 1) / (parts[1].endsWith('%') ? 1 : 100), num(parts[2], 1) / (parts[2].endsWith('%') ? 1 : 100), a);
|
|
78
|
+
case 'oklch': {
|
|
79
|
+
if (parts.length < 3)
|
|
80
|
+
return null;
|
|
81
|
+
const L = num(parts[0], 1);
|
|
82
|
+
const C = num(parts[1], 0.4);
|
|
83
|
+
const H = (parseFloat(parts[2]) || 0) * (Math.PI / 180);
|
|
84
|
+
return oklabToRgb(L, C * Math.cos(H), C * Math.sin(H), a);
|
|
85
|
+
}
|
|
86
|
+
case 'oklab':
|
|
87
|
+
if (parts.length < 3)
|
|
88
|
+
return null;
|
|
89
|
+
return oklabToRgb(num(parts[0], 1), num(parts[1], 0.4), num(parts[2], 0.4), a);
|
|
90
|
+
case 'color': {
|
|
91
|
+
if (parts[0] !== 'srgb' || parts.length < 4)
|
|
92
|
+
return null;
|
|
93
|
+
return { r: clamp255(num(parts[1], 1) * 255), g: clamp255(num(parts[2], 1) * 255), b: clamp255(num(parts[3], 1) * 255), a };
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function rgbToHsl({ r, g, b }) {
|
|
100
|
+
const rn = r / 255;
|
|
101
|
+
const gn = g / 255;
|
|
102
|
+
const bn = b / 255;
|
|
103
|
+
const max = Math.max(rn, gn, bn);
|
|
104
|
+
const min = Math.min(rn, gn, bn);
|
|
105
|
+
const l = (max + min) / 2;
|
|
106
|
+
if (max === min)
|
|
107
|
+
return { h: 0, s: 0, l };
|
|
108
|
+
const d = max - min;
|
|
109
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
110
|
+
let h;
|
|
111
|
+
if (max === rn)
|
|
112
|
+
h = (gn - bn) / d + (gn < bn ? 6 : 0);
|
|
113
|
+
else if (max === gn)
|
|
114
|
+
h = (bn - rn) / d + 2;
|
|
115
|
+
else
|
|
116
|
+
h = (rn - gn) / d + 4;
|
|
117
|
+
return { h: h * 60, s, l };
|
|
118
|
+
}
|
|
119
|
+
/** Pulls every color literal out of a CSS value such as a gradient or shadow. */
|
|
120
|
+
export function extractColors(value) {
|
|
121
|
+
const re = /#[0-9a-f]{3,8}\b|(?:rgba?|hsla?|oklch|oklab|color)\([^()]*\)/gi;
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const m of value.match(re) ?? []) {
|
|
124
|
+
const c = parseColor(m);
|
|
125
|
+
if (c)
|
|
126
|
+
out.push(c);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
export function hueFamily(c) {
|
|
131
|
+
const { h, s, l } = rgbToHsl(c);
|
|
132
|
+
if (s < 0.25 || l < 0.08 || l > 0.96)
|
|
133
|
+
return 'neutral';
|
|
134
|
+
if (h < 15 || h >= 345)
|
|
135
|
+
return 'red';
|
|
136
|
+
if (h < 40)
|
|
137
|
+
return 'orange';
|
|
138
|
+
if (h < 65)
|
|
139
|
+
return 'yellow';
|
|
140
|
+
if (h < 150)
|
|
141
|
+
return 'green';
|
|
142
|
+
if (h < 175)
|
|
143
|
+
return 'teal';
|
|
144
|
+
if (h < 200)
|
|
145
|
+
return 'cyan';
|
|
146
|
+
if (h < 230)
|
|
147
|
+
return 'blue';
|
|
148
|
+
if (h < 255)
|
|
149
|
+
return 'indigo';
|
|
150
|
+
if (h < 285)
|
|
151
|
+
return 'violet';
|
|
152
|
+
if (h < 320)
|
|
153
|
+
return 'fuchsia';
|
|
154
|
+
return 'pink';
|
|
155
|
+
}
|
|
156
|
+
const COOL_SYNTH = new Set(['blue', 'indigo', 'violet', 'fuchsia', 'pink', 'cyan']);
|
|
157
|
+
const CORE_SYNTH = new Set(['indigo', 'violet', 'fuchsia']);
|
|
158
|
+
/**
|
|
159
|
+
* The stock "AI landing page" gradient: two or more saturated stops drawn from the
|
|
160
|
+
* blue-indigo-violet-pink band, with at least one stop in indigo/violet/fuchsia.
|
|
161
|
+
*/
|
|
162
|
+
export function isStockGradient(value) {
|
|
163
|
+
const stops = extractColors(value).filter((c) => c.a > 0.15);
|
|
164
|
+
const families = stops.map(hueFamily).filter((f) => f !== 'neutral');
|
|
165
|
+
if (families.length < 2)
|
|
166
|
+
return false;
|
|
167
|
+
if (!families.every((f) => COOL_SYNTH.has(f)))
|
|
168
|
+
return false;
|
|
169
|
+
if (!families.some((f) => CORE_SYNTH.has(f)))
|
|
170
|
+
return false;
|
|
171
|
+
// A tonal ramp within one family (indigo-500 to indigo-700) is a deliberate choice, not the stock look.
|
|
172
|
+
return new Set(families).size >= 2;
|
|
173
|
+
}
|
|
174
|
+
export function isSaturated(c) {
|
|
175
|
+
const { s, l } = rgbToHsl(c);
|
|
176
|
+
return s >= 0.4 && l > 0.15 && l < 0.9;
|
|
177
|
+
}
|
|
178
|
+
export function toHex(c) {
|
|
179
|
+
return '#' + [c.r, c.g, c.b].map((n) => n.toString(16).padStart(2, '0')).join('');
|
|
180
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface PhraseMatch {
|
|
2
|
+
phrase: string;
|
|
3
|
+
index: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Stock marketing phrases that carry no product-specific information. Each entry is a
|
|
7
|
+
* label plus a pattern so reports can name the phrase family rather than the regex.
|
|
8
|
+
*/
|
|
9
|
+
export declare const STOCK_PHRASES: [string, RegExp][];
|
|
10
|
+
export declare function findStockPhrases(text: string): PhraseMatch[];
|
|
11
|
+
export declare const PLACEHOLDER_PATTERNS: [string, RegExp][];
|
|
12
|
+
export declare function findPlaceholders(text: string): PhraseMatch[];
|
|
13
|
+
export declare const CLAIM_PATTERNS: [string, RegExp][];
|
|
14
|
+
export declare function findClaims(text: string): PhraseMatch[];
|
|
15
|
+
/** Testimonial attribution like "Sarah Chen, CEO at Nimbus". */
|
|
16
|
+
export declare const ATTRIBUTION: RegExp;
|
|
17
|
+
export declare function hasEmoji(text: string): boolean;
|
|
18
|
+
export declare function firstEmoji(text: string): string | undefined;
|
|
19
|
+
export declare const GENERIC_LINK_TEXT: RegExp;
|
|
20
|
+
export declare function normalizeText(text: string): string;
|
|
21
|
+
export declare function truncate(text: string, max?: number): string;
|
|
22
|
+
export declare function snippetAround(text: string, index: number, radius?: number): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stock marketing phrases that carry no product-specific information. Each entry is a
|
|
3
|
+
* label plus a pattern so reports can name the phrase family rather than the regex.
|
|
4
|
+
*/
|
|
5
|
+
export const STOCK_PHRASES = [
|
|
6
|
+
['unlock the power/potential', /\bunlock(?:ing)? (?:the |your )?(?:full )?(?:power|potential)\b/i],
|
|
7
|
+
['supercharge', /\bsupercharg(?:e|es|ed|ing)\b/i],
|
|
8
|
+
['revolutionize', /\brevolutioni[sz](?:e|es|ing)\b/i],
|
|
9
|
+
['seamless', /\bseamless(?:ly)?\b/i],
|
|
10
|
+
['elevate your', /\belevate (?:your|the)\b/i],
|
|
11
|
+
['empower', /\bempower(?:s|ing)?\b/i],
|
|
12
|
+
['next-generation', /\bnext[- ]gen(?:eration)?\b/i],
|
|
13
|
+
['cutting-edge', /\bcutting[- ]edge\b/i],
|
|
14
|
+
['game-changing', /\bgame[- ]chang(?:er|ing)\b/i],
|
|
15
|
+
['all-in-one', /\ball[- ]in[- ]one\b/i],
|
|
16
|
+
['effortless', /\beffortless(?:ly)?\b/i],
|
|
17
|
+
['transform your', /\btransform (?:your|the way)\b/i],
|
|
18
|
+
['to the next level', /\bto the next level\b/i],
|
|
19
|
+
['built for the future', /\bbuilt for (?:the )?(?:future|modern)\b/i],
|
|
20
|
+
['harness the power', /\bharness(?:ing)? the power\b/i],
|
|
21
|
+
['streamline', /\bstreamlin(?:e|es|ed|ing)\b/i],
|
|
22
|
+
['world-class', /\bworld[- ]class\b/i],
|
|
23
|
+
['state-of-the-art', /\bstate[- ]of[- ]the[- ]art\b/i],
|
|
24
|
+
['lightning/blazing fast', /\b(?:lightning|blazing(?:ly)?)[- ]fast\b/i],
|
|
25
|
+
["in today's fast-paced world", /\bin today'?s (?:fast[- ]paced|digital|ever[- ]changing)\b/i],
|
|
26
|
+
["whether you're a", /\bwhether you'?re an? \w+/i],
|
|
27
|
+
['say goodbye to', /\bsay goodbye to\b/i],
|
|
28
|
+
['look no further', /\blook no further\b/i],
|
|
29
|
+
['boost your productivity', /\bboost (?:your )?productivity\b/i],
|
|
30
|
+
['unleash', /\bunleash(?:es|ing)?\b/i],
|
|
31
|
+
['reimagine', /\breimagin(?:e|ed|ing)\b/i],
|
|
32
|
+
['at your fingertips', /\bat your fingertips\b/i],
|
|
33
|
+
['ever-evolving', /\bever[- ]evolving\b/i],
|
|
34
|
+
['leverage', /\bleverag(?:e|es|ing)\b/i],
|
|
35
|
+
['innovative solutions', /\binnovative solutions?\b/i],
|
|
36
|
+
['the future of', /\bthe future of \w+/i],
|
|
37
|
+
['like never before', /\blike never before\b/i],
|
|
38
|
+
['made simple', /\bmade (?:simple|easy)\b/i],
|
|
39
|
+
['delve', /\bdelv(?:e|es|ing)\b/i],
|
|
40
|
+
['tapestry', /\btapestry\b/i],
|
|
41
|
+
];
|
|
42
|
+
export function findStockPhrases(text) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const [label, re] of STOCK_PHRASES) {
|
|
45
|
+
const m = re.exec(text);
|
|
46
|
+
if (m)
|
|
47
|
+
out.push({ phrase: label, index: m.index });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
export const PLACEHOLDER_PATTERNS = [
|
|
52
|
+
['lorem ipsum', /\blorem ipsum\b|\bdolor sit amet\b/i],
|
|
53
|
+
['placeholder name', /\b(?:john|jane) (?:doe|smith)\b/i],
|
|
54
|
+
['placeholder company', /\b(?:acme(?: corp(?:oration)?| inc)?|your company|company name|your brand|brand name)\b/i],
|
|
55
|
+
['example domain', /\b[\w.-]*@?example\.(?:com|org|net)\b/i],
|
|
56
|
+
['placeholder text', /\[(?:insert|your|add|placeholder)[^\]]*\]|\{\{\s*\w+\s*\}\}|\bplaceholder text\b/i],
|
|
57
|
+
['TODO marker', /\b(?:TODO|FIXME|TBD)\b/],
|
|
58
|
+
['dummy contact', /\b(?:123[- ]456[- ]7890|\(555\) ?\d{3}-\d{4}|555-\d{4}|123 (?:main|fake) st(?:reet)?)\b/i],
|
|
59
|
+
];
|
|
60
|
+
export function findPlaceholders(text) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const [label, re] of PLACEHOLDER_PATTERNS) {
|
|
63
|
+
const m = re.exec(text);
|
|
64
|
+
if (m)
|
|
65
|
+
out.push({ phrase: label, index: m.index });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
export const CLAIM_PATTERNS = [
|
|
70
|
+
['trusted by N', /\btrusted by (?:over |more than )?[\d,.]+\s*[kKmM]?\+?/],
|
|
71
|
+
['N+ users/customers', /\b[\d,.]+\s*[kKmM]?\+\s*(?:happy |satisfied |active )?(?:users|customers|developers|teams|companies|businesses|clients|creators)\b/i],
|
|
72
|
+
['percentage claim', /\b\d{2,3}(?:\.\d+)?%\s*(?:uptime|satisfaction|faster|more|increase|growth|accuracy|reduction|of (?:users|customers))\b/i],
|
|
73
|
+
['star rating', /★★★★★|\b(?:4\.\d|5(?:\.0)?)\s*\/\s*5\b|\b5[- ]star\b/i],
|
|
74
|
+
['N x faster', /\b\d+x (?:faster|more|better|quicker)\b/i],
|
|
75
|
+
];
|
|
76
|
+
export function findClaims(text) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const [label, re] of CLAIM_PATTERNS) {
|
|
79
|
+
const m = re.exec(text);
|
|
80
|
+
if (m)
|
|
81
|
+
out.push({ phrase: label, index: m.index });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** Testimonial attribution like "Sarah Chen, CEO at Nimbus". */
|
|
86
|
+
export const ATTRIBUTION = /\b[A-Z][a-z]+ [A-Z][a-z]+\s*[,\-–—|]\s*(?:CEO|CTO|COO|CFO|Founder|Co-founder|Head of|VP|Director|Lead|Manager|Product Manager|Engineer|Designer)\b/;
|
|
87
|
+
const EMOJI = /\p{Extended_Pictographic}/u;
|
|
88
|
+
export function hasEmoji(text) {
|
|
89
|
+
return EMOJI.test(text);
|
|
90
|
+
}
|
|
91
|
+
export function firstEmoji(text) {
|
|
92
|
+
return text.match(/\p{Extended_Pictographic}️?/u)?.[0];
|
|
93
|
+
}
|
|
94
|
+
export const GENERIC_LINK_TEXT = /^(?:learn more|read more|click here|more|here|details|see more|find out more)$/i;
|
|
95
|
+
export function normalizeText(text) {
|
|
96
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
97
|
+
}
|
|
98
|
+
export function truncate(text, max = 120) {
|
|
99
|
+
const t = normalizeText(text);
|
|
100
|
+
return t.length > max ? t.slice(0, max - 1) + '…' : t;
|
|
101
|
+
}
|
|
102
|
+
export function snippetAround(text, index, radius = 60) {
|
|
103
|
+
const start = Math.max(0, index - radius);
|
|
104
|
+
const end = Math.min(text.length, index + radius);
|
|
105
|
+
return (start > 0 ? '…' : '') + normalizeText(text.slice(start, end)) + (end < text.length ? '…' : '');
|
|
106
|
+
}
|