recker 1.0.27 → 1.0.28-next.3bf98c7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/browser/scrape/extractors.js +2 -1
  2. package/dist/browser/scrape/types.d.ts +2 -1
  3. package/dist/cli/index.js +142 -3
  4. package/dist/cli/tui/shell.d.ts +2 -0
  5. package/dist/cli/tui/shell.js +492 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/scrape/extractors.js +2 -1
  9. package/dist/scrape/index.d.ts +2 -0
  10. package/dist/scrape/index.js +1 -0
  11. package/dist/scrape/spider.d.ts +61 -0
  12. package/dist/scrape/spider.js +250 -0
  13. package/dist/scrape/types.d.ts +2 -1
  14. package/dist/seo/analyzer.d.ts +42 -0
  15. package/dist/seo/analyzer.js +742 -0
  16. package/dist/seo/index.d.ts +7 -0
  17. package/dist/seo/index.js +3 -0
  18. package/dist/seo/rules/accessibility.d.ts +2 -0
  19. package/dist/seo/rules/accessibility.js +694 -0
  20. package/dist/seo/rules/best-practices.d.ts +2 -0
  21. package/dist/seo/rules/best-practices.js +188 -0
  22. package/dist/seo/rules/content.d.ts +2 -0
  23. package/dist/seo/rules/content.js +236 -0
  24. package/dist/seo/rules/crawl.d.ts +2 -0
  25. package/dist/seo/rules/crawl.js +307 -0
  26. package/dist/seo/rules/cwv.d.ts +2 -0
  27. package/dist/seo/rules/cwv.js +337 -0
  28. package/dist/seo/rules/ecommerce.d.ts +2 -0
  29. package/dist/seo/rules/ecommerce.js +252 -0
  30. package/dist/seo/rules/i18n.d.ts +2 -0
  31. package/dist/seo/rules/i18n.js +222 -0
  32. package/dist/seo/rules/images.d.ts +2 -0
  33. package/dist/seo/rules/images.js +180 -0
  34. package/dist/seo/rules/index.d.ts +52 -0
  35. package/dist/seo/rules/index.js +143 -0
  36. package/dist/seo/rules/internal-linking.d.ts +2 -0
  37. package/dist/seo/rules/internal-linking.js +375 -0
  38. package/dist/seo/rules/links.d.ts +2 -0
  39. package/dist/seo/rules/links.js +150 -0
  40. package/dist/seo/rules/local.d.ts +2 -0
  41. package/dist/seo/rules/local.js +265 -0
  42. package/dist/seo/rules/meta.d.ts +2 -0
  43. package/dist/seo/rules/meta.js +523 -0
  44. package/dist/seo/rules/mobile.d.ts +2 -0
  45. package/dist/seo/rules/mobile.js +71 -0
  46. package/dist/seo/rules/performance.d.ts +2 -0
  47. package/dist/seo/rules/performance.js +246 -0
  48. package/dist/seo/rules/pwa.d.ts +2 -0
  49. package/dist/seo/rules/pwa.js +302 -0
  50. package/dist/seo/rules/readability.d.ts +2 -0
  51. package/dist/seo/rules/readability.js +255 -0
  52. package/dist/seo/rules/schema.d.ts +2 -0
  53. package/dist/seo/rules/schema.js +54 -0
  54. package/dist/seo/rules/security.d.ts +2 -0
  55. package/dist/seo/rules/security.js +525 -0
  56. package/dist/seo/rules/social.d.ts +2 -0
  57. package/dist/seo/rules/social.js +373 -0
  58. package/dist/seo/rules/structural.d.ts +2 -0
  59. package/dist/seo/rules/structural.js +155 -0
  60. package/dist/seo/rules/technical.d.ts +2 -0
  61. package/dist/seo/rules/technical.js +223 -0
  62. package/dist/seo/rules/thresholds.d.ts +196 -0
  63. package/dist/seo/rules/thresholds.js +118 -0
  64. package/dist/seo/rules/types.d.ts +346 -0
  65. package/dist/seo/rules/types.js +11 -0
  66. package/dist/seo/seo-spider.d.ts +47 -0
  67. package/dist/seo/seo-spider.js +362 -0
  68. package/dist/seo/types.d.ts +184 -0
  69. package/dist/seo/types.js +1 -0
  70. package/dist/utils/columns.d.ts +14 -0
  71. package/dist/utils/columns.js +69 -0
  72. package/package.json +1 -1
