@rankture/cli 0.1.0 → 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,24 +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
+ }
2055
+
2056
+ // src/version.ts
2057
+ var VERSION = "0.1.3";
1740
2058
 
1741
2059
  // src/reporters/terminal.ts
1742
2060
  import chalk from "chalk";
1743
- function formatTerminal(report) {
2061
+ function formatTerminal(report, opts = {}) {
1744
2062
  const lines = [];
1745
- lines.push("");
1746
- lines.push(chalk.bold.cyan(" Rankture SEO Check"));
1747
- lines.push(chalk.gray(" " + "\u2500".repeat(40)));
1748
- lines.push("");
1749
- lines.push(chalk.gray(` Checking ${report.path} (${report.summary.totalFiles} files)...`));
1750
- 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
+ }
1751
2071
  const critical = report.results.filter((r) => !r.passed && r.severity === "critical");
1752
2072
  const warnings = report.results.filter((r) => !r.passed && r.severity === "warning");
1753
- 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");
1754
2074
  if (critical.length > 0) {
1755
2075
  for (const result of critical) {
1756
2076
  lines.push(formatResult(result));
@@ -1766,8 +2086,10 @@ function formatTerminal(report) {
1766
2086
  lines.push(formatResult(result));
1767
2087
  }
1768
2088
  }
1769
- lines.push("");
1770
- lines.push(chalk.gray(" " + "\u2500".repeat(40)));
2089
+ if (!opts.quiet) {
2090
+ lines.push("");
2091
+ lines.push(chalk.gray(" " + "\u2500".repeat(40)));
2092
+ }
1771
2093
  const summaryParts = [];
1772
2094
  if (report.summary.critical > 0) {
1773
2095
  summaryParts.push(chalk.red.bold(`${report.summary.critical} critical`));
@@ -1780,6 +2102,9 @@ function formatTerminal(report) {
1780
2102
  }
1781
2103
  summaryParts.push(chalk.green(`${report.summary.passed} passed`));
1782
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
+ }
1783
2108
  lines.push("");
1784
2109
  if (report.summary.critical > 0) {
1785
2110
  lines.push(chalk.red.bold(" \u2717 Check failed (critical issues found)"));
@@ -1789,7 +2114,9 @@ function formatTerminal(report) {
1789
2114
  lines.push(chalk.green.bold(" \u2713 All checks passed!"));
1790
2115
  }
1791
2116
  lines.push("");
1792
- 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;
1793
2120
  }
1794
2121
  function formatResult(result) {
1795
2122
  const lines = [];
@@ -1831,8 +2158,8 @@ function formatResult(result) {
1831
2158
  lines.push("");
1832
2159
  return lines.join("\n");
1833
2160
  }
1834
- function printReport(report) {
1835
- console.log(formatTerminal(report));
2161
+ function printReport(report, opts = {}) {
2162
+ console.log(formatTerminal(report, opts));
1836
2163
  }
1837
2164
 
1838
2165
  // src/reporters/json.ts
@@ -2248,50 +2575,405 @@ function formatHtml(report) {
2248
2575
  </html>`;
2249
2576
  return html;
2250
2577
  }
2251
- function escapeHtml(text) {
2252
- 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}/`;
2253
2667
  }
2254
2668
 
2255
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
2256
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
2257
2901
  function getGitChangedFiles(basePath, stagedOnly) {
2258
2902
  try {
2259
- const cwd = path2.resolve(basePath);
2260
- 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", {
2261
2905
  cwd,
2262
2906
  encoding: "utf-8"
2263
2907
  }).trim();
2264
2908
  const stagedFiles = stagedOutput ? stagedOutput.split("\n") : [];
2265
2909
  if (stagedOnly) {
2266
- return stagedFiles.map((f) => path2.join(cwd, f));
2910
+ return stagedFiles.map((f) => path7.join(cwd, f));
2267
2911
  }
2268
- const unstagedOutput = execSync("git diff --name-only --diff-filter=ACMR", {
2912
+ const unstagedOutput = execSync2("git diff --name-only --diff-filter=ACMR", {
2269
2913
  cwd,
2270
2914
  encoding: "utf-8"
2271
2915
  }).trim();
2272
2916
  const unstagedFiles = unstagedOutput ? unstagedOutput.split("\n") : [];
2273
2917
  const allFiles = [.../* @__PURE__ */ new Set([...stagedFiles, ...unstagedFiles])];
2274
- return allFiles.map((f) => path2.join(cwd, f));
2918
+ return allFiles.map((f) => path7.join(cwd, f));
2275
2919
  } catch {
2276
2920
  return [];
2277
2921
  }
2278
2922
  }
2279
2923
  async function checkCommand(options) {
2280
- const spinner = ora("Scanning files...").start();
2924
+ const spinner = ora({
2925
+ text: "Scanning files...",
2926
+ isEnabled: Boolean(process.stdout.isTTY && !options.quiet)
2927
+ }).start();
2281
2928
  try {
2282
- const basePath = path2.resolve(options.path);
2283
- 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)) {
2284
2956
  spinner.fail(`Path not found: ${basePath}`);
2285
2957
  return 1;
2286
2958
  }
2287
- 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])];
2288
2970
  let targetFiles;
2289
2971
  const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
2290
2972
  if (options.changed || options.staged) {
2291
2973
  spinner.text = options.staged ? "Getting staged files..." : "Getting changed files...";
2292
2974
  const gitFiles = getGitChangedFiles(basePath, !!options.staged);
2293
2975
  targetFiles = gitFiles.filter((f) => {
2294
- const ext = path2.extname(f).toLowerCase();
2976
+ const ext = path7.extname(f).toLowerCase();
2295
2977
  return allExtensions.includes(ext);
2296
2978
  });
2297
2979
  if (targetFiles.length === 0) {
@@ -2300,7 +2982,7 @@ async function checkCommand(options) {
2300
2982
  }
2301
2983
  spinner.text = `Found ${targetFiles.length} ${options.staged ? "staged" : "changed"} file(s)`;
2302
2984
  } else if (stats.isFile()) {
2303
- const ext = path2.extname(basePath).toLowerCase();
2985
+ const ext = path7.extname(basePath).toLowerCase();
2304
2986
  if (!allExtensions.includes(ext)) {
2305
2987
  spinner.fail(`Unsupported file type: ${ext}. Supported: ${allExtensions.join(", ")}`);
2306
2988
  return 1;
@@ -2316,7 +2998,7 @@ async function checkCommand(options) {
2316
2998
  "**/.svelte-kit/**",
2317
2999
  "**/dist/**/*.js",
2318
3000
  "**/dist/**/*.css",
2319
- ...options.ignore
3001
+ ...effectiveIgnore
2320
3002
  ];
2321
3003
  const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
2322
3004
  const globPattern = `**/*.{${extensionPattern}}`;
@@ -2330,11 +3012,12 @@ async function checkCommand(options) {
2330
3012
  return 1;
2331
3013
  }
2332
3014
  }
3015
+ targetFiles.sort((a, b) => a.localeCompare(b));
2333
3016
  spinner.text = `Parsing ${targetFiles.length} file${targetFiles.length > 1 ? "s" : ""}...`;
2334
3017
  const pages = [];
2335
3018
  for (const filePath of targetFiles) {
2336
- const content = fs2.readFileSync(filePath, "utf-8");
2337
- const ext = path2.extname(filePath).toLowerCase();
3019
+ const content = fs6.readFileSync(filePath, "utf-8");
3020
+ const ext = path7.extname(filePath).toLowerCase();
2338
3021
  let page;
2339
3022
  if (SUPPORTED_EXTENSIONS.html.includes(ext)) {
2340
3023
  page = parseHtml(filePath, content);
@@ -2344,55 +3027,129 @@ async function checkCommand(options) {
2344
3027
  pages.push(page);
2345
3028
  }
2346
3029
  spinner.text = "Running checks...";
2347
- 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");
2348
3034
  const allResults = [];
2349
3035
  for (const page of pages) {
2350
- 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
+ }
2351
3041
  allResults.push(...results);
2352
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
+ );
2353
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;
2354
3093
  const report = {
2355
- version: "0.1.0",
3094
+ version: VERSION,
2356
3095
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2357
3096
  path: options.path,
2358
3097
  summary: {
2359
3098
  totalFiles: pages.length,
2360
- passed: allResults.filter((r) => r.passed).length,
2361
- critical: allResults.filter((r) => !r.passed && r.severity === "critical").length,
2362
- warning: allResults.filter((r) => !r.passed && r.severity === "warning").length,
2363
- 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
2364
3104
  },
2365
- results: allResults.filter((r) => !r.passed)
2366
- // Only include failures
3105
+ results: reportResults,
3106
+ ci: collectCiMetadata(process.env, effectiveBasePath),
3107
+ baseline: baselineMeta
2367
3108
  };
2368
- if (options.format === "json") {
2369
- 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);
2370
3121
  if (options.output) {
2371
- fs2.writeFileSync(options.output, json);
2372
- console.log(`Report written to ${options.output}`);
3122
+ fs6.writeFileSync(options.output, output);
3123
+ if (!options.quiet) console.log(`Report written to ${options.output}`);
2373
3124
  } else {
2374
- console.log(json);
3125
+ console.log(output);
2375
3126
  }
2376
3127
  } else if (options.format === "html") {
2377
3128
  const html = formatHtml(report);
3129
+ let reportPath;
2378
3130
  if (options.output) {
2379
- fs2.writeFileSync(options.output, html);
3131
+ fs6.writeFileSync(options.output, html);
3132
+ reportPath = path7.resolve(options.output);
2380
3133
  console.log(`
2381
3134
  \u2705 HTML report written to ${options.output}`);
2382
- console.log(` Open in browser: file://${path2.resolve(options.output)}
3135
+ console.log(` Open in browser: file://${reportPath}
2383
3136
  `);
2384
3137
  } else {
2385
3138
  const outputFile = `seo-report-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.html`;
2386
- fs2.writeFileSync(outputFile, html);
3139
+ fs6.writeFileSync(outputFile, html);
3140
+ reportPath = path7.resolve(outputFile);
2387
3141
  console.log(`
2388
3142
  \u2705 HTML report written to ${outputFile}`);
2389
- console.log(` Open in browser: file://${path2.resolve(outputFile)}
3143
+ console.log(` Open in browser: file://${reportPath}
2390
3144
  `);
2391
3145
  }
3146
+ if (options.open) openGeneratedFile(reportPath);
2392
3147
  } else {
2393
- printReport(report);
3148
+ printReport(report, { quiet: options.quiet, noColor: options.noColor });
2394
3149
  }
3150
+ if (options.annotations) printGitHubAnnotations(report.results, effectiveBasePath);
2395
3151
  let exitCode = 0;
3152
+ if (options.updateBaseline) return 0;
2396
3153
  switch (options.failOn) {
2397
3154
  case "critical":
2398
3155
  if (report.summary.critical > 0) exitCode = 1;
@@ -2405,23 +3162,54 @@ async function checkCommand(options) {
2405
3162
  exitCode = 1;
2406
3163
  }
2407
3164
  break;
3165
+ case "new":
3166
+ if ((report.baseline?.fresh ?? 0) > 0) exitCode = 1;
3167
+ break;
2408
3168
  case "none":
2409
3169
  default:
2410
3170
  exitCode = 0;
2411
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;
2412
3175
  return exitCode;
2413
3176
  } catch (error) {
2414
3177
  spinner.fail("Error running checks");
2415
3178
  console.error(error);
2416
- 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)}`);
2417
3203
  }
2418
3204
  }
2419
3205
  async function watchCommand(options) {
2420
- const basePath = path2.resolve(options.path);
2421
- if (!fs2.existsSync(basePath)) {
3206
+ const basePath = path7.resolve(options.path);
3207
+ if (!fs6.existsSync(basePath)) {
2422
3208
  console.error(`Path not found: ${basePath}`);
2423
3209
  process.exit(1);
2424
3210
  }
3211
+ const config = await loadConfig(basePath, options.config);
3212
+ const watchIgnore = [.../* @__PURE__ */ new Set([...config.ignore ?? [], ...options.ignore])];
2425
3213
  console.log("");
2426
3214
  console.log(" \u{1F440} Watch mode enabled");
2427
3215
  console.log(` Watching: ${basePath}`);
@@ -2430,7 +3218,7 @@ async function watchCommand(options) {
2430
3218
  await checkCommand(options);
2431
3219
  const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
2432
3220
  const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
2433
- const watchPattern = path2.join(basePath, `**/*.{${extensionPattern}}`);
3221
+ const watchPattern = path7.join(basePath, `**/*.{${extensionPattern}}`);
2434
3222
  const chokidar = await import("chokidar");
2435
3223
  let debounceTimer = null;
2436
3224
  let isRunning = false;
@@ -2460,7 +3248,7 @@ async function watchCommand(options) {
2460
3248
  "**/.next/**",
2461
3249
  "**/.nuxt/**",
2462
3250
  "**/.svelte-kit/**",
2463
- ...options.ignore.map((p) => `**/${p}`)
3251
+ ...watchIgnore
2464
3252
  ],
2465
3253
  persistent: true,
2466
3254
  ignoreInitial: true
@@ -2475,10 +3263,65 @@ async function watchCommand(options) {
2475
3263
  }
2476
3264
 
2477
3265
  // src/commands/validate.ts
2478
- import * as fs3 from "fs";
2479
- import * as path3 from "path";
3266
+ import * as fs7 from "fs";
3267
+ import * as path8 from "path";
2480
3268
  import chalk2 from "chalk";
2481
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
+ }
2482
3325
  async function validateRobotsTxt(filePath) {
2483
3326
  const result = {
2484
3327
  valid: true,
@@ -2486,13 +3329,13 @@ async function validateRobotsTxt(filePath) {
2486
3329
  warnings: [],
2487
3330
  info: []
2488
3331
  };
2489
- const absolutePath = path3.resolve(filePath);
2490
- if (!fs3.existsSync(absolutePath)) {
3332
+ const absolutePath = path8.resolve(filePath);
3333
+ if (!fs7.existsSync(absolutePath)) {
2491
3334
  result.valid = false;
2492
3335
  result.errors.push(`File not found: ${absolutePath}`);
2493
3336
  return result;
2494
3337
  }
2495
- const content = fs3.readFileSync(absolutePath, "utf-8");
3338
+ const content = fs7.readFileSync(absolutePath, "utf-8");
2496
3339
  const lines = content.split("\n");
2497
3340
  let hasUserAgent = false;
2498
3341
  let hasSitemap = false;
@@ -2593,13 +3436,13 @@ async function validateSitemap(filePath) {
2593
3436
  warnings: [],
2594
3437
  info: []
2595
3438
  };
2596
- const absolutePath = path3.resolve(filePath);
2597
- if (!fs3.existsSync(absolutePath)) {
3439
+ const absolutePath = path8.resolve(filePath);
3440
+ if (!fs7.existsSync(absolutePath)) {
2598
3441
  result.valid = false;
2599
3442
  result.errors.push(`File not found: ${absolutePath}`);
2600
3443
  return result;
2601
3444
  }
2602
- const content = fs3.readFileSync(absolutePath, "utf-8");
3445
+ const content = fs7.readFileSync(absolutePath, "utf-8");
2603
3446
  if (!content.trim().startsWith("<?xml")) {
2604
3447
  result.warnings.push('Missing XML declaration (<?xml version="1.0" encoding="UTF-8"?>)');
2605
3448
  }
@@ -2754,10 +3597,155 @@ function printValidationResult(type, filePath, result) {
2754
3597
  console.log("");
2755
3598
  }
2756
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
+
2757
3745
  // src/cli.ts
2758
3746
  var program = new Command();
2759
- program.name("rankture").description("SEO audit tool for developers - check your sites before deploy").version("0.1.0");
2760
- 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) => {
3747
+ program.name("rankture").description("SEO audit tool for developers - check your sites before deploy").version(VERSION);
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) => {
2761
3749
  const options = {
2762
3750
  path: pathArg,
2763
3751
  format: opts.format,
@@ -2767,7 +3755,22 @@ program.command("check <path>").description("Run SEO checks on local HTML files"
2767
3755
  config: opts.config,
2768
3756
  watch: opts.watch,
2769
3757
  changed: opts.changed,
2770
- 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
2771
3774
  };
2772
3775
  if (opts.watch) {
2773
3776
  await watchCommand(options);
@@ -2776,22 +3779,37 @@ program.command("check <path>").description("Run SEO checks on local HTML files"
2776
3779
  process.exit(exitCode);
2777
3780
  }
2778
3781
  });
2779
- 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) => {
2780
- console.log("");
2781
- console.log(" The audit command is coming soon!");
2782
- console.log("");
2783
- console.log(" For now, use the check command on your built files:");
2784
- console.log(" rankture check ./dist");
2785
- console.log("");
2786
- console.log(" Or run a free audit at:");
2787
- console.log(" https://rankture.com/free-seo-audit/");
2788
- console.log("");
2789
- process.exit(0);
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);
2790
3808
  });
2791
3809
  program.command("init").description("Create a rankture.config.json file").option("--force", "Overwrite existing config file").action(async (opts) => {
2792
- const fs4 = await import("fs");
3810
+ const fs8 = await import("fs");
2793
3811
  const configPath = "./rankture.config.json";
2794
- if (fs4.existsSync(configPath) && !opts.force) {
3812
+ if (fs8.existsSync(configPath) && !opts.force) {
2795
3813
  console.log("");
2796
3814
  console.log(" \u26A0\uFE0F rankture.config.json already exists");
2797
3815
  console.log(" Use --force to overwrite");
@@ -2833,7 +3851,7 @@ program.command("init").description("Create a rankture.config.json file").option
2833
3851
  descriptionLength: { min: 120, max: 160 }
2834
3852
  }
2835
3853
  };
2836
- fs4.writeFileSync(configPath, JSON.stringify(config, null, 2));
3854
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2));
2837
3855
  console.log("");
2838
3856
  console.log(" \u2705 Created rankture.config.json");
2839
3857
  console.log("");
@@ -2863,8 +3881,9 @@ program.command("validate <type> <file>").description("Validate sitemap.xml, rob
2863
3881
  printValidationResult("sitemap.xml", file, result);
2864
3882
  process.exit(result.valid ? 0 : 1);
2865
3883
  } else if (normalizedType === "schema") {
2866
- console.log("\n Schema validation coming soon!\n");
2867
- process.exit(0);
3884
+ const result = await validateSchema(file);
3885
+ printValidationResult("schema", file, result);
3886
+ process.exit(result.valid ? 0 : 1);
2868
3887
  }
2869
3888
  } catch (error) {
2870
3889
  console.error(`
@@ -2874,4 +3893,3 @@ program.command("validate <type> <file>").description("Validate sitemap.xml, rob
2874
3893
  }
2875
3894
  });
2876
3895
  program.parse();
2877
- //# sourceMappingURL=cli.mjs.map