@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/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/checks/meta-tags.ts
2
- function checkMetaTitle(page, allPages) {
2
+ var isDynamic = (value) => /\[dynamic(?::|\])/i.test(value) || /\{[^}]+\}/.test(value);
3
+ function checkMetaTitle(page, allPages, thresholds) {
3
4
  const results = [];
4
5
  if (!page.title) {
5
6
  results.push({
@@ -13,26 +14,27 @@ function checkMetaTitle(page, allPages) {
13
14
  });
14
15
  return results;
15
16
  }
17
+ const titleRange = thresholds?.titleLength ?? { min: 30, max: 60 };
16
18
  const titleLength = page.title.length;
17
- if (titleLength < 30) {
19
+ if (!isDynamic(page.title) && titleLength < titleRange.min) {
18
20
  results.push({
19
21
  checkId: "meta-title-length",
20
22
  name: "Title Too Short",
21
23
  severity: "warning",
22
24
  passed: false,
23
25
  file: page.filePath,
24
- message: `Title is too short (${titleLength} chars, recommended 30-60)`,
25
- fix: "Expand your title to be more descriptive (30-60 characters)"
26
+ message: `Title is too short (${titleLength} chars, expected ${titleRange.min}-${titleRange.max})`,
27
+ fix: `Expand your title to ${titleRange.min}-${titleRange.max} characters`
26
28
  });
27
- } else if (titleLength > 60) {
29
+ } else if (!isDynamic(page.title) && titleLength > titleRange.max) {
28
30
  results.push({
29
31
  checkId: "meta-title-length",
30
32
  name: "Title Too Long",
31
33
  severity: "warning",
32
34
  passed: false,
33
35
  file: page.filePath,
34
- message: `Title is too long (${titleLength} chars, recommended 30-60)`,
35
- fix: "Shorten your title to under 60 characters to prevent truncation in search results"
36
+ message: `Title is too long (${titleLength} chars, expected ${titleRange.min}-${titleRange.max})`,
37
+ fix: `Shorten your title to at most ${titleRange.max} characters`
36
38
  });
37
39
  }
38
40
  const duplicates = allPages.filter(
@@ -52,7 +54,7 @@ function checkMetaTitle(page, allPages) {
52
54
  }
53
55
  return results;
54
56
  }
55
- function checkMetaDescription(page, allPages) {
57
+ function checkMetaDescription(page, allPages, thresholds) {
56
58
  const results = [];
57
59
  if (!page.metaDescription) {
58
60
  results.push({
@@ -66,26 +68,27 @@ function checkMetaDescription(page, allPages) {
66
68
  });
67
69
  return results;
68
70
  }
71
+ const descriptionRange = thresholds?.descriptionLength ?? { min: 120, max: 160 };
69
72
  const descLength = page.metaDescription.length;
70
- if (descLength < 120) {
73
+ if (!isDynamic(page.metaDescription) && descLength < descriptionRange.min) {
71
74
  results.push({
72
75
  checkId: "meta-description-length",
73
76
  name: "Meta Description Too Short",
74
77
  severity: "warning",
75
78
  passed: false,
76
79
  file: page.filePath,
77
- message: `Meta description is too short (${descLength} chars, recommended 120-160)`,
78
- fix: "Expand your meta description to be more descriptive (120-160 characters)"
80
+ message: `Meta description is too short (${descLength} chars, expected ${descriptionRange.min}-${descriptionRange.max})`,
81
+ fix: `Expand your meta description to ${descriptionRange.min}-${descriptionRange.max} characters`
79
82
  });
80
- } else if (descLength > 160) {
83
+ } else if (!isDynamic(page.metaDescription) && descLength > descriptionRange.max) {
81
84
  results.push({
82
85
  checkId: "meta-description-length",
83
86
  name: "Meta Description Too Long",
84
87
  severity: "warning",
85
88
  passed: false,
86
89
  file: page.filePath,
87
- message: `Meta description is too long (${descLength} chars, recommended 120-160)`,
88
- fix: "Shorten your meta description to under 160 characters to prevent truncation"
90
+ message: `Meta description is too long (${descLength} chars, expected ${descriptionRange.min}-${descriptionRange.max})`,
91
+ fix: `Shorten your meta description to at most ${descriptionRange.max} characters`
89
92
  });
90
93
  }
91
94
  const duplicates = allPages.filter(
@@ -309,6 +312,50 @@ function checkInternalLinks(page, allPages, basePath) {
309
312
  }
310
313
  return results;
311
314
  }
315
+ async function checkInternalLinksHttp(page, pageUrl, fetcher = fetch) {
316
+ const brokenLinks = [];
317
+ const emptyLinks = page.internalLinks.filter((link) => !link.href || link.href === "#");
318
+ const origin = new URL(pageUrl).origin;
319
+ for (const link of page.internalLinks) {
320
+ if (!link.href || link.href === "#" || link.href.startsWith("#") || link.href.startsWith("javascript:")) continue;
321
+ if (/^\[dynamic/i.test(link.href)) continue;
322
+ const target = new URL(link.href, pageUrl);
323
+ if (target.origin !== origin) continue;
324
+ target.hash = "";
325
+ try {
326
+ let response = await fetcher(target, { method: "HEAD", redirect: "manual" });
327
+ if (response.status === 405) response = await fetcher(target, { method: "GET", redirect: "manual" });
328
+ if (response.status >= 400) brokenLinks.push(link.href);
329
+ } catch {
330
+ brokenLinks.push(link.href);
331
+ }
332
+ }
333
+ const results = [];
334
+ if (brokenLinks.length > 0) {
335
+ results.push({
336
+ checkId: "link-broken-internal",
337
+ name: "Broken Internal Links",
338
+ severity: "critical",
339
+ passed: false,
340
+ file: page.filePath,
341
+ message: `${brokenLinks.length} internal link(s) returned an error from the local server`,
342
+ fix: "Fix or remove links to pages that do not resolve successfully",
343
+ details: [...new Set(brokenLinks)]
344
+ });
345
+ }
346
+ if (emptyLinks.length > 0) {
347
+ results.push({
348
+ checkId: "link-empty-href",
349
+ name: "Empty Link Href",
350
+ severity: "warning",
351
+ passed: false,
352
+ file: page.filePath,
353
+ message: `${emptyLinks.length} link(s) have empty or "#" href`,
354
+ fix: "Add proper href values to all links or use buttons for non-navigation actions"
355
+ });
356
+ }
357
+ return results;
358
+ }
312
359
 
313
360
  // src/checks/schema.ts
314
361
  function checkSchema(page) {
@@ -347,6 +394,9 @@ function checkSchemaJsonValidity(page) {
347
394
  while ((match = jsonLdRegex.exec(page.html)) !== null) {
348
395
  index++;
349
396
  const content = match[1].trim();
397
+ if (page.isSourceFile && /\{\s*JSON\.stringify\s*\(|\[dynamic(?::|\])/i.test(content)) {
398
+ continue;
399
+ }
350
400
  if (!content) {
351
401
  results.push({
352
402
  checkId: "schema-empty",
@@ -831,11 +881,11 @@ function checkAccessibility(page) {
831
881
  const buttonsWithoutText = [];
832
882
  $("button").each((_, el) => {
833
883
  const $btn = $(el);
834
- const text = $btn.text().trim();
884
+ const text2 = $btn.text().trim();
835
885
  const ariaLabel = $btn.attr("aria-label");
836
886
  const ariaLabelledby = $btn.attr("aria-labelledby");
837
887
  const title = $btn.attr("title");
838
- if (!text && !ariaLabel && !ariaLabelledby && !title) {
888
+ if (!text2 && !ariaLabel && !ariaLabelledby && !title) {
839
889
  const className = $btn.attr("class") || "";
840
890
  const id = $btn.attr("id") || "";
841
891
  buttonsWithoutText.push(className || id || "unnamed button");
@@ -856,12 +906,12 @@ function checkAccessibility(page) {
856
906
  const linksWithoutText = [];
857
907
  $("a[href]").each((_, el) => {
858
908
  const $link = $(el);
859
- const text = $link.text().trim();
909
+ const text2 = $link.text().trim();
860
910
  const ariaLabel = $link.attr("aria-label");
861
911
  const ariaLabelledby = $link.attr("aria-labelledby");
862
912
  const title = $link.attr("title");
863
913
  const hasImg = $link.find("img[alt]").length > 0;
864
- if (!text && !ariaLabel && !ariaLabelledby && !title && !hasImg) {
914
+ if (!text2 && !ariaLabel && !ariaLabelledby && !title && !hasImg) {
865
915
  const href = $link.attr("href") || "";
866
916
  linksWithoutText.push(href.substring(0, 50));
867
917
  }
@@ -953,8 +1003,8 @@ function checkAccessibility(page) {
953
1003
  }
954
1004
  const emptyTableHeaders = [];
955
1005
  $("th").each((index, el) => {
956
- const text = $(el).text().trim();
957
- if (!text) {
1006
+ const text2 = $(el).text().trim();
1007
+ if (!text2) {
958
1008
  emptyTableHeaders.push(index + 1);
959
1009
  }
960
1010
  });
@@ -1027,6 +1077,61 @@ function checkFocusIndicators(page) {
1027
1077
  return results;
1028
1078
  }
1029
1079
 
1080
+ // src/lib/provenance.ts
1081
+ var RULE_PATTERNS = [
1082
+ [/^meta-title-(?:length|duplicate)$/, /<title\b|\btitle\s*:/i],
1083
+ [/^meta-description-(?:length|duplicate)$/, /<meta\b[^>]*(?:name|property)=["'](?:description|og:description)["']|\bdescription\s*:/i],
1084
+ [/^h1-multiple$/, /<h1\b|^#\s+/im],
1085
+ [/^heading-hierarchy$/, /<h[1-6]\b|^#{1,6}\s+/im],
1086
+ [/^image-alt-/, /<(?:img|Image|NuxtImg)\b|^\s*img\(/im],
1087
+ [/^link-(?:empty-href|broken-internal)$/, /<(?:a|Link|NuxtLink)\b|^\s*a\(/im],
1088
+ [/^schema-/, /<script\b[^>]*application\/ld\+json|["']@context["']|["']@type["']/i],
1089
+ [/^og-/, /<meta\b[^>]*property=["']og:/i],
1090
+ [/^twitter-/, /<meta\b[^>]*(?:name|property)=["']twitter:/i],
1091
+ [/^robots-/, /<meta\b[^>]*name=["']robots["']/i],
1092
+ [/^a11y-multiple-main$/, /<(?:main\b|[^>]+role=["']main["'])/i],
1093
+ [/^a11y-button-name$/, /<button\b/i],
1094
+ [/^a11y-link-name$/, /<a\b/i],
1095
+ [/^a11y-input-label$/, /<(?:input|textarea|select)\b/i],
1096
+ [/^a11y-tabindex-positive$/, /\btabindex\s*=\s*["']?[1-9]/i],
1097
+ [/^a11y-autoplay-audio$/, /<video\b[^>]*autoplay/i],
1098
+ [/^a11y-empty-th$/, /<th\b/i],
1099
+ [/^a11y-color-contrast$/, /\bcolor\s*:/i],
1100
+ [/^a11y-focus-outline$/, /\boutline\s*:\s*(?:none|0)/i]
1101
+ ];
1102
+ function escapeRegExp(value) {
1103
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1104
+ }
1105
+ function detailPattern(result) {
1106
+ const detail = result.details?.find((value) => value && value !== "unnamed" && !/^\d+$/.test(value));
1107
+ if (!detail) return void 0;
1108
+ if (result.checkId.startsWith("image-alt-")) {
1109
+ return new RegExp(`(?:<(?:img|Image|NuxtImg)\\b[^>]*\\bsrc\\s*=\\s*["']${escapeRegExp(detail)}["']|^\\s*img\\([^\\n]*\\bsrc\\s*=\\s*["']${escapeRegExp(detail)}["'])`, "im");
1110
+ }
1111
+ if (result.checkId.startsWith("link-") || result.checkId === "a11y-link-name") {
1112
+ return new RegExp(`<(?:a|Link|NuxtLink)\\b[^>]*\\bhref\\s*=\\s*["']${escapeRegExp(detail)}["']`, "i");
1113
+ }
1114
+ if (result.checkId === "heading-hierarchy") {
1115
+ const quoted = detail.match(/["'](.+?)["']/)?.[1]?.replace(/\.\.\.$/, "");
1116
+ if (quoted) return new RegExp(escapeRegExp(quoted), "i");
1117
+ }
1118
+ return void 0;
1119
+ }
1120
+ function offsetToLocation(source, offset) {
1121
+ const before = source.slice(0, offset);
1122
+ const lines = before.split("\n");
1123
+ return { line: lines.length, column: lines[lines.length - 1].length + 1 };
1124
+ }
1125
+ function locateFinding(page, result) {
1126
+ if (result.passed || result.line) return result;
1127
+ const source = page.sourceContent ?? page.html;
1128
+ const pattern = detailPattern(result) ?? RULE_PATTERNS.find(([rule]) => rule.test(result.checkId))?.[1];
1129
+ if (!pattern) return result;
1130
+ const match = pattern.exec(source);
1131
+ if (!match) return result;
1132
+ return { ...result, ...offsetToLocation(source, match.index) };
1133
+ }
1134
+
1030
1135
  // src/checks/index.ts
1031
1136
  var checks = [
1032
1137
  // Meta tags
@@ -1035,14 +1140,14 @@ var checks = [
1035
1140
  name: "Title Tag",
1036
1141
  description: "Check for missing, short, long, or duplicate title tags",
1037
1142
  severity: "critical",
1038
- run: (page, allPages) => checkMetaTitle(page, allPages)
1143
+ run: (page, allPages, _basePath, thresholds) => checkMetaTitle(page, allPages, thresholds)
1039
1144
  },
1040
1145
  {
1041
1146
  id: "meta-description",
1042
1147
  name: "Meta Description",
1043
1148
  description: "Check for missing, short, long, or duplicate meta descriptions",
1044
1149
  severity: "warning",
1045
- run: (page, allPages) => checkMetaDescription(page, allPages)
1150
+ run: (page, allPages, _basePath, thresholds) => checkMetaDescription(page, allPages, thresholds)
1046
1151
  },
1047
1152
  {
1048
1153
  id: "viewport",
@@ -1177,13 +1282,13 @@ var checks = [
1177
1282
  run: (page) => checkFocusIndicators(page)
1178
1283
  }
1179
1284
  ];
1180
- function runChecks(page, allPages, basePath, enabledChecks) {
1285
+ function runChecks(page, allPages, basePath, enabledChecks, thresholds) {
1181
1286
  const results = [];
1182
1287
  for (const check of checks) {
1183
1288
  if (enabledChecks && !enabledChecks.has(check.id)) {
1184
1289
  continue;
1185
1290
  }
1186
- const checkResults = check.run(page, allPages, basePath);
1291
+ const checkResults = check.run(page, allPages, basePath, thresholds);
1187
1292
  if (checkResults.every((r) => r.passed)) {
1188
1293
  results.push({
1189
1294
  checkId: check.id,
@@ -1194,7 +1299,7 @@ function runChecks(page, allPages, basePath, enabledChecks) {
1194
1299
  message: `${check.name}: no issues found`
1195
1300
  });
1196
1301
  }
1197
- results.push(...checkResults);
1302
+ results.push(...checkResults.map((result) => locateFinding(page, result)));
1198
1303
  }
1199
1304
  return results;
1200
1305
  }
@@ -1242,15 +1347,15 @@ function parseHtml(filePath, html) {
1242
1347
  const lang = $("html").attr("lang")?.trim() || void 0;
1243
1348
  const h1s = [];
1244
1349
  $("h1").each((_, el) => {
1245
- const text = $(el).text().trim();
1246
- if (text) h1s.push(text);
1350
+ const text2 = $(el).text().trim();
1351
+ if (text2) h1s.push(text2);
1247
1352
  });
1248
1353
  const headings = [];
1249
1354
  $("h1, h2, h3, h4, h5, h6").each((_, el) => {
1250
1355
  const tagName = el.tagName.toLowerCase();
1251
1356
  const level = parseInt(tagName.replace("h", ""), 10);
1252
- const text = $(el).text().trim();
1253
- if (text) headings.push({ level, text });
1357
+ const text2 = $(el).text().trim();
1358
+ if (text2) headings.push({ level, text: text2 });
1254
1359
  });
1255
1360
  const images = [];
1256
1361
  $("img").each((_, el) => {
@@ -1262,14 +1367,14 @@ function parseHtml(filePath, html) {
1262
1367
  const externalLinks = [];
1263
1368
  $("a[href]").each((_, el) => {
1264
1369
  const href = $(el).attr("href") || "";
1265
- const text = $(el).text().trim();
1370
+ const text2 = $(el).text().trim();
1266
1371
  if (href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) {
1267
1372
  return;
1268
1373
  }
1269
1374
  if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) {
1270
- externalLinks.push({ href, text });
1375
+ externalLinks.push({ href, text: text2 });
1271
1376
  } else {
1272
- internalLinks.push({ href, text });
1377
+ internalLinks.push({ href, text: text2 });
1273
1378
  }
1274
1379
  });
1275
1380
  const jsonLd = [];
@@ -1303,6 +1408,7 @@ function parseHtml(filePath, html) {
1303
1408
  return {
1304
1409
  filePath,
1305
1410
  html,
1411
+ sourceContent: html,
1306
1412
  title,
1307
1413
  metaDescription,
1308
1414
  canonical,
@@ -1323,7 +1429,7 @@ function parseHtml(filePath, html) {
1323
1429
  }
1324
1430
  function parseSourceFile(filePath, content) {
1325
1431
  const ext = filePath.toLowerCase().split(".").pop() || "";
1326
- let htmlContent = content;
1432
+ let htmlContent;
1327
1433
  switch (ext) {
1328
1434
  case "astro":
1329
1435
  htmlContent = parseAstroFile(content);
@@ -1466,7 +1572,7 @@ function parsePugFile(content) {
1466
1572
  }
1467
1573
  const tagMatch = trimmed.match(/^(h[1-6]|p|a|img|title|meta)([.#(][^\s]*)?\s*(.*)?$/);
1468
1574
  if (tagMatch) {
1469
- const [, tag, attrs, text] = tagMatch;
1575
+ const [, tag, attrs, text2] = tagMatch;
1470
1576
  if (tag === "img") {
1471
1577
  const srcMatch = (attrs || "").match(/src=['"]([^'"]+)['"]/);
1472
1578
  const altMatch = (attrs || "").match(/alt=['"]([^'"]*)['"]/);
@@ -1474,16 +1580,16 @@ function parsePugFile(content) {
1474
1580
  `;
1475
1581
  } else if (tag === "a") {
1476
1582
  const hrefMatch = (attrs || "").match(/href=['"]([^'"]+)['"]/);
1477
- htmlContent += `<a href="${hrefMatch?.[1] || ""}">${text || ""}</a>
1583
+ htmlContent += `<a href="${hrefMatch?.[1] || ""}">${text2 || ""}</a>
1478
1584
  `;
1479
1585
  } else if (tag === "title") {
1480
- htmlContent += `<title>${text || ""}</title>
1586
+ htmlContent += `<title>${text2 || ""}</title>
1481
1587
  `;
1482
1588
  } else if (tag === "meta") {
1483
1589
  htmlContent += `<meta ${attrs?.replace(/[()]/g, "") || ""}>
1484
1590
  `;
1485
1591
  } else {
1486
- htmlContent += `<${tag}>${text || ""}</${tag}>
1592
+ htmlContent += `<${tag}>${text2 || ""}</${tag}>
1487
1593
  `;
1488
1594
  }
1489
1595
  }
@@ -1506,7 +1612,7 @@ function convertMarkdownLinks(content) {
1506
1612
  return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
1507
1613
  }
1508
1614
  function extractJsxContent(content) {
1509
- let jsxContent = "";
1615
+ let jsxContent;
1510
1616
  const withoutImports = content.replace(/^import\s+.*?;?\s*$/gm, "");
1511
1617
  const returnBlocks = [];
1512
1618
  const standardReturns = withoutImports.match(/return\s*\([\s\S]*?\n\s*\);/g);
@@ -1631,12 +1737,12 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
1631
1737
  const $ = cheerio2.load(htmlContent, { xml: false });
1632
1738
  const h1s = [];
1633
1739
  $("h1").each((_, el) => {
1634
- let text = $(el).text().trim();
1740
+ let text2 = $(el).text().trim();
1635
1741
  const html = $(el).html() || "";
1636
1742
  if (html.includes("{") && html.includes("}")) {
1637
- text = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
1743
+ text2 = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
1638
1744
  }
1639
- if (text) h1s.push(text);
1745
+ if (text2) h1s.push(text2);
1640
1746
  });
1641
1747
  const mdH1Match = originalContent.match(/^#\s+(.+)$/m);
1642
1748
  if (mdH1Match && h1s.length === 0) {
@@ -1646,12 +1752,12 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
1646
1752
  $("h1, h2, h3, h4, h5, h6").each((_, el) => {
1647
1753
  const tagName = el.tagName.toLowerCase();
1648
1754
  const level = parseInt(tagName.replace("h", ""), 10);
1649
- let text = $(el).text().trim();
1755
+ let text2 = $(el).text().trim();
1650
1756
  const html = $(el).html() || "";
1651
1757
  if (html.includes("{") && html.includes("}")) {
1652
- text = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
1758
+ text2 = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
1653
1759
  }
1654
- if (text) headings.push({ level, text });
1760
+ if (text2) headings.push({ level, text: text2 });
1655
1761
  });
1656
1762
  const images = [];
1657
1763
  $("img").each((_, el) => {
@@ -1672,7 +1778,7 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
1672
1778
  const externalLinks = [];
1673
1779
  $("a[href], Link[href], Link[to]").each((_, el) => {
1674
1780
  let href = $(el).attr("href") || $(el).attr("to") || "";
1675
- const text = $(el).text().trim();
1781
+ const text2 = $(el).text().trim();
1676
1782
  if (!href || href === "#" || href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) {
1677
1783
  return;
1678
1784
  }
@@ -1680,9 +1786,9 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
1680
1786
  href = `[dynamic: ${href}]`;
1681
1787
  }
1682
1788
  if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) {
1683
- externalLinks.push({ href, text });
1789
+ externalLinks.push({ href, text: text2 });
1684
1790
  } else {
1685
- internalLinks.push({ href, text });
1791
+ internalLinks.push({ href, text: text2 });
1686
1792
  }
1687
1793
  });
1688
1794
  let title;
@@ -1707,9 +1813,21 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
1707
1813
  metaDescription = descMatch[1].trim();
1708
1814
  }
1709
1815
  }
1816
+ const jsonLd = [];
1817
+ $('script[type="application/ld+json"]').each((_, el) => {
1818
+ const value = ($(el).html() ?? "").trim();
1819
+ if (!value || /^(?:\{|\[)?\s*JSON\.stringify\s*\(/.test(value.replace(/^\{/, ""))) return;
1820
+ try {
1821
+ const parsed = JSON.parse(value);
1822
+ if (Array.isArray(parsed)) jsonLd.push(...parsed.filter((item) => item && typeof item === "object"));
1823
+ else if (parsed && typeof parsed === "object") jsonLd.push(parsed);
1824
+ } catch {
1825
+ }
1826
+ });
1710
1827
  return {
1711
1828
  filePath,
1712
1829
  html: originalContent,
1830
+ sourceContent: originalContent,
1713
1831
  title,
1714
1832
  metaDescription,
1715
1833
  canonical: void 0,
@@ -1721,7 +1839,7 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
1721
1839
  images,
1722
1840
  internalLinks,
1723
1841
  externalLinks,
1724
- jsonLd: [],
1842
+ jsonLd,
1725
1843
  ogTags: {},
1726
1844
  twitterTags: {},
1727
1845
  robots: void 0,
@@ -1733,6 +1851,7 @@ function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
1733
1851
  // src/lib/config.ts
1734
1852
  import * as fs2 from "fs";
1735
1853
  import * as path2 from "path";
1854
+ import { pathToFileURL } from "url";
1736
1855
  var CONFIG_FILES = [
1737
1856
  "rankture.config.json",
1738
1857
  "rankture.config.js",
@@ -1752,13 +1871,16 @@ var defaultConfig = {
1752
1871
  };
1753
1872
  async function loadConfig(basePath, configPath) {
1754
1873
  if (configPath) {
1755
- const absolutePath = path2.resolve(basePath, configPath);
1874
+ const absolutePath = path2.resolve(configPath);
1756
1875
  if (!fs2.existsSync(absolutePath)) {
1757
1876
  throw new Error(`Config file not found: ${absolutePath}`);
1758
1877
  }
1759
1878
  return parseConfigFile(absolutePath);
1760
1879
  }
1761
1880
  let currentDir = path2.resolve(basePath);
1881
+ if (fs2.existsSync(currentDir) && fs2.statSync(currentDir).isFile()) {
1882
+ currentDir = path2.dirname(currentDir);
1883
+ }
1762
1884
  const root = path2.parse(currentDir).root;
1763
1885
  while (currentDir !== root) {
1764
1886
  for (const configName of CONFIG_FILES) {
@@ -1772,6 +1894,7 @@ async function loadConfig(basePath, configPath) {
1772
1894
  try {
1773
1895
  const packageJson = JSON.parse(fs2.readFileSync(packageJsonPath, "utf-8"));
1774
1896
  if (packageJson.rankture) {
1897
+ assertValidConfig(packageJson.rankture, packageJsonPath);
1775
1898
  return mergeConfig(defaultConfig, packageJson.rankture);
1776
1899
  }
1777
1900
  } catch {
@@ -1785,18 +1908,21 @@ async function parseConfigFile(filePath) {
1785
1908
  const ext = path2.extname(filePath).toLowerCase();
1786
1909
  if (ext === ".js" || ext === ".mjs") {
1787
1910
  try {
1788
- const config = await import(filePath);
1789
- return mergeConfig(defaultConfig, config.default || config);
1911
+ const imported = await import(`${pathToFileURL(filePath).href}?t=${fs2.statSync(filePath).mtimeMs}`);
1912
+ const config = imported.default || imported;
1913
+ assertValidConfig(config, filePath);
1914
+ return mergeConfig(defaultConfig, config);
1790
1915
  } catch (error) {
1791
- throw new Error(`Failed to load config from ${filePath}: ${error}`);
1916
+ throw new Error(`Failed to load config from ${filePath}: ${error}`, { cause: error });
1792
1917
  }
1793
1918
  } else {
1794
1919
  try {
1795
1920
  const content = fs2.readFileSync(filePath, "utf-8");
1796
1921
  const config = JSON.parse(content);
1922
+ assertValidConfig(config, filePath);
1797
1923
  return mergeConfig(defaultConfig, config);
1798
1924
  } catch (error) {
1799
- throw new Error(`Failed to parse config from ${filePath}: ${error}`);
1925
+ throw new Error(`Failed to parse config from ${filePath}: ${error}`, { cause: error });
1800
1926
  }
1801
1927
  }
1802
1928
  }
@@ -1809,10 +1935,105 @@ function mergeConfig(defaults, userConfig) {
1809
1935
  rules: { ...defaults.rules, ...userConfig.rules },
1810
1936
  thresholds: {
1811
1937
  ...defaults.thresholds,
1812
- ...userConfig.thresholds
1938
+ ...userConfig.thresholds,
1939
+ titleLength: {
1940
+ ...defaults.thresholds?.titleLength ?? { min: 30, max: 60 },
1941
+ ...userConfig.thresholds?.titleLength
1942
+ },
1943
+ descriptionLength: {
1944
+ ...defaults.thresholds?.descriptionLength ?? { min: 120, max: 160 },
1945
+ ...userConfig.thresholds?.descriptionLength
1946
+ }
1813
1947
  }
1814
1948
  };
1815
1949
  }
1950
+ function validateConfig(config) {
1951
+ return getConfigErrors(config).length === 0;
1952
+ }
1953
+ function getConfigErrors(config) {
1954
+ const errors = [];
1955
+ if (typeof config !== "object" || config === null) {
1956
+ return ["configuration must export an object"];
1957
+ }
1958
+ const c = config;
1959
+ if (c.checks !== void 0) {
1960
+ if (typeof c.checks !== "object" || c.checks === null) {
1961
+ errors.push("checks must be an object whose values are booleans");
1962
+ } else {
1963
+ for (const [key, value] of Object.entries(c.checks)) {
1964
+ if (typeof value !== "boolean") errors.push(`checks.${key} must be true or false`);
1965
+ }
1966
+ }
1967
+ }
1968
+ if (c.ignore !== void 0) {
1969
+ if (!Array.isArray(c.ignore)) {
1970
+ errors.push("ignore must be an array of glob strings");
1971
+ }
1972
+ if (Array.isArray(c.ignore) && !c.ignore.every((item) => typeof item === "string")) {
1973
+ errors.push("ignore must contain only strings");
1974
+ }
1975
+ }
1976
+ if (c.rules !== void 0) {
1977
+ if (typeof c.rules !== "object" || c.rules === null) {
1978
+ errors.push("rules must be an object of critical, warning, or info severities");
1979
+ }
1980
+ const validSeverities = ["critical", "warning", "info"];
1981
+ for (const value of Object.values(c.rules)) {
1982
+ if (!validSeverities.includes(value)) {
1983
+ errors.push(`rules contains invalid severity "${String(value)}"`);
1984
+ }
1985
+ }
1986
+ }
1987
+ if (c.thresholds !== void 0) {
1988
+ if (typeof c.thresholds !== "object" || c.thresholds === null || Array.isArray(c.thresholds)) {
1989
+ errors.push("thresholds must be an object");
1990
+ } else {
1991
+ const thresholds = c.thresholds;
1992
+ for (const name of ["titleLength", "descriptionLength"]) {
1993
+ const value = thresholds[name];
1994
+ if (value === void 0) continue;
1995
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1996
+ errors.push(`thresholds.${name} must contain numeric min and max values`);
1997
+ continue;
1998
+ }
1999
+ const range = value;
2000
+ if (!Number.isFinite(range.min) || !Number.isFinite(range.max)) {
2001
+ errors.push(`thresholds.${name}.min and .max must be finite numbers`);
2002
+ } else if (range.min < 0 || range.max < range.min) {
2003
+ errors.push(`thresholds.${name} must satisfy 0 <= min <= max`);
2004
+ }
2005
+ }
2006
+ for (const name of ["maxImageSize", "maxPageSize"]) {
2007
+ const value = thresholds[name];
2008
+ if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) {
2009
+ errors.push(`thresholds.${name} must be a positive number`);
2010
+ }
2011
+ }
2012
+ }
2013
+ }
2014
+ if (c.apiKey !== void 0 && typeof c.apiKey !== "string") errors.push("apiKey must be a string");
2015
+ if (c.sync !== void 0 && typeof c.sync !== "boolean") errors.push("sync must be true or false");
2016
+ if (c.websiteId !== void 0 && typeof c.websiteId !== "string") errors.push("websiteId must be a string");
2017
+ if (c.apiUrl !== void 0) {
2018
+ if (typeof c.apiUrl !== "string") errors.push("apiUrl must be a string");
2019
+ else {
2020
+ try {
2021
+ const value = new URL(c.apiUrl);
2022
+ if (!["http:", "https:"].includes(value.protocol)) errors.push("apiUrl must use http or https");
2023
+ } catch {
2024
+ errors.push("apiUrl must be a valid URL");
2025
+ }
2026
+ }
2027
+ }
2028
+ return errors;
2029
+ }
2030
+ function assertValidConfig(config, source) {
2031
+ const errors = getConfigErrors(config);
2032
+ if (errors.length > 0) {
2033
+ throw new Error(`Invalid Rankture config (${source}):
2034
+ - ${errors.join("\n- ")}`);
2035
+ }
2036
+ }
1816
2037
  function getEnabledChecks(config, allChecks) {
1817
2038
  const enabled = /* @__PURE__ */ new Set();
1818
2039
  for (const checkId of allChecks) {
@@ -1824,143 +2045,764 @@ function getEnabledChecks(config, allChecks) {
1824
2045
  return enabled;
1825
2046
  }
1826
2047
 
1827
- // src/reporters/terminal.ts
1828
- import chalk from "chalk";
1829
- function formatTerminal(report) {
1830
- const lines = [];
1831
- lines.push("");
1832
- lines.push(chalk.bold.cyan(" Rankture SEO Check"));
1833
- lines.push(chalk.gray(" " + "\u2500".repeat(40)));
1834
- lines.push("");
1835
- lines.push(chalk.gray(` Checking ${report.path} (${report.summary.totalFiles} files)...`));
1836
- lines.push("");
1837
- const critical = report.results.filter((r) => !r.passed && r.severity === "critical");
1838
- const warnings = report.results.filter((r) => !r.passed && r.severity === "warning");
1839
- const info = report.results.filter((r) => !r.passed && r.severity === "info");
1840
- if (critical.length > 0) {
1841
- for (const result of critical) {
1842
- lines.push(formatResult(result));
1843
- }
2048
+ // src/lib/baseline.ts
2049
+ import * as fs3 from "fs";
2050
+ import * as path3 from "path";
2051
+ function issueKey(result, basePath) {
2052
+ const file = result.file ? path3.relative(basePath, path3.resolve(basePath, result.file)).split(path3.sep).join("/") : "";
2053
+ return `${result.checkId}|${file}`;
2054
+ }
2055
+ function buildBaseline(failures, basePath, timestamp) {
2056
+ const issues = {};
2057
+ for (const failure of failures) {
2058
+ const key = issueKey(failure, basePath);
2059
+ issues[key] = (issues[key] || 0) + 1;
1844
2060
  }
1845
- if (warnings.length > 0) {
1846
- for (const result of warnings) {
1847
- lines.push(formatResult(result));
1848
- }
2061
+ return { version: 1, createdAt: timestamp, issues };
2062
+ }
2063
+ function loadBaseline(filePath) {
2064
+ const absolute = path3.resolve(filePath);
2065
+ if (!fs3.existsSync(absolute)) {
2066
+ throw new Error(`Baseline file not found: ${absolute} (create it with --update-baseline)`);
1849
2067
  }
1850
- if (info.length > 0) {
1851
- for (const result of info) {
1852
- lines.push(formatResult(result));
2068
+ const parsed = JSON.parse(fs3.readFileSync(absolute, "utf-8"));
2069
+ if (parsed.version !== 1 || typeof parsed.issues !== "object" || parsed.issues === null) {
2070
+ throw new Error(`Unrecognized baseline format in ${absolute}`);
2071
+ }
2072
+ return parsed;
2073
+ }
2074
+ function saveBaseline(filePath, baseline) {
2075
+ fs3.writeFileSync(path3.resolve(filePath), JSON.stringify(baseline, null, 2) + "\n");
2076
+ }
2077
+ function applyBaseline(failures, baseline, basePath) {
2078
+ const remaining = new Map(Object.entries(baseline.issues));
2079
+ const fresh = [];
2080
+ let suppressed = 0;
2081
+ for (const failure of failures) {
2082
+ const key = issueKey(failure, basePath);
2083
+ const allowance = remaining.get(key) || 0;
2084
+ if (allowance > 0) {
2085
+ remaining.set(key, allowance - 1);
2086
+ suppressed++;
2087
+ } else {
2088
+ fresh.push(failure);
1853
2089
  }
1854
2090
  }
1855
- lines.push("");
1856
- lines.push(chalk.gray(" " + "\u2500".repeat(40)));
1857
- const summaryParts = [];
1858
- if (report.summary.critical > 0) {
1859
- summaryParts.push(chalk.red.bold(`${report.summary.critical} critical`));
2091
+ return { fresh, suppressed };
2092
+ }
2093
+
2094
+ // src/lib/ci-env.ts
2095
+ import { execSync } from "child_process";
2096
+ function collectCiMetadata(env = process.env, cwd) {
2097
+ if (env.GITHUB_ACTIONS === "true") {
2098
+ const prMatch = env.GITHUB_REF?.match(/^refs\/pull\/(\d+)\//);
2099
+ return withoutEmpty({
2100
+ provider: "github-actions",
2101
+ commit: env.GITHUB_SHA,
2102
+ branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
2103
+ pullRequest: prMatch ? Number(prMatch[1]) : void 0,
2104
+ 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
2105
+ });
1860
2106
  }
1861
- if (report.summary.warning > 0) {
1862
- summaryParts.push(chalk.yellow.bold(`${report.summary.warning} warnings`));
2107
+ if (env.GITLAB_CI === "true") {
2108
+ return withoutEmpty({
2109
+ provider: "gitlab-ci",
2110
+ commit: env.CI_COMMIT_SHA,
2111
+ branch: env.CI_COMMIT_REF_NAME,
2112
+ pullRequest: env.CI_MERGE_REQUEST_IID ? Number(env.CI_MERGE_REQUEST_IID) : void 0,
2113
+ buildUrl: env.CI_JOB_URL
2114
+ });
1863
2115
  }
1864
- if (report.summary.info > 0) {
1865
- summaryParts.push(chalk.blue(`${report.summary.info} info`));
2116
+ if (env.VERCEL === "1") {
2117
+ return withoutEmpty({
2118
+ provider: "vercel",
2119
+ commit: env.VERCEL_GIT_COMMIT_SHA,
2120
+ branch: env.VERCEL_GIT_COMMIT_REF
2121
+ });
1866
2122
  }
1867
- summaryParts.push(chalk.green(`${report.summary.passed} passed`));
1868
- lines.push(` Results: ${summaryParts.join(", ")}`);
1869
- lines.push("");
1870
- if (report.summary.critical > 0) {
1871
- lines.push(chalk.red.bold(" \u2717 Check failed (critical issues found)"));
1872
- } else if (report.summary.warning > 0) {
1873
- lines.push(chalk.yellow(" \u26A0 Check completed with warnings"));
1874
- } else {
1875
- lines.push(chalk.green.bold(" \u2713 All checks passed!"));
2123
+ try {
2124
+ const git = (args) => execSync(`git ${args}`, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2125
+ const commit = git("rev-parse HEAD");
2126
+ const branch = git("rev-parse --abbrev-ref HEAD");
2127
+ if (commit) {
2128
+ return { provider: "git", commit, branch: branch !== "HEAD" ? branch : void 0 };
2129
+ }
2130
+ } catch {
1876
2131
  }
1877
- lines.push("");
1878
- return lines.join("\n");
2132
+ return void 0;
1879
2133
  }
1880
- function formatResult(result) {
1881
- const lines = [];
1882
- let icon;
1883
- let color;
1884
- switch (result.severity) {
1885
- case "critical":
1886
- icon = "\u2717";
1887
- color = chalk.red;
1888
- break;
1889
- case "warning":
1890
- icon = "\u26A0";
1891
- color = chalk.yellow;
1892
- break;
1893
- case "info":
1894
- default:
1895
- icon = "\u2139";
1896
- color = chalk.blue;
1897
- break;
1898
- }
1899
- lines.push(
1900
- ` ${color(icon)} ${color.bold(result.severity.toUpperCase().padEnd(8))} ${chalk.gray(result.checkId)}`
2134
+ function withoutEmpty(meta) {
2135
+ return Object.fromEntries(
2136
+ Object.entries(meta).filter(([, value]) => value !== void 0 && value !== "")
1901
2137
  );
1902
- if (result.file) {
1903
- lines.push(` ${chalk.cyan(result.file)} - ${result.message}`);
1904
- } else {
1905
- lines.push(` ${result.message}`);
1906
- }
1907
- if (result.details && result.details.length > 0) {
1908
- const maxDetails = 5;
1909
- const showDetails = result.details.slice(0, maxDetails);
1910
- for (const detail of showDetails) {
1911
- lines.push(chalk.gray(` - ${detail}`));
1912
- }
1913
- if (result.details.length > maxDetails) {
1914
- lines.push(chalk.gray(` ... and ${result.details.length - maxDetails} more`));
2138
+ }
2139
+
2140
+ // src/lib/static-server.ts
2141
+ import * as fs4 from "fs";
2142
+ import * as http from "http";
2143
+ import * as path4 from "path";
2144
+ var CONTENT_TYPES = {
2145
+ ".css": "text/css; charset=utf-8",
2146
+ ".html": "text/html; charset=utf-8",
2147
+ ".js": "text/javascript; charset=utf-8",
2148
+ ".json": "application/json; charset=utf-8",
2149
+ ".svg": "image/svg+xml",
2150
+ ".txt": "text/plain; charset=utf-8",
2151
+ ".xml": "application/xml; charset=utf-8"
2152
+ };
2153
+ function resolveRequest(root, pathname) {
2154
+ let decoded;
2155
+ try {
2156
+ decoded = decodeURIComponent(pathname);
2157
+ } catch {
2158
+ return void 0;
2159
+ }
2160
+ const relative6 = decoded.replace(/^\/+/, "");
2161
+ const candidates = decoded.endsWith("/") ? [path4.join(relative6, "index.html")] : [relative6, `${relative6}.html`, path4.join(relative6, "index.html")];
2162
+ for (const candidate of candidates) {
2163
+ const resolved = path4.resolve(root, candidate || "index.html");
2164
+ if (resolved !== root && !resolved.startsWith(`${root}${path4.sep}`)) continue;
2165
+ if (fs4.existsSync(resolved) && fs4.statSync(resolved).isFile()) return resolved;
2166
+ }
2167
+ return void 0;
2168
+ }
2169
+ async function startStaticServer(rootPath, port = 0) {
2170
+ const root = path4.resolve(rootPath);
2171
+ const server = http.createServer((request, response) => {
2172
+ const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
2173
+ const file = resolveRequest(root, pathname);
2174
+ if (!file) {
2175
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
2176
+ response.end("Not found");
2177
+ return;
1915
2178
  }
1916
- }
1917
- lines.push("");
1918
- return lines.join("\n");
2179
+ response.writeHead(200, {
2180
+ "Content-Type": CONTENT_TYPES[path4.extname(file).toLowerCase()] ?? "application/octet-stream",
2181
+ "Content-Length": fs4.statSync(file).size
2182
+ });
2183
+ if (request.method === "HEAD") response.end();
2184
+ else fs4.createReadStream(file).pipe(response);
2185
+ });
2186
+ await new Promise((resolve10, reject) => {
2187
+ server.once("error", reject);
2188
+ server.listen(port, "127.0.0.1", resolve10);
2189
+ });
2190
+ const address = server.address();
2191
+ if (!address || typeof address === "string") throw new Error("Unable to determine local server address");
2192
+ return {
2193
+ origin: `http://127.0.0.1:${address.port}`,
2194
+ close: () => new Promise((resolve10, reject) => server.close((error) => error ? reject(error) : resolve10()))
2195
+ };
2196
+ }
2197
+ function pageUrlForFile(filePath, rootPath, origin) {
2198
+ let relative6 = path4.relative(rootPath, filePath).split(path4.sep).join("/");
2199
+ if (relative6 === "index.html") relative6 = "";
2200
+ else if (relative6.endsWith("/index.html")) relative6 = relative6.slice(0, -"index.html".length);
2201
+ return new URL(`/${relative6}`, origin).href;
1919
2202
  }
1920
2203
 
1921
- // src/reporters/json.ts
1922
- function formatJson(report) {
1923
- return JSON.stringify(report, null, 2);
2204
+ // src/lib/credentials.ts
2205
+ import * as fs5 from "fs";
2206
+ import * as os from "os";
2207
+ import * as path5 from "path";
2208
+ function credentialFile(env = process.env) {
2209
+ const base = env.RANKTURE_CONFIG_DIR || env.XDG_CONFIG_HOME || (process.platform === "win32" ? env.APPDATA : path5.join(os.homedir(), ".config"));
2210
+ return path5.join(base || path5.join(os.homedir(), ".config"), "rankture", "credentials.json");
2211
+ }
2212
+ function saveCredentials(credentials, env = process.env) {
2213
+ const file = credentialFile(env);
2214
+ fs5.mkdirSync(path5.dirname(file), { recursive: true, mode: 448 });
2215
+ fs5.writeFileSync(file, `${JSON.stringify(credentials, null, 2)}
2216
+ `, { mode: 384 });
2217
+ fs5.chmodSync(file, 384);
2218
+ return file;
2219
+ }
2220
+ function loadCredentials(env = process.env) {
2221
+ if (env.RANKTURE_API_KEY) {
2222
+ return { apiKey: env.RANKTURE_API_KEY, apiUrl: env.RANKTURE_API_URL || "https://rankture.com" };
2223
+ }
2224
+ const file = credentialFile(env);
2225
+ if (!fs5.existsSync(file)) return void 0;
2226
+ const value = JSON.parse(fs5.readFileSync(file, "utf8"));
2227
+ if (!value.apiKey || !value.apiUrl) return void 0;
2228
+ return { apiKey: value.apiKey, apiUrl: value.apiUrl.replace(/\/$/, "") };
2229
+ }
2230
+ function clearCredentials(env = process.env) {
2231
+ const file = credentialFile(env);
2232
+ if (!fs5.existsSync(file)) return false;
2233
+ fs5.unlinkSync(file);
2234
+ return true;
1924
2235
  }
1925
2236
 
1926
- // src/reporters/html.ts
1927
- function formatHtml(report) {
1928
- const { summary, results } = report;
1929
- const byFile = /* @__PURE__ */ new Map();
1930
- results.forEach((r) => {
1931
- const file = r.file || "Unknown";
1932
- if (!byFile.has(file)) {
1933
- byFile.set(file, []);
2237
+ // src/lib/api-client.ts
2238
+ import { createHash } from "crypto";
2239
+ function credentialsOrThrow(override) {
2240
+ const stored = loadCredentials();
2241
+ const apiKey = override?.apiKey || stored?.apiKey;
2242
+ const apiUrl = (override?.apiUrl || stored?.apiUrl || "https://rankture.com").replace(/\/$/, "");
2243
+ if (!apiKey) throw new Error("Not logged in. Run: rankture login --api-key <key>");
2244
+ return { apiKey, apiUrl };
2245
+ }
2246
+ async function cliApiRequest(pathname, init = {}, override) {
2247
+ const credentials = credentialsOrThrow(override);
2248
+ const response = await fetch(`${credentials.apiUrl}${pathname}`, {
2249
+ ...init,
2250
+ headers: {
2251
+ Authorization: `Bearer ${credentials.apiKey}`,
2252
+ "Content-Type": "application/json",
2253
+ "User-Agent": "rankture-cli",
2254
+ ...init.headers
1934
2255
  }
1935
- byFile.get(file).push(r);
1936
2256
  });
1937
- const totalChecks = summary.passed + summary.critical + summary.warning + summary.info;
1938
- const score = totalChecks > 0 ? Math.round(summary.passed / totalChecks * 100) : 100;
1939
- const scoreColor = score >= 90 ? "#22c55e" : score >= 70 ? "#eab308" : score >= 50 ? "#f97316" : "#ef4444";
1940
- const html = `<!DOCTYPE html>
1941
- <html lang="en">
1942
- <head>
1943
- <meta charset="UTF-8">
1944
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1945
- <title>SEO Audit Report - ${new Date(report.timestamp).toLocaleDateString()}</title>
1946
- <style>
1947
- * {
1948
- margin: 0;
1949
- padding: 0;
1950
- box-sizing: border-box;
2257
+ const body = await response.json().catch(() => ({}));
2258
+ if (!response.ok) throw new Error(String(body.error || body.message || `Rankture API returned ${response.status}`));
2259
+ return body;
2260
+ }
2261
+ var getIdentity = (override) => cliApiRequest("/api/cli/me", {}, override);
2262
+ var getWebsites = (override) => cliApiRequest("/api/cli/websites", {}, override);
2263
+ var syncCheckReport = (report, websiteId, override) => cliApiRequest("/api/cli/sync", {
2264
+ method: "POST",
2265
+ headers: { "X-Idempotency-Key": createHash("sha256").update(`${websiteId || ""}:${report.timestamp}:${report.ci?.commit || ""}`).digest("hex") },
2266
+ body: JSON.stringify({ schemaVersion: 1, report, websiteId })
2267
+ }, override);
2268
+ var startRemoteAudit = (url, options = {}, override) => cliApiRequest("/api/cli/audits", {
2269
+ method: "POST",
2270
+ body: JSON.stringify({ url, pages: options.pages })
2271
+ }, override);
2272
+ var getRemoteAudit = (auditId, override, full = false) => cliApiRequest(`/api/cli/audits/${encodeURIComponent(auditId)}${full ? "?full=true" : ""}`, {}, override);
2273
+
2274
+ // src/mcp/server.ts
2275
+ import { spawnSync } from "child_process";
2276
+ import * as path7 from "path";
2277
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2278
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2279
+ import * as z from "zod/v4";
2280
+
2281
+ // src/commands/validate.ts
2282
+ import * as fs6 from "fs";
2283
+ import * as path6 from "path";
2284
+ import chalk from "chalk";
2285
+ import { XMLParser } from "fast-xml-parser";
2286
+ async function validateSchema(filePath) {
2287
+ const result = { valid: true, errors: [], warnings: [], info: [] };
2288
+ const absolutePath = path6.resolve(filePath);
2289
+ if (!fs6.existsSync(absolutePath)) {
2290
+ return { ...result, valid: false, errors: [`File not found: ${absolutePath}`] };
2291
+ }
2292
+ const content = fs6.readFileSync(absolutePath, "utf-8");
2293
+ const ext = path6.extname(absolutePath).toLowerCase();
2294
+ if (ext === ".json" || ext === ".jsonld") {
2295
+ try {
2296
+ const parsed = JSON.parse(content);
2297
+ const nodes = Array.isArray(parsed) ? parsed : [parsed];
2298
+ for (const [index, node] of nodes.entries()) {
2299
+ validateSchemaNode(node, `Schema ${index + 1}`, result);
2300
+ }
2301
+ result.info.push(`${nodes.length} JSON-LD schema object(s) checked`);
2302
+ } catch (error) {
2303
+ result.valid = false;
2304
+ result.errors.push(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
1951
2305
  }
1952
-
1953
- body {
1954
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
1955
- background: #f8fafc;
1956
- color: #1e293b;
1957
- line-height: 1.6;
2306
+ return result;
2307
+ }
2308
+ const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
2309
+ if (!allExtensions.includes(ext)) {
2310
+ result.valid = false;
2311
+ result.errors.push(`Unsupported schema input ${ext || "(no extension)"}. Use JSON, HTML, or a supported source template.`);
2312
+ return result;
2313
+ }
2314
+ const page = SUPPORTED_EXTENSIONS.html.includes(ext) ? parseHtml(absolutePath, content) : parseSourceFile(absolutePath, content);
2315
+ const findings = [...checkSchemaJsonValidity(page), ...checkSchema(page)].filter((finding) => !finding.passed);
2316
+ for (const finding of findings) {
2317
+ const message = `${finding.checkId}: ${finding.message}`;
2318
+ if (finding.severity === "critical") {
2319
+ result.valid = false;
2320
+ result.errors.push(message);
2321
+ } else {
2322
+ result.warnings.push(message);
1958
2323
  }
1959
-
1960
- .container {
1961
- max-width: 1200px;
1962
- margin: 0 auto;
1963
- padding: 2rem;
2324
+ }
2325
+ result.info.push(`${page.jsonLd.length} parseable JSON-LD schema object(s) found`);
2326
+ return result;
2327
+ }
2328
+ function validateSchemaNode(node, label, result) {
2329
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
2330
+ result.valid = false;
2331
+ result.errors.push(`${label} must be a JSON object`);
2332
+ return;
2333
+ }
2334
+ const schema = node;
2335
+ if (!schema["@context"]) result.warnings.push(`${label} is missing @context`);
2336
+ if (!schema["@type"] && !Array.isArray(schema["@graph"])) result.warnings.push(`${label} is missing @type`);
2337
+ if (Array.isArray(schema["@graph"])) {
2338
+ schema["@graph"].forEach((entry, index) => validateSchemaNode(entry, `${label} @graph item ${index + 1}`, result));
2339
+ }
2340
+ }
2341
+ async function validateRobotsTxt(filePath) {
2342
+ const result = {
2343
+ valid: true,
2344
+ errors: [],
2345
+ warnings: [],
2346
+ info: []
2347
+ };
2348
+ const absolutePath = path6.resolve(filePath);
2349
+ if (!fs6.existsSync(absolutePath)) {
2350
+ result.valid = false;
2351
+ result.errors.push(`File not found: ${absolutePath}`);
2352
+ return result;
2353
+ }
2354
+ const content = fs6.readFileSync(absolutePath, "utf-8");
2355
+ const lines = content.split("\n");
2356
+ let hasUserAgent = false;
2357
+ let hasSitemap = false;
2358
+ let currentUserAgent = "";
2359
+ let lineNum = 0;
2360
+ for (const rawLine of lines) {
2361
+ lineNum++;
2362
+ const line = rawLine.trim();
2363
+ if (!line || line.startsWith("#")) {
2364
+ continue;
2365
+ }
2366
+ const colonIndex = line.indexOf(":");
2367
+ if (colonIndex === -1) {
2368
+ result.warnings.push(`Line ${lineNum}: Invalid format (missing colon): "${line}"`);
2369
+ continue;
2370
+ }
2371
+ const directive = line.substring(0, colonIndex).trim().toLowerCase();
2372
+ const value = line.substring(colonIndex + 1).trim();
2373
+ switch (directive) {
2374
+ case "user-agent":
2375
+ hasUserAgent = true;
2376
+ currentUserAgent = value;
2377
+ if (!value) {
2378
+ result.errors.push(`Line ${lineNum}: User-agent directive has no value`);
2379
+ result.valid = false;
2380
+ }
2381
+ break;
2382
+ case "disallow":
2383
+ if (!currentUserAgent) {
2384
+ result.errors.push(`Line ${lineNum}: Disallow before User-agent directive`);
2385
+ result.valid = false;
2386
+ }
2387
+ if (value && !value.startsWith("/") && value !== "*") {
2388
+ result.warnings.push(`Line ${lineNum}: Disallow path should start with /`);
2389
+ }
2390
+ break;
2391
+ case "allow":
2392
+ if (!currentUserAgent) {
2393
+ result.errors.push(`Line ${lineNum}: Allow before User-agent directive`);
2394
+ result.valid = false;
2395
+ }
2396
+ if (value && !value.startsWith("/") && value !== "*") {
2397
+ result.warnings.push(`Line ${lineNum}: Allow path should start with /`);
2398
+ }
2399
+ break;
2400
+ case "sitemap":
2401
+ hasSitemap = true;
2402
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
2403
+ result.errors.push(`Line ${lineNum}: Sitemap URL must be absolute (start with http:// or https://)`);
2404
+ result.valid = false;
2405
+ }
2406
+ break;
2407
+ case "crawl-delay": {
2408
+ const delay = parseFloat(value);
2409
+ if (isNaN(delay) || delay < 0) {
2410
+ result.warnings.push(`Line ${lineNum}: Invalid crawl-delay value: "${value}"`);
2411
+ } else if (delay > 10) {
2412
+ result.warnings.push(
2413
+ `Line ${lineNum}: Crawl-delay of ${delay}s is very high - may slow indexing`
2414
+ );
2415
+ }
2416
+ break;
2417
+ }
2418
+ case "host":
2419
+ result.info.push(`Line ${lineNum}: Host directive (Yandex-specific)`);
2420
+ break;
2421
+ case "clean-param":
2422
+ result.info.push(`Line ${lineNum}: Clean-param directive (Yandex-specific)`);
2423
+ break;
2424
+ default:
2425
+ result.warnings.push(`Line ${lineNum}: Unknown directive: "${directive}"`);
2426
+ }
2427
+ }
2428
+ if (!hasUserAgent) {
2429
+ result.errors.push("Missing User-agent directive (required)");
2430
+ result.valid = false;
2431
+ }
2432
+ if (!hasSitemap) {
2433
+ result.warnings.push("No Sitemap directive found - recommended to include sitemap URL");
2434
+ }
2435
+ if (content.includes("Disallow: /") && !content.includes("Allow:")) {
2436
+ const disallowAll = lines.some((l) => {
2437
+ const trimmed = l.trim().toLowerCase();
2438
+ return trimmed === "disallow: /" || trimmed === "disallow:/";
2439
+ });
2440
+ if (disallowAll) {
2441
+ result.warnings.push('Found "Disallow: /" which blocks all crawling - ensure this is intentional');
2442
+ }
2443
+ }
2444
+ result.info.push(`Total lines: ${lines.length}`);
2445
+ result.info.push(`User-agents defined: ${lines.filter((l) => l.trim().toLowerCase().startsWith("user-agent")).length}`);
2446
+ return result;
2447
+ }
2448
+ async function validateSitemap(filePath) {
2449
+ const result = {
2450
+ valid: true,
2451
+ errors: [],
2452
+ warnings: [],
2453
+ info: []
2454
+ };
2455
+ const absolutePath = path6.resolve(filePath);
2456
+ if (!fs6.existsSync(absolutePath)) {
2457
+ result.valid = false;
2458
+ result.errors.push(`File not found: ${absolutePath}`);
2459
+ return result;
2460
+ }
2461
+ const content = fs6.readFileSync(absolutePath, "utf-8");
2462
+ if (!content.trim().startsWith("<?xml")) {
2463
+ result.warnings.push('Missing XML declaration (<?xml version="1.0" encoding="UTF-8"?>)');
2464
+ }
2465
+ const parser = new XMLParser({
2466
+ ignoreAttributes: false,
2467
+ attributeNamePrefix: "@_"
2468
+ });
2469
+ let parsed;
2470
+ try {
2471
+ parsed = parser.parse(content);
2472
+ } catch (error) {
2473
+ result.valid = false;
2474
+ result.errors.push(`Invalid XML: ${error}`);
2475
+ return result;
2476
+ }
2477
+ const isSitemapIndex = !!parsed.sitemapindex;
2478
+ const isUrlset = !!parsed.urlset;
2479
+ if (!isSitemapIndex && !isUrlset) {
2480
+ result.valid = false;
2481
+ result.errors.push("Missing root element: expected <urlset> or <sitemapindex>");
2482
+ return result;
2483
+ }
2484
+ if (isSitemapIndex) {
2485
+ const sitemaps = parsed.sitemapindex.sitemap;
2486
+ const sitemapArray = Array.isArray(sitemaps) ? sitemaps : sitemaps ? [sitemaps] : [];
2487
+ result.info.push(`Sitemap index with ${sitemapArray.length} child sitemap(s)`);
2488
+ for (let i = 0; i < sitemapArray.length; i++) {
2489
+ const sitemap = sitemapArray[i];
2490
+ if (!sitemap.loc) {
2491
+ result.errors.push(`Sitemap ${i + 1}: Missing <loc> element (required)`);
2492
+ result.valid = false;
2493
+ } else if (!sitemap.loc.startsWith("http")) {
2494
+ result.errors.push(`Sitemap ${i + 1}: <loc> must be absolute URL`);
2495
+ result.valid = false;
2496
+ }
2497
+ }
2498
+ } else {
2499
+ const urls = parsed.urlset.url;
2500
+ const urlArray = Array.isArray(urls) ? urls : urls ? [urls] : [];
2501
+ result.info.push(`Sitemap with ${urlArray.length} URL(s)`);
2502
+ if (urlArray.length === 0) {
2503
+ result.warnings.push("Sitemap is empty (no URLs)");
2504
+ }
2505
+ if (urlArray.length > 5e4) {
2506
+ result.errors.push(`Sitemap has ${urlArray.length} URLs - maximum allowed is 50,000`);
2507
+ result.valid = false;
2508
+ }
2509
+ const fileSize = Buffer.byteLength(content, "utf-8");
2510
+ const fileSizeMB = fileSize / (1024 * 1024);
2511
+ if (fileSizeMB > 50) {
2512
+ result.errors.push(`Sitemap is ${fileSizeMB.toFixed(1)}MB - maximum allowed is 50MB`);
2513
+ result.valid = false;
2514
+ }
2515
+ const priorities = /* @__PURE__ */ new Set();
2516
+ const changefreqs = /* @__PURE__ */ new Set();
2517
+ let urlsWithoutLoc = 0;
2518
+ let urlsWithRelativeLoc = 0;
2519
+ let urlsWithLastmod = 0;
2520
+ let urlsWithPriority = 0;
2521
+ let urlsWithChangefreq = 0;
2522
+ const validChangefreqs = ["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"];
2523
+ const seenUrls = /* @__PURE__ */ new Set();
2524
+ const duplicateUrls = [];
2525
+ for (let i = 0; i < Math.min(urlArray.length, 1e3); i++) {
2526
+ const url = urlArray[i];
2527
+ if (!url.loc) {
2528
+ urlsWithoutLoc++;
2529
+ } else {
2530
+ if (seenUrls.has(url.loc)) {
2531
+ duplicateUrls.push(url.loc);
2532
+ }
2533
+ seenUrls.add(url.loc);
2534
+ if (!url.loc.startsWith("http")) {
2535
+ urlsWithRelativeLoc++;
2536
+ }
2537
+ }
2538
+ if (url.lastmod) {
2539
+ urlsWithLastmod++;
2540
+ const datePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)?)?$/;
2541
+ if (!datePattern.test(url.lastmod)) {
2542
+ result.warnings.push(`URL ${i + 1}: Invalid lastmod date format: "${url.lastmod}"`);
2543
+ }
2544
+ }
2545
+ if (url.priority !== void 0) {
2546
+ urlsWithPriority++;
2547
+ priorities.add(String(url.priority));
2548
+ const priority = parseFloat(url.priority);
2549
+ if (isNaN(priority) || priority < 0 || priority > 1) {
2550
+ result.warnings.push(`URL ${i + 1}: Invalid priority value: "${url.priority}" (must be 0.0-1.0)`);
2551
+ }
2552
+ }
2553
+ if (url.changefreq) {
2554
+ urlsWithChangefreq++;
2555
+ changefreqs.add(url.changefreq);
2556
+ if (!validChangefreqs.includes(url.changefreq.toLowerCase())) {
2557
+ result.warnings.push(`URL ${i + 1}: Invalid changefreq: "${url.changefreq}"`);
2558
+ }
2559
+ }
2560
+ }
2561
+ if (urlsWithoutLoc > 0) {
2562
+ result.errors.push(`${urlsWithoutLoc} URL(s) missing required <loc> element`);
2563
+ result.valid = false;
2564
+ }
2565
+ if (urlsWithRelativeLoc > 0) {
2566
+ result.errors.push(`${urlsWithRelativeLoc} URL(s) have relative URLs - must be absolute`);
2567
+ result.valid = false;
2568
+ }
2569
+ if (duplicateUrls.length > 0) {
2570
+ result.warnings.push(`${duplicateUrls.length} duplicate URL(s) found`);
2571
+ if (duplicateUrls.length <= 5) {
2572
+ duplicateUrls.forEach((url) => result.warnings.push(` Duplicate: ${url}`));
2573
+ }
2574
+ }
2575
+ if (urlsWithLastmod === 0) {
2576
+ result.info.push("No lastmod dates - consider adding for better crawling");
2577
+ } else {
2578
+ result.info.push(`${urlsWithLastmod} URL(s) have lastmod dates`);
2579
+ }
2580
+ if (priorities.size === 1) {
2581
+ result.info.push(`All URLs have same priority (${[...priorities][0]}) - consider varying for important pages`);
2582
+ }
2583
+ if (urlsWithPriority > 0 || urlsWithChangefreq > 0) {
2584
+ result.info.push("Note: Google largely ignores priority and changefreq - focus on lastmod instead");
2585
+ }
2586
+ }
2587
+ return result;
2588
+ }
2589
+
2590
+ // src/version.ts
2591
+ var VERSION = "0.1.3";
2592
+
2593
+ // src/mcp/server.ts
2594
+ function safePath(root, requested) {
2595
+ const resolved = path7.resolve(root, requested);
2596
+ if (resolved !== root && !resolved.startsWith(`${root}${path7.sep}`)) {
2597
+ throw new Error(`Path must stay inside MCP root: ${root}`);
2598
+ }
2599
+ return resolved;
2600
+ }
2601
+ var text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
2602
+ function createMcpServer(rootPath = process.cwd(), cliEntry = process.argv[1]) {
2603
+ const root = path7.resolve(rootPath);
2604
+ const server = new McpServer({ name: "rankture", version: VERSION });
2605
+ server.registerTool("check_path", {
2606
+ description: "Run Rankture SEO checks against a file or directory inside the configured project root.",
2607
+ inputSchema: {
2608
+ path: z.string().default("."),
2609
+ config: z.string().optional(),
2610
+ serve: z.boolean().default(false)
2611
+ },
2612
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
2613
+ }, async (input) => {
2614
+ const target = safePath(root, input.path);
2615
+ const args = [cliEntry, "check", target, "--format", "json"];
2616
+ if (input.config) args.push("--config", safePath(root, input.config));
2617
+ if (input.serve) args.push("--serve");
2618
+ const result = spawnSync(process.execPath, args, { cwd: root, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } });
2619
+ if (result.error) throw result.error;
2620
+ if (!result.stdout.trim()) throw new Error(result.stderr.trim() || `Rankture exited ${result.status}`);
2621
+ return text(JSON.parse(result.stdout));
2622
+ });
2623
+ server.registerTool("validate_schema", {
2624
+ description: "Validate a local JSON-LD file or HTML document inside the configured project root.",
2625
+ inputSchema: { file: z.string() },
2626
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
2627
+ }, async ({ file }) => text(await validateSchema(safePath(root, file))));
2628
+ server.registerTool("list_rules", {
2629
+ description: "List Rankture rule groups with their default severity and purpose.",
2630
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
2631
+ }, async () => text(checks.map(({ id, name, description, severity }) => ({ id, name, description, severity }))));
2632
+ server.registerTool("explain_rule", {
2633
+ description: "Explain a Rankture rule group by its stable ID.",
2634
+ inputSchema: { ruleId: z.string() },
2635
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
2636
+ }, async ({ ruleId }) => {
2637
+ const rule = checks.find((candidate) => candidate.id === ruleId || ruleId.startsWith(`${candidate.id}-`));
2638
+ if (!rule) throw new Error(`Unknown Rankture rule: ${ruleId}`);
2639
+ return text({ id: rule.id, name: rule.name, severity: rule.severity, description: rule.description });
2640
+ });
2641
+ return server;
2642
+ }
2643
+ async function startMcpServer(rootPath) {
2644
+ await createMcpServer(rootPath).connect(new StdioServerTransport());
2645
+ }
2646
+
2647
+ // src/integrations.ts
2648
+ import { fileURLToPath } from "url";
2649
+ import * as path10 from "path";
2650
+
2651
+ // src/commands/check.ts
2652
+ import * as fs7 from "fs";
2653
+ import * as path9 from "path";
2654
+ import fg from "fast-glob";
2655
+ import ora from "ora";
2656
+
2657
+ // src/reporters/terminal.ts
2658
+ import chalk2 from "chalk";
2659
+ function formatTerminal(report, opts = {}) {
2660
+ const lines = [];
2661
+ if (!opts.quiet) {
2662
+ lines.push("");
2663
+ lines.push(chalk2.bold.cyan(" Rankture SEO Check"));
2664
+ lines.push(chalk2.gray(" " + "\u2500".repeat(40)));
2665
+ lines.push("");
2666
+ lines.push(chalk2.gray(` Checking ${report.path} (${report.summary.totalFiles} files)...`));
2667
+ lines.push("");
2668
+ }
2669
+ const critical = report.results.filter((r) => !r.passed && r.severity === "critical");
2670
+ const warnings = report.results.filter((r) => !r.passed && r.severity === "warning");
2671
+ const info = opts.quiet ? [] : report.results.filter((r) => !r.passed && r.severity === "info");
2672
+ if (critical.length > 0) {
2673
+ for (const result of critical) {
2674
+ lines.push(formatResult(result));
2675
+ }
2676
+ }
2677
+ if (warnings.length > 0) {
2678
+ for (const result of warnings) {
2679
+ lines.push(formatResult(result));
2680
+ }
2681
+ }
2682
+ if (info.length > 0) {
2683
+ for (const result of info) {
2684
+ lines.push(formatResult(result));
2685
+ }
2686
+ }
2687
+ if (!opts.quiet) {
2688
+ lines.push("");
2689
+ lines.push(chalk2.gray(" " + "\u2500".repeat(40)));
2690
+ }
2691
+ const summaryParts = [];
2692
+ if (report.summary.critical > 0) {
2693
+ summaryParts.push(chalk2.red.bold(`${report.summary.critical} critical`));
2694
+ }
2695
+ if (report.summary.warning > 0) {
2696
+ summaryParts.push(chalk2.yellow.bold(`${report.summary.warning} warnings`));
2697
+ }
2698
+ if (report.summary.info > 0) {
2699
+ summaryParts.push(chalk2.blue(`${report.summary.info} info`));
2700
+ }
2701
+ summaryParts.push(chalk2.green(`${report.summary.passed} passed`));
2702
+ lines.push(` Results: ${summaryParts.join(", ")}`);
2703
+ if (report.baseline) {
2704
+ lines.push(chalk2.gray(` ${report.baseline.suppressed} known issue(s) suppressed by baseline ${report.baseline.file}`));
2705
+ }
2706
+ lines.push("");
2707
+ if (report.summary.critical > 0) {
2708
+ lines.push(chalk2.red.bold(" \u2717 Check failed (critical issues found)"));
2709
+ } else if (report.summary.warning > 0) {
2710
+ lines.push(chalk2.yellow(" \u26A0 Check completed with warnings"));
2711
+ } else {
2712
+ lines.push(chalk2.green.bold(" \u2713 All checks passed!"));
2713
+ }
2714
+ lines.push("");
2715
+ const output = lines.join("\n");
2716
+ const ansiPattern = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g");
2717
+ return opts.noColor ? output.replace(ansiPattern, "") : output;
2718
+ }
2719
+ function formatResult(result) {
2720
+ const lines = [];
2721
+ let icon;
2722
+ let color;
2723
+ switch (result.severity) {
2724
+ case "critical":
2725
+ icon = "\u2717";
2726
+ color = chalk2.red;
2727
+ break;
2728
+ case "warning":
2729
+ icon = "\u26A0";
2730
+ color = chalk2.yellow;
2731
+ break;
2732
+ case "info":
2733
+ default:
2734
+ icon = "\u2139";
2735
+ color = chalk2.blue;
2736
+ break;
2737
+ }
2738
+ lines.push(
2739
+ ` ${color(icon)} ${color.bold(result.severity.toUpperCase().padEnd(8))} ${chalk2.gray(result.checkId)}`
2740
+ );
2741
+ if (result.file) {
2742
+ lines.push(` ${chalk2.cyan(result.file)} - ${result.message}`);
2743
+ } else {
2744
+ lines.push(` ${result.message}`);
2745
+ }
2746
+ if (result.details && result.details.length > 0) {
2747
+ const maxDetails = 5;
2748
+ const showDetails = result.details.slice(0, maxDetails);
2749
+ for (const detail of showDetails) {
2750
+ lines.push(chalk2.gray(` - ${detail}`));
2751
+ }
2752
+ if (result.details.length > maxDetails) {
2753
+ lines.push(chalk2.gray(` ... and ${result.details.length - maxDetails} more`));
2754
+ }
2755
+ }
2756
+ lines.push("");
2757
+ return lines.join("\n");
2758
+ }
2759
+ function printReport(report, opts = {}) {
2760
+ console.log(formatTerminal(report, opts));
2761
+ }
2762
+
2763
+ // src/reporters/json.ts
2764
+ function formatJson(report) {
2765
+ return JSON.stringify(report, null, 2);
2766
+ }
2767
+
2768
+ // src/reporters/html.ts
2769
+ function formatHtml(report) {
2770
+ const { summary, results } = report;
2771
+ const byFile = /* @__PURE__ */ new Map();
2772
+ results.forEach((r) => {
2773
+ const file = r.file || "Unknown";
2774
+ if (!byFile.has(file)) {
2775
+ byFile.set(file, []);
2776
+ }
2777
+ byFile.get(file).push(r);
2778
+ });
2779
+ const totalChecks = summary.passed + summary.critical + summary.warning + summary.info;
2780
+ const score = totalChecks > 0 ? Math.round(summary.passed / totalChecks * 100) : 100;
2781
+ const scoreColor = score >= 90 ? "#22c55e" : score >= 70 ? "#eab308" : score >= 50 ? "#f97316" : "#ef4444";
2782
+ const html = `<!DOCTYPE html>
2783
+ <html lang="en">
2784
+ <head>
2785
+ <meta charset="UTF-8">
2786
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2787
+ <title>SEO Audit Report - ${new Date(report.timestamp).toLocaleDateString()}</title>
2788
+ <style>
2789
+ * {
2790
+ margin: 0;
2791
+ padding: 0;
2792
+ box-sizing: border-box;
2793
+ }
2794
+
2795
+ body {
2796
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
2797
+ background: #f8fafc;
2798
+ color: #1e293b;
2799
+ line-height: 1.6;
2800
+ }
2801
+
2802
+ .container {
2803
+ max-width: 1200px;
2804
+ margin: 0 auto;
2805
+ padding: 2rem;
1964
2806
  }
1965
2807
 
1966
2808
  header {
@@ -2245,348 +3087,531 @@ function formatHtml(report) {
2245
3087
  <div class="stat">
2246
3088
  <div class="stat-value">${summary.totalFiles}</div>
2247
3089
  <div class="stat-label">Files</div>
2248
- </div>
2249
- <div class="stat">
2250
- <div class="stat-value">${totalChecks}</div>
2251
- <div class="stat-label">Checks</div>
2252
- </div>
2253
- <div class="stat">
2254
- <div class="stat-value">${summary.passed}</div>
2255
- <div class="stat-label">Passed</div>
2256
- </div>
2257
- </div>
2258
- </div>
2259
- </header>
2260
-
2261
- <div class="summary-cards">
2262
- <div class="card card-critical">
2263
- <h3>${summary.critical}</h3>
2264
- <p>Critical Issues</p>
2265
- </div>
2266
- <div class="card card-warning">
2267
- <h3>${summary.warning}</h3>
2268
- <p>Warnings</p>
2269
- </div>
2270
- <div class="card card-info">
2271
- <h3>${summary.info}</h3>
2272
- <p>Info</p>
2273
- </div>
2274
- <div class="card card-passed">
2275
- <h3>${summary.passed}</h3>
2276
- <p>Passed</p>
2277
- </div>
2278
- </div>
2279
-
2280
- ${results.length === 0 ? `
2281
- <div class="section">
2282
- <div class="no-issues">
2283
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
2284
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
2285
- </svg>
2286
- <h2>All Checks Passed!</h2>
2287
- <p>No SEO issues found. Great job!</p>
2288
- </div>
2289
- </div>
2290
- ` : `
2291
- <div class="section">
2292
- <h2>Issues Found</h2>
2293
- ${Array.from(byFile.entries()).map(([file, issues]) => `
2294
- <div class="file-group">
2295
- <div class="file-name">${escapeHtml(file)}</div>
2296
- ${issues.map((issue) => `
2297
- <div class="issue">
2298
- <span class="issue-severity severity-${issue.severity}">${issue.severity}</span>
2299
- <div class="issue-content">
2300
- <div class="issue-name">${escapeHtml(issue.name)}</div>
2301
- <div class="issue-message">${escapeHtml(issue.message)}</div>
2302
- ${issue.fix ? `
2303
- <div class="issue-fix">
2304
- <strong>How to fix:</strong>
2305
- ${escapeHtml(issue.fix)}
2306
- </div>
2307
- ` : ""}
2308
- ${issue.details && issue.details.length > 0 ? `
2309
- <div class="issue-details">
2310
- <strong>Affected:</strong>
2311
- <ul>
2312
- ${issue.details.slice(0, 5).map((d) => `<li>${escapeHtml(d)}</li>`).join("")}
2313
- ${issue.details.length > 5 ? `<li>...and ${issue.details.length - 5} more</li>` : ""}
2314
- </ul>
2315
- </div>
2316
- ` : ""}
2317
- </div>
2318
- </div>
2319
- `).join("")}
2320
- </div>
2321
- `).join("")}
2322
- </div>
2323
- `}
2324
-
2325
- <footer>
2326
- <p>Generated by <a href="https://rankture.com">Rankture CLI</a> v${report.version}</p>
2327
- <p>For full audit with Core Web Vitals and AI suggestions, visit <a href="https://rankture.com/free-seo-audit/">rankture.com</a></p>
2328
- </footer>
2329
- </div>
2330
- </body>
2331
- </html>`;
2332
- return html;
2333
- }
2334
- function escapeHtml(text) {
2335
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2336
- }
2337
-
2338
- // src/commands/validate.ts
2339
- import * as fs3 from "fs";
2340
- import * as path3 from "path";
2341
- import chalk2 from "chalk";
2342
- import { XMLParser } from "fast-xml-parser";
2343
- async function validateRobotsTxt(filePath) {
2344
- const result = {
2345
- valid: true,
2346
- errors: [],
2347
- warnings: [],
2348
- info: []
2349
- };
2350
- const absolutePath = path3.resolve(filePath);
2351
- if (!fs3.existsSync(absolutePath)) {
2352
- result.valid = false;
2353
- result.errors.push(`File not found: ${absolutePath}`);
2354
- return result;
2355
- }
2356
- const content = fs3.readFileSync(absolutePath, "utf-8");
2357
- const lines = content.split("\n");
2358
- let hasUserAgent = false;
2359
- let hasSitemap = false;
2360
- let currentUserAgent = "";
2361
- let lineNum = 0;
2362
- for (const rawLine of lines) {
2363
- lineNum++;
2364
- const line = rawLine.trim();
2365
- if (!line || line.startsWith("#")) {
2366
- continue;
2367
- }
2368
- const colonIndex = line.indexOf(":");
2369
- if (colonIndex === -1) {
2370
- result.warnings.push(`Line ${lineNum}: Invalid format (missing colon): "${line}"`);
2371
- continue;
2372
- }
2373
- const directive = line.substring(0, colonIndex).trim().toLowerCase();
2374
- const value = line.substring(colonIndex + 1).trim();
2375
- switch (directive) {
2376
- case "user-agent":
2377
- hasUserAgent = true;
2378
- currentUserAgent = value;
2379
- if (!value) {
2380
- result.errors.push(`Line ${lineNum}: User-agent directive has no value`);
2381
- result.valid = false;
2382
- }
2383
- break;
2384
- case "disallow":
2385
- if (!currentUserAgent) {
2386
- result.errors.push(`Line ${lineNum}: Disallow before User-agent directive`);
2387
- result.valid = false;
2388
- }
2389
- if (value && !value.startsWith("/") && value !== "*") {
2390
- result.warnings.push(`Line ${lineNum}: Disallow path should start with /`);
2391
- }
2392
- break;
2393
- case "allow":
2394
- if (!currentUserAgent) {
2395
- result.errors.push(`Line ${lineNum}: Allow before User-agent directive`);
2396
- result.valid = false;
2397
- }
2398
- if (value && !value.startsWith("/") && value !== "*") {
2399
- result.warnings.push(`Line ${lineNum}: Allow path should start with /`);
2400
- }
2401
- break;
2402
- case "sitemap":
2403
- hasSitemap = true;
2404
- if (!value.startsWith("http://") && !value.startsWith("https://")) {
2405
- result.errors.push(`Line ${lineNum}: Sitemap URL must be absolute (start with http:// or https://)`);
2406
- result.valid = false;
2407
- }
2408
- break;
2409
- case "crawl-delay": {
2410
- const delay = parseFloat(value);
2411
- if (isNaN(delay) || delay < 0) {
2412
- result.warnings.push(`Line ${lineNum}: Invalid crawl-delay value: "${value}"`);
2413
- } else if (delay > 10) {
2414
- result.warnings.push(
2415
- `Line ${lineNum}: Crawl-delay of ${delay}s is very high - may slow indexing`
2416
- );
2417
- }
2418
- break;
2419
- }
2420
- case "host":
2421
- result.info.push(`Line ${lineNum}: Host directive (Yandex-specific)`);
2422
- break;
2423
- case "clean-param":
2424
- result.info.push(`Line ${lineNum}: Clean-param directive (Yandex-specific)`);
2425
- break;
2426
- default:
2427
- result.warnings.push(`Line ${lineNum}: Unknown directive: "${directive}"`);
2428
- }
2429
- }
2430
- if (!hasUserAgent) {
2431
- result.errors.push("Missing User-agent directive (required)");
2432
- result.valid = false;
2433
- }
2434
- if (!hasSitemap) {
2435
- result.warnings.push("No Sitemap directive found - recommended to include sitemap URL");
2436
- }
2437
- if (content.includes("Disallow: /") && !content.includes("Allow:")) {
2438
- const disallowAll = lines.some((l) => {
2439
- const trimmed = l.trim().toLowerCase();
2440
- return trimmed === "disallow: /" || trimmed === "disallow:/";
2441
- });
2442
- if (disallowAll) {
2443
- result.warnings.push('Found "Disallow: /" which blocks all crawling - ensure this is intentional');
2444
- }
2445
- }
2446
- result.info.push(`Total lines: ${lines.length}`);
2447
- result.info.push(`User-agents defined: ${lines.filter((l) => l.trim().toLowerCase().startsWith("user-agent")).length}`);
2448
- return result;
3090
+ </div>
3091
+ <div class="stat">
3092
+ <div class="stat-value">${totalChecks}</div>
3093
+ <div class="stat-label">Checks</div>
3094
+ </div>
3095
+ <div class="stat">
3096
+ <div class="stat-value">${summary.passed}</div>
3097
+ <div class="stat-label">Passed</div>
3098
+ </div>
3099
+ </div>
3100
+ </div>
3101
+ </header>
3102
+
3103
+ <div class="summary-cards">
3104
+ <div class="card card-critical">
3105
+ <h3>${summary.critical}</h3>
3106
+ <p>Critical Issues</p>
3107
+ </div>
3108
+ <div class="card card-warning">
3109
+ <h3>${summary.warning}</h3>
3110
+ <p>Warnings</p>
3111
+ </div>
3112
+ <div class="card card-info">
3113
+ <h3>${summary.info}</h3>
3114
+ <p>Info</p>
3115
+ </div>
3116
+ <div class="card card-passed">
3117
+ <h3>${summary.passed}</h3>
3118
+ <p>Passed</p>
3119
+ </div>
3120
+ </div>
3121
+
3122
+ ${results.length === 0 ? `
3123
+ <div class="section">
3124
+ <div class="no-issues">
3125
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
3126
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
3127
+ </svg>
3128
+ <h2>All Checks Passed!</h2>
3129
+ <p>No SEO issues found. Great job!</p>
3130
+ </div>
3131
+ </div>
3132
+ ` : `
3133
+ <div class="section">
3134
+ <h2>Issues Found</h2>
3135
+ ${Array.from(byFile.entries()).map(([file, issues]) => `
3136
+ <div class="file-group">
3137
+ <div class="file-name">${escapeHtml(file)}</div>
3138
+ ${issues.map((issue) => `
3139
+ <div class="issue">
3140
+ <span class="issue-severity severity-${issue.severity}">${issue.severity}</span>
3141
+ <div class="issue-content">
3142
+ <div class="issue-name">${escapeHtml(issue.name)}</div>
3143
+ <div class="issue-message">${escapeHtml(issue.message)}</div>
3144
+ ${issue.fix ? `
3145
+ <div class="issue-fix">
3146
+ <strong>How to fix:</strong>
3147
+ ${escapeHtml(issue.fix)}
3148
+ </div>
3149
+ ` : ""}
3150
+ ${issue.details && issue.details.length > 0 ? `
3151
+ <div class="issue-details">
3152
+ <strong>Affected:</strong>
3153
+ <ul>
3154
+ ${issue.details.slice(0, 5).map((d) => `<li>${escapeHtml(d)}</li>`).join("")}
3155
+ ${issue.details.length > 5 ? `<li>...and ${issue.details.length - 5} more</li>` : ""}
3156
+ </ul>
3157
+ </div>
3158
+ ` : ""}
3159
+ </div>
3160
+ </div>
3161
+ `).join("")}
3162
+ </div>
3163
+ `).join("")}
3164
+ </div>
3165
+ `}
3166
+
3167
+ <footer>
3168
+ <p>Generated by <a href="https://rankture.com">Rankture CLI</a> v${report.version}</p>
3169
+ <p>For full audit with Core Web Vitals and AI suggestions, visit <a href="https://rankture.com/free-seo-audit/">rankture.com</a></p>
3170
+ </footer>
3171
+ </div>
3172
+ </body>
3173
+ </html>`;
3174
+ return html;
2449
3175
  }
2450
- async function validateSitemap(filePath) {
2451
- const result = {
2452
- valid: true,
2453
- errors: [],
2454
- warnings: [],
2455
- info: []
2456
- };
2457
- const absolutePath = path3.resolve(filePath);
2458
- if (!fs3.existsSync(absolutePath)) {
2459
- result.valid = false;
2460
- result.errors.push(`File not found: ${absolutePath}`);
2461
- return result;
2462
- }
2463
- const content = fs3.readFileSync(absolutePath, "utf-8");
2464
- if (!content.trim().startsWith("<?xml")) {
2465
- result.warnings.push('Missing XML declaration (<?xml version="1.0" encoding="UTF-8"?>)');
3176
+ function escapeHtml(text2) {
3177
+ return text2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
3178
+ }
3179
+
3180
+ // src/reporters/markdown.ts
3181
+ function formatMarkdown(report) {
3182
+ const lines = [
3183
+ "# Rankture SEO Check",
3184
+ "",
3185
+ `- Files: ${report.summary.totalFiles}`,
3186
+ `- Score: ${report.summary.score ?? 100}`,
3187
+ `- Critical: ${report.summary.critical}`,
3188
+ `- Warnings: ${report.summary.warning}`,
3189
+ `- Info: ${report.summary.info}`
3190
+ ];
3191
+ if (report.baseline) lines.push(`- New issues: ${report.baseline.fresh}`);
3192
+ lines.push("", "## Actionable findings", "");
3193
+ if (report.results.length === 0) return [...lines, "No issues found.", ""].join("\n");
3194
+ for (const finding of report.results) {
3195
+ const location = `${finding.file ?? report.path}${finding.line ? `:${finding.line}${finding.column ? `:${finding.column}` : ""}` : ""}`;
3196
+ lines.push(`- **${finding.severity.toUpperCase()} ${finding.checkId}** \u2014 ${location}`);
3197
+ lines.push(` - ${finding.message}`);
3198
+ if (finding.fix) lines.push(` - Fix: ${finding.fix.replace(/\s+/g, " ").trim()}`);
2466
3199
  }
2467
- const parser = new XMLParser({
2468
- ignoreAttributes: false,
2469
- attributeNamePrefix: "@_"
3200
+ lines.push("");
3201
+ return lines.join("\n");
3202
+ }
3203
+
3204
+ // src/reporters/sarif.ts
3205
+ import * as path8 from "path";
3206
+ import { pathToFileURL as pathToFileURL2 } from "url";
3207
+ var SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
3208
+ var LEVEL_BY_SEVERITY = {
3209
+ critical: "error",
3210
+ warning: "warning",
3211
+ info: "note"
3212
+ };
3213
+ function formatSarif(report, basePath) {
3214
+ const failures = report.results.filter((r) => !r.passed);
3215
+ const absoluteBase = path8.resolve(basePath);
3216
+ const rules = [...new Map(failures.map((r) => [r.checkId, r])).values()].map((r) => ({
3217
+ id: r.checkId,
3218
+ name: r.name,
3219
+ shortDescription: { text: r.name },
3220
+ helpUri: `https://rankture.com/docs/cli#${r.checkId}`,
3221
+ defaultConfiguration: { level: LEVEL_BY_SEVERITY[r.severity] }
3222
+ }));
3223
+ const results = failures.map((r) => {
3224
+ const uri = r.file ? path8.relative(absoluteBase, path8.resolve(absoluteBase, r.file)).split(path8.sep).join("/") : void 0;
3225
+ return {
3226
+ ruleId: r.checkId,
3227
+ level: LEVEL_BY_SEVERITY[r.severity],
3228
+ message: { text: r.fix ? `${r.message} Fix: ${r.fix}` : r.message },
3229
+ ...uri ? {
3230
+ locations: [
3231
+ {
3232
+ physicalLocation: {
3233
+ artifactLocation: { uri, uriBaseId: "ROOTPATH" },
3234
+ ...r.line ? { region: { startLine: r.line, ...r.column ? { startColumn: r.column } : {} } } : {}
3235
+ }
3236
+ }
3237
+ ]
3238
+ } : {}
3239
+ };
2470
3240
  });
2471
- let parsed;
3241
+ const sarif = {
3242
+ $schema: SARIF_SCHEMA,
3243
+ version: "2.1.0",
3244
+ runs: [
3245
+ {
3246
+ tool: {
3247
+ driver: {
3248
+ name: "rankture",
3249
+ informationUri: "https://rankture.com",
3250
+ version: report.version,
3251
+ rules
3252
+ }
3253
+ },
3254
+ originalUriBaseIds: {
3255
+ ROOTPATH: { uri: ensureTrailingSlash(pathToFileURL2(absoluteBase).href) }
3256
+ },
3257
+ results
3258
+ }
3259
+ ]
3260
+ };
3261
+ return JSON.stringify(sarif, null, 2);
3262
+ }
3263
+ function ensureTrailingSlash(value) {
3264
+ return value.endsWith("/") ? value : `${value}/`;
3265
+ }
3266
+
3267
+ // src/commands/check.ts
3268
+ import { execSync as execSync2, spawn } from "child_process";
3269
+ function getGitChangedFiles(basePath, stagedOnly) {
2472
3270
  try {
2473
- parsed = parser.parse(content);
2474
- } catch (error) {
2475
- result.valid = false;
2476
- result.errors.push(`Invalid XML: ${error}`);
2477
- return result;
2478
- }
2479
- const isSitemapIndex = !!parsed.sitemapindex;
2480
- const isUrlset = !!parsed.urlset;
2481
- if (!isSitemapIndex && !isUrlset) {
2482
- result.valid = false;
2483
- result.errors.push("Missing root element: expected <urlset> or <sitemapindex>");
2484
- return result;
3271
+ const cwd = path9.resolve(basePath);
3272
+ const stagedOutput = execSync2("git diff --cached --name-only --diff-filter=ACMR", {
3273
+ cwd,
3274
+ encoding: "utf-8"
3275
+ }).trim();
3276
+ const stagedFiles = stagedOutput ? stagedOutput.split("\n") : [];
3277
+ if (stagedOnly) {
3278
+ return stagedFiles.map((f) => path9.join(cwd, f));
3279
+ }
3280
+ const unstagedOutput = execSync2("git diff --name-only --diff-filter=ACMR", {
3281
+ cwd,
3282
+ encoding: "utf-8"
3283
+ }).trim();
3284
+ const unstagedFiles = unstagedOutput ? unstagedOutput.split("\n") : [];
3285
+ const allFiles = [.../* @__PURE__ */ new Set([...stagedFiles, ...unstagedFiles])];
3286
+ return allFiles.map((f) => path9.join(cwd, f));
3287
+ } catch {
3288
+ return [];
2485
3289
  }
2486
- if (isSitemapIndex) {
2487
- const sitemaps = parsed.sitemapindex.sitemap;
2488
- const sitemapArray = Array.isArray(sitemaps) ? sitemaps : sitemaps ? [sitemaps] : [];
2489
- result.info.push(`Sitemap index with ${sitemapArray.length} child sitemap(s)`);
2490
- for (let i = 0; i < sitemapArray.length; i++) {
2491
- const sitemap = sitemapArray[i];
2492
- if (!sitemap.loc) {
2493
- result.errors.push(`Sitemap ${i + 1}: Missing <loc> element (required)`);
2494
- result.valid = false;
2495
- } else if (!sitemap.loc.startsWith("http")) {
2496
- result.errors.push(`Sitemap ${i + 1}: <loc> must be absolute URL`);
2497
- result.valid = false;
3290
+ }
3291
+ async function checkCommand(options) {
3292
+ const spinner = ora({
3293
+ text: "Scanning files...",
3294
+ isEnabled: Boolean(process.stdout.isTTY && !options.quiet)
3295
+ }).start();
3296
+ try {
3297
+ const basePath = path9.resolve(options.path);
3298
+ const formats = ["terminal", "json", "html", "sarif", "markdown"];
3299
+ const failLevels = ["none", "critical", "warning", "any", "new"];
3300
+ if (!formats.includes(options.format)) {
3301
+ spinner.fail(`Invalid format: ${options.format}. Use ${formats.join(", ")}`);
3302
+ return 1;
3303
+ }
3304
+ if (!failLevels.includes(options.failOn)) {
3305
+ spinner.fail(`Invalid fail-on level: ${options.failOn}. Use ${failLevels.join(", ")}`);
3306
+ return 1;
3307
+ }
3308
+ for (const [name, value, min, max] of [
3309
+ ["max-warnings", options.maxWarnings, 0, Number.MAX_SAFE_INTEGER],
3310
+ ["max-critical", options.maxCritical, 0, Number.MAX_SAFE_INTEGER],
3311
+ ["min-score", options.minScore, 0, 100],
3312
+ ["serve-port", options.servePort, 0, 65535]
3313
+ ]) {
3314
+ if (value !== void 0 && (!Number.isFinite(value) || value < min || value > max)) {
3315
+ spinner.fail(`--${name} must be between ${min} and ${max}`);
3316
+ return 1;
2498
3317
  }
2499
3318
  }
2500
- } else {
2501
- const urls = parsed.urlset.url;
2502
- const urlArray = Array.isArray(urls) ? urls : urls ? [urls] : [];
2503
- result.info.push(`Sitemap with ${urlArray.length} URL(s)`);
2504
- if (urlArray.length === 0) {
2505
- result.warnings.push("Sitemap is empty (no URLs)");
2506
- }
2507
- if (urlArray.length > 5e4) {
2508
- result.errors.push(`Sitemap has ${urlArray.length} URLs - maximum allowed is 50,000`);
2509
- result.valid = false;
2510
- }
2511
- const fileSize = Buffer.byteLength(content, "utf-8");
2512
- const fileSizeMB = fileSize / (1024 * 1024);
2513
- if (fileSizeMB > 50) {
2514
- result.errors.push(`Sitemap is ${fileSizeMB.toFixed(1)}MB - maximum allowed is 50MB`);
2515
- result.valid = false;
3319
+ if ((options.failOn === "new" || options.updateBaseline) && !options.baseline) {
3320
+ spinner.fail(`${options.failOn === "new" ? "--fail-on new" : "--update-baseline"} requires --baseline <file>`);
3321
+ return 1;
3322
+ }
3323
+ if (!fs7.existsSync(basePath)) {
3324
+ spinner.fail(`Path not found: ${basePath}`);
3325
+ return 1;
3326
+ }
3327
+ const stats = fs7.statSync(basePath);
3328
+ const config = await loadConfig(basePath, options.config);
3329
+ const availableChecks = getAvailableChecks();
3330
+ const unknownChecks = Object.keys(config.checks ?? {}).filter((id) => !availableChecks.includes(id));
3331
+ if (unknownChecks.length > 0) {
3332
+ spinner.fail(`Unknown check ID${unknownChecks.length > 1 ? "s" : ""}: ${unknownChecks.join(", ")}`);
3333
+ console.error(`Available checks: ${availableChecks.join(", ")}`);
3334
+ return 1;
3335
+ }
3336
+ const enabledChecks = getEnabledChecks(config, availableChecks);
3337
+ const effectiveIgnore = [.../* @__PURE__ */ new Set([...config.ignore ?? [], ...options.ignore])];
3338
+ let targetFiles;
3339
+ const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
3340
+ if (options.changed || options.staged) {
3341
+ spinner.text = options.staged ? "Getting staged files..." : "Getting changed files...";
3342
+ const gitFiles = getGitChangedFiles(basePath, !!options.staged);
3343
+ targetFiles = gitFiles.filter((f) => {
3344
+ const ext = path9.extname(f).toLowerCase();
3345
+ return allExtensions.includes(ext);
3346
+ });
3347
+ if (targetFiles.length === 0) {
3348
+ spinner.succeed(options.staged ? "No staged files to check" : "No changed files to check");
3349
+ return 0;
3350
+ }
3351
+ spinner.text = `Found ${targetFiles.length} ${options.staged ? "staged" : "changed"} file(s)`;
3352
+ } else if (stats.isFile()) {
3353
+ const ext = path9.extname(basePath).toLowerCase();
3354
+ if (!allExtensions.includes(ext)) {
3355
+ spinner.fail(`Unsupported file type: ${ext}. Supported: ${allExtensions.join(", ")}`);
3356
+ return 1;
3357
+ }
3358
+ targetFiles = [basePath];
3359
+ } else {
3360
+ const ignorePatterns = [
3361
+ "**/node_modules/**",
3362
+ "**/.git/**",
3363
+ "**/.astro/**",
3364
+ "**/.next/**",
3365
+ "**/.nuxt/**",
3366
+ "**/.svelte-kit/**",
3367
+ "**/dist/**/*.js",
3368
+ "**/dist/**/*.css",
3369
+ ...effectiveIgnore
3370
+ ];
3371
+ const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
3372
+ const globPattern = `**/*.{${extensionPattern}}`;
3373
+ targetFiles = await fg(globPattern, {
3374
+ cwd: basePath,
3375
+ ignore: ignorePatterns,
3376
+ absolute: true
3377
+ });
3378
+ if (targetFiles.length === 0) {
3379
+ spinner.fail(`No supported files found in ${basePath}. Looking for: ${allExtensions.join(", ")}`);
3380
+ return 1;
3381
+ }
2516
3382
  }
2517
- const priorities = /* @__PURE__ */ new Set();
2518
- const changefreqs = /* @__PURE__ */ new Set();
2519
- let urlsWithoutLoc = 0;
2520
- let urlsWithRelativeLoc = 0;
2521
- let urlsWithLastmod = 0;
2522
- let urlsWithPriority = 0;
2523
- let urlsWithChangefreq = 0;
2524
- const validChangefreqs = ["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"];
2525
- const seenUrls = /* @__PURE__ */ new Set();
2526
- const duplicateUrls = [];
2527
- for (let i = 0; i < Math.min(urlArray.length, 1e3); i++) {
2528
- const url = urlArray[i];
2529
- if (!url.loc) {
2530
- urlsWithoutLoc++;
3383
+ targetFiles.sort((a, b) => a.localeCompare(b));
3384
+ spinner.text = `Parsing ${targetFiles.length} file${targetFiles.length > 1 ? "s" : ""}...`;
3385
+ const pages = [];
3386
+ for (const filePath of targetFiles) {
3387
+ const content = fs7.readFileSync(filePath, "utf-8");
3388
+ const ext = path9.extname(filePath).toLowerCase();
3389
+ let page;
3390
+ if (SUPPORTED_EXTENSIONS.html.includes(ext)) {
3391
+ page = parseHtml(filePath, content);
2531
3392
  } else {
2532
- if (seenUrls.has(url.loc)) {
2533
- duplicateUrls.push(url.loc);
2534
- }
2535
- seenUrls.add(url.loc);
2536
- if (!url.loc.startsWith("http")) {
2537
- urlsWithRelativeLoc++;
2538
- }
3393
+ page = parseSourceFile(filePath, content);
2539
3394
  }
2540
- if (url.lastmod) {
2541
- urlsWithLastmod++;
2542
- const datePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)?)?$/;
2543
- if (!datePattern.test(url.lastmod)) {
2544
- result.warnings.push(`URL ${i + 1}: Invalid lastmod date format: "${url.lastmod}"`);
2545
- }
3395
+ pages.push(page);
3396
+ }
3397
+ spinner.text = "Running checks...";
3398
+ const effectiveBasePath = stats.isFile() ? path9.dirname(basePath) : basePath;
3399
+ const useHttpLinks = Boolean(options.serve && enabledChecks.has("internal-links"));
3400
+ const registryChecks = new Set(enabledChecks);
3401
+ if (useHttpLinks) registryChecks.delete("internal-links");
3402
+ const allResults = [];
3403
+ for (const page of pages) {
3404
+ const results = runChecks(page, pages, effectiveBasePath, registryChecks, config.thresholds);
3405
+ for (const result of results) {
3406
+ const override = config.rules?.[result.checkId] ?? Object.entries(config.rules ?? {}).find(([id]) => result.checkId.startsWith(`${id}-`))?.[1];
3407
+ if (override) result.severity = override;
2546
3408
  }
2547
- if (url.priority !== void 0) {
2548
- urlsWithPriority++;
2549
- priorities.add(String(url.priority));
2550
- const priority = parseFloat(url.priority);
2551
- if (isNaN(priority) || priority < 0 || priority > 1) {
2552
- result.warnings.push(`URL ${i + 1}: Invalid priority value: "${url.priority}" (must be 0.0-1.0)`);
2553
- }
3409
+ allResults.push(...results);
3410
+ }
3411
+ if (useHttpLinks) {
3412
+ const htmlPages = pages.filter((page) => !page.isSourceFile);
3413
+ if (htmlPages.length === 0) {
3414
+ spinner.fail("--serve requires at least one built .html file");
3415
+ return 1;
2554
3416
  }
2555
- if (url.changefreq) {
2556
- urlsWithChangefreq++;
2557
- changefreqs.add(url.changefreq);
2558
- if (!validChangefreqs.includes(url.changefreq.toLowerCase())) {
2559
- result.warnings.push(`URL ${i + 1}: Invalid changefreq: "${url.changefreq}"`);
3417
+ spinner.text = "Validating links through a local HTTP server...";
3418
+ const server = await startStaticServer(effectiveBasePath, options.servePort ?? 0);
3419
+ try {
3420
+ for (const page of htmlPages) {
3421
+ const findings = await checkInternalLinksHttp(
3422
+ page,
3423
+ pageUrlForFile(page.filePath, effectiveBasePath, server.origin)
3424
+ );
3425
+ if (findings.length === 0) {
3426
+ allResults.push({
3427
+ checkId: "internal-links",
3428
+ name: "Internal Links",
3429
+ severity: "critical",
3430
+ passed: true,
3431
+ file: page.filePath,
3432
+ message: "Internal Links: no issues found"
3433
+ });
3434
+ } else {
3435
+ allResults.push(...findings.map((result) => locateFinding(page, result)));
3436
+ }
2560
3437
  }
3438
+ } finally {
3439
+ await server.close();
2561
3440
  }
2562
3441
  }
2563
- if (urlsWithoutLoc > 0) {
2564
- result.errors.push(`${urlsWithoutLoc} URL(s) missing required <loc> element`);
2565
- result.valid = false;
2566
- }
2567
- if (urlsWithRelativeLoc > 0) {
2568
- result.errors.push(`${urlsWithRelativeLoc} URL(s) have relative URLs - must be absolute`);
2569
- result.valid = false;
2570
- }
2571
- if (duplicateUrls.length > 0) {
2572
- result.warnings.push(`${duplicateUrls.length} duplicate URL(s) found`);
2573
- if (duplicateUrls.length <= 5) {
2574
- duplicateUrls.forEach((url) => result.warnings.push(` Duplicate: ${url}`));
3442
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
3443
+ allResults.sort(
3444
+ (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)
3445
+ );
3446
+ spinner.stop();
3447
+ const failures = allResults.filter((result) => !result.passed);
3448
+ let reportResults = failures;
3449
+ let baselineMeta;
3450
+ if (options.baseline && options.updateBaseline) {
3451
+ saveBaseline(options.baseline, buildBaseline(failures, effectiveBasePath, (/* @__PURE__ */ new Date()).toISOString()));
3452
+ baselineMeta = { file: options.baseline, suppressed: failures.length, fresh: 0 };
3453
+ reportResults = [];
3454
+ } else if (options.baseline) {
3455
+ const comparison = applyBaseline(failures, loadBaseline(options.baseline), effectiveBasePath);
3456
+ reportResults = comparison.fresh;
3457
+ baselineMeta = { file: options.baseline, suppressed: comparison.suppressed, fresh: comparison.fresh.length };
3458
+ }
3459
+ const passedCount = allResults.filter((result) => result.passed).length;
3460
+ const score = allResults.length > 0 ? Math.round(passedCount / allResults.length * 100) : 100;
3461
+ const report = {
3462
+ version: VERSION,
3463
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3464
+ path: options.path,
3465
+ summary: {
3466
+ totalFiles: pages.length,
3467
+ passed: passedCount,
3468
+ critical: reportResults.filter((r) => r.severity === "critical").length,
3469
+ warning: reportResults.filter((r) => r.severity === "warning").length,
3470
+ info: reportResults.filter((r) => r.severity === "info").length,
3471
+ score
3472
+ },
3473
+ results: reportResults,
3474
+ ci: collectCiMetadata(process.env, effectiveBasePath),
3475
+ baseline: baselineMeta
3476
+ };
3477
+ const shouldSync = options.sync ?? config.sync ?? false;
3478
+ if (shouldSync) {
3479
+ const websiteId = options.websiteId || config.websiteId;
3480
+ const remote = await syncCheckReport(reportForSync(report, effectiveBasePath), websiteId, {
3481
+ apiKey: options.apiKey || config.apiKey,
3482
+ apiUrl: options.apiUrl || config.apiUrl
3483
+ });
3484
+ report.sync = { runId: remote.runId, websiteId };
3485
+ if (!options.quiet && options.format === "terminal") console.log(`Synced Rankture run ${remote.runId}`);
3486
+ }
3487
+ if (options.format === "json" || options.format === "sarif" || options.format === "markdown") {
3488
+ const output = options.format === "json" ? formatJson(report) : options.format === "sarif" ? formatSarif(report, effectiveBasePath) : formatMarkdown(report);
3489
+ if (options.output) {
3490
+ fs7.writeFileSync(options.output, output);
3491
+ if (!options.quiet) console.log(`Report written to ${options.output}`);
3492
+ } else {
3493
+ console.log(output);
2575
3494
  }
2576
- }
2577
- if (urlsWithLastmod === 0) {
2578
- result.info.push("No lastmod dates - consider adding for better crawling");
3495
+ } else if (options.format === "html") {
3496
+ const html = formatHtml(report);
3497
+ let reportPath;
3498
+ if (options.output) {
3499
+ fs7.writeFileSync(options.output, html);
3500
+ reportPath = path9.resolve(options.output);
3501
+ console.log(`
3502
+ \u2705 HTML report written to ${options.output}`);
3503
+ console.log(` Open in browser: file://${reportPath}
3504
+ `);
3505
+ } else {
3506
+ const outputFile = `seo-report-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.html`;
3507
+ fs7.writeFileSync(outputFile, html);
3508
+ reportPath = path9.resolve(outputFile);
3509
+ console.log(`
3510
+ \u2705 HTML report written to ${outputFile}`);
3511
+ console.log(` Open in browser: file://${reportPath}
3512
+ `);
3513
+ }
3514
+ if (options.open) openGeneratedFile(reportPath);
2579
3515
  } else {
2580
- result.info.push(`${urlsWithLastmod} URL(s) have lastmod dates`);
3516
+ printReport(report, { quiet: options.quiet, noColor: options.noColor });
3517
+ }
3518
+ if (options.annotations) printGitHubAnnotations(report.results, effectiveBasePath);
3519
+ let exitCode = 0;
3520
+ if (options.updateBaseline) return 0;
3521
+ switch (options.failOn) {
3522
+ case "critical":
3523
+ if (report.summary.critical > 0) exitCode = 1;
3524
+ break;
3525
+ case "warning":
3526
+ if (report.summary.critical > 0 || report.summary.warning > 0) exitCode = 1;
3527
+ break;
3528
+ case "any":
3529
+ if (report.summary.critical > 0 || report.summary.warning > 0 || report.summary.info > 0) {
3530
+ exitCode = 1;
3531
+ }
3532
+ break;
3533
+ case "new":
3534
+ if ((report.baseline?.fresh ?? 0) > 0) exitCode = 1;
3535
+ break;
3536
+ case "none":
3537
+ default:
3538
+ exitCode = 0;
2581
3539
  }
2582
- if (priorities.size === 1) {
2583
- result.info.push(`All URLs have same priority (${[...priorities][0]}) - consider varying for important pages`);
3540
+ if (options.maxWarnings !== void 0 && report.summary.warning > options.maxWarnings) exitCode = 1;
3541
+ if (options.maxCritical !== void 0 && report.summary.critical > options.maxCritical) exitCode = 1;
3542
+ if (options.minScore !== void 0 && score < options.minScore) exitCode = 1;
3543
+ return exitCode;
3544
+ } catch (error) {
3545
+ spinner.fail("Error running checks");
3546
+ console.error(error);
3547
+ return error instanceof Error && /Invalid Rankture config|Unknown check ID|Baseline file not found|Unrecognized baseline format/.test(error.message) ? 1 : 2;
3548
+ }
3549
+ }
3550
+ function openGeneratedFile(filePath) {
3551
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
3552
+ const args = process.platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
3553
+ spawn(command, args, { detached: true, stdio: "ignore" }).unref();
3554
+ }
3555
+ function reportForSync(report, basePath) {
3556
+ return {
3557
+ ...report,
3558
+ results: report.results.map((result) => ({
3559
+ ...result,
3560
+ file: result.file ? path9.relative(basePath, result.file).split(path9.sep).join("/") : void 0
3561
+ }))
3562
+ };
3563
+ }
3564
+ function printGitHubAnnotations(results, basePath) {
3565
+ const escape = (value) => value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
3566
+ for (const result of results) {
3567
+ const command = result.severity === "critical" ? "error" : result.severity === "warning" ? "warning" : "notice";
3568
+ const file = result.file ? path9.relative(basePath, result.file).split(path9.sep).join("/") : "";
3569
+ const properties = [file && `file=${escape(file)}`, result.line && `line=${result.line}`, result.column && `col=${result.column}`, `title=${escape(result.checkId)}`].filter(Boolean).join(",");
3570
+ console.log(`::${command} ${properties}::${escape(result.message)}`);
3571
+ }
3572
+ }
3573
+
3574
+ // src/integrations.ts
3575
+ function commandOptions(target, options) {
3576
+ return {
3577
+ path: target,
3578
+ config: options.config,
3579
+ failOn: options.failOn ?? "critical",
3580
+ format: options.format ?? "terminal",
3581
+ serve: options.serve,
3582
+ quiet: options.quiet,
3583
+ ignore: []
3584
+ };
3585
+ }
3586
+ async function runIntegration(target, options) {
3587
+ const code = await (options.runner ?? checkCommand)(commandOptions(target, options));
3588
+ if (code !== 0) throw new Error(`Rankture SEO checks failed with exit code ${code}`);
3589
+ }
3590
+ function ranktureAstro(options = {}) {
3591
+ return {
3592
+ name: "@rankture/cli/astro",
3593
+ hooks: {
3594
+ "astro:build:done": async ({ dir }) => {
3595
+ await runIntegration(options.path ? path10.resolve(options.path) : fileURLToPath(dir), options);
3596
+ }
2584
3597
  }
2585
- if (urlsWithPriority > 0 || urlsWithChangefreq > 0) {
2586
- result.info.push("Note: Google largely ignores priority and changefreq - focus on lastmod instead");
3598
+ };
3599
+ }
3600
+ function ranktureVite(options = {}) {
3601
+ let root = process.cwd();
3602
+ let outDir = "dist";
3603
+ return {
3604
+ name: "@rankture/cli/vite",
3605
+ apply: "build",
3606
+ enforce: "post",
3607
+ configResolved(config) {
3608
+ root = config.root || root;
3609
+ outDir = config.build?.outDir || outDir;
3610
+ },
3611
+ async closeBundle() {
3612
+ await runIntegration(options.path ? path10.resolve(root, options.path) : path10.resolve(root, outDir), options);
2587
3613
  }
2588
- }
2589
- return result;
3614
+ };
2590
3615
  }
2591
3616
 
2592
3617
  // src/index.ts
@@ -2613,6 +3638,8 @@ function checkPage(html, url = "file://local", basePath = ".") {
2613
3638
  }
2614
3639
  export {
2615
3640
  SUPPORTED_EXTENSIONS,
3641
+ applyBaseline,
3642
+ buildBaseline,
2616
3643
  checkAccessibility,
2617
3644
  checkCanonical,
2618
3645
  checkColorContrast,
@@ -2632,16 +3659,41 @@ export {
2632
3659
  checkTwitterCards,
2633
3660
  checkViewport,
2634
3661
  checks,
3662
+ clearCredentials,
3663
+ cliApiRequest,
3664
+ collectCiMetadata,
3665
+ createMcpServer,
3666
+ credentialFile,
2635
3667
  defaultConfig,
2636
3668
  formatHtml,
2637
3669
  formatJson,
3670
+ formatMarkdown,
3671
+ formatSarif,
2638
3672
  formatTerminal,
2639
3673
  getAvailableChecks,
3674
+ getConfigErrors,
2640
3675
  getEnabledChecks,
3676
+ getIdentity,
3677
+ getRemoteAudit,
3678
+ getWebsites,
3679
+ loadBaseline,
2641
3680
  loadConfig,
3681
+ loadCredentials,
3682
+ locateFinding,
3683
+ pageUrlForFile,
2642
3684
  parseHtml,
2643
3685
  parseSourceFile,
3686
+ ranktureAstro,
3687
+ ranktureVite,
2644
3688
  runChecks,
3689
+ saveBaseline,
3690
+ saveCredentials,
3691
+ startMcpServer,
3692
+ startRemoteAudit,
3693
+ startStaticServer,
3694
+ syncCheckReport,
3695
+ validateConfig,
2645
3696
  validateRobotsTxt,
3697
+ validateSchema,
2646
3698
  validateSitemap
2647
3699
  };