@@ -0,0 +1,252 @@
1
+ import { createResult } from './types.js';
2
+ export const ecommerceRules = [
3
+ {
4
+ id: 'ecommerce-product-schema',
5
+ name: 'Product Schema',
6
+ category: 'structured-data',
7
+ severity: 'warning',
8
+ description: 'Product pages should have Product schema for rich snippets',
9
+ check: (ctx) => {
10
+ if (!ctx.jsonLdTypes)
11
+ return null;
12
+ if (!ctx.isProductPage)
13
+ return null;
14
+ const hasProduct = ctx.jsonLdTypes.includes('Product');
15
+ if (!hasProduct) {
16
+ return createResult({ id: 'ecommerce-product-schema', name: 'Product Schema', category: 'structured-data', severity: 'warning' }, 'warn', 'Product page missing Product schema', {
17
+ recommendation: 'Add Product structured data for rich snippets in search results',
18
+ evidence: {
19
+ expected: 'Product schema with name, image, price, availability',
20
+ example: `{
21
+ "@type": "Product",
22
+ "name": "Product Name",
23
+ "image": "https://example.com/image.jpg",
24
+ "offers": {
25
+ "@type": "Offer",
26
+ "price": "99.99",
27
+ "priceCurrency": "USD",
28
+ "availability": "https://schema.org/InStock"
29
+ }
30
+ }`,
31
+ learnMore: 'https://developers.google.com/search/docs/appearance/structured-data/product',
32
+ },
33
+ });
34
+ }
35
+ return createResult({ id: 'ecommerce-product-schema', name: 'Product Schema', category: 'structured-data', severity: 'warning' }, 'pass', 'Product schema found');
36
+ },
37
+ },
38
+ {
39
+ id: 'ecommerce-product-price',
40
+ name: 'Product Price',
41
+ category: 'structured-data',
42
+ severity: 'warning',
43
+ description: 'Product schema should include price information',
44
+ check: (ctx) => {
45
+ if (!ctx.productSchema)
46
+ return null;
47
+ const hasPrice = ctx.productSchema.offers?.price !== undefined ||
48
+ ctx.productSchema.offers?.lowPrice !== undefined;
49
+ const hasCurrency = ctx.productSchema.offers?.priceCurrency !== undefined;
50
+ if (!hasPrice) {
51
+ return createResult({ id: 'ecommerce-product-price', name: 'Product Price', category: 'structured-data', severity: 'warning' }, 'warn', 'Product schema missing price', {
52
+ recommendation: 'Add price to Product offers for price display in search results',
53
+ evidence: {
54
+ expected: 'offers.price or offers.lowPrice with priceCurrency',
55
+ impact: 'Products without price may not show in Google Shopping results',
56
+ },
57
+ });
58
+ }
59
+ if (!hasCurrency) {
60
+ return createResult({ id: 'ecommerce-product-price', name: 'Product Price', category: 'structured-data', severity: 'warning' }, 'warn', 'Product schema missing currency', {
61
+ recommendation: 'Add priceCurrency (e.g., USD, EUR, BRL) to offers',
62
+ evidence: {
63
+ found: `price: ${ctx.productSchema.offers?.price}`,
64
+ expected: 'priceCurrency: "USD" or similar ISO 4217 code',
65
+ },
66
+ });
67
+ }
68
+ return createResult({ id: 'ecommerce-product-price', name: 'Product Price', category: 'structured-data', severity: 'warning' }, 'pass', `Price: ${ctx.productSchema.offers?.priceCurrency} ${ctx.productSchema.offers?.price || ctx.productSchema.offers?.lowPrice}`);
69
+ },
70
+ },
71
+ {
72
+ id: 'ecommerce-product-availability',
73
+ name: 'Product Availability',
74
+ category: 'structured-data',
75
+ severity: 'info',
76
+ description: 'Product schema should include availability status',
77
+ check: (ctx) => {
78
+ if (!ctx.productSchema?.offers)
79
+ return null;
80
+ const availability = ctx.productSchema.offers.availability;
81
+ if (!availability) {
82
+ return createResult({ id: 'ecommerce-product-availability', name: 'Product Availability', category: 'structured-data', severity: 'info' }, 'info', 'Product schema missing availability', {
83
+ recommendation: 'Add availability to help users know if product is in stock',
84
+ evidence: {
85
+ expected: 'availability: "https://schema.org/InStock" or similar',
86
+ example: 'InStock, OutOfStock, PreOrder, BackOrder, Discontinued',
87
+ learnMore: 'https://schema.org/ItemAvailability',
88
+ },
89
+ });
90
+ }
91
+ const availType = availability.replace('https://schema.org/', '').replace('http://schema.org/', '');
92
+ return createResult({ id: 'ecommerce-product-availability', name: 'Product Availability', category: 'structured-data', severity: 'info' }, 'pass', `Availability: ${availType}`);
93
+ },
94
+ },
95
+ {
96
+ id: 'ecommerce-product-image',
97
+ name: 'Product Image',
98
+ category: 'structured-data',
99
+ severity: 'warning',
100
+ description: 'Product schema should include high-quality images',
101
+ check: (ctx) => {
102
+ if (!ctx.productSchema)
103
+ return null;
104
+ const hasImage = ctx.productSchema.image !== undefined;
105
+ if (!hasImage) {
106
+ return createResult({ id: 'ecommerce-product-image', name: 'Product Image', category: 'structured-data', severity: 'warning' }, 'warn', 'Product schema missing image', {
107
+ recommendation: 'Add product images for visual search results',
108
+ evidence: {
109
+ expected: 'At least one high-quality product image',
110
+ impact: 'Products without images are less likely to appear in image search and shopping results',
111
+ },
112
+ });
113
+ }
114
+ const imageCount = Array.isArray(ctx.productSchema.image)
115
+ ? ctx.productSchema.image.length
116
+ : 1;
117
+ return createResult({ id: 'ecommerce-product-image', name: 'Product Image', category: 'structured-data', severity: 'warning' }, 'pass', `${imageCount} product image(s) in schema`);
118
+ },
119
+ },
120
+ {
121
+ id: 'ecommerce-product-reviews',
122
+ name: 'Product Reviews',
123
+ category: 'structured-data',
124
+ severity: 'info',
125
+ description: 'Product schema can include aggregate ratings for star snippets',
126
+ check: (ctx) => {
127
+ if (!ctx.productSchema)
128
+ return null;
129
+ const hasRating = ctx.productSchema.aggregateRating !== undefined;
130
+ const hasReviews = ctx.productSchema.review !== undefined;
131
+ if (!hasRating && !hasReviews) {
132
+ return createResult({ id: 'ecommerce-product-reviews', name: 'Product Reviews', category: 'structured-data', severity: 'info' }, 'info', 'Product schema has no reviews or ratings', {
133
+ recommendation: 'Add aggregateRating for star ratings in search results',
134
+ evidence: {
135
+ example: `"aggregateRating": {
136
+ "@type": "AggregateRating",
137
+ "ratingValue": "4.5",
138
+ "reviewCount": "42"
139
+ }`,
140
+ impact: 'Star ratings in search results can improve click-through rate by 20-30%',
141
+ },
142
+ });
143
+ }
144
+ if (hasRating && ctx.productSchema.aggregateRating) {
145
+ const rating = ctx.productSchema.aggregateRating;
146
+ return createResult({ id: 'ecommerce-product-reviews', name: 'Product Reviews', category: 'structured-data', severity: 'info' }, 'pass', `Rating: ${rating.ratingValue ?? '?'}/5 (${rating.reviewCount ?? rating.ratingCount ?? '?'} reviews)`);
147
+ }
148
+ return createResult({ id: 'ecommerce-product-reviews', name: 'Product Reviews', category: 'structured-data', severity: 'info' }, 'pass', 'Product has review data');
149
+ },
150
+ },
151
+ {
152
+ id: 'ecommerce-product-brand',
153
+ name: 'Product Brand',
154
+ category: 'structured-data',
155
+ severity: 'info',
156
+ description: 'Product schema should include brand information',
157
+ check: (ctx) => {
158
+ if (!ctx.productSchema)
159
+ return null;
160
+ const hasBrand = ctx.productSchema.brand !== undefined;
161
+ if (!hasBrand) {
162
+ return createResult({ id: 'ecommerce-product-brand', name: 'Product Brand', category: 'structured-data', severity: 'info' }, 'info', 'Product schema missing brand', {
163
+ recommendation: 'Add brand for better product identification',
164
+ evidence: {
165
+ example: `"brand": { "@type": "Brand", "name": "Brand Name" }`,
166
+ impact: 'Brand helps Google understand product context for shopping queries',
167
+ },
168
+ });
169
+ }
170
+ const brandName = typeof ctx.productSchema.brand === 'string'
171
+ ? ctx.productSchema.brand
172
+ : ctx.productSchema.brand?.name;
173
+ return createResult({ id: 'ecommerce-product-brand', name: 'Product Brand', category: 'structured-data', severity: 'info' }, 'pass', `Brand: ${brandName || 'specified'}`);
174
+ },
175
+ },
176
+ {
177
+ id: 'ecommerce-product-sku',
178
+ name: 'Product Identifiers',
179
+ category: 'structured-data',
180
+ severity: 'info',
181
+ description: 'Product schema should include unique identifiers (SKU, GTIN, MPN)',
182
+ check: (ctx) => {
183
+ if (!ctx.productSchema)
184
+ return null;
185
+ const hasSku = ctx.productSchema.sku !== undefined;
186
+ const hasGtin = ctx.productSchema.gtin !== undefined ||
187
+ ctx.productSchema.gtin13 !== undefined ||
188
+ ctx.productSchema.gtin14 !== undefined ||
189
+ ctx.productSchema.gtin8 !== undefined;
190
+ const hasMpn = ctx.productSchema.mpn !== undefined;
191
+ const identifiers = [];
192
+ if (hasSku)
193
+ identifiers.push('SKU');
194
+ if (hasGtin)
195
+ identifiers.push('GTIN');
196
+ if (hasMpn)
197
+ identifiers.push('MPN');
198
+ if (identifiers.length === 0) {
199
+ return createResult({ id: 'ecommerce-product-sku', name: 'Product Identifiers', category: 'structured-data', severity: 'info' }, 'info', 'Product schema missing identifiers', {
200
+ recommendation: 'Add SKU, GTIN, or MPN for better product matching',
201
+ evidence: {
202
+ expected: 'At least one of: sku, gtin, gtin13, gtin14, gtin8, mpn',
203
+ impact: 'Product identifiers help Google match products across retailers',
204
+ learnMore: 'https://support.google.com/merchants/answer/6324461',
205
+ },
206
+ });
207
+ }
208
+ return createResult({ id: 'ecommerce-product-sku', name: 'Product Identifiers', category: 'structured-data', severity: 'info' }, 'pass', `Identifiers: ${identifiers.join(', ')}`);
209
+ },
210
+ },
211
+ {
212
+ id: 'ecommerce-offer-valid-dates',
213
+ name: 'Offer Valid Dates',
214
+ category: 'structured-data',
215
+ severity: 'info',
216
+ description: 'Time-sensitive offers should have valid date ranges',
217
+ check: (ctx) => {
218
+ if (!ctx.productSchema?.offers)
219
+ return null;
220
+ const offers = ctx.productSchema.offers;
221
+ const priceValidUntil = offers.priceValidUntil;
222
+ const validFrom = offers.validFrom;
223
+ const validThrough = offers.validThrough;
224
+ if (priceValidUntil) {
225
+ const endDate = new Date(priceValidUntil);
226
+ const now = new Date();
227
+ if (endDate < now) {
228
+ return createResult({ id: 'ecommerce-offer-valid-dates', name: 'Offer Valid Dates', category: 'structured-data', severity: 'info' }, 'warn', 'Offer priceValidUntil date has passed', {
229
+ evidence: {
230
+ found: priceValidUntil,
231
+ issue: 'Expired offer dates should be updated or removed',
232
+ impact: 'Expired dates may cause Google to distrust your pricing data',
233
+ },
234
+ });
235
+ }
236
+ }
237
+ if (validThrough) {
238
+ const endDate = new Date(validThrough);
239
+ const now = new Date();
240
+ if (endDate < now) {
241
+ return createResult({ id: 'ecommerce-offer-valid-dates', name: 'Offer Valid Dates', category: 'structured-data', severity: 'info' }, 'warn', 'Offer validThrough date has passed', {
242
+ evidence: {
243
+ found: validThrough,
244
+ issue: 'Expired validity dates should be updated',
245
+ },
246
+ });
247
+ }
248
+ }
249
+ return null;
250
+ },
251
+ },
252
+ ];
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const i18nRules: SeoRule[];
@@ -0,0 +1,222 @@
1
+ import { createResult } from './types.js';
2
+ export const i18nRules = [
3
+ {
4
+ id: 'i18n-hreflang-exists',
5
+ name: 'Hreflang Tags',
6
+ category: 'technical',
7
+ severity: 'warning',
8
+ description: 'Multi-language sites should have hreflang tags for proper language targeting',
9
+ check: (ctx) => {
10
+ if (!ctx.hreflangTags || ctx.hreflangTags.length === 0) {
11
+ if (ctx.hasLang && ctx.langValue && ctx.langValue !== 'en') {
12
+ return createResult({ id: 'i18n-hreflang-exists', name: 'Hreflang Tags', category: 'technical', severity: 'warning' }, 'info', 'No hreflang tags found (recommended for multi-language sites)', {
13
+ recommendation: 'Add hreflang tags to indicate language/region alternatives',
14
+ evidence: {
15
+ expected: '<link rel="alternate" hreflang="en" href="https://example.com/en/">',
16
+ example: '<link rel="alternate" hreflang="en" href="https://example.com/en/">\n<link rel="alternate" hreflang="es" href="https://example.com/es/">\n<link rel="alternate" hreflang="x-default" href="https://example.com/">',
17
+ impact: 'Without hreflang, search engines may show wrong language version to users in different countries',
18
+ learnMore: 'https://developers.google.com/search/docs/specialty/international/localized-versions',
19
+ },
20
+ });
21
+ }
22
+ return null;
23
+ }
24
+ return createResult({ id: 'i18n-hreflang-exists', name: 'Hreflang Tags', category: 'technical', severity: 'warning' }, 'pass', `${ctx.hreflangTags.length} hreflang tag(s) found`, { value: ctx.hreflangTags.length });
25
+ },
26
+ },
27
+ {
28
+ id: 'i18n-hreflang-self',
29
+ name: 'Hreflang Self-Reference',
30
+ category: 'technical',
31
+ severity: 'warning',
32
+ description: 'Hreflang tags should include a self-referencing tag for the current page',
33
+ check: (ctx) => {
34
+ if (!ctx.hreflangTags || ctx.hreflangTags.length === 0)
35
+ return null;
36
+ if (!ctx.url)
37
+ return null;
38
+ const currentUrl = ctx.url.toLowerCase().replace(/\/$/, '');
39
+ const hasSelfRef = ctx.hreflangTags.some((tag) => {
40
+ const href = tag.href?.toLowerCase().replace(/\/$/, '');
41
+ return href === currentUrl;
42
+ });
43
+ if (!hasSelfRef) {
44
+ return createResult({ id: 'i18n-hreflang-self', name: 'Hreflang Self-Reference', category: 'technical', severity: 'warning' }, 'warn', 'Missing self-referencing hreflang tag', {
45
+ recommendation: 'Add a hreflang tag that points to the current page',
46
+ evidence: {
47
+ found: `Current URL: ${ctx.url}`,
48
+ expected: `<link rel="alternate" hreflang="${ctx.langValue || 'en'}" href="${ctx.url}">`,
49
+ impact: 'Google recommends including a self-referencing hreflang tag for clarity',
50
+ },
51
+ });
52
+ }
53
+ return null;
54
+ },
55
+ },
56
+ {
57
+ id: 'i18n-hreflang-x-default',
58
+ name: 'Hreflang X-Default',
59
+ category: 'technical',
60
+ severity: 'info',
61
+ description: 'Include x-default hreflang for users outside defined regions',
62
+ check: (ctx) => {
63
+ if (!ctx.hreflangTags || ctx.hreflangTags.length < 2)
64
+ return null;
65
+ const hasXDefault = ctx.hreflangTags.some((tag) => tag.lang === 'x-default');
66
+ if (!hasXDefault) {
67
+ return createResult({ id: 'i18n-hreflang-x-default', name: 'Hreflang X-Default', category: 'technical', severity: 'info' }, 'info', 'No x-default hreflang tag found', {
68
+ recommendation: 'Add hreflang="x-default" to specify the fallback page for unmatched languages',
69
+ evidence: {
70
+ expected: '<link rel="alternate" hreflang="x-default" href="https://example.com/">',
71
+ impact: 'Without x-default, users in unsupported regions may not see the best version',
72
+ },
73
+ });
74
+ }
75
+ return createResult({ id: 'i18n-hreflang-x-default', name: 'Hreflang X-Default', category: 'technical', severity: 'info' }, 'pass', 'x-default hreflang tag present');
76
+ },
77
+ },
78
+ {
79
+ id: 'i18n-hreflang-valid-codes',
80
+ name: 'Hreflang Valid Codes',
81
+ category: 'technical',
82
+ severity: 'warning',
83
+ description: 'Hreflang language codes must be valid ISO 639-1 codes',
84
+ check: (ctx) => {
85
+ if (!ctx.hreflangTags || ctx.hreflangTags.length === 0)
86
+ return null;
87
+ const validLanguageCodes = new Set([
88
+ 'aa', 'ab', 'af', 'ak', 'sq', 'am', 'ar', 'an', 'hy', 'as', 'av', 'ae', 'ay', 'az',
89
+ 'ba', 'bm', 'eu', 'be', 'bn', 'bh', 'bi', 'bo', 'bs', 'br', 'bg', 'my', 'ca', 'cs',
90
+ 'ch', 'ce', 'zh', 'cu', 'cv', 'kw', 'co', 'cr', 'cy', 'da', 'de', 'dv', 'nl', 'dz',
91
+ 'en', 'eo', 'et', 'ee', 'fo', 'fa', 'fj', 'fi', 'fr', 'fy', 'ff', 'ka', 'el', 'gn',
92
+ 'gu', 'ht', 'ha', 'he', 'hz', 'hi', 'ho', 'hr', 'hu', 'ig', 'is', 'io', 'ii', 'iu',
93
+ 'ie', 'ia', 'id', 'ik', 'it', 'jv', 'ja', 'kl', 'kn', 'ks', 'kr', 'kk', 'km', 'ki',
94
+ 'rw', 'ky', 'kv', 'kg', 'ko', 'kj', 'ku', 'lo', 'la', 'lv', 'li', 'ln', 'lt', 'lb',
95
+ 'lu', 'lg', 'mk', 'mh', 'ml', 'mi', 'mr', 'ms', 'mg', 'mt', 'mn', 'na', 'nv', 'nr',
96
+ 'nd', 'ng', 'ne', 'nn', 'nb', 'no', 'ny', 'oc', 'oj', 'or', 'om', 'os', 'pa', 'pi',
97
+ 'pl', 'pt', 'ps', 'qu', 'rm', 'ro', 'rn', 'ru', 'sg', 'sa', 'si', 'sk', 'sl', 'se',
98
+ 'sm', 'sn', 'sd', 'so', 'st', 'es', 'sc', 'sr', 'ss', 'su', 'sw', 'sv', 'ty', 'ta',
99
+ 'tt', 'te', 'tg', 'tl', 'th', 'ti', 'to', 'tn', 'ts', 'tk', 'tr', 'tw', 'ug', 'uk',
100
+ 'ur', 'uz', 've', 'vi', 'vo', 'wa', 'wo', 'xh', 'yi', 'yo', 'za', 'zu',
101
+ 'x-default',
102
+ ]);
103
+ const invalidTags = [];
104
+ for (const tag of ctx.hreflangTags) {
105
+ const lang = tag.lang?.toLowerCase().split('-')[0];
106
+ if (lang && !validLanguageCodes.has(lang)) {
107
+ invalidTags.push(tag.lang);
108
+ }
109
+ }
110
+ if (invalidTags.length > 0) {
111
+ return createResult({ id: 'i18n-hreflang-valid-codes', name: 'Hreflang Valid Codes', category: 'technical', severity: 'warning' }, 'warn', `Invalid hreflang codes: ${invalidTags.join(', ')}`, {
112
+ recommendation: 'Use valid ISO 639-1 language codes',
113
+ evidence: {
114
+ found: invalidTags,
115
+ expected: 'Valid ISO 639-1 codes like: en, es, fr, de, pt-BR, zh-CN',
116
+ learnMore: 'https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes',
117
+ },
118
+ });
119
+ }
120
+ return null;
121
+ },
122
+ },
123
+ {
124
+ id: 'i18n-hreflang-return-links',
125
+ name: 'Hreflang Return Links',
126
+ category: 'technical',
127
+ severity: 'warning',
128
+ description: 'All hreflang URLs should return links back to this page (bidirectional)',
129
+ check: (ctx) => {
130
+ if (!ctx.hreflangTags || ctx.hreflangTags.length < 2)
131
+ return null;
132
+ return createResult({ id: 'i18n-hreflang-return-links', name: 'Hreflang Return Links', category: 'technical', severity: 'warning' }, 'info', 'Hreflang return links cannot be verified from HTML alone', {
133
+ recommendation: 'Ensure all alternate pages link back to this page with matching hreflang tags',
134
+ evidence: {
135
+ impact: 'Missing return links can cause Google to ignore hreflang annotations',
136
+ learnMore: 'https://developers.google.com/search/docs/specialty/international/localized-versions#bidirectional',
137
+ },
138
+ });
139
+ },
140
+ },
141
+ {
142
+ id: 'i18n-content-language',
143
+ name: 'Content-Language Header',
144
+ category: 'technical',
145
+ severity: 'info',
146
+ description: 'Content-Language header can indicate the language of the document',
147
+ check: (ctx) => {
148
+ if (!ctx.responseHeaders)
149
+ return null;
150
+ const contentLang = ctx.responseHeaders['content-language'] || ctx.responseHeaders['Content-Language'];
151
+ if (!contentLang) {
152
+ if (ctx.hasLang) {
153
+ return createResult({ id: 'i18n-content-language', name: 'Content-Language Header', category: 'technical', severity: 'info' }, 'info', 'Content-Language header not set', {
154
+ recommendation: `Consider adding Content-Language: ${ctx.langValue || 'en'} header`,
155
+ evidence: {
156
+ impact: 'While not critical for SEO, it helps with content negotiation',
157
+ },
158
+ });
159
+ }
160
+ return null;
161
+ }
162
+ if (ctx.langValue) {
163
+ const headerLang = Array.isArray(contentLang) ? contentLang[0] : contentLang;
164
+ const headerLangPrimary = headerLang.toLowerCase().split('-')[0].split(',')[0].trim();
165
+ const htmlLangPrimary = ctx.langValue.toLowerCase().split('-')[0];
166
+ if (headerLangPrimary !== htmlLangPrimary) {
167
+ return createResult({ id: 'i18n-content-language', name: 'Content-Language Header', category: 'technical', severity: 'info' }, 'warn', `Content-Language (${headerLang}) doesn't match html lang (${ctx.langValue})`, {
168
+ recommendation: 'Ensure Content-Language header matches the html lang attribute',
169
+ });
170
+ }
171
+ }
172
+ return createResult({ id: 'i18n-content-language', name: 'Content-Language Header', category: 'technical', severity: 'info' }, 'pass', `Content-Language: ${contentLang}`);
173
+ },
174
+ },
175
+ {
176
+ id: 'i18n-lang-consistency',
177
+ name: 'Language Consistency',
178
+ category: 'technical',
179
+ severity: 'warning',
180
+ description: 'HTML lang attribute should match the og:locale if present',
181
+ check: (ctx) => {
182
+ if (!ctx.hasLang || !ctx.ogLocale)
183
+ return null;
184
+ const htmlLang = ctx.langValue?.toLowerCase().split('-')[0];
185
+ const ogLocaleLang = ctx.ogLocale.toLowerCase().split('_')[0];
186
+ if (htmlLang !== ogLocaleLang) {
187
+ return createResult({ id: 'i18n-lang-consistency', name: 'Language Consistency', category: 'technical', severity: 'warning' }, 'warn', `Language mismatch: html lang="${ctx.langValue}" vs og:locale="${ctx.ogLocale}"`, {
188
+ recommendation: 'Ensure html lang and og:locale represent the same language',
189
+ evidence: {
190
+ found: [`html lang="${ctx.langValue}"`, `og:locale="${ctx.ogLocale}"`],
191
+ impact: 'Inconsistent language signals can confuse search engines and social platforms',
192
+ },
193
+ });
194
+ }
195
+ return null;
196
+ },
197
+ },
198
+ {
199
+ id: 'i18n-lang-region',
200
+ name: 'Language Region Specificity',
201
+ category: 'technical',
202
+ severity: 'info',
203
+ description: 'Consider using region-specific language codes for better targeting',
204
+ check: (ctx) => {
205
+ if (!ctx.hasLang || !ctx.langValue)
206
+ return null;
207
+ const multiRegionalLangs = ['en', 'es', 'pt', 'zh', 'fr', 'de', 'ar'];
208
+ const langPrimary = ctx.langValue.toLowerCase().split('-')[0];
209
+ if (multiRegionalLangs.includes(langPrimary) && !ctx.langValue.includes('-')) {
210
+ return createResult({ id: 'i18n-lang-region', name: 'Language Region Specificity', category: 'technical', severity: 'info' }, 'info', `Consider using region-specific lang code (e.g., ${langPrimary}-US, ${langPrimary}-GB)`, {
211
+ recommendation: 'For multi-regional languages, specify the region for better targeting',
212
+ evidence: {
213
+ found: ctx.langValue,
214
+ expected: `${langPrimary}-XX (e.g., en-US, es-ES, pt-BR, zh-CN)`,
215
+ impact: 'Helps search engines serve the right regional variant',
216
+ },
217
+ });
218
+ }
219
+ return null;
220
+ },
221
+ },
222
+ ];
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const imageRules: SeoRule[];