@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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Branded template - full featured with grid overlay, gradient glows, and tag
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
- .glow {
27
+ .grid {
28
28
  position: absolute;
29
- inset: -10% -10% auto -10%;
30
- height: 520px;
31
- background:
32
- radial-gradient(circle at 15% 35%, ${ctx.accent}40, transparent 58%),
33
- radial-gradient(circle at 70% 0%, ${ctx.accentSecondary}38, transparent 65%),
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}08 1px, transparent 1px),
42
- linear-gradient(90deg, ${ctx.textColor}08 1px, transparent 1px);
43
- background-size: 40px 40px;
44
- opacity: 0.5;
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: 72px 80px;
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: 10px 18px;
60
- border-radius: 999px;
61
- border: 1px solid ${ctx.textColor}20;
62
- background: ${ctx.background}cc;
63
- font-size: 14px;
64
- font-weight: 600;
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.18em;
67
- color: ${ctx.accentSecondary};
68
- margin-bottom: 28px;
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: 72px;
74
- font-weight: 600;
75
- line-height: 1.1;
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: 20px;
91
+ margin-bottom: 24px;
92
+ letter-spacing: -0.02em;
78
93
  }
79
94
  .subtitle {
80
- font-size: 28px;
81
- color: ${ctx.textColor}99;
82
- max-width: 700px;
83
- line-height: 1.4;
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: 72px;
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: 16px;
95
- height: 16px;
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: 24px;
103
- font-weight: 600;
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;;;;;;;;;6CASiB,GAAG,CAAC,MAAM;4CACX,GAAG,CAAC,eAAe;6CAClB,GAAG,CAAC,MAAM;;;;;;;0BAO7B,GAAG,CAAC,SAAS;iCACN,GAAG,CAAC,SAAS;;;;;;;;;;;;;;;;;;;0BAmBpB,GAAG,CAAC,SAAS;oBACnB,GAAG,CAAC,UAAU;;;;;eAKnB,GAAG,CAAC,eAAe;;;;;sBAKZ,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;;;;;;;;;eASnB,GAAG,CAAC,SAAS;;;;;;;;;;;;;;;;oBAgBR,GAAG,CAAC,MAAM;8BACA,GAAG,CAAC,MAAM;;;sBAGlB,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;;;;;;;;;;MAU5B,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;;;;CAInE,CAAA"}
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"}
@@ -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