recker 1.0.27 → 1.0.28-next.9eb3868
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/dist/browser/scrape/extractors.js +2 -1
- package/dist/browser/scrape/types.d.ts +2 -1
- package/dist/cli/index.js +142 -3
- package/dist/cli/tui/shell.d.ts +1 -0
- package/dist/cli/tui/shell.js +157 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scrape/extractors.js +2 -1
- package/dist/scrape/types.d.ts +2 -1
- package/dist/seo/analyzer.d.ts +42 -0
- package/dist/seo/analyzer.js +727 -0
- package/dist/seo/index.d.ts +5 -0
- package/dist/seo/index.js +2 -0
- package/dist/seo/rules/accessibility.d.ts +2 -0
- package/dist/seo/rules/accessibility.js +128 -0
- package/dist/seo/rules/content.d.ts +2 -0
- package/dist/seo/rules/content.js +236 -0
- package/dist/seo/rules/crawl.d.ts +2 -0
- package/dist/seo/rules/crawl.js +307 -0
- package/dist/seo/rules/cwv.d.ts +2 -0
- package/dist/seo/rules/cwv.js +337 -0
- package/dist/seo/rules/ecommerce.d.ts +2 -0
- package/dist/seo/rules/ecommerce.js +252 -0
- package/dist/seo/rules/i18n.d.ts +2 -0
- package/dist/seo/rules/i18n.js +222 -0
- package/dist/seo/rules/images.d.ts +2 -0
- package/dist/seo/rules/images.js +180 -0
- package/dist/seo/rules/index.d.ts +52 -0
- package/dist/seo/rules/index.js +141 -0
- package/dist/seo/rules/internal-linking.d.ts +2 -0
- package/dist/seo/rules/internal-linking.js +375 -0
- package/dist/seo/rules/links.d.ts +2 -0
- package/dist/seo/rules/links.js +150 -0
- package/dist/seo/rules/local.d.ts +2 -0
- package/dist/seo/rules/local.js +265 -0
- package/dist/seo/rules/meta.d.ts +2 -0
- package/dist/seo/rules/meta.js +523 -0
- package/dist/seo/rules/mobile.d.ts +2 -0
- package/dist/seo/rules/mobile.js +71 -0
- package/dist/seo/rules/performance.d.ts +2 -0
- package/dist/seo/rules/performance.js +246 -0
- package/dist/seo/rules/pwa.d.ts +2 -0
- package/dist/seo/rules/pwa.js +302 -0
- package/dist/seo/rules/readability.d.ts +2 -0
- package/dist/seo/rules/readability.js +255 -0
- package/dist/seo/rules/schema.d.ts +2 -0
- package/dist/seo/rules/schema.js +54 -0
- package/dist/seo/rules/security.d.ts +2 -0
- package/dist/seo/rules/security.js +147 -0
- package/dist/seo/rules/social.d.ts +2 -0
- package/dist/seo/rules/social.js +373 -0
- package/dist/seo/rules/structural.d.ts +2 -0
- package/dist/seo/rules/structural.js +155 -0
- package/dist/seo/rules/technical.d.ts +2 -0
- package/dist/seo/rules/technical.js +223 -0
- package/dist/seo/rules/thresholds.d.ts +196 -0
- package/dist/seo/rules/thresholds.js +118 -0
- package/dist/seo/rules/types.d.ts +322 -0
- package/dist/seo/rules/types.js +11 -0
- package/dist/seo/types.d.ts +160 -0
- package/dist/seo/types.js +1 -0
- package/dist/utils/columns.d.ts +14 -0
- package/dist/utils/columns.js +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
import { SEO_THRESHOLDS } from './thresholds.js';
|
|
3
|
+
export const metaRules = [
|
|
4
|
+
{
|
|
5
|
+
id: 'title-exists',
|
|
6
|
+
name: 'Title Tag Exists',
|
|
7
|
+
category: 'title',
|
|
8
|
+
severity: 'error',
|
|
9
|
+
description: 'Page must have a title tag',
|
|
10
|
+
check: (ctx) => {
|
|
11
|
+
if (!ctx.title) {
|
|
12
|
+
return createResult({ id: 'title-exists', name: 'Title Tag', category: 'title', severity: 'error' }, 'fail', 'Missing title tag', {
|
|
13
|
+
recommendation: 'Add a unique, descriptive title tag between 50-60 characters',
|
|
14
|
+
evidence: {
|
|
15
|
+
expected: '<title>Your Page Title - Brand Name</title>',
|
|
16
|
+
found: 'No <title> tag found in <head>',
|
|
17
|
+
impact: 'Search engines cannot display your page title in results, reducing click-through rate',
|
|
18
|
+
example: '<head>\n <title>Product Name - Buy Online | YourStore</title>\n</head>',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'title-length',
|
|
27
|
+
name: 'Title Length',
|
|
28
|
+
category: 'title',
|
|
29
|
+
severity: 'warning',
|
|
30
|
+
description: 'Title should be between 50-60 characters',
|
|
31
|
+
check: (ctx) => {
|
|
32
|
+
if (!ctx.title)
|
|
33
|
+
return null;
|
|
34
|
+
const len = ctx.titleLength ?? ctx.title.length;
|
|
35
|
+
const { min, ideal, max } = SEO_THRESHOLDS.title;
|
|
36
|
+
if (len < min) {
|
|
37
|
+
return createResult({ id: 'title-length', name: 'Title Length', category: 'title', severity: 'warning' }, 'warn', `Title too short (${len} chars, min: ${min})`, { value: len, recommendation: `Expand title to ${ideal.min}-${ideal.max} characters` });
|
|
38
|
+
}
|
|
39
|
+
if (len > max) {
|
|
40
|
+
return createResult({ id: 'title-length', name: 'Title Length', category: 'title', severity: 'warning' }, 'warn', `Title too long (${len} chars, will be truncated after ~60)`, { value: len, recommendation: `Shorten title to under ${ideal.max} characters` });
|
|
41
|
+
}
|
|
42
|
+
if (len >= ideal.min && len <= ideal.max) {
|
|
43
|
+
return createResult({ id: 'title-length', name: 'Title Length', category: 'title', severity: 'warning' }, 'pass', `Title length ideal (${len} chars)`, { value: len });
|
|
44
|
+
}
|
|
45
|
+
return createResult({ id: 'title-length', name: 'Title Length', category: 'title', severity: 'warning' }, 'pass', `Title length OK (${len} chars)`, { value: len });
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'title-no-caps',
|
|
50
|
+
name: 'Title Case',
|
|
51
|
+
category: 'title',
|
|
52
|
+
severity: 'warning',
|
|
53
|
+
description: 'Title should not be ALL CAPS',
|
|
54
|
+
check: (ctx) => {
|
|
55
|
+
if (!ctx.title)
|
|
56
|
+
return null;
|
|
57
|
+
const words = ctx.title.split(/\s+/).filter((w) => w.length > 3);
|
|
58
|
+
const allCapsWords = words.filter((w) => w === w.toUpperCase() && /[A-Z]/.test(w));
|
|
59
|
+
if (allCapsWords.length > words.length / 2) {
|
|
60
|
+
return createResult({ id: 'title-no-caps', name: 'Title Case', category: 'title', severity: 'warning' }, 'warn', 'Title appears to be ALL CAPS', { recommendation: 'Use title case or sentence case for better readability' });
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'title-h1-different',
|
|
67
|
+
name: 'Title vs H1',
|
|
68
|
+
category: 'title',
|
|
69
|
+
severity: 'warning',
|
|
70
|
+
description: 'Title and H1 should be similar but not identical',
|
|
71
|
+
check: (ctx) => {
|
|
72
|
+
if (!ctx.title || !ctx.h1Text)
|
|
73
|
+
return null;
|
|
74
|
+
const titleNorm = ctx.title.toLowerCase().trim();
|
|
75
|
+
const h1Norm = ctx.h1Text.toLowerCase().trim();
|
|
76
|
+
if (titleNorm === h1Norm) {
|
|
77
|
+
return createResult({ id: 'title-h1-different', name: 'Title vs H1', category: 'title', severity: 'warning' }, 'warn', 'Title and H1 are identical', { recommendation: 'Consider making H1 slightly different from title for variety' });
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'meta-description-exists',
|
|
84
|
+
name: 'Meta Description Exists',
|
|
85
|
+
category: 'meta',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
description: 'Page must have a meta description',
|
|
88
|
+
check: (ctx) => {
|
|
89
|
+
if (!ctx.metaDescription) {
|
|
90
|
+
return createResult({ id: 'meta-description-exists', name: 'Meta Description', category: 'meta', severity: 'error' }, 'fail', 'Missing meta description', {
|
|
91
|
+
recommendation: 'Add a compelling meta description (120-155 characters) that summarizes the page content',
|
|
92
|
+
evidence: {
|
|
93
|
+
expected: '<meta name="description" content="Your page description here...">',
|
|
94
|
+
found: 'No meta description tag found',
|
|
95
|
+
impact: 'Search engines may generate their own snippet, which may not be optimal for click-through rate',
|
|
96
|
+
example: '<meta name="description" content="Shop the best deals on electronics. Free shipping on orders over $50. 30-day returns.">',
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'meta-description-length',
|
|
105
|
+
name: 'Meta Description Length',
|
|
106
|
+
category: 'meta',
|
|
107
|
+
severity: 'warning',
|
|
108
|
+
description: 'Meta description should be 120-155 characters',
|
|
109
|
+
check: (ctx) => {
|
|
110
|
+
if (!ctx.metaDescription)
|
|
111
|
+
return null;
|
|
112
|
+
const len = ctx.metaDescriptionLength ?? ctx.metaDescription.length;
|
|
113
|
+
const { min, ideal, max } = SEO_THRESHOLDS.metaDescription;
|
|
114
|
+
if (len < min) {
|
|
115
|
+
return createResult({ id: 'meta-description-length', name: 'Meta Description Length', category: 'meta', severity: 'warning' }, 'warn', `Description too short (${len} chars, min: ${min})`, { value: len, recommendation: `Expand to ${ideal.min}-${ideal.max} characters` });
|
|
116
|
+
}
|
|
117
|
+
if (len > max) {
|
|
118
|
+
return createResult({ id: 'meta-description-length', name: 'Meta Description Length', category: 'meta', severity: 'warning' }, 'warn', `Description may be truncated (${len} chars, max: ${max})`, { value: len, recommendation: `Shorten to under ${max} characters` });
|
|
119
|
+
}
|
|
120
|
+
if (len >= ideal.min && len <= ideal.max) {
|
|
121
|
+
return createResult({ id: 'meta-description-length', name: 'Meta Description Length', category: 'meta', severity: 'warning' }, 'pass', `Description length ideal (${len} chars)`, { value: len });
|
|
122
|
+
}
|
|
123
|
+
return createResult({ id: 'meta-description-length', name: 'Meta Description Length', category: 'meta', severity: 'warning' }, 'pass', `Description length OK (${len} chars)`, { value: len });
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'meta-description-unique',
|
|
128
|
+
name: 'Description Quality',
|
|
129
|
+
category: 'meta',
|
|
130
|
+
severity: 'info',
|
|
131
|
+
description: 'Meta description should be unique and compelling',
|
|
132
|
+
check: (ctx) => {
|
|
133
|
+
if (!ctx.metaDescription)
|
|
134
|
+
return null;
|
|
135
|
+
const desc = ctx.metaDescription.toLowerCase();
|
|
136
|
+
const placeholders = ['lorem ipsum', 'description here', 'todo', 'placeholder', 'change this'];
|
|
137
|
+
for (const placeholder of placeholders) {
|
|
138
|
+
if (desc.includes(placeholder)) {
|
|
139
|
+
return createResult({ id: 'meta-description-unique', name: 'Description Quality', category: 'meta', severity: 'info' }, 'warn', 'Meta description appears to be a placeholder', { recommendation: 'Replace with a unique, compelling description for better CTR' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'og-title-exists',
|
|
147
|
+
name: 'OG Title Exists',
|
|
148
|
+
category: 'og',
|
|
149
|
+
severity: 'error',
|
|
150
|
+
description: 'og:title must be defined (do not rely on <title>)',
|
|
151
|
+
check: (ctx) => {
|
|
152
|
+
if (!ctx.ogTitle) {
|
|
153
|
+
return createResult({ id: 'og-title-exists', name: 'OG Title', category: 'og', severity: 'error' }, 'fail', 'Missing og:title', {
|
|
154
|
+
recommendation: 'Add og:title meta tag for better social sharing on Facebook, LinkedIn, etc.',
|
|
155
|
+
evidence: {
|
|
156
|
+
expected: '<meta property="og:title" content="Your Page Title">',
|
|
157
|
+
found: 'No og:title meta tag found',
|
|
158
|
+
impact: 'Social platforms may use <title> or auto-generate a title, which may not be optimal',
|
|
159
|
+
example: '<meta property="og:title" content="Amazing Product - 50% Off Today Only!">',
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'og-title-length',
|
|
168
|
+
name: 'OG Title Length',
|
|
169
|
+
category: 'og',
|
|
170
|
+
severity: 'warning',
|
|
171
|
+
description: 'og:title should be 60-70 characters (max 90)',
|
|
172
|
+
check: (ctx) => {
|
|
173
|
+
if (!ctx.ogTitle)
|
|
174
|
+
return null;
|
|
175
|
+
const len = ctx.ogTitle.length;
|
|
176
|
+
const { ideal, max } = SEO_THRESHOLDS.og.title;
|
|
177
|
+
if (len > max) {
|
|
178
|
+
return createResult({ id: 'og-title-length', name: 'OG Title Length', category: 'og', severity: 'warning' }, 'warn', `og:title too long (${len} chars, truncates at ~${max})`, { value: len, recommendation: `Shorten to ${ideal.max} characters` });
|
|
179
|
+
}
|
|
180
|
+
if (len >= ideal.min && len <= ideal.max) {
|
|
181
|
+
return createResult({ id: 'og-title-length', name: 'OG Title Length', category: 'og', severity: 'warning' }, 'pass', `og:title length ideal (${len} chars)`, { value: len });
|
|
182
|
+
}
|
|
183
|
+
return createResult({ id: 'og-title-length', name: 'OG Title Length', category: 'og', severity: 'warning' }, 'pass', `og:title length OK (${len} chars)`, { value: len });
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'og-title-no-emoji',
|
|
188
|
+
name: 'OG Title No Emoji',
|
|
189
|
+
category: 'og',
|
|
190
|
+
severity: 'warning',
|
|
191
|
+
description: 'og:title should not contain emojis (some networks remove them)',
|
|
192
|
+
check: (ctx) => {
|
|
193
|
+
if (!ctx.ogTitle)
|
|
194
|
+
return null;
|
|
195
|
+
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u;
|
|
196
|
+
if (emojiRegex.test(ctx.ogTitle)) {
|
|
197
|
+
return createResult({ id: 'og-title-no-emoji', name: 'OG Title Emoji', category: 'og', severity: 'warning' }, 'warn', 'og:title contains emojis (some networks remove them)', { recommendation: 'Remove emojis from og:title for consistent display' });
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'og-description-exists',
|
|
204
|
+
name: 'OG Description Exists',
|
|
205
|
+
category: 'og',
|
|
206
|
+
severity: 'error',
|
|
207
|
+
description: 'og:description must be defined',
|
|
208
|
+
check: (ctx) => {
|
|
209
|
+
if (!ctx.ogDescription) {
|
|
210
|
+
return createResult({ id: 'og-description-exists', name: 'OG Description', category: 'og', severity: 'error' }, 'fail', 'Missing og:description', {
|
|
211
|
+
recommendation: 'Add og:description for compelling social media previews',
|
|
212
|
+
evidence: {
|
|
213
|
+
expected: '<meta property="og:description" content="Your description here...">',
|
|
214
|
+
found: 'No og:description meta tag found',
|
|
215
|
+
impact: 'Social shares may have no description or use auto-generated text',
|
|
216
|
+
example: '<meta property="og:description" content="Discover our latest collection. Shop now and get free shipping on orders over $50.">',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 'og-description-length',
|
|
225
|
+
name: 'OG Description Length',
|
|
226
|
+
category: 'og',
|
|
227
|
+
severity: 'warning',
|
|
228
|
+
description: 'og:description should be 110-155 characters (max 200)',
|
|
229
|
+
check: (ctx) => {
|
|
230
|
+
if (!ctx.ogDescription)
|
|
231
|
+
return null;
|
|
232
|
+
const len = ctx.ogDescription.length;
|
|
233
|
+
const { ideal, max } = SEO_THRESHOLDS.og.description;
|
|
234
|
+
if (len > max) {
|
|
235
|
+
return createResult({ id: 'og-description-length', name: 'OG Description Length', category: 'og', severity: 'warning' }, 'warn', `og:description too long (${len} chars, truncates at ~${max})`, { value: len, recommendation: `Shorten to ${ideal.max} characters` });
|
|
236
|
+
}
|
|
237
|
+
if (len >= ideal.min && len <= ideal.max) {
|
|
238
|
+
return createResult({ id: 'og-description-length', name: 'OG Description Length', category: 'og', severity: 'warning' }, 'pass', `og:description length ideal (${len} chars)`, { value: len });
|
|
239
|
+
}
|
|
240
|
+
return createResult({ id: 'og-description-length', name: 'OG Description Length', category: 'og', severity: 'warning' }, 'pass', `og:description length OK (${len} chars)`, { value: len });
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'og-image-exists',
|
|
245
|
+
name: 'OG Image Exists',
|
|
246
|
+
category: 'og',
|
|
247
|
+
severity: 'error',
|
|
248
|
+
description: 'og:image must be defined and publicly accessible',
|
|
249
|
+
check: (ctx) => {
|
|
250
|
+
if (!ctx.ogImage) {
|
|
251
|
+
return createResult({ id: 'og-image-exists', name: 'OG Image', category: 'og', severity: 'error' }, 'fail', 'Missing og:image', {
|
|
252
|
+
recommendation: 'Add og:image with a publicly accessible image (1200×630px recommended)',
|
|
253
|
+
evidence: {
|
|
254
|
+
expected: '<meta property="og:image" content="https://yoursite.com/image.jpg">',
|
|
255
|
+
found: 'No og:image meta tag found',
|
|
256
|
+
impact: 'Social shares will have no image preview, significantly reducing engagement',
|
|
257
|
+
example: '<meta property="og:image" content="https://yoursite.com/og-image.jpg">\n<meta property="og:image:width" content="1200">\n<meta property="og:image:height" content="630">',
|
|
258
|
+
learnMore: 'https://developers.facebook.com/docs/sharing/webmasters/images/',
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: 'og-image-https',
|
|
267
|
+
name: 'OG Image HTTPS',
|
|
268
|
+
category: 'og',
|
|
269
|
+
severity: 'error',
|
|
270
|
+
description: 'og:image URL must use HTTPS',
|
|
271
|
+
check: (ctx) => {
|
|
272
|
+
if (!ctx.ogImage)
|
|
273
|
+
return null;
|
|
274
|
+
if (ctx.ogImage.startsWith('http://')) {
|
|
275
|
+
return createResult({ id: 'og-image-https', name: 'OG Image HTTPS', category: 'og', severity: 'error' }, 'fail', 'og:image uses HTTP instead of HTTPS', { value: ctx.ogImage, recommendation: 'Always use HTTPS for og:image URLs' });
|
|
276
|
+
}
|
|
277
|
+
return createResult({ id: 'og-image-https', name: 'OG Image HTTPS', category: 'og', severity: 'error' }, 'pass', 'og:image uses HTTPS');
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: 'og-url-exists',
|
|
282
|
+
name: 'OG URL Exists',
|
|
283
|
+
category: 'og',
|
|
284
|
+
severity: 'warning',
|
|
285
|
+
description: 'og:url should be defined (canonical URL for sharing)',
|
|
286
|
+
check: (ctx) => {
|
|
287
|
+
if (!ctx.ogUrl) {
|
|
288
|
+
return createResult({ id: 'og-url-exists', name: 'OG URL', category: 'og', severity: 'warning' }, 'warn', 'Missing og:url', { recommendation: 'Add og:url with the canonical URL of the page' });
|
|
289
|
+
}
|
|
290
|
+
return createResult({ id: 'og-url-exists', name: 'OG URL', category: 'og', severity: 'warning' }, 'pass', 'og:url is defined', { value: ctx.ogUrl });
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: 'og-type-exists',
|
|
295
|
+
name: 'OG Type Exists',
|
|
296
|
+
category: 'og',
|
|
297
|
+
severity: 'warning',
|
|
298
|
+
description: 'og:type should be defined (website, article, etc.)',
|
|
299
|
+
check: (ctx) => {
|
|
300
|
+
if (!ctx.ogType) {
|
|
301
|
+
return createResult({ id: 'og-type-exists', name: 'OG Type', category: 'og', severity: 'warning' }, 'warn', 'Missing og:type', { recommendation: 'Add og:type (website, article, product, etc.)' });
|
|
302
|
+
}
|
|
303
|
+
return createResult({ id: 'og-type-exists', name: 'OG Type', category: 'og', severity: 'warning' }, 'pass', `og:type is defined (${ctx.ogType})`);
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: 'og-image-url-length',
|
|
308
|
+
name: 'OG Image URL Length',
|
|
309
|
+
category: 'og',
|
|
310
|
+
severity: 'warning',
|
|
311
|
+
description: 'og:image URL should be under 2000 characters',
|
|
312
|
+
check: (ctx) => {
|
|
313
|
+
if (!ctx.ogImage)
|
|
314
|
+
return null;
|
|
315
|
+
const maxLen = SEO_THRESHOLDS.og.meta.maxUrlLength;
|
|
316
|
+
if (ctx.ogImage.length > maxLen) {
|
|
317
|
+
return createResult({ id: 'og-image-url-length', name: 'OG Image URL Length', category: 'og', severity: 'warning' }, 'warn', `og:image URL too long (${ctx.ogImage.length} chars, max: ${maxLen})`, { value: ctx.ogImage.length, recommendation: 'Shorten the image URL path' });
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: 'og-image-url-quality',
|
|
324
|
+
name: 'OG Image URL Quality',
|
|
325
|
+
category: 'og',
|
|
326
|
+
severity: 'warning',
|
|
327
|
+
description: 'og:image URL should not have expiring tokens or excessive query params',
|
|
328
|
+
check: (ctx) => {
|
|
329
|
+
if (!ctx.ogImage)
|
|
330
|
+
return null;
|
|
331
|
+
try {
|
|
332
|
+
const url = new URL(ctx.ogImage);
|
|
333
|
+
const params = url.searchParams;
|
|
334
|
+
const expiringParams = ['expires', 'exp', 'token', 'sig', 'signature', 'auth'];
|
|
335
|
+
const hasExpiring = expiringParams.some((p) => params.has(p));
|
|
336
|
+
if (hasExpiring) {
|
|
337
|
+
return createResult({ id: 'og-image-url-quality', name: 'OG Image URL Quality', category: 'og', severity: 'warning' }, 'warn', 'og:image URL may have expiring tokens (Meta caches images)', { recommendation: 'Use permanent URLs without expiration tokens for og:image' });
|
|
338
|
+
}
|
|
339
|
+
if (Array.from(params.keys()).length > 5) {
|
|
340
|
+
return createResult({ id: 'og-image-url-quality', name: 'OG Image URL Quality', category: 'og', severity: 'warning' }, 'info', 'og:image URL has many query parameters', { recommendation: 'Simplify og:image URL for better caching' });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: 'og-description-emojis',
|
|
350
|
+
name: 'OG Description Emojis',
|
|
351
|
+
category: 'og',
|
|
352
|
+
severity: 'info',
|
|
353
|
+
description: 'og:description should not have excessive emojis',
|
|
354
|
+
check: (ctx) => {
|
|
355
|
+
if (!ctx.ogDescription)
|
|
356
|
+
return null;
|
|
357
|
+
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]/gu;
|
|
358
|
+
const emojis = ctx.ogDescription.match(emojiRegex) || [];
|
|
359
|
+
const maxEmojis = SEO_THRESHOLDS.og.meta.maxDescriptionEmojis;
|
|
360
|
+
if (emojis.length > maxEmojis) {
|
|
361
|
+
return createResult({ id: 'og-description-emojis', name: 'OG Description Emojis', category: 'og', severity: 'info' }, 'info', `og:description has ${emojis.length} emojis (recommended max: ${maxEmojis})`, { value: emojis.length, recommendation: 'Reduce emojis in og:description for better compatibility' });
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
id: 'og-title-caps',
|
|
368
|
+
name: 'OG Title Caps',
|
|
369
|
+
category: 'og',
|
|
370
|
+
severity: 'warning',
|
|
371
|
+
description: 'og:title should not be mostly uppercase (Meta may flag as low quality)',
|
|
372
|
+
check: (ctx) => {
|
|
373
|
+
if (!ctx.ogTitle)
|
|
374
|
+
return null;
|
|
375
|
+
const letters = ctx.ogTitle.replace(/[^a-zA-Z]/g, '');
|
|
376
|
+
if (letters.length < 5)
|
|
377
|
+
return null;
|
|
378
|
+
const uppercase = letters.replace(/[^A-Z]/g, '').length;
|
|
379
|
+
const percentage = Math.round((uppercase / letters.length) * 100);
|
|
380
|
+
const maxCaps = SEO_THRESHOLDS.og.meta.maxCapsPercentage;
|
|
381
|
+
if (percentage > maxCaps) {
|
|
382
|
+
return createResult({ id: 'og-title-caps', name: 'OG Title Caps', category: 'og', severity: 'warning' }, 'warn', `og:title has ${percentage}% uppercase (Meta may flag as low quality)`, { value: percentage, recommendation: 'Use normal capitalization in og:title' });
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
id: 'og-meta-complete',
|
|
389
|
+
name: 'Meta Complete',
|
|
390
|
+
category: 'og',
|
|
391
|
+
severity: 'warning',
|
|
392
|
+
description: 'All required OG tags for Meta/Facebook/Instagram must be present',
|
|
393
|
+
check: (ctx) => {
|
|
394
|
+
const required = {
|
|
395
|
+
'og:title': ctx.ogTitle,
|
|
396
|
+
'og:description': ctx.ogDescription,
|
|
397
|
+
'og:image': ctx.ogImage,
|
|
398
|
+
'og:url': ctx.ogUrl,
|
|
399
|
+
'og:type': ctx.ogType,
|
|
400
|
+
};
|
|
401
|
+
const missing = Object.entries(required)
|
|
402
|
+
.filter(([, value]) => !value)
|
|
403
|
+
.map(([key]) => key);
|
|
404
|
+
if (missing.length > 0) {
|
|
405
|
+
return createResult({ id: 'og-meta-complete', name: 'Meta Complete', category: 'og', severity: 'warning' }, 'warn', `Missing required Meta tags: ${missing.join(', ')}`, { recommendation: 'Meta (Facebook/Instagram) requires all 5 OG tags for proper previews' });
|
|
406
|
+
}
|
|
407
|
+
return createResult({ id: 'og-meta-complete', name: 'Meta Complete', category: 'og', severity: 'warning' }, 'pass', 'All required Meta OG tags present');
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
id: 'og-fallback-meta-title',
|
|
412
|
+
name: 'Fallback Meta Title',
|
|
413
|
+
category: 'og',
|
|
414
|
+
severity: 'info',
|
|
415
|
+
description: 'Having <meta name="title"> helps fallback on Reddit, Teams, Telegram',
|
|
416
|
+
check: (ctx) => {
|
|
417
|
+
if (ctx.ogTitle && !ctx.title) {
|
|
418
|
+
return createResult({ id: 'og-fallback-meta-title', name: 'Fallback Meta Title', category: 'og', severity: 'info' }, 'info', 'No <title> tag found (og:title exists)', { recommendation: 'Add <title> tag as fallback for universal compatibility' });
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: 'og-image-redirects',
|
|
425
|
+
name: 'OG Image Redirects',
|
|
426
|
+
category: 'og',
|
|
427
|
+
severity: 'warning',
|
|
428
|
+
description: 'og:image should not have redirect chains (Meta blocks >2 redirects)',
|
|
429
|
+
check: (ctx) => {
|
|
430
|
+
if (!ctx.ogImage)
|
|
431
|
+
return null;
|
|
432
|
+
try {
|
|
433
|
+
const url = new URL(ctx.ogImage);
|
|
434
|
+
const redirectPatterns = ['redirect', 'proxy', 'forward', 'goto', 'redir', 'bounce'];
|
|
435
|
+
const hasRedirectPattern = redirectPatterns.some((p) => url.pathname.toLowerCase().includes(p) || url.hostname.toLowerCase().includes(p));
|
|
436
|
+
if (hasRedirectPattern) {
|
|
437
|
+
return createResult({ id: 'og-image-redirects', name: 'OG Image Redirects', category: 'og', severity: 'warning' }, 'warn', 'og:image URL may contain redirects (Meta blocks >2 redirect chains)', { recommendation: 'Use direct, permanent image URLs for og:image' });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
id: 'og-image-public',
|
|
447
|
+
name: 'OG Image Public',
|
|
448
|
+
category: 'og',
|
|
449
|
+
severity: 'warning',
|
|
450
|
+
description: 'og:image must be publicly accessible (no auth, no private URLs)',
|
|
451
|
+
check: (ctx) => {
|
|
452
|
+
if (!ctx.ogImage)
|
|
453
|
+
return null;
|
|
454
|
+
try {
|
|
455
|
+
const url = new URL(ctx.ogImage);
|
|
456
|
+
if (url.username || url.password) {
|
|
457
|
+
return createResult({ id: 'og-image-public', name: 'OG Image Public', category: 'og', severity: 'warning' }, 'fail', 'og:image URL contains credentials (will fail on social platforms)', { recommendation: 'Use publicly accessible URLs without authentication' });
|
|
458
|
+
}
|
|
459
|
+
const hostname = url.hostname.toLowerCase();
|
|
460
|
+
const privatePatterns = ['localhost', '127.0.0.1', '192.168.', '10.', '172.16.', '172.17.', '172.18.', '172.19.'];
|
|
461
|
+
if (privatePatterns.some((p) => hostname.startsWith(p) || hostname === p.slice(0, -1))) {
|
|
462
|
+
return createResult({ id: 'og-image-public', name: 'OG Image Public', category: 'og', severity: 'warning' }, 'fail', 'og:image URL points to localhost/private IP (not accessible)', { recommendation: 'Use publicly accessible URLs for og:image' });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: 'twitter-card-exists',
|
|
472
|
+
name: 'Twitter Card Exists',
|
|
473
|
+
category: 'twitter',
|
|
474
|
+
severity: 'warning',
|
|
475
|
+
description: 'twitter:card should be defined (summary or summary_large_image)',
|
|
476
|
+
check: (ctx) => {
|
|
477
|
+
if (!ctx.twitterCard) {
|
|
478
|
+
return createResult({ id: 'twitter-card-exists', name: 'Twitter Card', category: 'twitter', severity: 'warning' }, 'warn', 'Missing twitter:card', { recommendation: 'Add twitter:card (summary or summary_large_image)' });
|
|
479
|
+
}
|
|
480
|
+
const validCards = ['summary', 'summary_large_image', 'player', 'app'];
|
|
481
|
+
if (!validCards.includes(ctx.twitterCard)) {
|
|
482
|
+
return createResult({ id: 'twitter-card-exists', name: 'Twitter Card', category: 'twitter', severity: 'warning' }, 'warn', `Invalid twitter:card value: ${ctx.twitterCard}`, { recommendation: 'Use summary or summary_large_image' });
|
|
483
|
+
}
|
|
484
|
+
return createResult({ id: 'twitter-card-exists', name: 'Twitter Card', category: 'twitter', severity: 'warning' }, 'pass', `twitter:card is defined (${ctx.twitterCard})`);
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
id: 'twitter-title-length',
|
|
489
|
+
name: 'Twitter Title Length',
|
|
490
|
+
category: 'twitter',
|
|
491
|
+
severity: 'warning',
|
|
492
|
+
description: 'twitter:title should be 55-70 characters',
|
|
493
|
+
check: (ctx) => {
|
|
494
|
+
const title = ctx.twitterTitle || ctx.ogTitle;
|
|
495
|
+
if (!title)
|
|
496
|
+
return null;
|
|
497
|
+
const len = title.length;
|
|
498
|
+
const { ideal, max } = SEO_THRESHOLDS.twitter.title;
|
|
499
|
+
if (len > max) {
|
|
500
|
+
return createResult({ id: 'twitter-title-length', name: 'Twitter Title Length', category: 'twitter', severity: 'warning' }, 'warn', `twitter:title too long (${len} chars, max: ${max})`, { value: len, recommendation: `Shorten to ${ideal.max} characters` });
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: 'twitter-description-length',
|
|
507
|
+
name: 'Twitter Description Length',
|
|
508
|
+
category: 'twitter',
|
|
509
|
+
severity: 'warning',
|
|
510
|
+
description: 'twitter:description should be 125-200 characters',
|
|
511
|
+
check: (ctx) => {
|
|
512
|
+
const description = ctx.twitterDescription || ctx.ogDescription;
|
|
513
|
+
if (!description)
|
|
514
|
+
return null;
|
|
515
|
+
const len = description.length;
|
|
516
|
+
const { max } = SEO_THRESHOLDS.twitter.description;
|
|
517
|
+
if (len > max) {
|
|
518
|
+
return createResult({ id: 'twitter-description-length', name: 'Twitter Description Length', category: 'twitter', severity: 'warning' }, 'warn', `twitter:description too long (${len} chars, max: ${max})`, { value: len, recommendation: `Shorten to ${max} characters` });
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const mobileRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'viewport-present',
|
|
5
|
+
name: 'Viewport',
|
|
6
|
+
category: 'mobile',
|
|
7
|
+
severity: 'error',
|
|
8
|
+
description: 'Page must have a viewport meta tag for mobile compatibility',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (!ctx.hasViewport) {
|
|
11
|
+
return createResult({ id: 'viewport-present', name: 'Viewport', category: 'mobile', severity: 'error' }, 'fail', 'Missing viewport meta tag', {
|
|
12
|
+
recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> for mobile responsiveness',
|
|
13
|
+
evidence: {
|
|
14
|
+
expected: '<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
15
|
+
found: 'No viewport meta tag found',
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return createResult({ id: 'viewport-present', name: 'Viewport', category: 'mobile', severity: 'error' }, 'pass', 'Viewport meta tag is present', { value: ctx.viewportContent });
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'viewport-scalable',
|
|
24
|
+
name: 'Viewport Scalable',
|
|
25
|
+
category: 'mobile',
|
|
26
|
+
severity: 'warning',
|
|
27
|
+
description: 'Viewport should allow user scaling for accessibility',
|
|
28
|
+
check: (ctx) => {
|
|
29
|
+
if (!ctx.viewportContent)
|
|
30
|
+
return null;
|
|
31
|
+
const content = ctx.viewportContent.toLowerCase();
|
|
32
|
+
const hasUserScalableNo = /user-scalable\s*=\s*no/.test(content);
|
|
33
|
+
const hasMaximumScale1 = /maximum-scale\s*=\s*1(\.0)?/.test(content);
|
|
34
|
+
if (hasUserScalableNo || hasMaximumScale1) {
|
|
35
|
+
const issues = [];
|
|
36
|
+
if (hasUserScalableNo)
|
|
37
|
+
issues.push('user-scalable=no');
|
|
38
|
+
if (hasMaximumScale1)
|
|
39
|
+
issues.push('maximum-scale=1');
|
|
40
|
+
return createResult({ id: 'viewport-scalable', name: 'Viewport Scalable', category: 'mobile', severity: 'warning' }, 'warn', `Viewport disables user scaling: ${issues.join(', ')}`, {
|
|
41
|
+
recommendation: 'Remove user-scalable=no and maximum-scale=1 to allow pinch-to-zoom for accessibility',
|
|
42
|
+
evidence: {
|
|
43
|
+
found: ctx.viewportContent,
|
|
44
|
+
issue: `Found: ${issues.join(', ')}`,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return createResult({ id: 'viewport-scalable', name: 'Viewport Scalable', category: 'mobile', severity: 'warning' }, 'pass', 'Viewport allows user scaling');
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'mobile-font-size',
|
|
53
|
+
name: 'Mobile Font Size',
|
|
54
|
+
category: 'mobile',
|
|
55
|
+
severity: 'info',
|
|
56
|
+
description: 'Content should use readable font sizes on mobile (at least 16px base)',
|
|
57
|
+
check: (ctx) => {
|
|
58
|
+
return createResult({ id: 'mobile-font-size', name: 'Mobile Font Size', category: 'mobile', severity: 'info' }, 'info', 'Font size check requires CSS analysis', { recommendation: 'Use a minimum base font size of 16px for mobile readability' });
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'mobile-tap-targets',
|
|
63
|
+
name: 'Mobile Tap Targets',
|
|
64
|
+
category: 'mobile',
|
|
65
|
+
severity: 'info',
|
|
66
|
+
description: 'Interactive elements should be at least 48x48px for touch accessibility',
|
|
67
|
+
check: (ctx) => {
|
|
68
|
+
return createResult({ id: 'mobile-tap-targets', name: 'Mobile Tap Targets', category: 'mobile', severity: 'info' }, 'info', 'Tap target size check requires CSS analysis', { recommendation: 'Ensure buttons and links are at least 48x48px with 8px spacing for mobile usability' });
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
];
|