@glincker/geo-audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1808 @@
1
+ #!/usr/bin/env node
2
+ import chalk from 'chalk';
3
+ import * as cheerio from 'cheerio';
4
+
5
+ // src/fetcher.ts
6
+ var DEFAULT_TIMEOUT = 1e4;
7
+ var DEFAULT_USER_AGENT = "GeoKit/0.1.0 (+https://geo.glincker.com; AI-readiness audit)";
8
+ var BLOCKED_PATTERNS = [
9
+ /^127\./,
10
+ /^10\./,
11
+ /^172\.(1[6-9]|2\d|3[01])\./,
12
+ /^192\.168\./,
13
+ /^0\./,
14
+ /^localhost$/i,
15
+ /^\[::1\]$/
16
+ ];
17
+ function isBlockedHost(hostname) {
18
+ return BLOCKED_PATTERNS.some((p) => p.test(hostname));
19
+ }
20
+ function normalizeUrl(input) {
21
+ let url = input.trim();
22
+ if (!/^https?:\/\//i.test(url)) {
23
+ url = `https://${url}`;
24
+ }
25
+ return url;
26
+ }
27
+ async function safeFetch(url, options) {
28
+ const parsed = new URL(url);
29
+ if (isBlockedHost(parsed.hostname)) {
30
+ throw new Error(`Blocked: ${parsed.hostname} is a private/internal address`);
31
+ }
32
+ const controller = new AbortController();
33
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
34
+ const timer = setTimeout(() => controller.abort(), timeout);
35
+ try {
36
+ const res = await fetch(url, {
37
+ signal: controller.signal,
38
+ headers: {
39
+ "User-Agent": options.userAgent ?? DEFAULT_USER_AGENT,
40
+ Accept: "text/html,application/xhtml+xml,*/*"
41
+ },
42
+ redirect: "follow"
43
+ });
44
+ return res;
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+ async function fetchResource(url, options) {
50
+ try {
51
+ const res = await safeFetch(url, options);
52
+ const body = await res.text();
53
+ return { ok: res.ok, status: res.status, body };
54
+ } catch (error) {
55
+ const message = error instanceof Error ? error.message : "Unknown fetch error";
56
+ return { ok: false, status: 0, body: "", error: message };
57
+ }
58
+ }
59
+ async function fetchPageData(rawUrl, options = {}) {
60
+ const url = normalizeUrl(rawUrl);
61
+ const origin = new URL(url).origin;
62
+ const startTime = performance.now();
63
+ let ttfb = 0;
64
+ const mainRes = await safeFetch(url, options);
65
+ ttfb = performance.now() - startTime;
66
+ const html = await mainRes.text();
67
+ const totalTime = performance.now() - startTime;
68
+ const headers = {};
69
+ mainRes.headers.forEach((value, key) => {
70
+ headers[key.toLowerCase()] = value;
71
+ });
72
+ const [llmsTxt, robotsTxt, sitemapXml] = await Promise.all([
73
+ fetchResource(`${origin}/llms.txt`, options),
74
+ fetchResource(`${origin}/robots.txt`, options),
75
+ fetchResource(`${origin}/sitemap.xml`, options)
76
+ ]);
77
+ return {
78
+ url,
79
+ html,
80
+ statusCode: mainRes.status,
81
+ headers,
82
+ ttfb,
83
+ totalTime,
84
+ llmsTxt,
85
+ robotsTxt,
86
+ sitemapXml
87
+ };
88
+ }
89
+
90
+ // src/rules/r01-llms-txt.ts
91
+ var r01LlmsTxt = {
92
+ id: "R01",
93
+ name: "llms.txt Exists",
94
+ description: "Check for /llms.txt file that helps AI systems understand your site",
95
+ category: "discoverability",
96
+ maxScore: 10,
97
+ check(page) {
98
+ const { llmsTxt } = page;
99
+ if (!llmsTxt.ok || llmsTxt.status !== 200) {
100
+ return {
101
+ id: this.id,
102
+ name: this.name,
103
+ description: this.description,
104
+ category: this.category,
105
+ status: "fail",
106
+ score: 0,
107
+ maxScore: this.maxScore,
108
+ message: "No /llms.txt file found",
109
+ recommendation: "Add /llms.txt to help AI systems understand your site. See https://llmstxt.org for the spec."
110
+ };
111
+ }
112
+ const body = llmsTxt.body.trim();
113
+ if (body.length === 0) {
114
+ return {
115
+ id: this.id,
116
+ name: this.name,
117
+ description: this.description,
118
+ category: this.category,
119
+ status: "fail",
120
+ score: 0,
121
+ maxScore: this.maxScore,
122
+ message: "/llms.txt exists but is empty",
123
+ recommendation: "Add content to your /llms.txt \u2014 it should be valid markdown with an H1 heading."
124
+ };
125
+ }
126
+ const hasH1 = /^#\s+.+/m.test(body);
127
+ if (!hasH1) {
128
+ return {
129
+ id: this.id,
130
+ name: this.name,
131
+ description: this.description,
132
+ category: this.category,
133
+ status: "warn",
134
+ score: 5,
135
+ maxScore: this.maxScore,
136
+ message: "/llms.txt exists but missing H1 heading",
137
+ recommendation: "Add a markdown H1 heading (# Your Site Name) to your /llms.txt for proper structure."
138
+ };
139
+ }
140
+ return {
141
+ id: this.id,
142
+ name: this.name,
143
+ description: this.description,
144
+ category: this.category,
145
+ status: "pass",
146
+ score: 10,
147
+ maxScore: this.maxScore,
148
+ message: "/llms.txt found with valid markdown structure"
149
+ };
150
+ }
151
+ };
152
+
153
+ // src/rules/r02-robots-txt.ts
154
+ var AI_BOTS = [
155
+ "GPTBot",
156
+ "ClaudeBot",
157
+ "PerplexityBot",
158
+ "Google-Extended",
159
+ "Amazonbot",
160
+ "anthropic-ai",
161
+ "CCBot",
162
+ "ChatGPT-User",
163
+ "Bytespider",
164
+ "cohere-ai"
165
+ ];
166
+ var r02RobotsTxt = {
167
+ id: "R02",
168
+ name: "robots.txt AI Crawler Rules",
169
+ description: "Check if robots.txt has explicit rules for AI crawlers",
170
+ category: "discoverability",
171
+ maxScore: 10,
172
+ check(page) {
173
+ const { robotsTxt } = page;
174
+ if (!robotsTxt.ok || robotsTxt.status !== 200) {
175
+ return {
176
+ id: this.id,
177
+ name: this.name,
178
+ description: this.description,
179
+ category: this.category,
180
+ status: "fail",
181
+ score: 0,
182
+ maxScore: this.maxScore,
183
+ message: "No robots.txt found",
184
+ recommendation: "Add a robots.txt file with explicit rules for AI crawlers (GPTBot, ClaudeBot, etc.)"
185
+ };
186
+ }
187
+ const body = robotsTxt.body.toLowerCase();
188
+ const foundBots = AI_BOTS.filter(
189
+ (bot) => body.includes(`user-agent: ${bot.toLowerCase()}`)
190
+ );
191
+ if (foundBots.length === 0) {
192
+ return {
193
+ id: this.id,
194
+ name: this.name,
195
+ description: this.description,
196
+ category: this.category,
197
+ status: "warn",
198
+ score: 5,
199
+ maxScore: this.maxScore,
200
+ message: "robots.txt exists but has no AI-specific crawler rules",
201
+ recommendation: "Add explicit User-agent rules for GPTBot, ClaudeBot, and PerplexityBot to control AI crawler access.",
202
+ details: { foundBots: [] }
203
+ };
204
+ }
205
+ return {
206
+ id: this.id,
207
+ name: this.name,
208
+ description: this.description,
209
+ category: this.category,
210
+ status: "pass",
211
+ score: 10,
212
+ maxScore: this.maxScore,
213
+ message: `robots.txt has rules for ${foundBots.length} AI crawler(s)`,
214
+ details: { foundBots }
215
+ };
216
+ }
217
+ };
218
+
219
+ // src/rules/r03-sitemap.ts
220
+ var r03Sitemap = {
221
+ id: "R03",
222
+ name: "Sitemap.xml Exists",
223
+ description: "Check for XML sitemap for AI crawler discovery",
224
+ category: "discoverability",
225
+ maxScore: 10,
226
+ check(page) {
227
+ const { sitemapXml, robotsTxt } = page;
228
+ let sitemapInRobots = false;
229
+ if (robotsTxt.ok) {
230
+ sitemapInRobots = /^sitemap:\s*https?:\/\//im.test(robotsTxt.body);
231
+ }
232
+ const sitemapExists = sitemapXml.ok && sitemapXml.status === 200;
233
+ const isXml = sitemapExists && (sitemapXml.body.includes("<?xml") || sitemapXml.body.includes("<urlset") || sitemapXml.body.includes("<sitemapindex"));
234
+ if (isXml) {
235
+ return {
236
+ id: this.id,
237
+ name: this.name,
238
+ description: this.description,
239
+ category: this.category,
240
+ status: "pass",
241
+ score: 10,
242
+ maxScore: this.maxScore,
243
+ message: "Valid XML sitemap found",
244
+ details: { sitemapInRobots }
245
+ };
246
+ }
247
+ if (sitemapInRobots) {
248
+ return {
249
+ id: this.id,
250
+ name: this.name,
251
+ description: this.description,
252
+ category: this.category,
253
+ status: "warn",
254
+ score: 5,
255
+ maxScore: this.maxScore,
256
+ message: "Sitemap referenced in robots.txt but /sitemap.xml not directly accessible",
257
+ recommendation: "Ensure your sitemap.xml is accessible at the root URL for maximum crawler compatibility."
258
+ };
259
+ }
260
+ return {
261
+ id: this.id,
262
+ name: this.name,
263
+ description: this.description,
264
+ category: this.category,
265
+ status: "fail",
266
+ score: 0,
267
+ maxScore: this.maxScore,
268
+ message: "No sitemap.xml found",
269
+ recommendation: "Add a sitemap.xml to help AI crawlers discover all your pages."
270
+ };
271
+ }
272
+ };
273
+ var KNOWN_TYPES = [
274
+ "Organization",
275
+ "WebPage",
276
+ "WebSite",
277
+ "Article",
278
+ "BlogPosting",
279
+ "FAQ",
280
+ "FAQPage",
281
+ "Product",
282
+ "LocalBusiness",
283
+ "Person",
284
+ "BreadcrumbList",
285
+ "HowTo",
286
+ "Event",
287
+ "SoftwareApplication",
288
+ "Course",
289
+ "Recipe",
290
+ "VideoObject"
291
+ ];
292
+ var r04JsonLd = {
293
+ id: "R04",
294
+ name: "JSON-LD Schema.org Markup",
295
+ description: "Check for structured data using JSON-LD format",
296
+ category: "structured-data",
297
+ maxScore: 10,
298
+ check(page) {
299
+ const $ = cheerio.load(page.html);
300
+ const scripts = $('script[type="application/ld+json"]');
301
+ if (scripts.length === 0) {
302
+ return {
303
+ id: this.id,
304
+ name: this.name,
305
+ description: this.description,
306
+ category: this.category,
307
+ status: "fail",
308
+ score: 0,
309
+ maxScore: this.maxScore,
310
+ message: "No JSON-LD structured data found",
311
+ recommendation: "Add JSON-LD Schema.org markup to help AI understand your page content. Start with Organization or WebPage schema."
312
+ };
313
+ }
314
+ const types = [];
315
+ let hasErrors = false;
316
+ scripts.each((_, el) => {
317
+ const text = $(el).text();
318
+ try {
319
+ const data = JSON.parse(text);
320
+ const extractType = (obj) => {
321
+ if (obj["@type"]) {
322
+ const t = Array.isArray(obj["@type"]) ? obj["@type"] : [obj["@type"]];
323
+ types.push(...t);
324
+ }
325
+ if (obj["@graph"] && Array.isArray(obj["@graph"])) {
326
+ for (const item of obj["@graph"]) {
327
+ extractType(item);
328
+ }
329
+ }
330
+ };
331
+ extractType(data);
332
+ } catch {
333
+ hasErrors = true;
334
+ }
335
+ });
336
+ if (hasErrors && types.length === 0) {
337
+ return {
338
+ id: this.id,
339
+ name: this.name,
340
+ description: this.description,
341
+ category: this.category,
342
+ status: "warn",
343
+ score: 3,
344
+ maxScore: this.maxScore,
345
+ message: "JSON-LD found but contains parse errors",
346
+ recommendation: "Fix JSON-LD syntax errors. Validate at https://search.google.com/test/rich-results",
347
+ details: { types }
348
+ };
349
+ }
350
+ const recognized = types.filter((t) => KNOWN_TYPES.includes(t));
351
+ if (types.length > 0 && recognized.length === 0) {
352
+ return {
353
+ id: this.id,
354
+ name: this.name,
355
+ description: this.description,
356
+ category: this.category,
357
+ status: "warn",
358
+ score: 5,
359
+ maxScore: this.maxScore,
360
+ message: `JSON-LD found with unrecognized type(s): ${types.join(", ")}`,
361
+ recommendation: "Use standard Schema.org types like Organization, WebPage, Article, or Product.",
362
+ details: { types }
363
+ };
364
+ }
365
+ return {
366
+ id: this.id,
367
+ name: this.name,
368
+ description: this.description,
369
+ category: this.category,
370
+ status: "pass",
371
+ score: 10,
372
+ maxScore: this.maxScore,
373
+ message: `JSON-LD found: ${recognized.join(", ")}`,
374
+ details: { types: recognized }
375
+ };
376
+ }
377
+ };
378
+ var CORE_OG_TAGS = ["og:title", "og:description", "og:image", "og:type"];
379
+ var r05OpenGraph = {
380
+ id: "R05",
381
+ name: "OpenGraph Tags",
382
+ description: "Check for core OpenGraph meta tags (title, description, image, type)",
383
+ category: "structured-data",
384
+ maxScore: 10,
385
+ check(page) {
386
+ const $ = cheerio.load(page.html);
387
+ const found = [];
388
+ const missing = [];
389
+ for (const tag of CORE_OG_TAGS) {
390
+ const el = $(`meta[property="${tag}"]`);
391
+ if (el.length > 0 && el.attr("content")?.trim()) {
392
+ found.push(tag);
393
+ } else {
394
+ missing.push(tag);
395
+ }
396
+ }
397
+ if (found.length === 0) {
398
+ return {
399
+ id: this.id,
400
+ name: this.name,
401
+ description: this.description,
402
+ category: this.category,
403
+ status: "fail",
404
+ score: 0,
405
+ maxScore: this.maxScore,
406
+ message: "No OpenGraph tags found",
407
+ recommendation: "Add og:title, og:description, og:image, and og:type meta tags for better AI and social sharing.",
408
+ details: { found, missing }
409
+ };
410
+ }
411
+ if (missing.length > 0) {
412
+ const score = Math.round(found.length / CORE_OG_TAGS.length * this.maxScore);
413
+ return {
414
+ id: this.id,
415
+ name: this.name,
416
+ description: this.description,
417
+ category: this.category,
418
+ status: "warn",
419
+ score,
420
+ maxScore: this.maxScore,
421
+ message: `Missing OpenGraph tags: ${missing.join(", ")}`,
422
+ recommendation: `Add missing tags: ${missing.join(", ")}`,
423
+ details: { found, missing }
424
+ };
425
+ }
426
+ return {
427
+ id: this.id,
428
+ name: this.name,
429
+ description: this.description,
430
+ category: this.category,
431
+ status: "pass",
432
+ score: 10,
433
+ maxScore: this.maxScore,
434
+ message: "All 4 core OpenGraph tags present",
435
+ details: { found }
436
+ };
437
+ }
438
+ };
439
+ var r06MetaDescription = {
440
+ id: "R06",
441
+ name: "Meta Description",
442
+ description: "Check for meta description tag with appropriate length",
443
+ category: "structured-data",
444
+ maxScore: 5,
445
+ check(page) {
446
+ const $ = cheerio.load(page.html);
447
+ const el = $('meta[name="description"]');
448
+ const content = el.attr("content")?.trim() ?? "";
449
+ if (!content) {
450
+ return {
451
+ id: this.id,
452
+ name: this.name,
453
+ description: this.description,
454
+ category: this.category,
455
+ status: "fail",
456
+ score: 0,
457
+ maxScore: this.maxScore,
458
+ message: "No meta description found",
459
+ recommendation: "Add a meta description (50-160 characters) to summarize your page content."
460
+ };
461
+ }
462
+ const len = content.length;
463
+ if (len < 50) {
464
+ return {
465
+ id: this.id,
466
+ name: this.name,
467
+ description: this.description,
468
+ category: this.category,
469
+ status: "warn",
470
+ score: 2,
471
+ maxScore: this.maxScore,
472
+ message: `Meta description too short (${len} chars, min 50)`,
473
+ recommendation: "Expand your meta description to at least 50 characters for better AI comprehension.",
474
+ details: { length: len }
475
+ };
476
+ }
477
+ if (len > 160) {
478
+ return {
479
+ id: this.id,
480
+ name: this.name,
481
+ description: this.description,
482
+ category: this.category,
483
+ status: "warn",
484
+ score: 3,
485
+ maxScore: this.maxScore,
486
+ message: `Meta description too long (${len} chars, max 160)`,
487
+ recommendation: "Shorten your meta description to 160 characters or less.",
488
+ details: { length: len }
489
+ };
490
+ }
491
+ return {
492
+ id: this.id,
493
+ name: this.name,
494
+ description: this.description,
495
+ category: this.category,
496
+ status: "pass",
497
+ score: 5,
498
+ maxScore: this.maxScore,
499
+ message: `Meta description present (${len} chars)`,
500
+ details: { length: len }
501
+ };
502
+ }
503
+ };
504
+ var r07Canonical = {
505
+ id: "R07",
506
+ name: "Canonical URL",
507
+ description: "Check for link rel=canonical with absolute URL",
508
+ category: "structured-data",
509
+ maxScore: 5,
510
+ check(page) {
511
+ const $ = cheerio.load(page.html);
512
+ const el = $('link[rel="canonical"]');
513
+ const href = el.attr("href")?.trim() ?? "";
514
+ if (!href) {
515
+ return {
516
+ id: this.id,
517
+ name: this.name,
518
+ description: this.description,
519
+ category: this.category,
520
+ status: "fail",
521
+ score: 0,
522
+ maxScore: this.maxScore,
523
+ message: "No canonical URL found",
524
+ recommendation: 'Add <link rel="canonical" href="https://..."> to prevent duplicate content issues with AI crawlers.'
525
+ };
526
+ }
527
+ const isAbsolute = /^https?:\/\//i.test(href);
528
+ if (!isAbsolute) {
529
+ return {
530
+ id: this.id,
531
+ name: this.name,
532
+ description: this.description,
533
+ category: this.category,
534
+ status: "warn",
535
+ score: 2,
536
+ maxScore: this.maxScore,
537
+ message: "Canonical URL is relative, should be absolute",
538
+ recommendation: "Use an absolute URL (starting with https://) for the canonical link.",
539
+ details: { canonical: href }
540
+ };
541
+ }
542
+ return {
543
+ id: this.id,
544
+ name: this.name,
545
+ description: this.description,
546
+ category: this.category,
547
+ status: "pass",
548
+ score: 5,
549
+ maxScore: this.maxScore,
550
+ message: "Canonical URL present",
551
+ details: { canonical: href }
552
+ };
553
+ }
554
+ };
555
+ var r08Headings = {
556
+ id: "R08",
557
+ name: "Heading Hierarchy",
558
+ description: "Check for proper heading structure (single H1, no skipped levels)",
559
+ category: "content-quality",
560
+ maxScore: 10,
561
+ check(page) {
562
+ const $ = cheerio.load(page.html);
563
+ const headings = [];
564
+ $("h1, h2, h3, h4, h5, h6").each((_, el) => {
565
+ const tag = $(el).prop("tagName")?.toLowerCase() ?? "";
566
+ const level = parseInt(tag.replace("h", ""), 10);
567
+ const text = $(el).text().trim();
568
+ if (!isNaN(level)) {
569
+ headings.push({ level, text });
570
+ }
571
+ });
572
+ if (headings.length === 0) {
573
+ return {
574
+ id: this.id,
575
+ name: this.name,
576
+ description: this.description,
577
+ category: this.category,
578
+ status: "fail",
579
+ score: 0,
580
+ maxScore: this.maxScore,
581
+ message: "No headings found on page",
582
+ recommendation: "Add an H1 heading and use proper heading hierarchy for AI content parsing."
583
+ };
584
+ }
585
+ const h1Count = headings.filter((h) => h.level === 1).length;
586
+ const issues = [];
587
+ if (h1Count === 0) {
588
+ issues.push("No H1 heading found");
589
+ } else if (h1Count > 1) {
590
+ issues.push(`Multiple H1 headings (${h1Count})`);
591
+ }
592
+ for (let i = 1; i < headings.length; i++) {
593
+ const prev = headings[i - 1].level;
594
+ const curr = headings[i].level;
595
+ if (curr > prev + 1) {
596
+ issues.push(`Skipped from H${prev} to H${curr}`);
597
+ break;
598
+ }
599
+ }
600
+ if (h1Count === 0) {
601
+ return {
602
+ id: this.id,
603
+ name: this.name,
604
+ description: this.description,
605
+ category: this.category,
606
+ status: "fail",
607
+ score: 2,
608
+ maxScore: this.maxScore,
609
+ message: issues.join("; "),
610
+ recommendation: "Add a single H1 heading that describes your page content.",
611
+ details: { h1Count, totalHeadings: headings.length, issues }
612
+ };
613
+ }
614
+ if (issues.length > 0) {
615
+ return {
616
+ id: this.id,
617
+ name: this.name,
618
+ description: this.description,
619
+ category: this.category,
620
+ status: "warn",
621
+ score: 5,
622
+ maxScore: this.maxScore,
623
+ message: issues.join("; "),
624
+ recommendation: "Fix heading hierarchy: use a single H1 and don't skip levels (e.g., H1 \u2192 H3 without H2).",
625
+ details: { h1Count, totalHeadings: headings.length, issues }
626
+ };
627
+ }
628
+ return {
629
+ id: this.id,
630
+ name: this.name,
631
+ description: this.description,
632
+ category: this.category,
633
+ status: "pass",
634
+ score: 10,
635
+ maxScore: this.maxScore,
636
+ message: `Proper heading hierarchy (${headings.length} headings)`,
637
+ details: { h1Count, totalHeadings: headings.length }
638
+ };
639
+ }
640
+ };
641
+ var r09SsrContent = {
642
+ id: "R09",
643
+ name: "Content Accessibility (SSR)",
644
+ description: "Check if meaningful content is available in initial HTML without JavaScript",
645
+ category: "content-quality",
646
+ maxScore: 10,
647
+ check(page) {
648
+ const $ = cheerio.load(page.html);
649
+ $("script, style, noscript, nav, footer, header, svg, iframe").remove();
650
+ const bodyText = $("body").text().replace(/\s+/g, " ").trim();
651
+ const textLength = bodyText.length;
652
+ if (textLength < 100) {
653
+ const hasReactRoot = $('[id="root"], [id="app"], [id="__next"]').length > 0;
654
+ const hasJsBundles = page.html.includes(".js") && textLength < 50;
655
+ return {
656
+ id: this.id,
657
+ name: this.name,
658
+ description: this.description,
659
+ category: this.category,
660
+ status: "fail",
661
+ score: 0,
662
+ maxScore: this.maxScore,
663
+ message: hasJsBundles || hasReactRoot ? "Page appears to be client-side rendered only \u2014 AI crawlers can't read JavaScript" : `Very little text content in initial HTML (${textLength} chars)`,
664
+ recommendation: "Use server-side rendering (SSR) or static generation (SSG) so AI crawlers can read your content without JavaScript.",
665
+ details: { textLength, isClientOnly: hasReactRoot || hasJsBundles }
666
+ };
667
+ }
668
+ if (textLength < 500) {
669
+ return {
670
+ id: this.id,
671
+ name: this.name,
672
+ description: this.description,
673
+ category: this.category,
674
+ status: "warn",
675
+ score: 5,
676
+ maxScore: this.maxScore,
677
+ message: `Thin content in initial HTML (${textLength} chars)`,
678
+ recommendation: "Consider adding more server-rendered content. AI crawlers prefer text-heavy pages.",
679
+ details: { textLength }
680
+ };
681
+ }
682
+ return {
683
+ id: this.id,
684
+ name: this.name,
685
+ description: this.description,
686
+ category: this.category,
687
+ status: "pass",
688
+ score: 10,
689
+ maxScore: this.maxScore,
690
+ message: `Good server-rendered content (${textLength} chars)`,
691
+ details: { textLength }
692
+ };
693
+ }
694
+ };
695
+ var r10Faq = {
696
+ id: "R10",
697
+ name: "FAQ Content Detection",
698
+ description: "Check for FAQ content and corresponding FAQ schema markup",
699
+ category: "content-quality",
700
+ maxScore: 5,
701
+ check(page) {
702
+ const $ = cheerio.load(page.html);
703
+ const hasFaqHeading = /faq|frequently\s+asked|questions/i.test(page.html);
704
+ const hasDlList = $("dl dt").length >= 2;
705
+ const hasDetailsSummary = $("details summary").length >= 2;
706
+ const hasAccordion = $('[class*="accordion"], [class*="faq"], [data-faq]').length > 0;
707
+ const hasFaqContent = hasFaqHeading || hasDlList || hasDetailsSummary || hasAccordion;
708
+ if (!hasFaqContent) {
709
+ return {
710
+ id: this.id,
711
+ name: this.name,
712
+ description: this.description,
713
+ category: this.category,
714
+ status: "skip",
715
+ score: 5,
716
+ // Full points if no FAQ content — not penalized
717
+ maxScore: this.maxScore,
718
+ message: "No FAQ content detected (not penalized)"
719
+ };
720
+ }
721
+ let hasFaqSchema = false;
722
+ $('script[type="application/ld+json"]').each((_, el) => {
723
+ const text = $(el).text();
724
+ try {
725
+ const data = JSON.parse(text);
726
+ const checkType = (obj) => {
727
+ if (obj["@type"] === "FAQPage" || obj["@type"] === "FAQ") return true;
728
+ if (obj["@graph"] && Array.isArray(obj["@graph"])) {
729
+ return obj["@graph"].some(
730
+ (item) => checkType(item)
731
+ );
732
+ }
733
+ return false;
734
+ };
735
+ if (checkType(data)) hasFaqSchema = true;
736
+ } catch {
737
+ }
738
+ });
739
+ if (!hasFaqSchema) {
740
+ return {
741
+ id: this.id,
742
+ name: this.name,
743
+ description: this.description,
744
+ category: this.category,
745
+ status: "warn",
746
+ score: 2,
747
+ maxScore: this.maxScore,
748
+ message: "FAQ content detected but no FAQPage schema markup",
749
+ recommendation: "Add FAQPage JSON-LD schema to your FAQ section \u2014 FAQ schema has the highest citation rate in AI-generated answers."
750
+ };
751
+ }
752
+ return {
753
+ id: this.id,
754
+ name: this.name,
755
+ description: this.description,
756
+ category: this.category,
757
+ status: "pass",
758
+ score: 5,
759
+ maxScore: this.maxScore,
760
+ message: "FAQ content with FAQPage schema markup detected"
761
+ };
762
+ }
763
+ };
764
+
765
+ // src/rules/r11-response-time.ts
766
+ var r11ResponseTime = {
767
+ id: "R11",
768
+ name: "Response Time",
769
+ description: "Check Time to First Byte (TTFB) \u2014 AI crawlers have strict timeouts",
770
+ category: "technical",
771
+ maxScore: 10,
772
+ check(page) {
773
+ const ttfb = Math.round(page.ttfb);
774
+ if (ttfb > 2e3) {
775
+ return {
776
+ id: this.id,
777
+ name: this.name,
778
+ description: this.description,
779
+ category: this.category,
780
+ status: "fail",
781
+ score: 0,
782
+ maxScore: this.maxScore,
783
+ message: `Slow TTFB: ${ttfb}ms (should be <500ms)`,
784
+ recommendation: "Improve server response time. AI crawlers may skip slow sites. Consider caching, CDN, or server optimization.",
785
+ details: { ttfb }
786
+ };
787
+ }
788
+ if (ttfb > 500) {
789
+ const score = Math.max(2, Math.round(10 - (ttfb - 500) / 1500 * 8));
790
+ return {
791
+ id: this.id,
792
+ name: this.name,
793
+ description: this.description,
794
+ category: this.category,
795
+ status: "warn",
796
+ score,
797
+ maxScore: this.maxScore,
798
+ message: `Moderate TTFB: ${ttfb}ms (target <500ms)`,
799
+ recommendation: "Consider improving server response time for better AI crawler compatibility.",
800
+ details: { ttfb }
801
+ };
802
+ }
803
+ return {
804
+ id: this.id,
805
+ name: this.name,
806
+ description: this.description,
807
+ category: this.category,
808
+ status: "pass",
809
+ score: 10,
810
+ maxScore: this.maxScore,
811
+ message: `Fast TTFB: ${ttfb}ms`,
812
+ details: { ttfb }
813
+ };
814
+ }
815
+ };
816
+
817
+ // src/rules/r12-content-type.ts
818
+ var r12ContentType = {
819
+ id: "R12",
820
+ name: "Content-Type & Encoding",
821
+ description: "Verify proper content-type header and compression",
822
+ category: "technical",
823
+ maxScore: 5,
824
+ check(page) {
825
+ const contentType = page.headers["content-type"] ?? "";
826
+ const encoding = page.headers["content-encoding"] ?? "";
827
+ const isHtml = contentType.includes("text/html");
828
+ const hasCharset = contentType.includes("charset=") || contentType.includes("utf-8");
829
+ const hasCompression = encoding.includes("gzip") || encoding.includes("br") || encoding.includes("deflate");
830
+ if (!isHtml) {
831
+ return {
832
+ id: this.id,
833
+ name: this.name,
834
+ description: this.description,
835
+ category: this.category,
836
+ status: "fail",
837
+ score: 0,
838
+ maxScore: this.maxScore,
839
+ message: `Wrong content-type: ${contentType || "(none)"}`,
840
+ recommendation: "Ensure your server returns Content-Type: text/html; charset=utf-8",
841
+ details: { contentType, encoding }
842
+ };
843
+ }
844
+ const issues = [];
845
+ let score = 5;
846
+ if (!hasCharset) {
847
+ issues.push("missing charset");
848
+ score -= 1;
849
+ }
850
+ if (!hasCompression) {
851
+ issues.push("no compression (gzip/brotli)");
852
+ score -= 2;
853
+ }
854
+ if (issues.length > 0) {
855
+ return {
856
+ id: this.id,
857
+ name: this.name,
858
+ description: this.description,
859
+ category: this.category,
860
+ status: "warn",
861
+ score,
862
+ maxScore: this.maxScore,
863
+ message: `Content-type OK but ${issues.join(", ")}`,
864
+ recommendation: issues.includes("no compression") ? "Enable gzip or brotli compression to reduce payload size for AI crawlers." : "Add charset=utf-8 to your Content-Type header.",
865
+ details: { contentType, encoding }
866
+ };
867
+ }
868
+ return {
869
+ id: this.id,
870
+ name: this.name,
871
+ description: this.description,
872
+ category: this.category,
873
+ status: "pass",
874
+ score: 5,
875
+ maxScore: this.maxScore,
876
+ message: "Proper content-type with compression",
877
+ details: { contentType, encoding }
878
+ };
879
+ }
880
+ };
881
+ var r13LangTag = {
882
+ id: "R13",
883
+ name: "Language Tag",
884
+ description: "Check for lang attribute on <html> element for accessibility and i18n",
885
+ category: "content-quality",
886
+ maxScore: 3,
887
+ check(page) {
888
+ const $ = cheerio.load(page.html);
889
+ const htmlEl = $("html");
890
+ const langAttr = htmlEl.attr("lang");
891
+ if (!langAttr || langAttr.trim() === "") {
892
+ return {
893
+ id: this.id,
894
+ name: this.name,
895
+ description: this.description,
896
+ category: this.category,
897
+ status: "fail",
898
+ score: 0,
899
+ maxScore: this.maxScore,
900
+ message: "No lang attribute found on <html> element",
901
+ recommendation: 'Add lang attribute to <html> element (e.g., <html lang="en">) for accessibility and search engines.'
902
+ };
903
+ }
904
+ const validLangPattern = /^[a-z]{2,3}(-[A-Z]{2})?$/;
905
+ if (!validLangPattern.test(langAttr.trim())) {
906
+ return {
907
+ id: this.id,
908
+ name: this.name,
909
+ description: this.description,
910
+ category: this.category,
911
+ status: "warn",
912
+ score: 1,
913
+ maxScore: this.maxScore,
914
+ message: `Lang attribute present but may be invalid: "${langAttr}"`,
915
+ recommendation: 'Ensure lang attribute uses valid BCP-47 language code (e.g., "en", "en-US", "de").',
916
+ details: { langCode: langAttr }
917
+ };
918
+ }
919
+ return {
920
+ id: this.id,
921
+ name: this.name,
922
+ description: this.description,
923
+ category: this.category,
924
+ status: "pass",
925
+ score: 3,
926
+ maxScore: this.maxScore,
927
+ message: `Valid lang attribute: "${langAttr}"`,
928
+ details: { langCode: langAttr }
929
+ };
930
+ }
931
+ };
932
+
933
+ // src/rules/r14-https.ts
934
+ var r14Https = {
935
+ id: "R14",
936
+ name: "HTTPS Enforcement",
937
+ description: "Verify that the page is served over secure HTTPS",
938
+ category: "technical",
939
+ maxScore: 3,
940
+ check(page) {
941
+ const isHttps = page.url.startsWith("https://");
942
+ if (!isHttps) {
943
+ return {
944
+ id: this.id,
945
+ name: this.name,
946
+ description: this.description,
947
+ category: this.category,
948
+ status: "fail",
949
+ score: 0,
950
+ maxScore: this.maxScore,
951
+ message: "Page is not served over HTTPS",
952
+ recommendation: "Enable HTTPS for your site. This is critical for security, SEO, and user trust.",
953
+ details: { protocol: "http" }
954
+ };
955
+ }
956
+ return {
957
+ id: this.id,
958
+ name: this.name,
959
+ description: this.description,
960
+ category: this.category,
961
+ status: "pass",
962
+ score: 3,
963
+ maxScore: this.maxScore,
964
+ message: "Page is served over HTTPS",
965
+ details: { protocol: "https" }
966
+ };
967
+ }
968
+ };
969
+ var r15AltText = {
970
+ id: "R15",
971
+ name: "Image Alt Text Coverage",
972
+ description: "Check that images have descriptive alt text for accessibility and AI parsing",
973
+ category: "content-quality",
974
+ maxScore: 5,
975
+ check(page) {
976
+ const $ = cheerio.load(page.html);
977
+ const images = $("img");
978
+ const contentImages = [];
979
+ images.each((_, el) => {
980
+ const $el = $(el);
981
+ const role = $el.attr("role");
982
+ if (role !== "presentation") {
983
+ contentImages.push($el);
984
+ }
985
+ });
986
+ const totalImages = contentImages.length;
987
+ if (totalImages === 0) {
988
+ return {
989
+ id: this.id,
990
+ name: this.name,
991
+ description: this.description,
992
+ category: this.category,
993
+ status: "pass",
994
+ score: 5,
995
+ maxScore: this.maxScore,
996
+ message: "No images found on page",
997
+ details: { totalImages: 0, imagesWithAlt: 0, percentage: 100 }
998
+ };
999
+ }
1000
+ let imagesWithAlt = 0;
1001
+ contentImages.forEach(($el) => {
1002
+ const alt = $el.attr("alt");
1003
+ if (alt !== void 0 && alt.trim() !== "") {
1004
+ imagesWithAlt++;
1005
+ }
1006
+ });
1007
+ const percentage = Math.round(imagesWithAlt / totalImages * 100);
1008
+ if (percentage >= 90) {
1009
+ return {
1010
+ id: this.id,
1011
+ name: this.name,
1012
+ description: this.description,
1013
+ category: this.category,
1014
+ status: "pass",
1015
+ score: 5,
1016
+ maxScore: this.maxScore,
1017
+ message: `${imagesWithAlt} of ${totalImages} images have alt text (${percentage}%)`,
1018
+ details: { totalImages, imagesWithAlt, percentage }
1019
+ };
1020
+ }
1021
+ if (percentage >= 50) {
1022
+ return {
1023
+ id: this.id,
1024
+ name: this.name,
1025
+ description: this.description,
1026
+ category: this.category,
1027
+ status: "warn",
1028
+ score: 3,
1029
+ maxScore: this.maxScore,
1030
+ message: `Only ${imagesWithAlt} of ${totalImages} images have alt text (${percentage}%)`,
1031
+ recommendation: "Add descriptive alt text to all images. Alt text helps screen readers, SEO, and AI systems understand your images.",
1032
+ details: { totalImages, imagesWithAlt, percentage }
1033
+ };
1034
+ }
1035
+ return {
1036
+ id: this.id,
1037
+ name: this.name,
1038
+ description: this.description,
1039
+ category: this.category,
1040
+ status: "fail",
1041
+ score: 0,
1042
+ maxScore: this.maxScore,
1043
+ message: `Only ${imagesWithAlt} of ${totalImages} images have alt text (${percentage}%)`,
1044
+ recommendation: "Add descriptive alt text to all images. This is critical for accessibility and helps AI systems understand your content.",
1045
+ details: { totalImages, imagesWithAlt, percentage }
1046
+ };
1047
+ }
1048
+ };
1049
+ var r16SemanticHtml = {
1050
+ id: "R16",
1051
+ name: "Semantic HTML",
1052
+ description: "Check for semantic HTML5 elements (main, article, section, nav, etc.)",
1053
+ category: "content-quality",
1054
+ maxScore: 5,
1055
+ check(page) {
1056
+ const $ = cheerio.load(page.html);
1057
+ const semanticElements = [
1058
+ "main",
1059
+ "article",
1060
+ "section",
1061
+ "nav",
1062
+ "aside",
1063
+ "header",
1064
+ "footer"
1065
+ ];
1066
+ const foundElements = /* @__PURE__ */ new Set();
1067
+ semanticElements.forEach((tag) => {
1068
+ if ($(tag).length > 0) {
1069
+ foundElements.add(tag);
1070
+ }
1071
+ });
1072
+ const count = foundElements.size;
1073
+ const elementList = Array.from(foundElements).sort().join(", ");
1074
+ if (count >= 3) {
1075
+ return {
1076
+ id: this.id,
1077
+ name: this.name,
1078
+ description: this.description,
1079
+ category: this.category,
1080
+ status: "pass",
1081
+ score: 5,
1082
+ maxScore: this.maxScore,
1083
+ message: `Good use of semantic HTML: ${elementList}`,
1084
+ details: { semanticElements: Array.from(foundElements), count }
1085
+ };
1086
+ }
1087
+ if (count >= 1) {
1088
+ return {
1089
+ id: this.id,
1090
+ name: this.name,
1091
+ description: this.description,
1092
+ category: this.category,
1093
+ status: "warn",
1094
+ score: 3,
1095
+ maxScore: this.maxScore,
1096
+ message: `Limited semantic HTML: ${elementList || "none"}`,
1097
+ recommendation: "Use more semantic HTML5 elements (main, article, section, nav, aside, header, footer) to improve content structure for AI parsing and accessibility.",
1098
+ details: { semanticElements: Array.from(foundElements), count }
1099
+ };
1100
+ }
1101
+ return {
1102
+ id: this.id,
1103
+ name: this.name,
1104
+ description: this.description,
1105
+ category: this.category,
1106
+ status: "fail",
1107
+ score: 0,
1108
+ maxScore: this.maxScore,
1109
+ message: "No semantic HTML5 elements found",
1110
+ recommendation: "Replace generic <div> elements with semantic HTML5 elements like <main>, <article>, <section>, <nav>, <header>, and <footer> to help AI systems understand your content structure.",
1111
+ details: { semanticElements: [], count: 0 }
1112
+ };
1113
+ }
1114
+ };
1115
+ var IDENTITY_TYPES = ["Organization", "Person", "LocalBusiness"];
1116
+ var r17IdentitySchema = {
1117
+ id: "R17",
1118
+ name: "Identity Schema Detection",
1119
+ description: "Check for Organization, Person, or LocalBusiness schema in JSON-LD",
1120
+ category: "structured-data",
1121
+ maxScore: 5,
1122
+ check(page) {
1123
+ const $ = cheerio.load(page.html);
1124
+ const scripts = $('script[type="application/ld+json"]');
1125
+ if (scripts.length === 0) {
1126
+ return {
1127
+ id: this.id,
1128
+ name: this.name,
1129
+ description: this.description,
1130
+ category: this.category,
1131
+ status: "fail",
1132
+ score: 0,
1133
+ maxScore: this.maxScore,
1134
+ message: "No JSON-LD found on page",
1135
+ recommendation: "Add JSON-LD with Organization, Person, or LocalBusiness schema to establish your identity for AI systems."
1136
+ };
1137
+ }
1138
+ const types = [];
1139
+ let hasJsonLd = false;
1140
+ scripts.each((_, el) => {
1141
+ const text = $(el).text();
1142
+ try {
1143
+ const data = JSON.parse(text);
1144
+ hasJsonLd = true;
1145
+ const extractType = (obj) => {
1146
+ if (obj["@type"]) {
1147
+ const t = Array.isArray(obj["@type"]) ? obj["@type"] : [obj["@type"]];
1148
+ types.push(...t);
1149
+ }
1150
+ if (obj["@graph"] && Array.isArray(obj["@graph"])) {
1151
+ for (const item of obj["@graph"]) {
1152
+ extractType(item);
1153
+ }
1154
+ }
1155
+ };
1156
+ extractType(data);
1157
+ } catch {
1158
+ }
1159
+ });
1160
+ const identityTypes = types.filter((t) => IDENTITY_TYPES.includes(t));
1161
+ if (identityTypes.length > 0) {
1162
+ return {
1163
+ id: this.id,
1164
+ name: this.name,
1165
+ description: this.description,
1166
+ category: this.category,
1167
+ status: "pass",
1168
+ score: 5,
1169
+ maxScore: this.maxScore,
1170
+ message: `Identity schema found: ${identityTypes.join(", ")}`,
1171
+ details: { types: identityTypes }
1172
+ };
1173
+ }
1174
+ if (hasJsonLd) {
1175
+ return {
1176
+ id: this.id,
1177
+ name: this.name,
1178
+ description: this.description,
1179
+ category: this.category,
1180
+ status: "warn",
1181
+ score: 2,
1182
+ maxScore: this.maxScore,
1183
+ message: "JSON-LD exists but no identity schema found",
1184
+ recommendation: "Add Organization, Person, or LocalBusiness schema to help AI systems understand who you are.",
1185
+ details: { types }
1186
+ };
1187
+ }
1188
+ return {
1189
+ id: this.id,
1190
+ name: this.name,
1191
+ description: this.description,
1192
+ category: this.category,
1193
+ status: "fail",
1194
+ score: 0,
1195
+ maxScore: this.maxScore,
1196
+ message: "No valid JSON-LD found",
1197
+ recommendation: "Add JSON-LD with Organization, Person, or LocalBusiness schema to establish your identity."
1198
+ };
1199
+ }
1200
+ };
1201
+ var r18RssFeed = {
1202
+ id: "R18",
1203
+ name: "RSS/Atom Feed Detection",
1204
+ description: "Check for discoverable RSS or Atom feed links in HTML head",
1205
+ category: "discoverability",
1206
+ maxScore: 5,
1207
+ check(page) {
1208
+ const $ = cheerio.load(page.html);
1209
+ const feedLinks = $('link[rel="alternate"]');
1210
+ if (feedLinks.length === 0) {
1211
+ return {
1212
+ id: this.id,
1213
+ name: this.name,
1214
+ description: this.description,
1215
+ category: this.category,
1216
+ status: "fail",
1217
+ score: 0,
1218
+ maxScore: this.maxScore,
1219
+ message: "No feed link found",
1220
+ recommendation: 'Add <link rel="alternate" type="application/rss+xml" href="/feed.xml"> to make your content discoverable via feeds.'
1221
+ };
1222
+ }
1223
+ const rssFeeds = [];
1224
+ const atomFeeds = [];
1225
+ const otherFeeds = [];
1226
+ feedLinks.each((_, el) => {
1227
+ const type = $(el).attr("type");
1228
+ const href = $(el).attr("href");
1229
+ if (type === "application/rss+xml" && href) {
1230
+ rssFeeds.push(href);
1231
+ } else if (type === "application/atom+xml" && href) {
1232
+ atomFeeds.push(href);
1233
+ } else if (href) {
1234
+ otherFeeds.push(href);
1235
+ }
1236
+ });
1237
+ const validFeeds = rssFeeds.length + atomFeeds.length;
1238
+ if (validFeeds > 0) {
1239
+ const feedTypes = [];
1240
+ if (rssFeeds.length > 0) feedTypes.push(`RSS (${rssFeeds.length})`);
1241
+ if (atomFeeds.length > 0) feedTypes.push(`Atom (${atomFeeds.length})`);
1242
+ return {
1243
+ id: this.id,
1244
+ name: this.name,
1245
+ description: this.description,
1246
+ category: this.category,
1247
+ status: "pass",
1248
+ score: 5,
1249
+ maxScore: this.maxScore,
1250
+ message: `Feed link(s) found: ${feedTypes.join(", ")}`,
1251
+ details: { rssFeeds, atomFeeds }
1252
+ };
1253
+ }
1254
+ if (otherFeeds.length > 0) {
1255
+ return {
1256
+ id: this.id,
1257
+ name: this.name,
1258
+ description: this.description,
1259
+ category: this.category,
1260
+ status: "warn",
1261
+ score: 2,
1262
+ maxScore: this.maxScore,
1263
+ message: "Feed link found but missing or incorrect type attribute",
1264
+ recommendation: 'Set type="application/rss+xml" or type="application/atom+xml" on feed link elements.',
1265
+ details: { otherFeeds }
1266
+ };
1267
+ }
1268
+ return {
1269
+ id: this.id,
1270
+ name: this.name,
1271
+ description: this.description,
1272
+ category: this.category,
1273
+ status: "fail",
1274
+ score: 0,
1275
+ maxScore: this.maxScore,
1276
+ message: "No valid feed link found",
1277
+ recommendation: 'Add <link rel="alternate" type="application/rss+xml" href="/feed.xml"> for content discoverability.'
1278
+ };
1279
+ }
1280
+ };
1281
+
1282
+ // src/rules/r19-llms-quality.ts
1283
+ var r19LlmsQuality = {
1284
+ id: "R19",
1285
+ name: "llms.txt Content Quality",
1286
+ description: "Analyze the quality and completeness of llms.txt content",
1287
+ category: "discoverability",
1288
+ maxScore: 5,
1289
+ check(page) {
1290
+ const { llmsTxt } = page;
1291
+ if (!llmsTxt.ok || llmsTxt.status !== 200) {
1292
+ return {
1293
+ id: this.id,
1294
+ name: this.name,
1295
+ description: this.description,
1296
+ category: this.category,
1297
+ status: "skip",
1298
+ score: 5,
1299
+ maxScore: this.maxScore,
1300
+ message: "llms.txt does not exist (skipped)"
1301
+ };
1302
+ }
1303
+ const body = llmsTxt.body.trim();
1304
+ if (body.length === 0) {
1305
+ return {
1306
+ id: this.id,
1307
+ name: this.name,
1308
+ description: this.description,
1309
+ category: this.category,
1310
+ status: "skip",
1311
+ score: 5,
1312
+ maxScore: this.maxScore,
1313
+ message: "llms.txt is empty (skipped)"
1314
+ };
1315
+ }
1316
+ let qualityScore = 0;
1317
+ const signals = [];
1318
+ if (/^#\s+.+/m.test(body)) {
1319
+ qualityScore++;
1320
+ signals.push("H1 heading");
1321
+ }
1322
+ if (body.length >= 100) {
1323
+ qualityScore++;
1324
+ signals.push("sufficient length");
1325
+ }
1326
+ if (/\[.+\]\(.+\)/m.test(body)) {
1327
+ qualityScore++;
1328
+ signals.push("contains links");
1329
+ }
1330
+ const h2Count = (body.match(/^##\s+.+/gm) || []).length;
1331
+ if (h2Count >= 2) {
1332
+ qualityScore++;
1333
+ signals.push("multiple sections");
1334
+ }
1335
+ const nonHeadingLines = body.split("\n").filter((line) => !line.trim().startsWith("#") && line.trim().length > 0);
1336
+ if (nonHeadingLines.length >= 3) {
1337
+ qualityScore++;
1338
+ signals.push("descriptive content");
1339
+ }
1340
+ if (qualityScore >= 4) {
1341
+ return {
1342
+ id: this.id,
1343
+ name: this.name,
1344
+ description: this.description,
1345
+ category: this.category,
1346
+ status: "pass",
1347
+ score: 5,
1348
+ maxScore: this.maxScore,
1349
+ message: `High-quality llms.txt (${qualityScore}/5 signals): ${signals.join(", ")}`,
1350
+ details: { qualityScore, signals }
1351
+ };
1352
+ }
1353
+ if (qualityScore >= 2) {
1354
+ return {
1355
+ id: this.id,
1356
+ name: this.name,
1357
+ description: this.description,
1358
+ category: this.category,
1359
+ status: "warn",
1360
+ score: 3,
1361
+ maxScore: this.maxScore,
1362
+ message: `llms.txt could be improved (${qualityScore}/5 signals)`,
1363
+ recommendation: "Enhance llms.txt with: H1 heading, sufficient content (100+ chars), markdown links, multiple sections (## headings), and descriptive text.",
1364
+ details: { qualityScore, signals }
1365
+ };
1366
+ }
1367
+ return {
1368
+ id: this.id,
1369
+ name: this.name,
1370
+ description: this.description,
1371
+ category: this.category,
1372
+ status: "fail",
1373
+ score: 1,
1374
+ maxScore: this.maxScore,
1375
+ message: `Minimal llms.txt quality (${qualityScore}/5 signals)`,
1376
+ recommendation: "Improve llms.txt with: H1 heading, more content, markdown links, multiple sections, and descriptive paragraphs.",
1377
+ details: { qualityScore, signals }
1378
+ };
1379
+ }
1380
+ };
1381
+ var r20Viewport = {
1382
+ id: "R20",
1383
+ name: "Mobile Viewport Meta Tag",
1384
+ description: "Check for proper viewport meta tag for mobile responsiveness",
1385
+ category: "technical",
1386
+ maxScore: 3,
1387
+ check(page) {
1388
+ const $ = cheerio.load(page.html);
1389
+ const viewport = $('meta[name="viewport"]');
1390
+ if (viewport.length === 0) {
1391
+ return {
1392
+ id: this.id,
1393
+ name: this.name,
1394
+ description: this.description,
1395
+ category: this.category,
1396
+ status: "fail",
1397
+ score: 0,
1398
+ maxScore: this.maxScore,
1399
+ message: "No viewport meta tag found",
1400
+ recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> for proper mobile rendering.'
1401
+ };
1402
+ }
1403
+ const content = viewport.attr("content") || "";
1404
+ if (content.includes("width=device-width")) {
1405
+ return {
1406
+ id: this.id,
1407
+ name: this.name,
1408
+ description: this.description,
1409
+ category: this.category,
1410
+ status: "pass",
1411
+ score: 3,
1412
+ maxScore: this.maxScore,
1413
+ message: "Viewport meta tag properly configured",
1414
+ details: { content }
1415
+ };
1416
+ }
1417
+ return {
1418
+ id: this.id,
1419
+ name: this.name,
1420
+ description: this.description,
1421
+ category: this.category,
1422
+ status: "warn",
1423
+ score: 1,
1424
+ maxScore: this.maxScore,
1425
+ message: "Viewport meta tag exists but missing width=device-width",
1426
+ recommendation: 'Update viewport meta tag to include "width=device-width" for proper mobile responsiveness.',
1427
+ details: { content }
1428
+ };
1429
+ }
1430
+ };
1431
+
1432
+ // src/rules/index.ts
1433
+ var allRules = [
1434
+ // AI Discoverability (40 pts)
1435
+ r01LlmsTxt,
1436
+ r02RobotsTxt,
1437
+ r03Sitemap,
1438
+ r18RssFeed,
1439
+ r19LlmsQuality,
1440
+ // Structured Data (35 pts)
1441
+ r04JsonLd,
1442
+ r05OpenGraph,
1443
+ r06MetaDescription,
1444
+ r07Canonical,
1445
+ r17IdentitySchema,
1446
+ // Content Quality (38 pts)
1447
+ r08Headings,
1448
+ r09SsrContent,
1449
+ r10Faq,
1450
+ r13LangTag,
1451
+ r15AltText,
1452
+ r16SemanticHtml,
1453
+ // Technical AI-Readiness (21 pts)
1454
+ r11ResponseTime,
1455
+ r12ContentType,
1456
+ r14Https,
1457
+ r20Viewport
1458
+ ];
1459
+
1460
+ // src/score.ts
1461
+ var CATEGORY_DEFS = [
1462
+ { name: "AI Discoverability", slug: "discoverability", maxPoints: 40 },
1463
+ { name: "Structured Data", slug: "structured-data", maxPoints: 35 },
1464
+ { name: "Content Quality", slug: "content-quality", maxPoints: 38 },
1465
+ { name: "Technical AI-Readiness", slug: "technical", maxPoints: 21 }
1466
+ ];
1467
+ new Map(CATEGORY_DEFS.map((c) => [c.slug, c]));
1468
+ function getGrade(score) {
1469
+ if (score >= 90) return "A";
1470
+ if (score >= 75) return "B";
1471
+ if (score >= 60) return "C";
1472
+ if (score >= 40) return "D";
1473
+ return "F";
1474
+ }
1475
+ function buildCategories(results) {
1476
+ return CATEGORY_DEFS.map((def) => {
1477
+ const rules = results.filter((r) => r.category === def.slug);
1478
+ const score = rules.reduce((sum, r) => sum + r.score, 0);
1479
+ return {
1480
+ name: def.name,
1481
+ slug: def.slug,
1482
+ maxPoints: def.maxPoints,
1483
+ score: Math.min(score, def.maxPoints),
1484
+ rules
1485
+ };
1486
+ });
1487
+ }
1488
+ function calculateScore(results) {
1489
+ const totalMax = results.reduce((sum, r) => sum + r.maxScore, 0);
1490
+ if (totalMax === 0) return 0;
1491
+ const totalScore = results.reduce((sum, r) => sum + r.score, 0);
1492
+ return Math.round(totalScore / totalMax * 100);
1493
+ }
1494
+ function buildRecommendations(results) {
1495
+ return results.filter((r) => r.status !== "pass" && r.status !== "skip" && r.recommendation).map((r) => ({
1496
+ rule: r.id,
1497
+ message: r.recommendation,
1498
+ impact: r.maxScore - r.score
1499
+ })).sort((a, b) => b.impact - a.impact);
1500
+ }
1501
+
1502
+ // src/audit.ts
1503
+ var VERSION = "0.1.0";
1504
+ async function audit(url, options = {}) {
1505
+ const startTime = performance.now();
1506
+ const pageData = await fetchPageData(url, options);
1507
+ const results = [];
1508
+ for (const rule of allRules) {
1509
+ const result = await rule.check(pageData);
1510
+ results.push(result);
1511
+ }
1512
+ const score = calculateScore(results);
1513
+ const grade = getGrade(score);
1514
+ const categories = buildCategories(results);
1515
+ const recommendations = buildRecommendations(results);
1516
+ const duration = Math.round(performance.now() - startTime);
1517
+ return {
1518
+ url: pageData.url,
1519
+ score,
1520
+ grade,
1521
+ categories,
1522
+ rules: results,
1523
+ recommendations,
1524
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1525
+ duration,
1526
+ version: VERSION
1527
+ };
1528
+ }
1529
+
1530
+ // src/cli.ts
1531
+ var VERSION2 = "0.1.0";
1532
+ function parseArgs(args) {
1533
+ const flags = {};
1534
+ let i = 0;
1535
+ while (i < args.length) {
1536
+ const arg = args[i];
1537
+ switch (arg) {
1538
+ case "--help":
1539
+ case "-h":
1540
+ flags.help = true;
1541
+ break;
1542
+ case "--version":
1543
+ case "-v":
1544
+ case "-V":
1545
+ flags.version = true;
1546
+ break;
1547
+ case "--json":
1548
+ case "-j":
1549
+ flags.json = true;
1550
+ break;
1551
+ case "--verbose":
1552
+ flags.verbose = true;
1553
+ break;
1554
+ case "--quiet":
1555
+ case "-q":
1556
+ flags.quiet = true;
1557
+ break;
1558
+ case "--debug":
1559
+ flags.debug = true;
1560
+ break;
1561
+ case "--insecure":
1562
+ flags.insecure = true;
1563
+ break;
1564
+ case "--no-recommendations":
1565
+ case "--no-recs":
1566
+ flags.noRecommendations = true;
1567
+ break;
1568
+ case "--fail-under": {
1569
+ i++;
1570
+ const val = args[i];
1571
+ if (val === void 0 || isNaN(Number(val))) {
1572
+ console.error(chalk.red("Error: --fail-under requires a numeric value"));
1573
+ process.exit(1);
1574
+ }
1575
+ flags.failUnder = Number(val);
1576
+ break;
1577
+ }
1578
+ case "--timeout": {
1579
+ i++;
1580
+ const val = args[i];
1581
+ if (val === void 0 || isNaN(Number(val))) {
1582
+ console.error(chalk.red("Error: --timeout requires a numeric value"));
1583
+ process.exit(1);
1584
+ }
1585
+ flags.timeout = Number(val);
1586
+ break;
1587
+ }
1588
+ default:
1589
+ if (!arg.startsWith("-")) {
1590
+ flags.url = arg;
1591
+ } else {
1592
+ console.error(chalk.red(`Unknown flag: ${arg}`));
1593
+ process.exit(1);
1594
+ }
1595
+ }
1596
+ i++;
1597
+ }
1598
+ return flags;
1599
+ }
1600
+ function printHelp() {
1601
+ console.log(`
1602
+ ${chalk.bold("geo-audit")} \u2014 Audit any website's AI-readiness
1603
+
1604
+ ${chalk.dim("USAGE")}
1605
+ npx geo-audit <url> [options]
1606
+ echo "https://example.com" | npx geo-audit [options]
1607
+
1608
+ ${chalk.dim("OPTIONS")}
1609
+ ${chalk.cyan("--json, -j")} Output results as JSON
1610
+ ${chalk.cyan("--verbose")} Show all rules including passed ones
1611
+ ${chalk.cyan("--quiet, -q")} Only show score and grade
1612
+ ${chalk.cyan("--fail-under <n>")} Exit code 2 if score is below threshold
1613
+ ${chalk.cyan("--no-recommendations, --no-recs")}
1614
+ Skip recommendations section
1615
+ ${chalk.cyan("--timeout <ms>")} Override default 10s fetch timeout
1616
+ ${chalk.cyan("--insecure")} Skip SSL verification
1617
+ ${chalk.cyan("--debug")} Show debug information
1618
+ ${chalk.cyan("--help, -h")} Show this help message
1619
+ ${chalk.cyan("--version, -v, -V")} Show version
1620
+
1621
+ ${chalk.dim("EXIT CODES")}
1622
+ 0 Success
1623
+ 1 Error (DNS, timeout, network)
1624
+ 2 Score below --fail-under threshold
1625
+
1626
+ ${chalk.dim("EXAMPLES")}
1627
+ npx geo-audit https://example.com
1628
+ npx geo-audit example.com --json
1629
+ npx geo-audit example.com --fail-under 80
1630
+ npx geo-audit example.com --timeout 20000 --no-recs
1631
+
1632
+ ${chalk.dim("Powered by GeoKit \u2014 geo.glincker.com")}
1633
+ `);
1634
+ }
1635
+ function statusIcon(status) {
1636
+ switch (status) {
1637
+ case "pass":
1638
+ return chalk.green(" \u2705");
1639
+ case "warn":
1640
+ return chalk.yellow(" \u26A0\uFE0F ");
1641
+ case "fail":
1642
+ return chalk.red(" \u274C");
1643
+ case "skip":
1644
+ return chalk.dim(" \u2796");
1645
+ default:
1646
+ return " ";
1647
+ }
1648
+ }
1649
+ function gradeColor(grade) {
1650
+ switch (grade) {
1651
+ case "A":
1652
+ return chalk.green;
1653
+ case "B":
1654
+ return chalk.greenBright;
1655
+ case "C":
1656
+ return chalk.yellow;
1657
+ case "D":
1658
+ return chalk.redBright;
1659
+ case "F":
1660
+ return chalk.red;
1661
+ default:
1662
+ return chalk.white;
1663
+ }
1664
+ }
1665
+ function printCategory(cat, verbose) {
1666
+ const pct = cat.maxPoints > 0 ? Math.round(cat.score / cat.maxPoints * 100) : 0;
1667
+ const color = pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
1668
+ console.log(
1669
+ `
1670
+ ${chalk.bold(cat.name)}${chalk.dim(` ${color(`${cat.score}/${cat.maxPoints}`)}`)}`
1671
+ );
1672
+ for (const rule of cat.rules) {
1673
+ if (!verbose && rule.status === "pass") continue;
1674
+ printRule(rule);
1675
+ }
1676
+ }
1677
+ function printRule(rule) {
1678
+ const icon = statusIcon(rule.status);
1679
+ const score = chalk.dim(`${rule.score}/${rule.maxScore}`);
1680
+ console.log(`${icon} ${rule.name} ${score}`);
1681
+ if (rule.status !== "pass" && rule.status !== "skip") {
1682
+ console.log(chalk.dim(` ${rule.message}`));
1683
+ }
1684
+ }
1685
+ function printResult(result, flags) {
1686
+ const colorFn = gradeColor(result.grade);
1687
+ const hostname = new URL(result.url).hostname;
1688
+ console.log(`
1689
+ ${chalk.bold("\u{1F50D} GEO Audit:")} ${hostname}`);
1690
+ console.log(chalk.dim("\u2501".repeat(40)));
1691
+ console.log(
1692
+ `
1693
+ Score: ${colorFn(chalk.bold(`${result.score}/100`))} (${colorFn(result.grade)})`
1694
+ );
1695
+ if (flags.quiet) {
1696
+ return;
1697
+ }
1698
+ for (const cat of result.categories) {
1699
+ printCategory(cat, flags.verbose ?? false);
1700
+ }
1701
+ if (!flags.noRecommendations && result.recommendations.length > 0) {
1702
+ console.log(`
1703
+ ${chalk.bold("\u{1F4CB} Top Recommendations:")}`);
1704
+ const top = result.recommendations.slice(0, 5);
1705
+ for (let i = 0; i < top.length; i++) {
1706
+ const rec = top[i];
1707
+ console.log(
1708
+ ` ${chalk.cyan(`${i + 1}.`)} ${rec.message} ${chalk.dim(`(+${rec.impact} points)`)}`
1709
+ );
1710
+ }
1711
+ }
1712
+ console.log(
1713
+ `
1714
+ ${chalk.dim(`Completed in ${result.duration}ms`)}`
1715
+ );
1716
+ console.log(chalk.dim("Powered by GeoKit \u2014 geo.glincker.com\n"));
1717
+ }
1718
+ async function main() {
1719
+ if (process.env.NO_COLOR !== void 0 || process.env.FORCE_COLOR === "0") {
1720
+ chalk.level = 0;
1721
+ }
1722
+ const args = process.argv.slice(2);
1723
+ const flags = parseArgs(args);
1724
+ if (flags.version) {
1725
+ console.log(VERSION2);
1726
+ return;
1727
+ }
1728
+ if (flags.help) {
1729
+ printHelp();
1730
+ return;
1731
+ }
1732
+ if (!flags.url && !process.stdin.isTTY) {
1733
+ const chunks = [];
1734
+ for await (const chunk of process.stdin) {
1735
+ chunks.push(chunk);
1736
+ }
1737
+ flags.url = Buffer.concat(chunks).toString().trim();
1738
+ }
1739
+ if (!flags.url) {
1740
+ printHelp();
1741
+ return;
1742
+ }
1743
+ let spinnerInterval = null;
1744
+ process.on("SIGINT", () => {
1745
+ if (spinnerInterval) {
1746
+ clearInterval(spinnerInterval);
1747
+ }
1748
+ process.stderr.write("\r" + " ".repeat(60) + "\r");
1749
+ process.exit(130);
1750
+ });
1751
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1752
+ let frameIdx = 0;
1753
+ spinnerInterval = setInterval(() => {
1754
+ process.stderr.write(`\r${chalk.cyan(frames[frameIdx % frames.length])} Auditing ${flags.url}...`);
1755
+ frameIdx++;
1756
+ }, 80);
1757
+ try {
1758
+ const result = await audit(flags.url, {
1759
+ insecure: flags.insecure,
1760
+ timeout: flags.timeout
1761
+ });
1762
+ clearInterval(spinnerInterval);
1763
+ spinnerInterval = null;
1764
+ process.stderr.write("\r" + " ".repeat(60) + "\r");
1765
+ if (flags.json) {
1766
+ console.log(JSON.stringify(result, null, 2));
1767
+ } else {
1768
+ printResult(result, flags);
1769
+ }
1770
+ if (flags.failUnder !== void 0 && result.score < flags.failUnder) {
1771
+ process.exit(2);
1772
+ }
1773
+ } catch (error) {
1774
+ if (spinnerInterval) {
1775
+ clearInterval(spinnerInterval);
1776
+ }
1777
+ process.stderr.write("\r" + " ".repeat(60) + "\r");
1778
+ if (flags.debug) {
1779
+ console.error(error);
1780
+ }
1781
+ const message = error instanceof Error ? error.message : "Unknown error";
1782
+ if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
1783
+ console.error(
1784
+ chalk.red(`Could not resolve hostname. Check the URL and try again.`)
1785
+ );
1786
+ } else if (message.includes("abort") || message.includes("timeout")) {
1787
+ console.error(
1788
+ chalk.red(
1789
+ `Request timed out. The site may be slow or blocking requests.`
1790
+ )
1791
+ );
1792
+ } else if (message.includes("SSL") || message.includes("certificate")) {
1793
+ console.error(
1794
+ chalk.red(
1795
+ `SSL certificate error. Try with --insecure (not recommended).`
1796
+ )
1797
+ );
1798
+ } else if (message.includes("Blocked")) {
1799
+ console.error(chalk.red(message));
1800
+ } else {
1801
+ console.error(chalk.red(`Error: ${message}`));
1802
+ }
1803
+ process.exit(1);
1804
+ }
1805
+ }
1806
+ main();
1807
+ //# sourceMappingURL=cli.js.map
1808
+ //# sourceMappingURL=cli.js.map