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