@arach/og 0.1.0 → 0.2.1
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/README.md +106 -0
- package/dist/audit.d.ts +22 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +188 -0
- package/dist/audit.js.map +1 -0
- package/dist/cli.js +156 -23
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/templates/branded.d.ts +1 -1
- package/dist/templates/branded.d.ts.map +1 -1
- package/dist/templates/branded.js +78 -40
- package/dist/templates/branded.js.map +1 -1
- package/dist/validate.d.ts +16 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +459 -0
- package/dist/validate.js.map +1 -0
- package/dist/viewer.d.ts +2 -0
- package/dist/viewer.d.ts.map +1 -0
- package/dist/viewer.js +434 -0
- package/dist/viewer.js.map +1 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Branded template - full featured with grid overlay,
|
|
2
|
+
* Branded template - full featured with grid overlay, corner crosses, and refined typography
|
|
3
3
|
*/
|
|
4
4
|
export const branded = (ctx) => `
|
|
5
5
|
<!DOCTYPE html>
|
|
@@ -24,30 +24,42 @@ export const branded = (ctx) => `
|
|
|
24
24
|
position: relative;
|
|
25
25
|
overflow: hidden;
|
|
26
26
|
}
|
|
27
|
-
.
|
|
27
|
+
.grid {
|
|
28
28
|
position: absolute;
|
|
29
|
-
inset:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
radial-gradient(circle at 85% 40%, ${ctx.accent}30, transparent 55%);
|
|
35
|
-
pointer-events: none;
|
|
29
|
+
inset: 0;
|
|
30
|
+
background-image:
|
|
31
|
+
linear-gradient(${ctx.textColor}06 1px, transparent 1px),
|
|
32
|
+
linear-gradient(90deg, ${ctx.textColor}06 1px, transparent 1px);
|
|
33
|
+
background-size: 60px 60px;
|
|
36
34
|
}
|
|
37
|
-
.grid {
|
|
35
|
+
.grid-small {
|
|
38
36
|
position: absolute;
|
|
39
37
|
inset: 0;
|
|
40
38
|
background-image:
|
|
41
|
-
linear-gradient(${ctx.textColor}
|
|
42
|
-
linear-gradient(90deg, ${ctx.textColor}
|
|
43
|
-
background-size:
|
|
44
|
-
|
|
39
|
+
linear-gradient(${ctx.textColor}03 1px, transparent 1px),
|
|
40
|
+
linear-gradient(90deg, ${ctx.textColor}03 1px, transparent 1px);
|
|
41
|
+
background-size: 20px 20px;
|
|
42
|
+
}
|
|
43
|
+
.corner-cross {
|
|
44
|
+
position: absolute;
|
|
45
|
+
stroke: ${ctx.accent};
|
|
46
|
+
stroke-width: 1;
|
|
47
|
+
stroke-dasharray: 6 4;
|
|
48
|
+
opacity: 0.7;
|
|
49
|
+
}
|
|
50
|
+
.corner-tl {
|
|
51
|
+
top: 0;
|
|
52
|
+
left: 0;
|
|
53
|
+
}
|
|
54
|
+
.corner-br {
|
|
55
|
+
bottom: 0;
|
|
56
|
+
right: 0;
|
|
45
57
|
}
|
|
46
58
|
.content {
|
|
47
59
|
position: relative;
|
|
48
60
|
z-index: 1;
|
|
49
61
|
height: 100%;
|
|
50
|
-
padding:
|
|
62
|
+
padding: 80px;
|
|
51
63
|
display: flex;
|
|
52
64
|
flex-direction: column;
|
|
53
65
|
justify-content: center;
|
|
@@ -56,57 +68,82 @@ export const branded = (ctx) => `
|
|
|
56
68
|
display: inline-flex;
|
|
57
69
|
align-items: center;
|
|
58
70
|
gap: 8px;
|
|
59
|
-
padding:
|
|
60
|
-
border-radius:
|
|
61
|
-
border: 1px solid ${ctx.
|
|
62
|
-
background: ${ctx.
|
|
63
|
-
font-
|
|
64
|
-
font-
|
|
71
|
+
padding: 8px 16px;
|
|
72
|
+
border-radius: 6px;
|
|
73
|
+
border: 1px solid ${ctx.accent}30;
|
|
74
|
+
background: ${ctx.accent}10;
|
|
75
|
+
font-family: '${ctx.fonts[1] || ctx.fonts[0]}', system-ui, sans-serif;
|
|
76
|
+
font-size: 13px;
|
|
77
|
+
font-weight: 500;
|
|
65
78
|
text-transform: uppercase;
|
|
66
|
-
letter-spacing: 0.
|
|
67
|
-
color: ${ctx.
|
|
68
|
-
margin-bottom:
|
|
79
|
+
letter-spacing: 0.12em;
|
|
80
|
+
color: ${ctx.accent};
|
|
81
|
+
margin-bottom: 32px;
|
|
69
82
|
width: fit-content;
|
|
70
83
|
}
|
|
71
84
|
.title {
|
|
72
85
|
font-family: '${ctx.fonts[0]}', serif;
|
|
73
|
-
font-size:
|
|
74
|
-
font-weight:
|
|
75
|
-
|
|
86
|
+
font-size: 80px;
|
|
87
|
+
font-weight: 400;
|
|
88
|
+
font-style: italic;
|
|
89
|
+
line-height: 1.05;
|
|
76
90
|
max-width: 900px;
|
|
77
|
-
margin-bottom:
|
|
91
|
+
margin-bottom: 24px;
|
|
92
|
+
letter-spacing: -0.02em;
|
|
78
93
|
}
|
|
79
94
|
.subtitle {
|
|
80
|
-
font-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
font-family: '${ctx.fonts[1] || ctx.fonts[0]}', system-ui, sans-serif;
|
|
96
|
+
font-size: 26px;
|
|
97
|
+
font-weight: 400;
|
|
98
|
+
color: ${ctx.textColor}80;
|
|
99
|
+
max-width: 650px;
|
|
100
|
+
line-height: 1.5;
|
|
84
101
|
}
|
|
85
102
|
.brand {
|
|
86
103
|
position: absolute;
|
|
87
|
-
bottom:
|
|
104
|
+
bottom: 80px;
|
|
88
105
|
left: 80px;
|
|
89
106
|
display: flex;
|
|
90
107
|
align-items: center;
|
|
91
108
|
gap: 12px;
|
|
92
109
|
}
|
|
93
110
|
.brand-dot {
|
|
94
|
-
width:
|
|
95
|
-
height:
|
|
111
|
+
width: 10px;
|
|
112
|
+
height: 10px;
|
|
96
113
|
border-radius: 50%;
|
|
97
114
|
background: ${ctx.accent};
|
|
98
|
-
box-shadow: 0 0 0 6px ${ctx.accent}33;
|
|
99
115
|
}
|
|
100
116
|
.brand-name {
|
|
101
|
-
font-family: '${ctx.fonts[0]}', serif;
|
|
102
|
-
font-size:
|
|
103
|
-
font-weight:
|
|
117
|
+
font-family: '${ctx.fonts[1] || ctx.fonts[0]}', system-ui, sans-serif;
|
|
118
|
+
font-size: 18px;
|
|
119
|
+
font-weight: 500;
|
|
120
|
+
color: ${ctx.textColor}90;
|
|
121
|
+
}
|
|
122
|
+
.bottom-bar {
|
|
123
|
+
position: absolute;
|
|
124
|
+
bottom: 0;
|
|
125
|
+
left: 0;
|
|
126
|
+
right: 0;
|
|
127
|
+
height: 6px;
|
|
128
|
+
background: linear-gradient(90deg, ${ctx.accent}, ${ctx.accentSecondary || ctx.accent}80);
|
|
129
|
+
opacity: 0.6;
|
|
104
130
|
}
|
|
105
131
|
</style>
|
|
106
132
|
</head>
|
|
107
133
|
<body>
|
|
108
|
-
<div class="glow"></div>
|
|
109
134
|
<div class="grid"></div>
|
|
135
|
+
<div class="grid-small"></div>
|
|
136
|
+
|
|
137
|
+
<!-- Corner crosses -->
|
|
138
|
+
<svg class="corner-cross corner-tl" width="200" height="200" viewBox="0 0 200 200">
|
|
139
|
+
<path d="M 20 60 L 140 60" fill="none"/>
|
|
140
|
+
<path d="M 60 20 L 60 140" fill="none"/>
|
|
141
|
+
</svg>
|
|
142
|
+
<svg class="corner-cross corner-br" width="200" height="200" viewBox="0 0 200 200">
|
|
143
|
+
<path d="M 60 140 L 180 140" fill="none"/>
|
|
144
|
+
<path d="M 140 60 L 140 180" fill="none"/>
|
|
145
|
+
</svg>
|
|
146
|
+
|
|
110
147
|
<div class="content">
|
|
111
148
|
${ctx.tag ? `<div class="tag">${ctx.tag}</div>` : ''}
|
|
112
149
|
<h1 class="title">${ctx.title}</h1>
|
|
@@ -116,6 +153,7 @@ export const branded = (ctx) => `
|
|
|
116
153
|
<span class="brand-dot"></span>
|
|
117
154
|
<span class="brand-name">${ctx.title.split('|')[0]?.trim() || ''}</span>
|
|
118
155
|
</div>
|
|
156
|
+
<div class="bottom-bar"></div>
|
|
119
157
|
</body>
|
|
120
158
|
</html>
|
|
121
159
|
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branded.js","sourceRoot":"","sources":["../../src/templates/branded.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,CAAC,MAAM,OAAO,GAAqB,CAAC,GAAG,EAAE,EAAE,CAAC;;;;;;;yDAOO,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC;;;;;;;;eAQnG,GAAG,CAAC,KAAK;gBACR,GAAG,CAAC,MAAM;oBACN,GAAG,CAAC,UAAU;sBACZ,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;eACnC,GAAG,CAAC,SAAS
|
|
1
|
+
{"version":3,"file":"branded.js","sourceRoot":"","sources":["../../src/templates/branded.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,CAAC,MAAM,OAAO,GAAqB,CAAC,GAAG,EAAE,EAAE,CAAC;;;;;;;yDAOO,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC;;;;;;;;eAQnG,GAAG,CAAC,KAAK;gBACR,GAAG,CAAC,MAAM;oBACN,GAAG,CAAC,UAAU;sBACZ,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;eACnC,GAAG,CAAC,SAAS;;;;;;;;0BAQF,GAAG,CAAC,SAAS;iCACN,GAAG,CAAC,SAAS;;;;;;;0BAOpB,GAAG,CAAC,SAAS;iCACN,GAAG,CAAC,SAAS;;;;;gBAK9B,GAAG,CAAC,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;0BA4BA,GAAG,CAAC,MAAM;oBAChB,GAAG,CAAC,MAAM;sBACR,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;;;;;eAKnC,GAAG,CAAC,MAAM;;;;;sBAKH,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;;;;;;;;;;sBAUZ,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;;;eAGnC,GAAG,CAAC,SAAS;;;;;;;;;;;;;;;;oBAgBR,GAAG,CAAC,MAAM;;;sBAGR,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;;;eAGnC,GAAG,CAAC,SAAS;;;;;;;;2CAQe,GAAG,CAAC,MAAM,KAAK,GAAG,CAAC,eAAe,IAAI,GAAG,CAAC,MAAM;;;;;;;;;;;;;;;;;;;;MAoBrF,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,oBAAoB,GAAG,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,EAAE;wBAChC,GAAG,CAAC,KAAK;MAC3B,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,uBAAuB,GAAG,CAAC,QAAQ,MAAM,CAAC,CAAC,CAAC,EAAE;;;;+BAIpC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE;;;;;CAKnE,CAAA"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ValidationResult {
|
|
2
|
+
url: string;
|
|
3
|
+
score: number;
|
|
4
|
+
maxScore: number;
|
|
5
|
+
checks: ValidationCheck[];
|
|
6
|
+
}
|
|
7
|
+
export interface ValidationCheck {
|
|
8
|
+
name: string;
|
|
9
|
+
status: 'pass' | 'warn' | 'fail';
|
|
10
|
+
message: string;
|
|
11
|
+
value?: string | number;
|
|
12
|
+
recommendation?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function validateOG(targetUrl: string): Promise<ValidationResult>;
|
|
15
|
+
export declare function formatValidationResult(result: ValidationResult): string;
|
|
16
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,eAAe,EAAE,CAAA;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AA0BD,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAsD7E;AAiaD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CA4BvE"}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
const THRESHOLDS = {
|
|
2
|
+
title: { min: 30, optimal: { min: 50, max: 60 }, max: 90 },
|
|
3
|
+
description: { min: 70, optimal: { min: 110, max: 160 }, max: 200 },
|
|
4
|
+
image: {
|
|
5
|
+
width: 1200,
|
|
6
|
+
height: 630,
|
|
7
|
+
maxSizeKB: 600,
|
|
8
|
+
formats: ['image/png', 'image/jpeg', 'image/webp']
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
export async function validateOG(targetUrl) {
|
|
12
|
+
const checks = [];
|
|
13
|
+
// Fetch the page
|
|
14
|
+
let html;
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(targetUrl, {
|
|
17
|
+
headers: { 'User-Agent': 'og-validator/1.0' }
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new Error(`HTTP ${response.status}`);
|
|
21
|
+
}
|
|
22
|
+
html = await response.text();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return {
|
|
26
|
+
url: targetUrl,
|
|
27
|
+
score: 0,
|
|
28
|
+
maxScore: 100,
|
|
29
|
+
checks: [{
|
|
30
|
+
name: 'URL Accessible',
|
|
31
|
+
status: 'fail',
|
|
32
|
+
message: `Could not fetch URL: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
33
|
+
}]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Parse OG tags
|
|
37
|
+
const tags = parseOGTags(html);
|
|
38
|
+
// Check og:title
|
|
39
|
+
checks.push(checkTitle(tags.title));
|
|
40
|
+
// Check og:description
|
|
41
|
+
checks.push(checkDescription(tags.description));
|
|
42
|
+
// Check og:image
|
|
43
|
+
const imageChecks = await checkImage(tags.image, targetUrl);
|
|
44
|
+
checks.push(...imageChecks);
|
|
45
|
+
// Check og:url
|
|
46
|
+
checks.push(checkUrl(tags.url, targetUrl));
|
|
47
|
+
// Check twitter:card
|
|
48
|
+
checks.push(checkTwitterCard(tags.twitterCard));
|
|
49
|
+
// Calculate score
|
|
50
|
+
const score = calculateScore(checks);
|
|
51
|
+
return {
|
|
52
|
+
url: targetUrl,
|
|
53
|
+
score,
|
|
54
|
+
maxScore: 100,
|
|
55
|
+
checks
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function parseOGTags(html) {
|
|
59
|
+
const getMetaContent = (property) => {
|
|
60
|
+
// Match both property="" and name="" attributes
|
|
61
|
+
const patterns = [
|
|
62
|
+
new RegExp(`<meta[^>]*property=["']${property}["'][^>]*content=["']([^"']*)["']`, 'i'),
|
|
63
|
+
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']${property}["']`, 'i'),
|
|
64
|
+
new RegExp(`<meta[^>]*name=["']${property}["'][^>]*content=["']([^"']*)["']`, 'i'),
|
|
65
|
+
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*name=["']${property}["']`, 'i'),
|
|
66
|
+
];
|
|
67
|
+
for (const pattern of patterns) {
|
|
68
|
+
const match = html.match(pattern);
|
|
69
|
+
if (match)
|
|
70
|
+
return match[1];
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
title: getMetaContent('og:title'),
|
|
76
|
+
description: getMetaContent('og:description'),
|
|
77
|
+
image: getMetaContent('og:image'),
|
|
78
|
+
url: getMetaContent('og:url'),
|
|
79
|
+
type: getMetaContent('og:type'),
|
|
80
|
+
siteName: getMetaContent('og:site_name'),
|
|
81
|
+
twitterCard: getMetaContent('twitter:card'),
|
|
82
|
+
twitterTitle: getMetaContent('twitter:title'),
|
|
83
|
+
twitterDescription: getMetaContent('twitter:description'),
|
|
84
|
+
twitterImage: getMetaContent('twitter:image'),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function checkTitle(title) {
|
|
88
|
+
if (!title) {
|
|
89
|
+
return {
|
|
90
|
+
name: 'og:title',
|
|
91
|
+
status: 'fail',
|
|
92
|
+
message: 'Missing og:title tag',
|
|
93
|
+
recommendation: 'Add <meta property="og:title" content="Your Title"> to your page'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const len = title.length;
|
|
97
|
+
const { min, optimal, max } = THRESHOLDS.title;
|
|
98
|
+
if (len < min) {
|
|
99
|
+
return {
|
|
100
|
+
name: 'og:title',
|
|
101
|
+
status: 'warn',
|
|
102
|
+
message: `Title too short (${len} chars)`,
|
|
103
|
+
value: len,
|
|
104
|
+
recommendation: `Aim for ${optimal.min}-${optimal.max} characters for best display`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (len > max) {
|
|
108
|
+
return {
|
|
109
|
+
name: 'og:title',
|
|
110
|
+
status: 'warn',
|
|
111
|
+
message: `Title too long (${len} chars) - may be truncated`,
|
|
112
|
+
value: len,
|
|
113
|
+
recommendation: `Keep under ${max} characters, ideally ${optimal.min}-${optimal.max}`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (len >= optimal.min && len <= optimal.max) {
|
|
117
|
+
return {
|
|
118
|
+
name: 'og:title',
|
|
119
|
+
status: 'pass',
|
|
120
|
+
message: `Title length is optimal (${len} chars)`,
|
|
121
|
+
value: len
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name: 'og:title',
|
|
126
|
+
status: 'pass',
|
|
127
|
+
message: `Title length is acceptable (${len} chars)`,
|
|
128
|
+
value: len,
|
|
129
|
+
recommendation: `Optimal length is ${optimal.min}-${optimal.max} characters`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function checkDescription(description) {
|
|
133
|
+
if (!description) {
|
|
134
|
+
return {
|
|
135
|
+
name: 'og:description',
|
|
136
|
+
status: 'fail',
|
|
137
|
+
message: 'Missing og:description tag',
|
|
138
|
+
recommendation: 'Add <meta property="og:description" content="Your description"> to your page'
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const len = description.length;
|
|
142
|
+
const { min, optimal, max } = THRESHOLDS.description;
|
|
143
|
+
if (len < min) {
|
|
144
|
+
return {
|
|
145
|
+
name: 'og:description',
|
|
146
|
+
status: 'warn',
|
|
147
|
+
message: `Description too short (${len} chars)`,
|
|
148
|
+
value: len,
|
|
149
|
+
recommendation: `Aim for ${optimal.min}-${optimal.max} characters for best display`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (len > max) {
|
|
153
|
+
return {
|
|
154
|
+
name: 'og:description',
|
|
155
|
+
status: 'warn',
|
|
156
|
+
message: `Description too long (${len} chars) - may be truncated`,
|
|
157
|
+
value: len,
|
|
158
|
+
recommendation: `Keep under ${max} characters, ideally ${optimal.min}-${optimal.max}`
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (len >= optimal.min && len <= optimal.max) {
|
|
162
|
+
return {
|
|
163
|
+
name: 'og:description',
|
|
164
|
+
status: 'pass',
|
|
165
|
+
message: `Description length is optimal (${len} chars)`,
|
|
166
|
+
value: len
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
name: 'og:description',
|
|
171
|
+
status: 'pass',
|
|
172
|
+
message: `Description length is acceptable (${len} chars)`,
|
|
173
|
+
value: len,
|
|
174
|
+
recommendation: `Optimal length is ${optimal.min}-${optimal.max} characters`
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
async function checkImage(imageUrl, pageUrl) {
|
|
178
|
+
const checks = [];
|
|
179
|
+
if (!imageUrl) {
|
|
180
|
+
checks.push({
|
|
181
|
+
name: 'og:image',
|
|
182
|
+
status: 'fail',
|
|
183
|
+
message: 'Missing og:image tag',
|
|
184
|
+
recommendation: 'Add <meta property="og:image" content="https://example.com/og.png"> to your page'
|
|
185
|
+
});
|
|
186
|
+
return checks;
|
|
187
|
+
}
|
|
188
|
+
// Resolve relative URLs
|
|
189
|
+
let absoluteUrl = imageUrl;
|
|
190
|
+
if (imageUrl.startsWith('/') && pageUrl) {
|
|
191
|
+
const url = new URL(pageUrl);
|
|
192
|
+
absoluteUrl = `${url.protocol}//${url.host}${imageUrl}`;
|
|
193
|
+
}
|
|
194
|
+
else if (!imageUrl.startsWith('http') && pageUrl) {
|
|
195
|
+
const url = new URL(pageUrl);
|
|
196
|
+
absoluteUrl = `${url.protocol}//${url.host}/${imageUrl}`;
|
|
197
|
+
}
|
|
198
|
+
// Check if URL is absolute
|
|
199
|
+
if (!imageUrl.startsWith('http')) {
|
|
200
|
+
checks.push({
|
|
201
|
+
name: 'og:image URL',
|
|
202
|
+
status: 'warn',
|
|
203
|
+
message: 'Image URL should be absolute',
|
|
204
|
+
value: imageUrl,
|
|
205
|
+
recommendation: 'Use full URL like https://example.com/og.png for best compatibility'
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
checks.push({
|
|
210
|
+
name: 'og:image URL',
|
|
211
|
+
status: 'pass',
|
|
212
|
+
message: 'Image URL is absolute',
|
|
213
|
+
value: imageUrl
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// Fetch image to check size and dimensions
|
|
217
|
+
try {
|
|
218
|
+
const response = await fetch(absoluteUrl, {
|
|
219
|
+
headers: { 'User-Agent': 'og-validator/1.0' }
|
|
220
|
+
});
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
checks.push({
|
|
223
|
+
name: 'og:image accessible',
|
|
224
|
+
status: 'fail',
|
|
225
|
+
message: `Image not accessible (HTTP ${response.status})`,
|
|
226
|
+
recommendation: 'Ensure the image URL is publicly accessible'
|
|
227
|
+
});
|
|
228
|
+
return checks;
|
|
229
|
+
}
|
|
230
|
+
checks.push({
|
|
231
|
+
name: 'og:image accessible',
|
|
232
|
+
status: 'pass',
|
|
233
|
+
message: 'Image is accessible'
|
|
234
|
+
});
|
|
235
|
+
// Check content type
|
|
236
|
+
const contentType = response.headers.get('content-type') || '';
|
|
237
|
+
if (THRESHOLDS.image.formats.some(f => contentType.includes(f.split('/')[1]))) {
|
|
238
|
+
checks.push({
|
|
239
|
+
name: 'og:image format',
|
|
240
|
+
status: 'pass',
|
|
241
|
+
message: `Valid format (${contentType})`
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
checks.push({
|
|
246
|
+
name: 'og:image format',
|
|
247
|
+
status: 'warn',
|
|
248
|
+
message: `Unusual format (${contentType})`,
|
|
249
|
+
recommendation: 'Use PNG, JPEG, or WebP for best compatibility'
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// Check file size
|
|
253
|
+
const buffer = await response.arrayBuffer();
|
|
254
|
+
const sizeKB = Math.round(buffer.byteLength / 1024);
|
|
255
|
+
if (sizeKB > THRESHOLDS.image.maxSizeKB) {
|
|
256
|
+
checks.push({
|
|
257
|
+
name: 'og:image size',
|
|
258
|
+
status: 'fail',
|
|
259
|
+
message: `Image too large (${sizeKB}KB)`,
|
|
260
|
+
value: sizeKB,
|
|
261
|
+
recommendation: `Keep under ${THRESHOLDS.image.maxSizeKB}KB for WhatsApp and other platforms`
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
else if (sizeKB > THRESHOLDS.image.maxSizeKB * 0.8) {
|
|
265
|
+
checks.push({
|
|
266
|
+
name: 'og:image size',
|
|
267
|
+
status: 'warn',
|
|
268
|
+
message: `Image size is borderline (${sizeKB}KB)`,
|
|
269
|
+
value: sizeKB,
|
|
270
|
+
recommendation: `Aim for under ${Math.round(THRESHOLDS.image.maxSizeKB * 0.8)}KB for safety margin`
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
checks.push({
|
|
275
|
+
name: 'og:image size',
|
|
276
|
+
status: 'pass',
|
|
277
|
+
message: `Image size is good (${sizeKB}KB)`,
|
|
278
|
+
value: sizeKB
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Check dimensions (PNG only for now - would need image library for full support)
|
|
282
|
+
const dimensions = getPNGDimensions(new Uint8Array(buffer));
|
|
283
|
+
if (dimensions) {
|
|
284
|
+
const { width, height } = dimensions;
|
|
285
|
+
const expectedWidth = THRESHOLDS.image.width;
|
|
286
|
+
const expectedHeight = THRESHOLDS.image.height;
|
|
287
|
+
if (width === expectedWidth && height === expectedHeight) {
|
|
288
|
+
checks.push({
|
|
289
|
+
name: 'og:image dimensions',
|
|
290
|
+
status: 'pass',
|
|
291
|
+
message: `Perfect dimensions (${width}x${height})`,
|
|
292
|
+
value: `${width}x${height}`
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
else if (width >= expectedWidth && height >= expectedHeight) {
|
|
296
|
+
checks.push({
|
|
297
|
+
name: 'og:image dimensions',
|
|
298
|
+
status: 'warn',
|
|
299
|
+
message: `Dimensions larger than needed (${width}x${height})`,
|
|
300
|
+
value: `${width}x${height}`,
|
|
301
|
+
recommendation: `Recommended: ${expectedWidth}x${expectedHeight}px`
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
checks.push({
|
|
306
|
+
name: 'og:image dimensions',
|
|
307
|
+
status: 'warn',
|
|
308
|
+
message: `Non-standard dimensions (${width}x${height})`,
|
|
309
|
+
value: `${width}x${height}`,
|
|
310
|
+
recommendation: `Recommended: ${expectedWidth}x${expectedHeight}px for best display`
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
checks.push({
|
|
317
|
+
name: 'og:image accessible',
|
|
318
|
+
status: 'fail',
|
|
319
|
+
message: `Could not fetch image: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
320
|
+
recommendation: 'Ensure the image URL is publicly accessible'
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return checks;
|
|
324
|
+
}
|
|
325
|
+
function checkUrl(ogUrl, pageUrl) {
|
|
326
|
+
if (!ogUrl) {
|
|
327
|
+
return {
|
|
328
|
+
name: 'og:url',
|
|
329
|
+
status: 'warn',
|
|
330
|
+
message: 'Missing og:url tag',
|
|
331
|
+
recommendation: 'Add <meta property="og:url" content="https://example.com/page"> for canonical URL'
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
name: 'og:url',
|
|
336
|
+
status: 'pass',
|
|
337
|
+
message: 'og:url is present',
|
|
338
|
+
value: ogUrl
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function checkTwitterCard(card) {
|
|
342
|
+
if (!card) {
|
|
343
|
+
return {
|
|
344
|
+
name: 'twitter:card',
|
|
345
|
+
status: 'warn',
|
|
346
|
+
message: 'Missing twitter:card tag',
|
|
347
|
+
recommendation: 'Add <meta name="twitter:card" content="summary_large_image"> for Twitter/X'
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const validCards = ['summary', 'summary_large_image', 'app', 'player'];
|
|
351
|
+
if (!validCards.includes(card)) {
|
|
352
|
+
return {
|
|
353
|
+
name: 'twitter:card',
|
|
354
|
+
status: 'warn',
|
|
355
|
+
message: `Unknown twitter:card type: ${card}`,
|
|
356
|
+
value: card,
|
|
357
|
+
recommendation: 'Use "summary_large_image" for best image display'
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (card === 'summary') {
|
|
361
|
+
return {
|
|
362
|
+
name: 'twitter:card',
|
|
363
|
+
status: 'pass',
|
|
364
|
+
message: 'twitter:card is set to summary',
|
|
365
|
+
value: card,
|
|
366
|
+
recommendation: 'Consider "summary_large_image" for larger image display'
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
name: 'twitter:card',
|
|
371
|
+
status: 'pass',
|
|
372
|
+
message: `twitter:card is set to ${card}`,
|
|
373
|
+
value: card
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function getPNGDimensions(data) {
|
|
377
|
+
// PNG signature: 137 80 78 71 13 10 26 10
|
|
378
|
+
if (data[0] !== 137 || data[1] !== 80 || data[2] !== 78 || data[3] !== 71) {
|
|
379
|
+
// Try JPEG
|
|
380
|
+
if (data[0] === 0xFF && data[1] === 0xD8) {
|
|
381
|
+
return getJPEGDimensions(data);
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
// Width is at bytes 16-19, height at 20-23 (big endian)
|
|
386
|
+
const width = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19];
|
|
387
|
+
const height = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23];
|
|
388
|
+
return { width, height };
|
|
389
|
+
}
|
|
390
|
+
function getJPEGDimensions(data) {
|
|
391
|
+
let offset = 2; // Skip SOI marker
|
|
392
|
+
while (offset < data.length) {
|
|
393
|
+
if (data[offset] !== 0xFF)
|
|
394
|
+
return null;
|
|
395
|
+
const marker = data[offset + 1];
|
|
396
|
+
// SOF0, SOF1, SOF2 markers contain dimensions
|
|
397
|
+
if (marker >= 0xC0 && marker <= 0xC3) {
|
|
398
|
+
const height = (data[offset + 5] << 8) | data[offset + 6];
|
|
399
|
+
const width = (data[offset + 7] << 8) | data[offset + 8];
|
|
400
|
+
return { width, height };
|
|
401
|
+
}
|
|
402
|
+
// Skip to next marker
|
|
403
|
+
const length = (data[offset + 2] << 8) | data[offset + 3];
|
|
404
|
+
offset += 2 + length;
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
function calculateScore(checks) {
|
|
409
|
+
const weights = {
|
|
410
|
+
'og:title': 15,
|
|
411
|
+
'og:description': 15,
|
|
412
|
+
'og:image': 20,
|
|
413
|
+
'og:image URL': 5,
|
|
414
|
+
'og:image accessible': 15,
|
|
415
|
+
'og:image format': 5,
|
|
416
|
+
'og:image size': 10,
|
|
417
|
+
'og:image dimensions': 10,
|
|
418
|
+
'og:url': 5,
|
|
419
|
+
'twitter:card': 5
|
|
420
|
+
};
|
|
421
|
+
let score = 0;
|
|
422
|
+
let maxPossible = 0;
|
|
423
|
+
for (const check of checks) {
|
|
424
|
+
const weight = weights[check.name] || 5;
|
|
425
|
+
maxPossible += weight;
|
|
426
|
+
if (check.status === 'pass') {
|
|
427
|
+
score += weight;
|
|
428
|
+
}
|
|
429
|
+
else if (check.status === 'warn') {
|
|
430
|
+
score += weight * 0.5;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return Math.round((score / maxPossible) * 100);
|
|
434
|
+
}
|
|
435
|
+
export function formatValidationResult(result) {
|
|
436
|
+
const lines = [];
|
|
437
|
+
lines.push('');
|
|
438
|
+
lines.push(` OG Validation: ${result.url}`);
|
|
439
|
+
lines.push(` ${'─'.repeat(50)}`);
|
|
440
|
+
lines.push('');
|
|
441
|
+
for (const check of result.checks) {
|
|
442
|
+
const icon = check.status === 'pass' ? '✓' : check.status === 'warn' ? '!' : '✗';
|
|
443
|
+
const color = check.status === 'pass' ? '\x1b[32m' : check.status === 'warn' ? '\x1b[33m' : '\x1b[31m';
|
|
444
|
+
const reset = '\x1b[0m';
|
|
445
|
+
lines.push(` ${color}${icon}${reset} ${check.name}`);
|
|
446
|
+
lines.push(` ${check.message}`);
|
|
447
|
+
if (check.recommendation) {
|
|
448
|
+
lines.push(` → ${check.recommendation}`);
|
|
449
|
+
}
|
|
450
|
+
lines.push('');
|
|
451
|
+
}
|
|
452
|
+
const scoreColor = result.score >= 80 ? '\x1b[32m' : result.score >= 50 ? '\x1b[33m' : '\x1b[31m';
|
|
453
|
+
const reset = '\x1b[0m';
|
|
454
|
+
lines.push(` ${'─'.repeat(50)}`);
|
|
455
|
+
lines.push(` Score: ${scoreColor}${result.score}${reset}/100`);
|
|
456
|
+
lines.push('');
|
|
457
|
+
return lines.join('\n');
|
|
458
|
+
}
|
|
459
|
+
//# sourceMappingURL=validate.js.map
|