@rankture/cli 0.1.1 → 0.1.3

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.mjs CHANGED
@@ -4,8 +4,8 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/commands/check.ts
7
- import * as fs2 from "fs";
8
- import * as path2 from "path";
7
+ import * as fs6 from "fs";
8
+ import * as path7 from "path";
9
9
  import fg from "fast-glob";
10
10
  import ora from "ora";
11
11
 
@@ -49,15 +49,15 @@ function parseHtml(filePath, html) {
49
49
  const lang = $("html").attr("lang")?.trim() || void 0;
50
50
  const h1s = [];
51
51
  $("h1").each((_, el) => {
52
- const text = $(el).text().trim();
53
- if (text) h1s.push(text);
52
+ const text2 = $(el).text().trim();
53
+ if (text2) h1s.push(text2);
54
54
  });
55
55
  const headings = [];
56
56
  $("h1, h2, h3, h4, h5, h6").each((_, el) => {
57
57
  const tagName = el.tagName.toLowerCase();
58
58
  const level = parseInt(tagName.replace("h", ""), 10);
59
- const text = $(el).text().trim();
60
- if (text) headings.push({ level, text });
59
+ const text2 = $(el).text().trim();
60
+ if (text2) headings.push({ level, text: text2 });
61
61
  });
62
62
  const images = [];
63
63
  $("img").each((_, el) => {
@@ -69,14 +69,14 @@ function parseHtml(filePath, html) {
69
69
  const externalLinks = [];
70
70
  $("a[href]").each((_, el) => {
71
71
  const href = $(el).attr("href") || "";
72
- const text = $(el).text().trim();
72
+ const text2 = $(el).text().trim();
73
73
  if (href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) {
74
74
  return;
75
75
  }
76
76
  if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) {
77
- externalLinks.push({ href, text });
77
+ externalLinks.push({ href, text: text2 });
78
78
  } else {
79
- internalLinks.push({ href, text });
79
+ internalLinks.push({ href, text: text2 });
80
80
  }
81
81
  });
82
82
  const jsonLd = [];
@@ -110,6 +110,7 @@ function parseHtml(filePath, html) {
110
110
  return {
111
111
  filePath,
112
112
  html,
113
+ sourceContent: html,
113
114
  title,
114
115
  metaDescription,
115
116
  canonical,
@@ -130,7 +131,7 @@ function parseHtml(filePath, html) {
130
131
  }
131
132
  function parseSourceFile(filePath, content) {
132
133
  const ext = filePath.toLowerCase().split(".").pop() || "";
133
- let htmlContent = content;
134
+ let htmlContent;
134
135
  switch (ext) {
135
136
  case "astro":
136
137
  htmlContent = parseAstroFile(content);
@@ -273,7 +274,7 @@ function parsePugFile(content) {
273
274
  }
274
275
  const tagMatch = trimmed.match(/^(h[1-6]|p|a|img|title|meta)([.#(][^\s]*)?\s*(.*)?$/);
275
276
  if (tagMatch) {
276
- const [, tag, attrs, text] = tagMatch;
277
+ const [, tag, attrs, text2] = tagMatch;
277
278
  if (tag === "img") {
278
279
  const srcMatch = (attrs || "").match(/src=['"]([^'"]+)['"]/);
279
280
  const altMatch = (attrs || "").match(/alt=['"]([^'"]*)['"]/);
@@ -281,16 +282,16 @@ function parsePugFile(content) {
281
282
  `;
282
283
  } else if (tag === "a") {
283
284
  const hrefMatch = (attrs || "").match(/href=['"]([^'"]+)['"]/);
284
- htmlContent += `<a href="${hrefMatch?.[1] || ""}">${text || ""}</a>
285
+ htmlContent += `<a href="${hrefMatch?.[1] || ""}">${text2 || ""}</a>
285
286
  `;
286
287
  } else if (tag === "title") {
287
- htmlContent += `<title>${text || ""}</title>
288
+ htmlContent += `<title>${text2 || ""}</title>
288
289
  `;
289
290
  } else if (tag === "meta") {
290
291
  htmlContent += `<meta ${attrs?.replace(/[()]/g, "") || ""}>
291
292
  `;
292
293
  } else {
293
- htmlContent += `<${tag}>${text || ""}</${tag}>
294
+ htmlContent += `<${tag}>${text2 || ""}</${tag}>
294
295
  `;
295
296
  }
296
297
  }
@@ -313,7 +314,7 @@ function convertMarkdownLinks(content) {
313
314
  return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
314
315
  }
315
316
  function extractJsxContent(content) {
316
- let jsxContent = "";
317
+ let jsxContent;
317
318
  const withoutImports = content.replace(/^import\s+.*?;?\s*$/gm, "");
318
319
  const returnBlocks = [];
319
320
  const standardReturns = withoutImports.match(/return\s*\([\s\S]*?\n\s*\);/g);
@@ -438,12 +439,12 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
438
439
  const $ = cheerio.load(htmlContent, { xml: false });
439
440
  const h1s = [];
440
441
  $("h1").each((_, el) => {
441
- let text = $(el).text().trim();
442
+ let text2 = $(el).text().trim();
442
443
  const html = $(el).html() || "";
443
444
  if (html.includes("{") && html.includes("}")) {
444
- text = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
445
+ text2 = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
445
446
  }
446
- if (text) h1s.push(text);
447
+ if (text2) h1s.push(text2);
447
448
  });
448
449
  const mdH1Match = originalContent.match(/^#\s+(.+)$/m);
449
450
  if (mdH1Match && h1s.length === 0) {
@@ -453,12 +454,12 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
453
454
  $("h1, h2, h3, h4, h5, h6").each((_, el) => {
454
455
  const tagName = el.tagName.toLowerCase();
455
456
  const level = parseInt(tagName.replace("h", ""), 10);
456
- let text = $(el).text().trim();
457
+ let text2 = $(el).text().trim();
457
458
  const html = $(el).html() || "";
458
459
  if (html.includes("{") && html.includes("}")) {
459
- text = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
460
+ text2 = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
460
461
  }
461
- if (text) headings.push({ level, text });
462
+ if (text2) headings.push({ level, text: text2 });
462
463
  });
463
464
  const images = [];
464
465
  $("img").each((_, el) => {
@@ -479,7 +480,7 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
479
480
  const externalLinks = [];
480
481
  $("a[href], Link[href], Link[to]").each((_, el) => {
481
482
  let href = $(el).attr("href") || $(el).attr("to") || "";
482
- const text = $(el).text().trim();
483
+ const text2 = $(el).text().trim();
483
484
  if (!href || href === "#" || href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) {
484
485
  return;
485
486
  }
@@ -487,9 +488,9 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
487
488
  href = `[dynamic: ${href}]`;
488
489
  }
489
490
  if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) {
490
- externalLinks.push({ href, text });
491
+ externalLinks.push({ href, text: text2 });
491
492
  } else {
492
- internalLinks.push({ href, text });
493
+ internalLinks.push({ href, text: text2 });
493
494
  }
494
495
  });
495
496
  let title;
@@ -514,9 +515,21 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
514
515
  metaDescription = descMatch[1].trim();
515
516
  }
516
517
  }
518
+ const jsonLd = [];
519
+ $('script[type="application/ld+json"]').each((_, el) => {
520
+ const value = ($(el).html() ?? "").trim();
521
+ if (!value || /^(?:\{|\[)?\s*JSON\.stringify\s*\(/.test(value.replace(/^\{/, ""))) return;
522
+ try {
523
+ const parsed = JSON.parse(value);
524
+ if (Array.isArray(parsed)) jsonLd.push(...parsed.filter((item) => item && typeof item === "object"));
525
+ else if (parsed && typeof parsed === "object") jsonLd.push(parsed);
526
+ } catch {
527
+ }
528
+ });
517
529
  return {
518
530
  filePath,
519
531
  html: originalContent,
532
+ sourceContent: originalContent,
520
533
  title,
521
534
  metaDescription,
522
535
  canonical: void 0,
@@ -528,7 +541,7 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
528
541
  images,
529
542
  internalLinks,
530
543
  externalLinks,
531
- jsonLd: [],
544
+ jsonLd,
532
545
  ogTags: {},
533
546
  twitterTags: {},
534
547
  robots: void 0,
@@ -538,7 +551,8 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
538
551
  }
539
552
 
540
553
  // src/checks/meta-tags.ts
541
- function checkMetaTitle(page, allPages) {
554
+ var isDynamic = (value) => /\[dynamic(?::|\])/i.test(value) || /\{[^}]+\}/.test(value);
555
+ function checkMetaTitle(page, allPages, thresholds) {
542
556
  const results = [];
543
557
  if (!page.title) {
544
558
  results.push({
@@ -552,26 +566,27 @@ function checkMetaTitle(page, allPages) {
552
566
  });
553
567
  return results;
554
568
  }
569
+ const titleRange = thresholds?.titleLength ?? { min: 30, max: 60 };
555
570
  const titleLength = page.title.length;
556
- if (titleLength < 30) {
571
+ if (!isDynamic(page.title) && titleLength < titleRange.min) {
557
572
  results.push({
558
573
  checkId: "meta-title-length",
559
574
  name: "Title Too Short",
560
575
  severity: "warning",
561
576
  passed: false,
562
577
  file: page.filePath,
563
- message: `Title is too short (${titleLength} chars, recommended 30-60)`,
564
- fix: "Expand your title to be more descriptive (30-60 characters)"
578
+ message: `Title is too short (${titleLength} chars, expected ${titleRange.min}-${titleRange.max})`,
579
+ fix: `Expand your title to ${titleRange.min}-${titleRange.max} characters`
565
580
  });
566
- } else if (titleLength > 60) {
581
+ } else if (!isDynamic(page.title) && titleLength > titleRange.max) {
567
582
  results.push({
568
583
  checkId: "meta-title-length",
569
584
  name: "Title Too Long",
570
585
  severity: "warning",
571
586
  passed: false,
572
587
  file: page.filePath,
573
- message: `Title is too long (${titleLength} chars, recommended 30-60)`,
574
- fix: "Shorten your title to under 60 characters to prevent truncation in search results"
588
+ message: `Title is too long (${titleLength} chars, expected ${titleRange.min}-${titleRange.max})`,
589
+ fix: `Shorten your title to at most ${titleRange.max} characters`
575
590
  });
576
591
  }
577
592
  const duplicates = allPages.filter(
@@ -591,7 +606,7 @@ function checkMetaTitle(page, allPages) {
591
606
  }
592
607
  return results;
593
608
  }
594
- function checkMetaDescription(page, allPages) {
609
+ function checkMetaDescription(page, allPages, thresholds) {
595
610
  const results = [];
596
611
  if (!page.metaDescription) {
597
612
  results.push({
@@ -605,26 +620,27 @@ function checkMetaDescription(page, allPages) {
605
620
  });
606
621
  return results;
607
622
  }
623
+ const descriptionRange = thresholds?.descriptionLength ?? { min: 120, max: 160 };
608
624
  const descLength = page.metaDescription.length;
609
- if (descLength < 120) {
625
+ if (!isDynamic(page.metaDescription) && descLength < descriptionRange.min) {
610
626
  results.push({
611
627
  checkId: "meta-description-length",
612
628
  name: "Meta Description Too Short",
613
629
  severity: "warning",
614
630
  passed: false,
615
631
  file: page.filePath,
616
- message: `Meta description is too short (${descLength} chars, recommended 120-160)`,
617
- fix: "Expand your meta description to be more descriptive (120-160 characters)"
632
+ message: `Meta description is too short (${descLength} chars, expected ${descriptionRange.min}-${descriptionRange.max})`,
633
+ fix: `Expand your meta description to ${descriptionRange.min}-${descriptionRange.max} characters`
618
634
  });
619
- } else if (descLength > 160) {
635
+ } else if (!isDynamic(page.metaDescription) && descLength > descriptionRange.max) {
620
636
  results.push({
621
637
  checkId: "meta-description-length",
622
638
  name: "Meta Description Too Long",
623
639
  severity: "warning",
624
640
  passed: false,
625
641
  file: page.filePath,
626
- message: `Meta description is too long (${descLength} chars, recommended 120-160)`,
627
- fix: "Shorten your meta description to under 160 characters to prevent truncation"
642
+ message: `Meta description is too long (${descLength} chars, expected ${descriptionRange.min}-${descriptionRange.max})`,
643
+ fix: `Shorten your meta description to at most ${descriptionRange.max} characters`
628
644
  });
629
645
  }
630
646
  const duplicates = allPages.filter(
@@ -848,6 +864,50 @@ function checkInternalLinks(page, allPages, basePath) {
848
864
  }
849
865
  return results;
850
866
  }
867
+ async function checkInternalLinksHttp(page, pageUrl, fetcher = fetch) {
868
+ const brokenLinks = [];
869
+ const emptyLinks = page.internalLinks.filter((link) => !link.href || link.href === "#");
870
+ const origin = new URL(pageUrl).origin;
871
+ for (const link of page.internalLinks) {
872
+ if (!link.href || link.href === "#" || link.href.startsWith("#") || link.href.startsWith("javascript:")) continue;
873
+ if (/^\[dynamic/i.test(link.href)) continue;
874
+ const target = new URL(link.href, pageUrl);
875
+ if (target.origin !== origin) continue;
876
+ target.hash = "";
877
+ try {
878
+ let response = await fetcher(target, { method: "HEAD", redirect: "manual" });
879
+ if (response.status === 405) response = await fetcher(target, { method: "GET", redirect: "manual" });
880
+ if (response.status >= 400) brokenLinks.push(link.href);
881
+ } catch {
882
+ brokenLinks.push(link.href);
883
+ }
884
+ }
885
+ const results = [];
886
+ if (brokenLinks.length > 0) {
887
+ results.push({
888
+ checkId: "link-broken-internal",
889
+ name: "Broken Internal Links",
890
+ severity: "critical",
891
+ passed: false,
892
+ file: page.filePath,
893
+ message: `${brokenLinks.length} internal link(s) returned an error from the local server`,
894
+ fix: "Fix or remove links to pages that do not resolve successfully",
895
+ details: [...new Set(brokenLinks)]
896
+ });
897
+ }
898
+ if (emptyLinks.length > 0) {
899
+ results.push({
900
+ checkId: "link-empty-href",
901
+ name: "Empty Link Href",
902
+ severity: "warning",
903
+ passed: false,
904
+ file: page.filePath,
905
+ message: `${emptyLinks.length} link(s) have empty or "#" href`,
906
+ fix: "Add proper href values to all links or use buttons for non-navigation actions"
907
+ });
908
+ }
909
+ return results;
910
+ }
851
911
 
852
912
  // src/checks/schema.ts
853
913
  function checkSchema(page) {
@@ -886,6 +946,9 @@ function checkSchemaJsonValidity(page) {
886
946
  while ((match = jsonLdRegex.exec(page.html)) !== null) {
887
947
  index++;
888
948
  const content = match[1].trim();
949
+ if (page.isSourceFile && /\{\s*JSON\.stringify\s*\(|\[dynamic(?::|\])/i.test(content)) {
950
+ continue;
951
+ }
889
952
  if (!content) {
890
953
  results.push({
891
954
  checkId: "schema-empty",
@@ -1370,11 +1433,11 @@ function checkAccessibility(page) {
1370
1433
  const buttonsWithoutText = [];
1371
1434
  $("button").each((_, el) => {
1372
1435
  const $btn = $(el);
1373
- const text = $btn.text().trim();
1436
+ const text2 = $btn.text().trim();
1374
1437
  const ariaLabel = $btn.attr("aria-label");
1375
1438
  const ariaLabelledby = $btn.attr("aria-labelledby");
1376
1439
  const title = $btn.attr("title");
1377
- if (!text && !ariaLabel && !ariaLabelledby && !title) {
1440
+ if (!text2 && !ariaLabel && !ariaLabelledby && !title) {
1378
1441
  const className = $btn.attr("class") || "";
1379
1442
  const id = $btn.attr("id") || "";
1380
1443
  buttonsWithoutText.push(className || id || "unnamed button");
@@ -1395,12 +1458,12 @@ function checkAccessibility(page) {
1395
1458
  const linksWithoutText = [];
1396
1459
  $("a[href]").each((_, el) => {
1397
1460
  const $link = $(el);
1398
- const text = $link.text().trim();
1461
+ const text2 = $link.text().trim();
1399
1462
  const ariaLabel = $link.attr("aria-label");
1400
1463
  const ariaLabelledby = $link.attr("aria-labelledby");
1401
1464
  const title = $link.attr("title");
1402
1465
  const hasImg = $link.find("img[alt]").length > 0;
1403
- if (!text && !ariaLabel && !ariaLabelledby && !title && !hasImg) {
1466
+ if (!text2 && !ariaLabel && !ariaLabelledby && !title && !hasImg) {
1404
1467
  const href = $link.attr("href") || "";
1405
1468
  linksWithoutText.push(href.substring(0, 50));
1406
1469
  }
@@ -1492,8 +1555,8 @@ function checkAccessibility(page) {
1492
1555
  }
1493
1556
  const emptyTableHeaders = [];
1494
1557
  $("th").each((index, el) => {
1495
- const text = $(el).text().trim();
1496
- if (!text) {
1558
+ const text2 = $(el).text().trim();
1559
+ if (!text2) {
1497
1560
  emptyTableHeaders.push(index + 1);
1498
1561
  }
1499
1562
  });
@@ -1566,6 +1629,61 @@ function checkFocusIndicators(page) {
1566
1629
  return results;
1567
1630
  }
1568
1631
 
1632
+ // src/lib/provenance.ts
1633
+ var RULE_PATTERNS = [
1634
+ [/^meta-title-(?:length|duplicate)$/, /<title\b|\btitle\s*:/i],
1635
+ [/^meta-description-(?:length|duplicate)$/, /<meta\b[^>]*(?:name|property)=["'](?:description|og:description)["']|\bdescription\s*:/i],
1636
+ [/^h1-multiple$/, /<h1\b|^#\s+/im],
1637
+ [/^heading-hierarchy$/, /<h[1-6]\b|^#{1,6}\s+/im],
1638
+ [/^image-alt-/, /<(?:img|Image|NuxtImg)\b|^\s*img\(/im],
1639
+ [/^link-(?:empty-href|broken-internal)$/, /<(?:a|Link|NuxtLink)\b|^\s*a\(/im],
1640
+ [/^schema-/, /<script\b[^>]*application\/ld\+json|["']@context["']|["']@type["']/i],
1641
+ [/^og-/, /<meta\b[^>]*property=["']og:/i],
1642
+ [/^twitter-/, /<meta\b[^>]*(?:name|property)=["']twitter:/i],
1643
+ [/^robots-/, /<meta\b[^>]*name=["']robots["']/i],
1644
+ [/^a11y-multiple-main$/, /<(?:main\b|[^>]+role=["']main["'])/i],
1645
+ [/^a11y-button-name$/, /<button\b/i],
1646
+ [/^a11y-link-name$/, /<a\b/i],
1647
+ [/^a11y-input-label$/, /<(?:input|textarea|select)\b/i],
1648
+ [/^a11y-tabindex-positive$/, /\btabindex\s*=\s*["']?[1-9]/i],
1649
+ [/^a11y-autoplay-audio$/, /<video\b[^>]*autoplay/i],
1650
+ [/^a11y-empty-th$/, /<th\b/i],
1651
+ [/^a11y-color-contrast$/, /\bcolor\s*:/i],
1652
+ [/^a11y-focus-outline$/, /\boutline\s*:\s*(?:none|0)/i]
1653
+ ];
1654
+ function escapeRegExp(value) {
1655
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1656
+ }
1657
+ function detailPattern(result) {
1658
+ const detail = result.details?.find((value) => value && value !== "unnamed" && !/^\d+$/.test(value));
1659
+ if (!detail) return void 0;
1660
+ if (result.checkId.startsWith("image-alt-")) {
1661
+ return new RegExp(`(?:<(?:img|Image|NuxtImg)\\b[^>]*\\bsrc\\s*=\\s*["']${escapeRegExp(detail)}["']|^\\s*img\\([^\\n]*\\bsrc\\s*=\\s*["']${escapeRegExp(detail)}["'])`, "im");
1662
+ }
1663
+ if (result.checkId.startsWith("link-") || result.checkId === "a11y-link-name") {
1664
+ return new RegExp(`<(?:a|Link|NuxtLink)\\b[^>]*\\bhref\\s*=\\s*["']${escapeRegExp(detail)}["']`, "i");
1665
+ }
1666
+ if (result.checkId === "heading-hierarchy") {
1667
+ const quoted = detail.match(/["'](.+?)["']/)?.[1]?.replace(/\.\.\.$/, "");
1668
+ if (quoted) return new RegExp(escapeRegExp(quoted), "i");
1669
+ }
1670
+ return void 0;
1671
+ }
1672
+ function offsetToLocation(source, offset) {
1673
+ const before = source.slice(0, offset);
1674
+ const lines = before.split("\n");
1675
+ return { line: lines.length, column: lines[lines.length - 1].length + 1 };
1676
+ }
1677
+ function locateFinding(page, result) {
1678
+ if (result.passed || result.line) return result;
1679
+ const source = page.sourceContent ?? page.html;
1680
+ const pattern = detailPattern(result) ?? RULE_PATTERNS.find(([rule]) => rule.test(result.checkId))?.[1];
1681
+ if (!pattern) return result;
1682
+ const match = pattern.exec(source);
1683
+ if (!match) return result;
1684
+ return { ...result, ...offsetToLocation(source, match.index) };
1685
+ }
1686
+
1569
1687
  // src/checks/index.ts
1570
1688
  var checks = [
1571
1689
  // Meta tags
@@ -1574,14 +1692,14 @@ var checks = [
1574
1692
  name: "Title Tag",
1575
1693
  description: "Check for missing, short, long, or duplicate title tags",
1576
1694
  severity: "critical",
1577
- run: (page, allPages) => checkMetaTitle(page, allPages)
1695
+ run: (page, allPages, _basePath, thresholds) => checkMetaTitle(page, allPages, thresholds)
1578
1696
  },
1579
1697
  {
1580
1698
  id: "meta-description",
1581
1699
  name: "Meta Description",
1582
1700
  description: "Check for missing, short, long, or duplicate meta descriptions",
1583
1701
  severity: "warning",
1584
- run: (page, allPages) => checkMetaDescription(page, allPages)
1702
+ run: (page, allPages, _basePath, thresholds) => checkMetaDescription(page, allPages, thresholds)
1585
1703
  },
1586
1704
  {
1587
1705
  id: "viewport",
@@ -1716,13 +1834,13 @@ var checks = [
1716
1834
  run: (page) => checkFocusIndicators(page)
1717
1835
  }
1718
1836
  ];
1719
- function runChecks(page, allPages, basePath, enabledChecks) {
1837
+ function runChecks(page, allPages, basePath, enabledChecks, thresholds) {
1720
1838
  const results = [];
1721
1839
  for (const check of checks) {
1722
1840
  if (enabledChecks && !enabledChecks.has(check.id)) {
1723
1841
  continue;
1724
1842
  }
1725
- const checkResults = check.run(page, allPages, basePath);
1843
+ const checkResults = check.run(page, allPages, basePath, thresholds);
1726
1844
  if (checkResults.every((r) => r.passed)) {
1727
1845
  results.push({
1728
1846
  checkId: check.id,
@@ -1733,27 +1851,226 @@ function runChecks(page, allPages, basePath, enabledChecks) {
1733
1851
  message: `${check.name}: no issues found`
1734
1852
  });
1735
1853
  }
1736
- results.push(...checkResults);
1854
+ results.push(...checkResults.map((result) => locateFinding(page, result)));
1737
1855
  }
1738
1856
  return results;
1739
1857
  }
1858
+ function getAvailableChecks() {
1859
+ return checks.map((c) => c.id);
1860
+ }
1861
+
1862
+ // src/lib/config.ts
1863
+ import * as fs2 from "fs";
1864
+ import * as path2 from "path";
1865
+ import { pathToFileURL } from "url";
1866
+ var CONFIG_FILES = [
1867
+ "rankture.config.json",
1868
+ "rankture.config.js",
1869
+ "rankture.config.mjs",
1870
+ ".rankturerc",
1871
+ ".rankturerc.json",
1872
+ ".rankturerc.js"
1873
+ ];
1874
+ var defaultConfig = {
1875
+ checks: {},
1876
+ ignore: [],
1877
+ rules: {},
1878
+ thresholds: {
1879
+ titleLength: { min: 30, max: 60 },
1880
+ descriptionLength: { min: 120, max: 160 }
1881
+ }
1882
+ };
1883
+ async function loadConfig(basePath, configPath) {
1884
+ if (configPath) {
1885
+ const absolutePath = path2.resolve(configPath);
1886
+ if (!fs2.existsSync(absolutePath)) {
1887
+ throw new Error(`Config file not found: ${absolutePath}`);
1888
+ }
1889
+ return parseConfigFile(absolutePath);
1890
+ }
1891
+ let currentDir = path2.resolve(basePath);
1892
+ if (fs2.existsSync(currentDir) && fs2.statSync(currentDir).isFile()) {
1893
+ currentDir = path2.dirname(currentDir);
1894
+ }
1895
+ const root = path2.parse(currentDir).root;
1896
+ while (currentDir !== root) {
1897
+ for (const configName of CONFIG_FILES) {
1898
+ const configFilePath = path2.join(currentDir, configName);
1899
+ if (fs2.existsSync(configFilePath)) {
1900
+ return parseConfigFile(configFilePath);
1901
+ }
1902
+ }
1903
+ const packageJsonPath = path2.join(currentDir, "package.json");
1904
+ if (fs2.existsSync(packageJsonPath)) {
1905
+ try {
1906
+ const packageJson = JSON.parse(fs2.readFileSync(packageJsonPath, "utf-8"));
1907
+ if (packageJson.rankture) {
1908
+ assertValidConfig(packageJson.rankture, packageJsonPath);
1909
+ return mergeConfig(defaultConfig, packageJson.rankture);
1910
+ }
1911
+ } catch {
1912
+ }
1913
+ }
1914
+ currentDir = path2.dirname(currentDir);
1915
+ }
1916
+ return defaultConfig;
1917
+ }
1918
+ async function parseConfigFile(filePath) {
1919
+ const ext = path2.extname(filePath).toLowerCase();
1920
+ if (ext === ".js" || ext === ".mjs") {
1921
+ try {
1922
+ const imported = await import(`${pathToFileURL(filePath).href}?t=${fs2.statSync(filePath).mtimeMs}`);
1923
+ const config = imported.default || imported;
1924
+ assertValidConfig(config, filePath);
1925
+ return mergeConfig(defaultConfig, config);
1926
+ } catch (error) {
1927
+ throw new Error(`Failed to load config from ${filePath}: ${error}`, { cause: error });
1928
+ }
1929
+ } else {
1930
+ try {
1931
+ const content = fs2.readFileSync(filePath, "utf-8");
1932
+ const config = JSON.parse(content);
1933
+ assertValidConfig(config, filePath);
1934
+ return mergeConfig(defaultConfig, config);
1935
+ } catch (error) {
1936
+ throw new Error(`Failed to parse config from ${filePath}: ${error}`, { cause: error });
1937
+ }
1938
+ }
1939
+ }
1940
+ function mergeConfig(defaults, userConfig) {
1941
+ return {
1942
+ ...defaults,
1943
+ ...userConfig,
1944
+ checks: { ...defaults.checks, ...userConfig.checks },
1945
+ ignore: [...defaults.ignore || [], ...userConfig.ignore || []],
1946
+ rules: { ...defaults.rules, ...userConfig.rules },
1947
+ thresholds: {
1948
+ ...defaults.thresholds,
1949
+ ...userConfig.thresholds,
1950
+ titleLength: {
1951
+ ...defaults.thresholds?.titleLength ?? { min: 30, max: 60 },
1952
+ ...userConfig.thresholds?.titleLength
1953
+ },
1954
+ descriptionLength: {
1955
+ ...defaults.thresholds?.descriptionLength ?? { min: 120, max: 160 },
1956
+ ...userConfig.thresholds?.descriptionLength
1957
+ }
1958
+ }
1959
+ };
1960
+ }
1961
+ function getConfigErrors(config) {
1962
+ const errors = [];
1963
+ if (typeof config !== "object" || config === null) {
1964
+ return ["configuration must export an object"];
1965
+ }
1966
+ const c = config;
1967
+ if (c.checks !== void 0) {
1968
+ if (typeof c.checks !== "object" || c.checks === null) {
1969
+ errors.push("checks must be an object whose values are booleans");
1970
+ } else {
1971
+ for (const [key, value] of Object.entries(c.checks)) {
1972
+ if (typeof value !== "boolean") errors.push(`checks.${key} must be true or false`);
1973
+ }
1974
+ }
1975
+ }
1976
+ if (c.ignore !== void 0) {
1977
+ if (!Array.isArray(c.ignore)) {
1978
+ errors.push("ignore must be an array of glob strings");
1979
+ }
1980
+ if (Array.isArray(c.ignore) && !c.ignore.every((item) => typeof item === "string")) {
1981
+ errors.push("ignore must contain only strings");
1982
+ }
1983
+ }
1984
+ if (c.rules !== void 0) {
1985
+ if (typeof c.rules !== "object" || c.rules === null) {
1986
+ errors.push("rules must be an object of critical, warning, or info severities");
1987
+ }
1988
+ const validSeverities = ["critical", "warning", "info"];
1989
+ for (const value of Object.values(c.rules)) {
1990
+ if (!validSeverities.includes(value)) {
1991
+ errors.push(`rules contains invalid severity "${String(value)}"`);
1992
+ }
1993
+ }
1994
+ }
1995
+ if (c.thresholds !== void 0) {
1996
+ if (typeof c.thresholds !== "object" || c.thresholds === null || Array.isArray(c.thresholds)) {
1997
+ errors.push("thresholds must be an object");
1998
+ } else {
1999
+ const thresholds = c.thresholds;
2000
+ for (const name of ["titleLength", "descriptionLength"]) {
2001
+ const value = thresholds[name];
2002
+ if (value === void 0) continue;
2003
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2004
+ errors.push(`thresholds.${name} must contain numeric min and max values`);
2005
+ continue;
2006
+ }
2007
+ const range = value;
2008
+ if (!Number.isFinite(range.min) || !Number.isFinite(range.max)) {
2009
+ errors.push(`thresholds.${name}.min and .max must be finite numbers`);
2010
+ } else if (range.min < 0 || range.max < range.min) {
2011
+ errors.push(`thresholds.${name} must satisfy 0 <= min <= max`);
2012
+ }
2013
+ }
2014
+ for (const name of ["maxImageSize", "maxPageSize"]) {
2015
+ const value = thresholds[name];
2016
+ if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) {
2017
+ errors.push(`thresholds.${name} must be a positive number`);
2018
+ }
2019
+ }
2020
+ }
2021
+ }
2022
+ if (c.apiKey !== void 0 && typeof c.apiKey !== "string") errors.push("apiKey must be a string");
2023
+ if (c.sync !== void 0 && typeof c.sync !== "boolean") errors.push("sync must be true or false");
2024
+ if (c.websiteId !== void 0 && typeof c.websiteId !== "string") errors.push("websiteId must be a string");
2025
+ if (c.apiUrl !== void 0) {
2026
+ if (typeof c.apiUrl !== "string") errors.push("apiUrl must be a string");
2027
+ else {
2028
+ try {
2029
+ const value = new URL(c.apiUrl);
2030
+ if (!["http:", "https:"].includes(value.protocol)) errors.push("apiUrl must use http or https");
2031
+ } catch {
2032
+ errors.push("apiUrl must be a valid URL");
2033
+ }
2034
+ }
2035
+ }
2036
+ return errors;
2037
+ }
2038
+ function assertValidConfig(config, source) {
2039
+ const errors = getConfigErrors(config);
2040
+ if (errors.length > 0) {
2041
+ throw new Error(`Invalid Rankture config (${source}):
2042
+ - ${errors.join("\n- ")}`);
2043
+ }
2044
+ }
2045
+ function getEnabledChecks(config, allChecks) {
2046
+ const enabled = /* @__PURE__ */ new Set();
2047
+ for (const checkId of allChecks) {
2048
+ if (config.checks?.[checkId] === false) {
2049
+ continue;
2050
+ }
2051
+ enabled.add(checkId);
2052
+ }
2053
+ return enabled;
2054
+ }
1740
2055
 
1741
2056
  // src/version.ts
1742
- var VERSION = "0.1.1";
2057
+ var VERSION = "0.1.3";
1743
2058
 
1744
2059
  // src/reporters/terminal.ts
1745
2060
  import chalk from "chalk";
1746
- function formatTerminal(report) {
2061
+ function formatTerminal(report, opts = {}) {
1747
2062
  const lines = [];
1748
- lines.push("");
1749
- lines.push(chalk.bold.cyan(" Rankture SEO Check"));
1750
- lines.push(chalk.gray(" " + "\u2500".repeat(40)));
1751
- lines.push("");
1752
- lines.push(chalk.gray(` Checking ${report.path} (${report.summary.totalFiles} files)...`));
1753
- lines.push("");
2063
+ if (!opts.quiet) {
2064
+ lines.push("");
2065
+ lines.push(chalk.bold.cyan(" Rankture SEO Check"));
2066
+ lines.push(chalk.gray(" " + "\u2500".repeat(40)));
2067
+ lines.push("");
2068
+ lines.push(chalk.gray(` Checking ${report.path} (${report.summary.totalFiles} files)...`));
2069
+ lines.push("");
2070
+ }
1754
2071
  const critical = report.results.filter((r) => !r.passed && r.severity === "critical");
1755
2072
  const warnings = report.results.filter((r) => !r.passed && r.severity === "warning");
1756
- const info = report.results.filter((r) => !r.passed && r.severity === "info");
2073
+ const info = opts.quiet ? [] : report.results.filter((r) => !r.passed && r.severity === "info");
1757
2074
  if (critical.length > 0) {
1758
2075
  for (const result of critical) {
1759
2076
  lines.push(formatResult(result));
@@ -1769,8 +2086,10 @@ function formatTerminal(report) {
1769
2086
  lines.push(formatResult(result));
1770
2087
  }
1771
2088
  }
1772
- lines.push("");
1773
- lines.push(chalk.gray(" " + "\u2500".repeat(40)));
2089
+ if (!opts.quiet) {
2090
+ lines.push("");
2091
+ lines.push(chalk.gray(" " + "\u2500".repeat(40)));
2092
+ }
1774
2093
  const summaryParts = [];
1775
2094
  if (report.summary.critical > 0) {
1776
2095
  summaryParts.push(chalk.red.bold(`${report.summary.critical} critical`));
@@ -1783,6 +2102,9 @@ function formatTerminal(report) {
1783
2102
  }
1784
2103
  summaryParts.push(chalk.green(`${report.summary.passed} passed`));
1785
2104
  lines.push(` Results: ${summaryParts.join(", ")}`);
2105
+ if (report.baseline) {
2106
+ lines.push(chalk.gray(` ${report.baseline.suppressed} known issue(s) suppressed by baseline ${report.baseline.file}`));
2107
+ }
1786
2108
  lines.push("");
1787
2109
  if (report.summary.critical > 0) {
1788
2110
  lines.push(chalk.red.bold(" \u2717 Check failed (critical issues found)"));
@@ -1792,7 +2114,9 @@ function formatTerminal(report) {
1792
2114
  lines.push(chalk.green.bold(" \u2713 All checks passed!"));
1793
2115
  }
1794
2116
  lines.push("");
1795
- return lines.join("\n");
2117
+ const output = lines.join("\n");
2118
+ const ansiPattern = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g");
2119
+ return opts.noColor ? output.replace(ansiPattern, "") : output;
1796
2120
  }
1797
2121
  function formatResult(result) {
1798
2122
  const lines = [];
@@ -1834,8 +2158,8 @@ function formatResult(result) {
1834
2158
  lines.push("");
1835
2159
  return lines.join("\n");
1836
2160
  }
1837
- function printReport(report) {
1838
- console.log(formatTerminal(report));
2161
+ function printReport(report, opts = {}) {
2162
+ console.log(formatTerminal(report, opts));
1839
2163
  }
1840
2164
 
1841
2165
  // src/reporters/json.ts
@@ -2251,50 +2575,405 @@ function formatHtml(report) {
2251
2575
  </html>`;
2252
2576
  return html;
2253
2577
  }
2254
- function escapeHtml(text) {
2255
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2578
+ function escapeHtml(text2) {
2579
+ return text2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2580
+ }
2581
+
2582
+ // src/reporters/markdown.ts
2583
+ function formatMarkdown(report) {
2584
+ const lines = [
2585
+ "# Rankture SEO Check",
2586
+ "",
2587
+ `- Files: ${report.summary.totalFiles}`,
2588
+ `- Score: ${report.summary.score ?? 100}`,
2589
+ `- Critical: ${report.summary.critical}`,
2590
+ `- Warnings: ${report.summary.warning}`,
2591
+ `- Info: ${report.summary.info}`
2592
+ ];
2593
+ if (report.baseline) lines.push(`- New issues: ${report.baseline.fresh}`);
2594
+ lines.push("", "## Actionable findings", "");
2595
+ if (report.results.length === 0) return [...lines, "No issues found.", ""].join("\n");
2596
+ for (const finding of report.results) {
2597
+ const location = `${finding.file ?? report.path}${finding.line ? `:${finding.line}${finding.column ? `:${finding.column}` : ""}` : ""}`;
2598
+ lines.push(`- **${finding.severity.toUpperCase()} ${finding.checkId}** \u2014 ${location}`);
2599
+ lines.push(` - ${finding.message}`);
2600
+ if (finding.fix) lines.push(` - Fix: ${finding.fix.replace(/\s+/g, " ").trim()}`);
2601
+ }
2602
+ lines.push("");
2603
+ return lines.join("\n");
2604
+ }
2605
+
2606
+ // src/reporters/sarif.ts
2607
+ import * as path3 from "path";
2608
+ import { pathToFileURL as pathToFileURL2 } from "url";
2609
+ var SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
2610
+ var LEVEL_BY_SEVERITY = {
2611
+ critical: "error",
2612
+ warning: "warning",
2613
+ info: "note"
2614
+ };
2615
+ function formatSarif(report, basePath) {
2616
+ const failures = report.results.filter((r) => !r.passed);
2617
+ const absoluteBase = path3.resolve(basePath);
2618
+ const rules = [...new Map(failures.map((r) => [r.checkId, r])).values()].map((r) => ({
2619
+ id: r.checkId,
2620
+ name: r.name,
2621
+ shortDescription: { text: r.name },
2622
+ helpUri: `https://rankture.com/docs/cli#${r.checkId}`,
2623
+ defaultConfiguration: { level: LEVEL_BY_SEVERITY[r.severity] }
2624
+ }));
2625
+ const results = failures.map((r) => {
2626
+ const uri = r.file ? path3.relative(absoluteBase, path3.resolve(absoluteBase, r.file)).split(path3.sep).join("/") : void 0;
2627
+ return {
2628
+ ruleId: r.checkId,
2629
+ level: LEVEL_BY_SEVERITY[r.severity],
2630
+ message: { text: r.fix ? `${r.message} Fix: ${r.fix}` : r.message },
2631
+ ...uri ? {
2632
+ locations: [
2633
+ {
2634
+ physicalLocation: {
2635
+ artifactLocation: { uri, uriBaseId: "ROOTPATH" },
2636
+ ...r.line ? { region: { startLine: r.line, ...r.column ? { startColumn: r.column } : {} } } : {}
2637
+ }
2638
+ }
2639
+ ]
2640
+ } : {}
2641
+ };
2642
+ });
2643
+ const sarif = {
2644
+ $schema: SARIF_SCHEMA,
2645
+ version: "2.1.0",
2646
+ runs: [
2647
+ {
2648
+ tool: {
2649
+ driver: {
2650
+ name: "rankture",
2651
+ informationUri: "https://rankture.com",
2652
+ version: report.version,
2653
+ rules
2654
+ }
2655
+ },
2656
+ originalUriBaseIds: {
2657
+ ROOTPATH: { uri: ensureTrailingSlash(pathToFileURL2(absoluteBase).href) }
2658
+ },
2659
+ results
2660
+ }
2661
+ ]
2662
+ };
2663
+ return JSON.stringify(sarif, null, 2);
2664
+ }
2665
+ function ensureTrailingSlash(value) {
2666
+ return value.endsWith("/") ? value : `${value}/`;
2256
2667
  }
2257
2668
 
2258
2669
  // src/commands/check.ts
2670
+ import { execSync as execSync2, spawn } from "child_process";
2671
+
2672
+ // src/lib/baseline.ts
2673
+ import * as fs3 from "fs";
2674
+ import * as path4 from "path";
2675
+ function issueKey(result, basePath) {
2676
+ const file = result.file ? path4.relative(basePath, path4.resolve(basePath, result.file)).split(path4.sep).join("/") : "";
2677
+ return `${result.checkId}|${file}`;
2678
+ }
2679
+ function buildBaseline(failures, basePath, timestamp) {
2680
+ const issues = {};
2681
+ for (const failure of failures) {
2682
+ const key = issueKey(failure, basePath);
2683
+ issues[key] = (issues[key] || 0) + 1;
2684
+ }
2685
+ return { version: 1, createdAt: timestamp, issues };
2686
+ }
2687
+ function loadBaseline(filePath) {
2688
+ const absolute = path4.resolve(filePath);
2689
+ if (!fs3.existsSync(absolute)) {
2690
+ throw new Error(`Baseline file not found: ${absolute} (create it with --update-baseline)`);
2691
+ }
2692
+ const parsed = JSON.parse(fs3.readFileSync(absolute, "utf-8"));
2693
+ if (parsed.version !== 1 || typeof parsed.issues !== "object" || parsed.issues === null) {
2694
+ throw new Error(`Unrecognized baseline format in ${absolute}`);
2695
+ }
2696
+ return parsed;
2697
+ }
2698
+ function saveBaseline(filePath, baseline) {
2699
+ fs3.writeFileSync(path4.resolve(filePath), JSON.stringify(baseline, null, 2) + "\n");
2700
+ }
2701
+ function applyBaseline(failures, baseline, basePath) {
2702
+ const remaining = new Map(Object.entries(baseline.issues));
2703
+ const fresh = [];
2704
+ let suppressed = 0;
2705
+ for (const failure of failures) {
2706
+ const key = issueKey(failure, basePath);
2707
+ const allowance = remaining.get(key) || 0;
2708
+ if (allowance > 0) {
2709
+ remaining.set(key, allowance - 1);
2710
+ suppressed++;
2711
+ } else {
2712
+ fresh.push(failure);
2713
+ }
2714
+ }
2715
+ return { fresh, suppressed };
2716
+ }
2717
+
2718
+ // src/lib/ci-env.ts
2259
2719
  import { execSync } from "child_process";
2720
+ function collectCiMetadata(env = process.env, cwd) {
2721
+ if (env.GITHUB_ACTIONS === "true") {
2722
+ const prMatch = env.GITHUB_REF?.match(/^refs\/pull\/(\d+)\//);
2723
+ return withoutEmpty({
2724
+ provider: "github-actions",
2725
+ commit: env.GITHUB_SHA,
2726
+ branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
2727
+ pullRequest: prMatch ? Number(prMatch[1]) : void 0,
2728
+ buildUrl: env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : void 0
2729
+ });
2730
+ }
2731
+ if (env.GITLAB_CI === "true") {
2732
+ return withoutEmpty({
2733
+ provider: "gitlab-ci",
2734
+ commit: env.CI_COMMIT_SHA,
2735
+ branch: env.CI_COMMIT_REF_NAME,
2736
+ pullRequest: env.CI_MERGE_REQUEST_IID ? Number(env.CI_MERGE_REQUEST_IID) : void 0,
2737
+ buildUrl: env.CI_JOB_URL
2738
+ });
2739
+ }
2740
+ if (env.VERCEL === "1") {
2741
+ return withoutEmpty({
2742
+ provider: "vercel",
2743
+ commit: env.VERCEL_GIT_COMMIT_SHA,
2744
+ branch: env.VERCEL_GIT_COMMIT_REF
2745
+ });
2746
+ }
2747
+ try {
2748
+ const git = (args) => execSync(`git ${args}`, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2749
+ const commit = git("rev-parse HEAD");
2750
+ const branch = git("rev-parse --abbrev-ref HEAD");
2751
+ if (commit) {
2752
+ return { provider: "git", commit, branch: branch !== "HEAD" ? branch : void 0 };
2753
+ }
2754
+ } catch {
2755
+ }
2756
+ return void 0;
2757
+ }
2758
+ function withoutEmpty(meta) {
2759
+ return Object.fromEntries(
2760
+ Object.entries(meta).filter(([, value]) => value !== void 0 && value !== "")
2761
+ );
2762
+ }
2763
+
2764
+ // src/lib/static-server.ts
2765
+ import * as fs4 from "fs";
2766
+ import * as http from "http";
2767
+ import * as path5 from "path";
2768
+ var CONTENT_TYPES = {
2769
+ ".css": "text/css; charset=utf-8",
2770
+ ".html": "text/html; charset=utf-8",
2771
+ ".js": "text/javascript; charset=utf-8",
2772
+ ".json": "application/json; charset=utf-8",
2773
+ ".svg": "image/svg+xml",
2774
+ ".txt": "text/plain; charset=utf-8",
2775
+ ".xml": "application/xml; charset=utf-8"
2776
+ };
2777
+ function resolveRequest(root, pathname) {
2778
+ let decoded;
2779
+ try {
2780
+ decoded = decodeURIComponent(pathname);
2781
+ } catch {
2782
+ return void 0;
2783
+ }
2784
+ const relative6 = decoded.replace(/^\/+/, "");
2785
+ const candidates = decoded.endsWith("/") ? [path5.join(relative6, "index.html")] : [relative6, `${relative6}.html`, path5.join(relative6, "index.html")];
2786
+ for (const candidate of candidates) {
2787
+ const resolved = path5.resolve(root, candidate || "index.html");
2788
+ if (resolved !== root && !resolved.startsWith(`${root}${path5.sep}`)) continue;
2789
+ if (fs4.existsSync(resolved) && fs4.statSync(resolved).isFile()) return resolved;
2790
+ }
2791
+ return void 0;
2792
+ }
2793
+ async function startStaticServer(rootPath, port = 0) {
2794
+ const root = path5.resolve(rootPath);
2795
+ const server = http.createServer((request, response) => {
2796
+ const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
2797
+ const file = resolveRequest(root, pathname);
2798
+ if (!file) {
2799
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
2800
+ response.end("Not found");
2801
+ return;
2802
+ }
2803
+ response.writeHead(200, {
2804
+ "Content-Type": CONTENT_TYPES[path5.extname(file).toLowerCase()] ?? "application/octet-stream",
2805
+ "Content-Length": fs4.statSync(file).size
2806
+ });
2807
+ if (request.method === "HEAD") response.end();
2808
+ else fs4.createReadStream(file).pipe(response);
2809
+ });
2810
+ await new Promise((resolve9, reject) => {
2811
+ server.once("error", reject);
2812
+ server.listen(port, "127.0.0.1", resolve9);
2813
+ });
2814
+ const address = server.address();
2815
+ if (!address || typeof address === "string") throw new Error("Unable to determine local server address");
2816
+ return {
2817
+ origin: `http://127.0.0.1:${address.port}`,
2818
+ close: () => new Promise((resolve9, reject) => server.close((error) => error ? reject(error) : resolve9()))
2819
+ };
2820
+ }
2821
+ function pageUrlForFile(filePath, rootPath, origin) {
2822
+ let relative6 = path5.relative(rootPath, filePath).split(path5.sep).join("/");
2823
+ if (relative6 === "index.html") relative6 = "";
2824
+ else if (relative6.endsWith("/index.html")) relative6 = relative6.slice(0, -"index.html".length);
2825
+ return new URL(`/${relative6}`, origin).href;
2826
+ }
2827
+
2828
+ // src/lib/api-client.ts
2829
+ import { createHash } from "crypto";
2830
+
2831
+ // src/lib/credentials.ts
2832
+ import * as fs5 from "fs";
2833
+ import * as os from "os";
2834
+ import * as path6 from "path";
2835
+ function credentialFile(env = process.env) {
2836
+ const base = env.RANKTURE_CONFIG_DIR || env.XDG_CONFIG_HOME || (process.platform === "win32" ? env.APPDATA : path6.join(os.homedir(), ".config"));
2837
+ return path6.join(base || path6.join(os.homedir(), ".config"), "rankture", "credentials.json");
2838
+ }
2839
+ function saveCredentials(credentials, env = process.env) {
2840
+ const file = credentialFile(env);
2841
+ fs5.mkdirSync(path6.dirname(file), { recursive: true, mode: 448 });
2842
+ fs5.writeFileSync(file, `${JSON.stringify(credentials, null, 2)}
2843
+ `, { mode: 384 });
2844
+ fs5.chmodSync(file, 384);
2845
+ return file;
2846
+ }
2847
+ function loadCredentials(env = process.env) {
2848
+ if (env.RANKTURE_API_KEY) {
2849
+ return { apiKey: env.RANKTURE_API_KEY, apiUrl: env.RANKTURE_API_URL || "https://rankture.com" };
2850
+ }
2851
+ const file = credentialFile(env);
2852
+ if (!fs5.existsSync(file)) return void 0;
2853
+ const value = JSON.parse(fs5.readFileSync(file, "utf8"));
2854
+ if (!value.apiKey || !value.apiUrl) return void 0;
2855
+ return { apiKey: value.apiKey, apiUrl: value.apiUrl.replace(/\/$/, "") };
2856
+ }
2857
+ function clearCredentials(env = process.env) {
2858
+ const file = credentialFile(env);
2859
+ if (!fs5.existsSync(file)) return false;
2860
+ fs5.unlinkSync(file);
2861
+ return true;
2862
+ }
2863
+
2864
+ // src/lib/api-client.ts
2865
+ function credentialsOrThrow(override) {
2866
+ const stored = loadCredentials();
2867
+ const apiKey = override?.apiKey || stored?.apiKey;
2868
+ const apiUrl = (override?.apiUrl || stored?.apiUrl || "https://rankture.com").replace(/\/$/, "");
2869
+ if (!apiKey) throw new Error("Not logged in. Run: rankture login --api-key <key>");
2870
+ return { apiKey, apiUrl };
2871
+ }
2872
+ async function cliApiRequest(pathname, init = {}, override) {
2873
+ const credentials = credentialsOrThrow(override);
2874
+ const response = await fetch(`${credentials.apiUrl}${pathname}`, {
2875
+ ...init,
2876
+ headers: {
2877
+ Authorization: `Bearer ${credentials.apiKey}`,
2878
+ "Content-Type": "application/json",
2879
+ "User-Agent": "rankture-cli",
2880
+ ...init.headers
2881
+ }
2882
+ });
2883
+ const body = await response.json().catch(() => ({}));
2884
+ if (!response.ok) throw new Error(String(body.error || body.message || `Rankture API returned ${response.status}`));
2885
+ return body;
2886
+ }
2887
+ var getIdentity = (override) => cliApiRequest("/api/cli/me", {}, override);
2888
+ var getWebsites = (override) => cliApiRequest("/api/cli/websites", {}, override);
2889
+ var syncCheckReport = (report, websiteId, override) => cliApiRequest("/api/cli/sync", {
2890
+ method: "POST",
2891
+ headers: { "X-Idempotency-Key": createHash("sha256").update(`${websiteId || ""}:${report.timestamp}:${report.ci?.commit || ""}`).digest("hex") },
2892
+ body: JSON.stringify({ schemaVersion: 1, report, websiteId })
2893
+ }, override);
2894
+ var startRemoteAudit = (url, options = {}, override) => cliApiRequest("/api/cli/audits", {
2895
+ method: "POST",
2896
+ body: JSON.stringify({ url, pages: options.pages })
2897
+ }, override);
2898
+ var getRemoteAudit = (auditId, override, full = false) => cliApiRequest(`/api/cli/audits/${encodeURIComponent(auditId)}${full ? "?full=true" : ""}`, {}, override);
2899
+
2900
+ // src/commands/check.ts
2260
2901
  function getGitChangedFiles(basePath, stagedOnly) {
2261
2902
  try {
2262
- const cwd = path2.resolve(basePath);
2263
- const stagedOutput = execSync("git diff --cached --name-only --diff-filter=ACMR", {
2903
+ const cwd = path7.resolve(basePath);
2904
+ const stagedOutput = execSync2("git diff --cached --name-only --diff-filter=ACMR", {
2264
2905
  cwd,
2265
2906
  encoding: "utf-8"
2266
2907
  }).trim();
2267
2908
  const stagedFiles = stagedOutput ? stagedOutput.split("\n") : [];
2268
2909
  if (stagedOnly) {
2269
- return stagedFiles.map((f) => path2.join(cwd, f));
2910
+ return stagedFiles.map((f) => path7.join(cwd, f));
2270
2911
  }
2271
- const unstagedOutput = execSync("git diff --name-only --diff-filter=ACMR", {
2912
+ const unstagedOutput = execSync2("git diff --name-only --diff-filter=ACMR", {
2272
2913
  cwd,
2273
2914
  encoding: "utf-8"
2274
2915
  }).trim();
2275
2916
  const unstagedFiles = unstagedOutput ? unstagedOutput.split("\n") : [];
2276
2917
  const allFiles = [.../* @__PURE__ */ new Set([...stagedFiles, ...unstagedFiles])];
2277
- return allFiles.map((f) => path2.join(cwd, f));
2918
+ return allFiles.map((f) => path7.join(cwd, f));
2278
2919
  } catch {
2279
2920
  return [];
2280
2921
  }
2281
2922
  }
2282
2923
  async function checkCommand(options) {
2283
- const spinner = ora("Scanning files...").start();
2924
+ const spinner = ora({
2925
+ text: "Scanning files...",
2926
+ isEnabled: Boolean(process.stdout.isTTY && !options.quiet)
2927
+ }).start();
2284
2928
  try {
2285
- const basePath = path2.resolve(options.path);
2286
- if (!fs2.existsSync(basePath)) {
2929
+ const basePath = path7.resolve(options.path);
2930
+ const formats = ["terminal", "json", "html", "sarif", "markdown"];
2931
+ const failLevels = ["none", "critical", "warning", "any", "new"];
2932
+ if (!formats.includes(options.format)) {
2933
+ spinner.fail(`Invalid format: ${options.format}. Use ${formats.join(", ")}`);
2934
+ return 1;
2935
+ }
2936
+ if (!failLevels.includes(options.failOn)) {
2937
+ spinner.fail(`Invalid fail-on level: ${options.failOn}. Use ${failLevels.join(", ")}`);
2938
+ return 1;
2939
+ }
2940
+ for (const [name, value, min, max] of [
2941
+ ["max-warnings", options.maxWarnings, 0, Number.MAX_SAFE_INTEGER],
2942
+ ["max-critical", options.maxCritical, 0, Number.MAX_SAFE_INTEGER],
2943
+ ["min-score", options.minScore, 0, 100],
2944
+ ["serve-port", options.servePort, 0, 65535]
2945
+ ]) {
2946
+ if (value !== void 0 && (!Number.isFinite(value) || value < min || value > max)) {
2947
+ spinner.fail(`--${name} must be between ${min} and ${max}`);
2948
+ return 1;
2949
+ }
2950
+ }
2951
+ if ((options.failOn === "new" || options.updateBaseline) && !options.baseline) {
2952
+ spinner.fail(`${options.failOn === "new" ? "--fail-on new" : "--update-baseline"} requires --baseline <file>`);
2953
+ return 1;
2954
+ }
2955
+ if (!fs6.existsSync(basePath)) {
2287
2956
  spinner.fail(`Path not found: ${basePath}`);
2288
2957
  return 1;
2289
2958
  }
2290
- const stats = fs2.statSync(basePath);
2959
+ const stats = fs6.statSync(basePath);
2960
+ const config = await loadConfig(basePath, options.config);
2961
+ const availableChecks = getAvailableChecks();
2962
+ const unknownChecks = Object.keys(config.checks ?? {}).filter((id) => !availableChecks.includes(id));
2963
+ if (unknownChecks.length > 0) {
2964
+ spinner.fail(`Unknown check ID${unknownChecks.length > 1 ? "s" : ""}: ${unknownChecks.join(", ")}`);
2965
+ console.error(`Available checks: ${availableChecks.join(", ")}`);
2966
+ return 1;
2967
+ }
2968
+ const enabledChecks = getEnabledChecks(config, availableChecks);
2969
+ const effectiveIgnore = [.../* @__PURE__ */ new Set([...config.ignore ?? [], ...options.ignore])];
2291
2970
  let targetFiles;
2292
2971
  const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
2293
2972
  if (options.changed || options.staged) {
2294
2973
  spinner.text = options.staged ? "Getting staged files..." : "Getting changed files...";
2295
2974
  const gitFiles = getGitChangedFiles(basePath, !!options.staged);
2296
2975
  targetFiles = gitFiles.filter((f) => {
2297
- const ext = path2.extname(f).toLowerCase();
2976
+ const ext = path7.extname(f).toLowerCase();
2298
2977
  return allExtensions.includes(ext);
2299
2978
  });
2300
2979
  if (targetFiles.length === 0) {
@@ -2303,7 +2982,7 @@ async function checkCommand(options) {
2303
2982
  }
2304
2983
  spinner.text = `Found ${targetFiles.length} ${options.staged ? "staged" : "changed"} file(s)`;
2305
2984
  } else if (stats.isFile()) {
2306
- const ext = path2.extname(basePath).toLowerCase();
2985
+ const ext = path7.extname(basePath).toLowerCase();
2307
2986
  if (!allExtensions.includes(ext)) {
2308
2987
  spinner.fail(`Unsupported file type: ${ext}. Supported: ${allExtensions.join(", ")}`);
2309
2988
  return 1;
@@ -2319,7 +2998,7 @@ async function checkCommand(options) {
2319
2998
  "**/.svelte-kit/**",
2320
2999
  "**/dist/**/*.js",
2321
3000
  "**/dist/**/*.css",
2322
- ...options.ignore
3001
+ ...effectiveIgnore
2323
3002
  ];
2324
3003
  const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
2325
3004
  const globPattern = `**/*.{${extensionPattern}}`;
@@ -2333,11 +3012,12 @@ async function checkCommand(options) {
2333
3012
  return 1;
2334
3013
  }
2335
3014
  }
3015
+ targetFiles.sort((a, b) => a.localeCompare(b));
2336
3016
  spinner.text = `Parsing ${targetFiles.length} file${targetFiles.length > 1 ? "s" : ""}...`;
2337
3017
  const pages = [];
2338
3018
  for (const filePath of targetFiles) {
2339
- const content = fs2.readFileSync(filePath, "utf-8");
2340
- const ext = path2.extname(filePath).toLowerCase();
3019
+ const content = fs6.readFileSync(filePath, "utf-8");
3020
+ const ext = path7.extname(filePath).toLowerCase();
2341
3021
  let page;
2342
3022
  if (SUPPORTED_EXTENSIONS.html.includes(ext)) {
2343
3023
  page = parseHtml(filePath, content);
@@ -2347,55 +3027,129 @@ async function checkCommand(options) {
2347
3027
  pages.push(page);
2348
3028
  }
2349
3029
  spinner.text = "Running checks...";
2350
- const effectiveBasePath = stats.isFile() ? path2.dirname(basePath) : basePath;
3030
+ const effectiveBasePath = stats.isFile() ? path7.dirname(basePath) : basePath;
3031
+ const useHttpLinks = Boolean(options.serve && enabledChecks.has("internal-links"));
3032
+ const registryChecks = new Set(enabledChecks);
3033
+ if (useHttpLinks) registryChecks.delete("internal-links");
2351
3034
  const allResults = [];
2352
3035
  for (const page of pages) {
2353
- const results = runChecks(page, pages, effectiveBasePath);
3036
+ const results = runChecks(page, pages, effectiveBasePath, registryChecks, config.thresholds);
3037
+ for (const result of results) {
3038
+ const override = config.rules?.[result.checkId] ?? Object.entries(config.rules ?? {}).find(([id]) => result.checkId.startsWith(`${id}-`))?.[1];
3039
+ if (override) result.severity = override;
3040
+ }
2354
3041
  allResults.push(...results);
2355
3042
  }
3043
+ if (useHttpLinks) {
3044
+ const htmlPages = pages.filter((page) => !page.isSourceFile);
3045
+ if (htmlPages.length === 0) {
3046
+ spinner.fail("--serve requires at least one built .html file");
3047
+ return 1;
3048
+ }
3049
+ spinner.text = "Validating links through a local HTTP server...";
3050
+ const server = await startStaticServer(effectiveBasePath, options.servePort ?? 0);
3051
+ try {
3052
+ for (const page of htmlPages) {
3053
+ const findings = await checkInternalLinksHttp(
3054
+ page,
3055
+ pageUrlForFile(page.filePath, effectiveBasePath, server.origin)
3056
+ );
3057
+ if (findings.length === 0) {
3058
+ allResults.push({
3059
+ checkId: "internal-links",
3060
+ name: "Internal Links",
3061
+ severity: "critical",
3062
+ passed: true,
3063
+ file: page.filePath,
3064
+ message: "Internal Links: no issues found"
3065
+ });
3066
+ } else {
3067
+ allResults.push(...findings.map((result) => locateFinding(page, result)));
3068
+ }
3069
+ }
3070
+ } finally {
3071
+ await server.close();
3072
+ }
3073
+ }
3074
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
3075
+ allResults.sort(
3076
+ (a, b) => (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER) || severityOrder[a.severity] - severityOrder[b.severity] || a.checkId.localeCompare(b.checkId) || a.message.localeCompare(b.message)
3077
+ );
2356
3078
  spinner.stop();
3079
+ const failures = allResults.filter((result) => !result.passed);
3080
+ let reportResults = failures;
3081
+ let baselineMeta;
3082
+ if (options.baseline && options.updateBaseline) {
3083
+ saveBaseline(options.baseline, buildBaseline(failures, effectiveBasePath, (/* @__PURE__ */ new Date()).toISOString()));
3084
+ baselineMeta = { file: options.baseline, suppressed: failures.length, fresh: 0 };
3085
+ reportResults = [];
3086
+ } else if (options.baseline) {
3087
+ const comparison = applyBaseline(failures, loadBaseline(options.baseline), effectiveBasePath);
3088
+ reportResults = comparison.fresh;
3089
+ baselineMeta = { file: options.baseline, suppressed: comparison.suppressed, fresh: comparison.fresh.length };
3090
+ }
3091
+ const passedCount = allResults.filter((result) => result.passed).length;
3092
+ const score = allResults.length > 0 ? Math.round(passedCount / allResults.length * 100) : 100;
2357
3093
  const report = {
2358
3094
  version: VERSION,
2359
3095
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2360
3096
  path: options.path,
2361
3097
  summary: {
2362
3098
  totalFiles: pages.length,
2363
- passed: allResults.filter((r) => r.passed).length,
2364
- critical: allResults.filter((r) => !r.passed && r.severity === "critical").length,
2365
- warning: allResults.filter((r) => !r.passed && r.severity === "warning").length,
2366
- info: allResults.filter((r) => !r.passed && r.severity === "info").length
3099
+ passed: passedCount,
3100
+ critical: reportResults.filter((r) => r.severity === "critical").length,
3101
+ warning: reportResults.filter((r) => r.severity === "warning").length,
3102
+ info: reportResults.filter((r) => r.severity === "info").length,
3103
+ score
2367
3104
  },
2368
- results: allResults.filter((r) => !r.passed)
2369
- // Only include failures
3105
+ results: reportResults,
3106
+ ci: collectCiMetadata(process.env, effectiveBasePath),
3107
+ baseline: baselineMeta
2370
3108
  };
2371
- if (options.format === "json") {
2372
- const json = formatJson(report);
3109
+ const shouldSync = options.sync ?? config.sync ?? false;
3110
+ if (shouldSync) {
3111
+ const websiteId = options.websiteId || config.websiteId;
3112
+ const remote = await syncCheckReport(reportForSync(report, effectiveBasePath), websiteId, {
3113
+ apiKey: options.apiKey || config.apiKey,
3114
+ apiUrl: options.apiUrl || config.apiUrl
3115
+ });
3116
+ report.sync = { runId: remote.runId, websiteId };
3117
+ if (!options.quiet && options.format === "terminal") console.log(`Synced Rankture run ${remote.runId}`);
3118
+ }
3119
+ if (options.format === "json" || options.format === "sarif" || options.format === "markdown") {
3120
+ const output = options.format === "json" ? formatJson(report) : options.format === "sarif" ? formatSarif(report, effectiveBasePath) : formatMarkdown(report);
2373
3121
  if (options.output) {
2374
- fs2.writeFileSync(options.output, json);
2375
- console.log(`Report written to ${options.output}`);
3122
+ fs6.writeFileSync(options.output, output);
3123
+ if (!options.quiet) console.log(`Report written to ${options.output}`);
2376
3124
  } else {
2377
- console.log(json);
3125
+ console.log(output);
2378
3126
  }
2379
3127
  } else if (options.format === "html") {
2380
3128
  const html = formatHtml(report);
3129
+ let reportPath;
2381
3130
  if (options.output) {
2382
- fs2.writeFileSync(options.output, html);
3131
+ fs6.writeFileSync(options.output, html);
3132
+ reportPath = path7.resolve(options.output);
2383
3133
  console.log(`
2384
3134
  \u2705 HTML report written to ${options.output}`);
2385
- console.log(` Open in browser: file://${path2.resolve(options.output)}
3135
+ console.log(` Open in browser: file://${reportPath}
2386
3136
  `);
2387
3137
  } else {
2388
3138
  const outputFile = `seo-report-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.html`;
2389
- fs2.writeFileSync(outputFile, html);
3139
+ fs6.writeFileSync(outputFile, html);
3140
+ reportPath = path7.resolve(outputFile);
2390
3141
  console.log(`
2391
3142
  \u2705 HTML report written to ${outputFile}`);
2392
- console.log(` Open in browser: file://${path2.resolve(outputFile)}
3143
+ console.log(` Open in browser: file://${reportPath}
2393
3144
  `);
2394
3145
  }
3146
+ if (options.open) openGeneratedFile(reportPath);
2395
3147
  } else {
2396
- printReport(report);
3148
+ printReport(report, { quiet: options.quiet, noColor: options.noColor });
2397
3149
  }
3150
+ if (options.annotations) printGitHubAnnotations(report.results, effectiveBasePath);
2398
3151
  let exitCode = 0;
3152
+ if (options.updateBaseline) return 0;
2399
3153
  switch (options.failOn) {
2400
3154
  case "critical":
2401
3155
  if (report.summary.critical > 0) exitCode = 1;
@@ -2408,23 +3162,54 @@ async function checkCommand(options) {
2408
3162
  exitCode = 1;
2409
3163
  }
2410
3164
  break;
3165
+ case "new":
3166
+ if ((report.baseline?.fresh ?? 0) > 0) exitCode = 1;
3167
+ break;
2411
3168
  case "none":
2412
3169
  default:
2413
3170
  exitCode = 0;
2414
3171
  }
3172
+ if (options.maxWarnings !== void 0 && report.summary.warning > options.maxWarnings) exitCode = 1;
3173
+ if (options.maxCritical !== void 0 && report.summary.critical > options.maxCritical) exitCode = 1;
3174
+ if (options.minScore !== void 0 && score < options.minScore) exitCode = 1;
2415
3175
  return exitCode;
2416
3176
  } catch (error) {
2417
3177
  spinner.fail("Error running checks");
2418
3178
  console.error(error);
2419
- return 1;
3179
+ return error instanceof Error && /Invalid Rankture config|Unknown check ID|Baseline file not found|Unrecognized baseline format/.test(error.message) ? 1 : 2;
3180
+ }
3181
+ }
3182
+ function openGeneratedFile(filePath) {
3183
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
3184
+ const args = process.platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
3185
+ spawn(command, args, { detached: true, stdio: "ignore" }).unref();
3186
+ }
3187
+ function reportForSync(report, basePath) {
3188
+ return {
3189
+ ...report,
3190
+ results: report.results.map((result) => ({
3191
+ ...result,
3192
+ file: result.file ? path7.relative(basePath, result.file).split(path7.sep).join("/") : void 0
3193
+ }))
3194
+ };
3195
+ }
3196
+ function printGitHubAnnotations(results, basePath) {
3197
+ const escape = (value) => value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
3198
+ for (const result of results) {
3199
+ const command = result.severity === "critical" ? "error" : result.severity === "warning" ? "warning" : "notice";
3200
+ const file = result.file ? path7.relative(basePath, result.file).split(path7.sep).join("/") : "";
3201
+ const properties = [file && `file=${escape(file)}`, result.line && `line=${result.line}`, result.column && `col=${result.column}`, `title=${escape(result.checkId)}`].filter(Boolean).join(",");
3202
+ console.log(`::${command} ${properties}::${escape(result.message)}`);
2420
3203
  }
2421
3204
  }
2422
3205
  async function watchCommand(options) {
2423
- const basePath = path2.resolve(options.path);
2424
- if (!fs2.existsSync(basePath)) {
3206
+ const basePath = path7.resolve(options.path);
3207
+ if (!fs6.existsSync(basePath)) {
2425
3208
  console.error(`Path not found: ${basePath}`);
2426
3209
  process.exit(1);
2427
3210
  }
3211
+ const config = await loadConfig(basePath, options.config);
3212
+ const watchIgnore = [.../* @__PURE__ */ new Set([...config.ignore ?? [], ...options.ignore])];
2428
3213
  console.log("");
2429
3214
  console.log(" \u{1F440} Watch mode enabled");
2430
3215
  console.log(` Watching: ${basePath}`);
@@ -2433,7 +3218,7 @@ async function watchCommand(options) {
2433
3218
  await checkCommand(options);
2434
3219
  const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
2435
3220
  const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
2436
- const watchPattern = path2.join(basePath, `**/*.{${extensionPattern}}`);
3221
+ const watchPattern = path7.join(basePath, `**/*.{${extensionPattern}}`);
2437
3222
  const chokidar = await import("chokidar");
2438
3223
  let debounceTimer = null;
2439
3224
  let isRunning = false;
@@ -2463,7 +3248,7 @@ async function watchCommand(options) {
2463
3248
  "**/.next/**",
2464
3249
  "**/.nuxt/**",
2465
3250
  "**/.svelte-kit/**",
2466
- ...options.ignore.map((p) => `**/${p}`)
3251
+ ...watchIgnore
2467
3252
  ],
2468
3253
  persistent: true,
2469
3254
  ignoreInitial: true
@@ -2478,10 +3263,65 @@ async function watchCommand(options) {
2478
3263
  }
2479
3264
 
2480
3265
  // src/commands/validate.ts
2481
- import * as fs3 from "fs";
2482
- import * as path3 from "path";
3266
+ import * as fs7 from "fs";
3267
+ import * as path8 from "path";
2483
3268
  import chalk2 from "chalk";
2484
3269
  import { XMLParser } from "fast-xml-parser";
3270
+ async function validateSchema(filePath) {
3271
+ const result = { valid: true, errors: [], warnings: [], info: [] };
3272
+ const absolutePath = path8.resolve(filePath);
3273
+ if (!fs7.existsSync(absolutePath)) {
3274
+ return { ...result, valid: false, errors: [`File not found: ${absolutePath}`] };
3275
+ }
3276
+ const content = fs7.readFileSync(absolutePath, "utf-8");
3277
+ const ext = path8.extname(absolutePath).toLowerCase();
3278
+ if (ext === ".json" || ext === ".jsonld") {
3279
+ try {
3280
+ const parsed = JSON.parse(content);
3281
+ const nodes = Array.isArray(parsed) ? parsed : [parsed];
3282
+ for (const [index, node] of nodes.entries()) {
3283
+ validateSchemaNode(node, `Schema ${index + 1}`, result);
3284
+ }
3285
+ result.info.push(`${nodes.length} JSON-LD schema object(s) checked`);
3286
+ } catch (error) {
3287
+ result.valid = false;
3288
+ result.errors.push(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
3289
+ }
3290
+ return result;
3291
+ }
3292
+ const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
3293
+ if (!allExtensions.includes(ext)) {
3294
+ result.valid = false;
3295
+ result.errors.push(`Unsupported schema input ${ext || "(no extension)"}. Use JSON, HTML, or a supported source template.`);
3296
+ return result;
3297
+ }
3298
+ const page = SUPPORTED_EXTENSIONS.html.includes(ext) ? parseHtml(absolutePath, content) : parseSourceFile(absolutePath, content);
3299
+ const findings = [...checkSchemaJsonValidity(page), ...checkSchema(page)].filter((finding) => !finding.passed);
3300
+ for (const finding of findings) {
3301
+ const message = `${finding.checkId}: ${finding.message}`;
3302
+ if (finding.severity === "critical") {
3303
+ result.valid = false;
3304
+ result.errors.push(message);
3305
+ } else {
3306
+ result.warnings.push(message);
3307
+ }
3308
+ }
3309
+ result.info.push(`${page.jsonLd.length} parseable JSON-LD schema object(s) found`);
3310
+ return result;
3311
+ }
3312
+ function validateSchemaNode(node, label, result) {
3313
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
3314
+ result.valid = false;
3315
+ result.errors.push(`${label} must be a JSON object`);
3316
+ return;
3317
+ }
3318
+ const schema = node;
3319
+ if (!schema["@context"]) result.warnings.push(`${label} is missing @context`);
3320
+ if (!schema["@type"] && !Array.isArray(schema["@graph"])) result.warnings.push(`${label} is missing @type`);
3321
+ if (Array.isArray(schema["@graph"])) {
3322
+ schema["@graph"].forEach((entry, index) => validateSchemaNode(entry, `${label} @graph item ${index + 1}`, result));
3323
+ }
3324
+ }
2485
3325
  async function validateRobotsTxt(filePath) {
2486
3326
  const result = {
2487
3327
  valid: true,
@@ -2489,13 +3329,13 @@ async function validateRobotsTxt(filePath) {
2489
3329
  warnings: [],
2490
3330
  info: []
2491
3331
  };
2492
- const absolutePath = path3.resolve(filePath);
2493
- if (!fs3.existsSync(absolutePath)) {
3332
+ const absolutePath = path8.resolve(filePath);
3333
+ if (!fs7.existsSync(absolutePath)) {
2494
3334
  result.valid = false;
2495
3335
  result.errors.push(`File not found: ${absolutePath}`);
2496
3336
  return result;
2497
3337
  }
2498
- const content = fs3.readFileSync(absolutePath, "utf-8");
3338
+ const content = fs7.readFileSync(absolutePath, "utf-8");
2499
3339
  const lines = content.split("\n");
2500
3340
  let hasUserAgent = false;
2501
3341
  let hasSitemap = false;
@@ -2596,13 +3436,13 @@ async function validateSitemap(filePath) {
2596
3436
  warnings: [],
2597
3437
  info: []
2598
3438
  };
2599
- const absolutePath = path3.resolve(filePath);
2600
- if (!fs3.existsSync(absolutePath)) {
3439
+ const absolutePath = path8.resolve(filePath);
3440
+ if (!fs7.existsSync(absolutePath)) {
2601
3441
  result.valid = false;
2602
3442
  result.errors.push(`File not found: ${absolutePath}`);
2603
3443
  return result;
2604
3444
  }
2605
- const content = fs3.readFileSync(absolutePath, "utf-8");
3445
+ const content = fs7.readFileSync(absolutePath, "utf-8");
2606
3446
  if (!content.trim().startsWith("<?xml")) {
2607
3447
  result.warnings.push('Missing XML declaration (<?xml version="1.0" encoding="UTF-8"?>)');
2608
3448
  }
@@ -2757,10 +3597,155 @@ function printValidationResult(type, filePath, result) {
2757
3597
  console.log("");
2758
3598
  }
2759
3599
 
3600
+ // src/commands/audit.ts
3601
+ async function auditCommand(target, options) {
3602
+ try {
3603
+ const parsed = new URL(target);
3604
+ if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Audit URL must use http or https");
3605
+ if (options.pages !== void 0 && (!Number.isInteger(options.pages) || options.pages < 1)) {
3606
+ throw new Error("--pages must be a positive integer");
3607
+ }
3608
+ if (options.timeout !== void 0 && (!Number.isFinite(options.timeout) || options.timeout < 1)) {
3609
+ throw new Error("--timeout must be a positive number of seconds");
3610
+ }
3611
+ if (options.format && !["terminal", "json"].includes(options.format)) {
3612
+ throw new Error("--format must be terminal or json");
3613
+ }
3614
+ const credentials = { apiKey: options.apiKey, apiUrl: options.apiUrl };
3615
+ const started = await startRemoteAudit(parsed.href, { pages: options.pages }, credentials);
3616
+ if (!options.wait) {
3617
+ print({ auditId: started.auditId, status: "queued" }, options.format);
3618
+ return 0;
3619
+ }
3620
+ const timeoutMs = Math.max(1, options.timeout ?? 900) * 1e3;
3621
+ const deadline = Date.now() + timeoutMs;
3622
+ while (Date.now() < deadline) {
3623
+ const audit = await getRemoteAudit(started.auditId, credentials);
3624
+ const status = String(audit.status || "unknown");
3625
+ if (status === "completed") {
3626
+ const full = await getRemoteAudit(started.auditId, credentials, true);
3627
+ print(full, options.format);
3628
+ return 0;
3629
+ }
3630
+ if (["failed", "cancelled"].includes(status)) {
3631
+ print(audit, options.format);
3632
+ return 1;
3633
+ }
3634
+ await new Promise((resolve9) => setTimeout(resolve9, 3e3));
3635
+ }
3636
+ console.error(`Audit ${started.auditId} is still running after ${options.timeout ?? 900} seconds`);
3637
+ return 2;
3638
+ } catch (error) {
3639
+ console.error(error instanceof Error ? error.message : "Unable to run audit");
3640
+ return 2;
3641
+ }
3642
+ }
3643
+ function print(value, format) {
3644
+ if (format === "json") console.log(JSON.stringify(value, null, 2));
3645
+ else {
3646
+ console.log(`Audit: ${String(value.auditId || value.id || "")}`);
3647
+ console.log(`Status: ${String(value.status || "unknown")}`);
3648
+ if (value.score !== void 0 && value.score !== null) console.log(`Score: ${String(value.score)}`);
3649
+ if (value.failureReason) console.log(`Failure: ${String(value.failureReason)}`);
3650
+ }
3651
+ }
3652
+
3653
+ // src/commands/auth.ts
3654
+ async function loginCommand(apiKey, apiUrl = "https://rankture.com") {
3655
+ const key = apiKey || process.env.RANKTURE_API_KEY;
3656
+ if (!key) {
3657
+ console.error("An API key is required. Create one in Rankture and run: rankture login --api-key <key>");
3658
+ return 1;
3659
+ }
3660
+ try {
3661
+ const identity = await getIdentity({ apiKey: key, apiUrl });
3662
+ saveCredentials({ apiKey: key, apiUrl: apiUrl.replace(/\/$/, "") });
3663
+ console.log(`Logged in as ${identity.user.email}`);
3664
+ return 0;
3665
+ } catch (error) {
3666
+ console.error(error instanceof Error ? error.message : "Unable to validate API key");
3667
+ return 1;
3668
+ }
3669
+ }
3670
+ function logoutCommand() {
3671
+ console.log(clearCredentials() ? "Logged out of Rankture" : "No stored Rankture login found");
3672
+ return 0;
3673
+ }
3674
+ async function whoamiCommand() {
3675
+ try {
3676
+ const identity = await getIdentity();
3677
+ console.log(`${identity.user.email}
3678
+ Scopes: ${identity.scopes.join(", ")}`);
3679
+ return 0;
3680
+ } catch (error) {
3681
+ console.error(error instanceof Error ? error.message : "Unable to load Rankture identity");
3682
+ return 1;
3683
+ }
3684
+ }
3685
+
3686
+ // src/mcp/server.ts
3687
+ import { spawnSync } from "child_process";
3688
+ import * as path9 from "path";
3689
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3690
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3691
+ import * as z from "zod/v4";
3692
+ function safePath(root, requested) {
3693
+ const resolved = path9.resolve(root, requested);
3694
+ if (resolved !== root && !resolved.startsWith(`${root}${path9.sep}`)) {
3695
+ throw new Error(`Path must stay inside MCP root: ${root}`);
3696
+ }
3697
+ return resolved;
3698
+ }
3699
+ var text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
3700
+ function createMcpServer(rootPath = process.cwd(), cliEntry = process.argv[1]) {
3701
+ const root = path9.resolve(rootPath);
3702
+ const server = new McpServer({ name: "rankture", version: VERSION });
3703
+ server.registerTool("check_path", {
3704
+ description: "Run Rankture SEO checks against a file or directory inside the configured project root.",
3705
+ inputSchema: {
3706
+ path: z.string().default("."),
3707
+ config: z.string().optional(),
3708
+ serve: z.boolean().default(false)
3709
+ },
3710
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
3711
+ }, async (input) => {
3712
+ const target = safePath(root, input.path);
3713
+ const args = [cliEntry, "check", target, "--format", "json"];
3714
+ if (input.config) args.push("--config", safePath(root, input.config));
3715
+ if (input.serve) args.push("--serve");
3716
+ const result = spawnSync(process.execPath, args, { cwd: root, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } });
3717
+ if (result.error) throw result.error;
3718
+ if (!result.stdout.trim()) throw new Error(result.stderr.trim() || `Rankture exited ${result.status}`);
3719
+ return text(JSON.parse(result.stdout));
3720
+ });
3721
+ server.registerTool("validate_schema", {
3722
+ description: "Validate a local JSON-LD file or HTML document inside the configured project root.",
3723
+ inputSchema: { file: z.string() },
3724
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
3725
+ }, async ({ file }) => text(await validateSchema(safePath(root, file))));
3726
+ server.registerTool("list_rules", {
3727
+ description: "List Rankture rule groups with their default severity and purpose.",
3728
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
3729
+ }, async () => text(checks.map(({ id, name, description, severity }) => ({ id, name, description, severity }))));
3730
+ server.registerTool("explain_rule", {
3731
+ description: "Explain a Rankture rule group by its stable ID.",
3732
+ inputSchema: { ruleId: z.string() },
3733
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
3734
+ }, async ({ ruleId }) => {
3735
+ const rule = checks.find((candidate) => candidate.id === ruleId || ruleId.startsWith(`${candidate.id}-`));
3736
+ if (!rule) throw new Error(`Unknown Rankture rule: ${ruleId}`);
3737
+ return text({ id: rule.id, name: rule.name, severity: rule.severity, description: rule.description });
3738
+ });
3739
+ return server;
3740
+ }
3741
+ async function startMcpServer(rootPath) {
3742
+ await createMcpServer(rootPath).connect(new StdioServerTransport());
3743
+ }
3744
+
2760
3745
  // src/cli.ts
2761
3746
  var program = new Command();
2762
3747
  program.name("rankture").description("SEO audit tool for developers - check your sites before deploy").version(VERSION);
2763
- program.command("check <path>").description("Run SEO checks on local HTML files").option("-f, --format <format>", "Output format: terminal, json, html", "terminal").option("-o, --output <file>", "Write output to file").option("--fail-on <level>", "Exit with code 1 on: none, critical, warning, any", "none").option("--ignore <patterns...>", "Glob patterns to ignore", []).option("--config <file>", "Config file path").option("-w, --watch", "Watch for file changes and re-run checks").option("--changed", "Only check files changed in git (staged + unstaged)").option("--staged", "Only check git staged files").action(async (pathArg, opts) => {
3748
+ program.command("check <path>").description("Run SEO checks on local HTML files").option("-f, --format <format>", "Output format: terminal, json, html, sarif, markdown", "terminal").option("-o, --output <file>", "Write output to file").option("--fail-on <level>", "Exit with code 1 on: none, critical, warning, any, new", "none").option("--ignore <patterns...>", "Glob patterns to ignore", []).option("--config <file>", "Config file path").option("-w, --watch", "Watch for file changes and re-run checks").option("--changed", "Only check files changed in git (staged + unstaged)").option("--staged", "Only check git staged files").option("--quiet", "Suppress progress and informational findings").option("--no-color", "Disable ANSI colors").option("--baseline <file>", "Compare against a committed Rankture baseline").option("--update-baseline", "Write current failures to --baseline and exit successfully").option("--max-warnings <number>", "Fail when warnings exceed this count").option("--max-critical <number>", "Fail when critical issues exceed this count").option("--min-score <number>", "Fail when score is below this percentage").option("--annotations", "Emit GitHub Actions workflow annotations").option("--serve", "Validate built-site links through an ephemeral local HTTP server").option("--serve-port <number>", "Preferred local server port (0 selects an available port)").option("--open", "Open a generated HTML report in the system browser").option("--sync", "Upload the report to Rankture").option("--website-id <id>", "Associate a synced report with a Rankture website").option("--api-key <key>", "One-run API key override (prefer login or RANKTURE_API_KEY)").option("--api-url <url>", "Rankture API origin override").action(async (pathArg, opts) => {
2764
3749
  const options = {
2765
3750
  path: pathArg,
2766
3751
  format: opts.format,
@@ -2770,7 +3755,22 @@ program.command("check <path>").description("Run SEO checks on local HTML files"
2770
3755
  config: opts.config,
2771
3756
  watch: opts.watch,
2772
3757
  changed: opts.changed,
2773
- staged: opts.staged
3758
+ staged: opts.staged,
3759
+ quiet: opts.quiet,
3760
+ noColor: opts.color === false,
3761
+ baseline: opts.baseline,
3762
+ updateBaseline: opts.updateBaseline,
3763
+ maxWarnings: opts.maxWarnings === void 0 ? void 0 : Number(opts.maxWarnings),
3764
+ maxCritical: opts.maxCritical === void 0 ? void 0 : Number(opts.maxCritical),
3765
+ minScore: opts.minScore === void 0 ? void 0 : Number(opts.minScore),
3766
+ annotations: opts.annotations,
3767
+ serve: opts.serve,
3768
+ servePort: opts.servePort === void 0 ? void 0 : Number(opts.servePort),
3769
+ open: opts.open,
3770
+ sync: opts.sync,
3771
+ websiteId: opts.websiteId,
3772
+ apiKey: opts.apiKey,
3773
+ apiUrl: opts.apiUrl
2774
3774
  };
2775
3775
  if (opts.watch) {
2776
3776
  await watchCommand(options);
@@ -2779,22 +3779,37 @@ program.command("check <path>").description("Run SEO checks on local HTML files"
2779
3779
  process.exit(exitCode);
2780
3780
  }
2781
3781
  });
2782
- program.command("audit <url>").description("Audit a live URL (requires API key for full features)").option("--api-key <key>", "Rankture API key for Pro features").option("--sync", "Sync results to Rankture dashboard").option("--mobile", "Run mobile audit", true).option("--desktop", "Run desktop audit", false).option("--pages <number>", "Max pages to audit (Pro only)", "1").option("-f, --format <format>", "Output format: terminal, json, html", "terminal").option("--fail-on <level>", "Exit with code 1 on: none, critical, warning, any", "none").action(async (_url, _opts) => {
2783
- console.error("");
2784
- console.error(" The audit command is not implemented yet.");
2785
- console.error("");
2786
- console.error(" For now, use the check command on your built files:");
2787
- console.error(" rankture check ./dist");
2788
- console.error("");
2789
- console.error(" Or run a free audit at:");
2790
- console.error(" https://rankture.com/free-seo-audit/");
2791
- console.error("");
2792
- process.exit(2);
3782
+ program.command("audit <url>").description("Audit a live URL (requires API key for full features)").option("--api-key <key>", "Rankture API key for Pro features").option("--pages <number>", "Max pages to audit (Pro only)", "1").option("-f, --format <format>", "Output format: terminal, json", "terminal").option("--wait", "Wait for the audit to finish").option("--timeout <seconds>", "Maximum wait time", "900").option("--api-url <url>", "Rankture API origin override").action(async (url, opts) => {
3783
+ process.exit(await auditCommand(url, {
3784
+ apiKey: opts.apiKey,
3785
+ apiUrl: opts.apiUrl,
3786
+ pages: Number(opts.pages),
3787
+ wait: opts.wait,
3788
+ timeout: Number(opts.timeout),
3789
+ format: opts.format
3790
+ }));
3791
+ });
3792
+ program.command("login").description("Validate and securely store a Rankture API key").option("--api-key <key>", "Rankture CLI API key").option("--api-url <url>", "Rankture API origin", "https://rankture.com").action(async (opts) => process.exit(await loginCommand(opts.apiKey, opts.apiUrl)));
3793
+ program.command("logout").description("Remove the locally stored Rankture API key").action(() => process.exit(logoutCommand()));
3794
+ program.command("whoami").description("Show the authenticated Rankture account and scopes").action(async () => process.exit(await whoamiCommand()));
3795
+ program.command("websites").description("List websites available to the authenticated CLI account").option("--json", "Output machine-readable JSON").action(async (opts) => {
3796
+ try {
3797
+ const result = await getWebsites();
3798
+ if (opts.json) console.log(JSON.stringify(result.websites, null, 2));
3799
+ else for (const website of result.websites) console.log(`${website.id} ${website.url}`);
3800
+ process.exit(0);
3801
+ } catch (error) {
3802
+ console.error(error instanceof Error ? error.message : "Unable to list websites");
3803
+ process.exit(1);
3804
+ }
3805
+ });
3806
+ program.command("mcp").description("Start the local Rankture MCP server over stdio").option("--root <path>", "Restrict file access to this project root", process.cwd()).action(async (opts) => {
3807
+ await startMcpServer(opts.root);
2793
3808
  });
2794
3809
  program.command("init").description("Create a rankture.config.json file").option("--force", "Overwrite existing config file").action(async (opts) => {
2795
- const fs4 = await import("fs");
3810
+ const fs8 = await import("fs");
2796
3811
  const configPath = "./rankture.config.json";
2797
- if (fs4.existsSync(configPath) && !opts.force) {
3812
+ if (fs8.existsSync(configPath) && !opts.force) {
2798
3813
  console.log("");
2799
3814
  console.log(" \u26A0\uFE0F rankture.config.json already exists");
2800
3815
  console.log(" Use --force to overwrite");
@@ -2836,7 +3851,7 @@ program.command("init").description("Create a rankture.config.json file").option
2836
3851
  descriptionLength: { min: 120, max: 160 }
2837
3852
  }
2838
3853
  };
2839
- fs4.writeFileSync(configPath, JSON.stringify(config, null, 2));
3854
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2));
2840
3855
  console.log("");
2841
3856
  console.log(" \u2705 Created rankture.config.json");
2842
3857
  console.log("");
@@ -2866,9 +3881,9 @@ program.command("validate <type> <file>").description("Validate sitemap.xml, rob
2866
3881
  printValidationResult("sitemap.xml", file, result);
2867
3882
  process.exit(result.valid ? 0 : 1);
2868
3883
  } else if (normalizedType === "schema") {
2869
- console.error("\n Schema validation is not implemented yet.");
2870
- console.error(" JSON-LD syntax and structure checks run as part of: rankture check <path>\n");
2871
- process.exit(2);
3884
+ const result = await validateSchema(file);
3885
+ printValidationResult("schema", file, result);
3886
+ process.exit(result.valid ? 0 : 1);
2872
3887
  }
2873
3888
  } catch (error) {
2874
3889
  console.error(`