@rankture/cli 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -22
- package/dist/cli.mjs +1149 -131
- package/dist/index.d.ts +236 -21
- package/dist/index.mjs +1546 -495
- package/package.json +8 -5
- package/dist/cli.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
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,24 +1851,226 @@ function runChecks(page, allPages, basePath, enabledChecks) {
|
|
|
1733
1851
|
message: `${check.name}: no issues found`
|
|
1734
1852
|
});
|
|
1735
1853
|
}
|
|
1736
|
-
results.push(...checkResults);
|
|
1854
|
+
results.push(...checkResults.map((result) => locateFinding(page, result)));
|
|
1737
1855
|
}
|
|
1738
1856
|
return results;
|
|
1739
1857
|
}
|
|
1858
|
+
function getAvailableChecks() {
|
|
1859
|
+
return checks.map((c) => c.id);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// src/lib/config.ts
|
|
1863
|
+
import * as fs2 from "fs";
|
|
1864
|
+
import * as path2 from "path";
|
|
1865
|
+
import { pathToFileURL } from "url";
|
|
1866
|
+
var CONFIG_FILES = [
|
|
1867
|
+
"rankture.config.json",
|
|
1868
|
+
"rankture.config.js",
|
|
1869
|
+
"rankture.config.mjs",
|
|
1870
|
+
".rankturerc",
|
|
1871
|
+
".rankturerc.json",
|
|
1872
|
+
".rankturerc.js"
|
|
1873
|
+
];
|
|
1874
|
+
var defaultConfig = {
|
|
1875
|
+
checks: {},
|
|
1876
|
+
ignore: [],
|
|
1877
|
+
rules: {},
|
|
1878
|
+
thresholds: {
|
|
1879
|
+
titleLength: { min: 30, max: 60 },
|
|
1880
|
+
descriptionLength: { min: 120, max: 160 }
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
1883
|
+
async function loadConfig(basePath, configPath) {
|
|
1884
|
+
if (configPath) {
|
|
1885
|
+
const absolutePath = path2.resolve(configPath);
|
|
1886
|
+
if (!fs2.existsSync(absolutePath)) {
|
|
1887
|
+
throw new Error(`Config file not found: ${absolutePath}`);
|
|
1888
|
+
}
|
|
1889
|
+
return parseConfigFile(absolutePath);
|
|
1890
|
+
}
|
|
1891
|
+
let currentDir = path2.resolve(basePath);
|
|
1892
|
+
if (fs2.existsSync(currentDir) && fs2.statSync(currentDir).isFile()) {
|
|
1893
|
+
currentDir = path2.dirname(currentDir);
|
|
1894
|
+
}
|
|
1895
|
+
const root = path2.parse(currentDir).root;
|
|
1896
|
+
while (currentDir !== root) {
|
|
1897
|
+
for (const configName of CONFIG_FILES) {
|
|
1898
|
+
const configFilePath = path2.join(currentDir, configName);
|
|
1899
|
+
if (fs2.existsSync(configFilePath)) {
|
|
1900
|
+
return parseConfigFile(configFilePath);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
const packageJsonPath = path2.join(currentDir, "package.json");
|
|
1904
|
+
if (fs2.existsSync(packageJsonPath)) {
|
|
1905
|
+
try {
|
|
1906
|
+
const packageJson = JSON.parse(fs2.readFileSync(packageJsonPath, "utf-8"));
|
|
1907
|
+
if (packageJson.rankture) {
|
|
1908
|
+
assertValidConfig(packageJson.rankture, packageJsonPath);
|
|
1909
|
+
return mergeConfig(defaultConfig, packageJson.rankture);
|
|
1910
|
+
}
|
|
1911
|
+
} catch {
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
currentDir = path2.dirname(currentDir);
|
|
1915
|
+
}
|
|
1916
|
+
return defaultConfig;
|
|
1917
|
+
}
|
|
1918
|
+
async function parseConfigFile(filePath) {
|
|
1919
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
1920
|
+
if (ext === ".js" || ext === ".mjs") {
|
|
1921
|
+
try {
|
|
1922
|
+
const imported = await import(`${pathToFileURL(filePath).href}?t=${fs2.statSync(filePath).mtimeMs}`);
|
|
1923
|
+
const config = imported.default || imported;
|
|
1924
|
+
assertValidConfig(config, filePath);
|
|
1925
|
+
return mergeConfig(defaultConfig, config);
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
throw new Error(`Failed to load config from ${filePath}: ${error}`, { cause: error });
|
|
1928
|
+
}
|
|
1929
|
+
} else {
|
|
1930
|
+
try {
|
|
1931
|
+
const content = fs2.readFileSync(filePath, "utf-8");
|
|
1932
|
+
const config = JSON.parse(content);
|
|
1933
|
+
assertValidConfig(config, filePath);
|
|
1934
|
+
return mergeConfig(defaultConfig, config);
|
|
1935
|
+
} catch (error) {
|
|
1936
|
+
throw new Error(`Failed to parse config from ${filePath}: ${error}`, { cause: error });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
function mergeConfig(defaults, userConfig) {
|
|
1941
|
+
return {
|
|
1942
|
+
...defaults,
|
|
1943
|
+
...userConfig,
|
|
1944
|
+
checks: { ...defaults.checks, ...userConfig.checks },
|
|
1945
|
+
ignore: [...defaults.ignore || [], ...userConfig.ignore || []],
|
|
1946
|
+
rules: { ...defaults.rules, ...userConfig.rules },
|
|
1947
|
+
thresholds: {
|
|
1948
|
+
...defaults.thresholds,
|
|
1949
|
+
...userConfig.thresholds,
|
|
1950
|
+
titleLength: {
|
|
1951
|
+
...defaults.thresholds?.titleLength ?? { min: 30, max: 60 },
|
|
1952
|
+
...userConfig.thresholds?.titleLength
|
|
1953
|
+
},
|
|
1954
|
+
descriptionLength: {
|
|
1955
|
+
...defaults.thresholds?.descriptionLength ?? { min: 120, max: 160 },
|
|
1956
|
+
...userConfig.thresholds?.descriptionLength
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
function getConfigErrors(config) {
|
|
1962
|
+
const errors = [];
|
|
1963
|
+
if (typeof config !== "object" || config === null) {
|
|
1964
|
+
return ["configuration must export an object"];
|
|
1965
|
+
}
|
|
1966
|
+
const c = config;
|
|
1967
|
+
if (c.checks !== void 0) {
|
|
1968
|
+
if (typeof c.checks !== "object" || c.checks === null) {
|
|
1969
|
+
errors.push("checks must be an object whose values are booleans");
|
|
1970
|
+
} else {
|
|
1971
|
+
for (const [key, value] of Object.entries(c.checks)) {
|
|
1972
|
+
if (typeof value !== "boolean") errors.push(`checks.${key} must be true or false`);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
if (c.ignore !== void 0) {
|
|
1977
|
+
if (!Array.isArray(c.ignore)) {
|
|
1978
|
+
errors.push("ignore must be an array of glob strings");
|
|
1979
|
+
}
|
|
1980
|
+
if (Array.isArray(c.ignore) && !c.ignore.every((item) => typeof item === "string")) {
|
|
1981
|
+
errors.push("ignore must contain only strings");
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
if (c.rules !== void 0) {
|
|
1985
|
+
if (typeof c.rules !== "object" || c.rules === null) {
|
|
1986
|
+
errors.push("rules must be an object of critical, warning, or info severities");
|
|
1987
|
+
}
|
|
1988
|
+
const validSeverities = ["critical", "warning", "info"];
|
|
1989
|
+
for (const value of Object.values(c.rules)) {
|
|
1990
|
+
if (!validSeverities.includes(value)) {
|
|
1991
|
+
errors.push(`rules contains invalid severity "${String(value)}"`);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
if (c.thresholds !== void 0) {
|
|
1996
|
+
if (typeof c.thresholds !== "object" || c.thresholds === null || Array.isArray(c.thresholds)) {
|
|
1997
|
+
errors.push("thresholds must be an object");
|
|
1998
|
+
} else {
|
|
1999
|
+
const thresholds = c.thresholds;
|
|
2000
|
+
for (const name of ["titleLength", "descriptionLength"]) {
|
|
2001
|
+
const value = thresholds[name];
|
|
2002
|
+
if (value === void 0) continue;
|
|
2003
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2004
|
+
errors.push(`thresholds.${name} must contain numeric min and max values`);
|
|
2005
|
+
continue;
|
|
2006
|
+
}
|
|
2007
|
+
const range = value;
|
|
2008
|
+
if (!Number.isFinite(range.min) || !Number.isFinite(range.max)) {
|
|
2009
|
+
errors.push(`thresholds.${name}.min and .max must be finite numbers`);
|
|
2010
|
+
} else if (range.min < 0 || range.max < range.min) {
|
|
2011
|
+
errors.push(`thresholds.${name} must satisfy 0 <= min <= max`);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
for (const name of ["maxImageSize", "maxPageSize"]) {
|
|
2015
|
+
const value = thresholds[name];
|
|
2016
|
+
if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) {
|
|
2017
|
+
errors.push(`thresholds.${name} must be a positive number`);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
if (c.apiKey !== void 0 && typeof c.apiKey !== "string") errors.push("apiKey must be a string");
|
|
2023
|
+
if (c.sync !== void 0 && typeof c.sync !== "boolean") errors.push("sync must be true or false");
|
|
2024
|
+
if (c.websiteId !== void 0 && typeof c.websiteId !== "string") errors.push("websiteId must be a string");
|
|
2025
|
+
if (c.apiUrl !== void 0) {
|
|
2026
|
+
if (typeof c.apiUrl !== "string") errors.push("apiUrl must be a string");
|
|
2027
|
+
else {
|
|
2028
|
+
try {
|
|
2029
|
+
const value = new URL(c.apiUrl);
|
|
2030
|
+
if (!["http:", "https:"].includes(value.protocol)) errors.push("apiUrl must use http or https");
|
|
2031
|
+
} catch {
|
|
2032
|
+
errors.push("apiUrl must be a valid URL");
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
return errors;
|
|
2037
|
+
}
|
|
2038
|
+
function assertValidConfig(config, source) {
|
|
2039
|
+
const errors = getConfigErrors(config);
|
|
2040
|
+
if (errors.length > 0) {
|
|
2041
|
+
throw new Error(`Invalid Rankture config (${source}):
|
|
2042
|
+
- ${errors.join("\n- ")}`);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
function getEnabledChecks(config, allChecks) {
|
|
2046
|
+
const enabled = /* @__PURE__ */ new Set();
|
|
2047
|
+
for (const checkId of allChecks) {
|
|
2048
|
+
if (config.checks?.[checkId] === false) {
|
|
2049
|
+
continue;
|
|
2050
|
+
}
|
|
2051
|
+
enabled.add(checkId);
|
|
2052
|
+
}
|
|
2053
|
+
return enabled;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// src/version.ts
|
|
2057
|
+
var VERSION = "0.1.3";
|
|
1740
2058
|
|
|
1741
2059
|
// src/reporters/terminal.ts
|
|
1742
2060
|
import chalk from "chalk";
|
|
1743
|
-
function formatTerminal(report) {
|
|
2061
|
+
function formatTerminal(report, opts = {}) {
|
|
1744
2062
|
const lines = [];
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
2063
|
+
if (!opts.quiet) {
|
|
2064
|
+
lines.push("");
|
|
2065
|
+
lines.push(chalk.bold.cyan(" Rankture SEO Check"));
|
|
2066
|
+
lines.push(chalk.gray(" " + "\u2500".repeat(40)));
|
|
2067
|
+
lines.push("");
|
|
2068
|
+
lines.push(chalk.gray(` Checking ${report.path} (${report.summary.totalFiles} files)...`));
|
|
2069
|
+
lines.push("");
|
|
2070
|
+
}
|
|
1751
2071
|
const critical = report.results.filter((r) => !r.passed && r.severity === "critical");
|
|
1752
2072
|
const warnings = report.results.filter((r) => !r.passed && r.severity === "warning");
|
|
1753
|
-
const info = report.results.filter((r) => !r.passed && r.severity === "info");
|
|
2073
|
+
const info = opts.quiet ? [] : report.results.filter((r) => !r.passed && r.severity === "info");
|
|
1754
2074
|
if (critical.length > 0) {
|
|
1755
2075
|
for (const result of critical) {
|
|
1756
2076
|
lines.push(formatResult(result));
|
|
@@ -1766,8 +2086,10 @@ function formatTerminal(report) {
|
|
|
1766
2086
|
lines.push(formatResult(result));
|
|
1767
2087
|
}
|
|
1768
2088
|
}
|
|
1769
|
-
|
|
1770
|
-
|
|
2089
|
+
if (!opts.quiet) {
|
|
2090
|
+
lines.push("");
|
|
2091
|
+
lines.push(chalk.gray(" " + "\u2500".repeat(40)));
|
|
2092
|
+
}
|
|
1771
2093
|
const summaryParts = [];
|
|
1772
2094
|
if (report.summary.critical > 0) {
|
|
1773
2095
|
summaryParts.push(chalk.red.bold(`${report.summary.critical} critical`));
|
|
@@ -1780,6 +2102,9 @@ function formatTerminal(report) {
|
|
|
1780
2102
|
}
|
|
1781
2103
|
summaryParts.push(chalk.green(`${report.summary.passed} passed`));
|
|
1782
2104
|
lines.push(` Results: ${summaryParts.join(", ")}`);
|
|
2105
|
+
if (report.baseline) {
|
|
2106
|
+
lines.push(chalk.gray(` ${report.baseline.suppressed} known issue(s) suppressed by baseline ${report.baseline.file}`));
|
|
2107
|
+
}
|
|
1783
2108
|
lines.push("");
|
|
1784
2109
|
if (report.summary.critical > 0) {
|
|
1785
2110
|
lines.push(chalk.red.bold(" \u2717 Check failed (critical issues found)"));
|
|
@@ -1789,7 +2114,9 @@ function formatTerminal(report) {
|
|
|
1789
2114
|
lines.push(chalk.green.bold(" \u2713 All checks passed!"));
|
|
1790
2115
|
}
|
|
1791
2116
|
lines.push("");
|
|
1792
|
-
|
|
2117
|
+
const output = lines.join("\n");
|
|
2118
|
+
const ansiPattern = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g");
|
|
2119
|
+
return opts.noColor ? output.replace(ansiPattern, "") : output;
|
|
1793
2120
|
}
|
|
1794
2121
|
function formatResult(result) {
|
|
1795
2122
|
const lines = [];
|
|
@@ -1831,8 +2158,8 @@ function formatResult(result) {
|
|
|
1831
2158
|
lines.push("");
|
|
1832
2159
|
return lines.join("\n");
|
|
1833
2160
|
}
|
|
1834
|
-
function printReport(report) {
|
|
1835
|
-
console.log(formatTerminal(report));
|
|
2161
|
+
function printReport(report, opts = {}) {
|
|
2162
|
+
console.log(formatTerminal(report, opts));
|
|
1836
2163
|
}
|
|
1837
2164
|
|
|
1838
2165
|
// src/reporters/json.ts
|
|
@@ -2248,50 +2575,405 @@ function formatHtml(report) {
|
|
|
2248
2575
|
</html>`;
|
|
2249
2576
|
return html;
|
|
2250
2577
|
}
|
|
2251
|
-
function escapeHtml(
|
|
2252
|
-
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}/`;
|
|
2253
2667
|
}
|
|
2254
2668
|
|
|
2255
2669
|
// src/commands/check.ts
|
|
2670
|
+
import { execSync as execSync2, spawn } from "child_process";
|
|
2671
|
+
|
|
2672
|
+
// src/lib/baseline.ts
|
|
2673
|
+
import * as fs3 from "fs";
|
|
2674
|
+
import * as path4 from "path";
|
|
2675
|
+
function issueKey(result, basePath) {
|
|
2676
|
+
const file = result.file ? path4.relative(basePath, path4.resolve(basePath, result.file)).split(path4.sep).join("/") : "";
|
|
2677
|
+
return `${result.checkId}|${file}`;
|
|
2678
|
+
}
|
|
2679
|
+
function buildBaseline(failures, basePath, timestamp) {
|
|
2680
|
+
const issues = {};
|
|
2681
|
+
for (const failure of failures) {
|
|
2682
|
+
const key = issueKey(failure, basePath);
|
|
2683
|
+
issues[key] = (issues[key] || 0) + 1;
|
|
2684
|
+
}
|
|
2685
|
+
return { version: 1, createdAt: timestamp, issues };
|
|
2686
|
+
}
|
|
2687
|
+
function loadBaseline(filePath) {
|
|
2688
|
+
const absolute = path4.resolve(filePath);
|
|
2689
|
+
if (!fs3.existsSync(absolute)) {
|
|
2690
|
+
throw new Error(`Baseline file not found: ${absolute} (create it with --update-baseline)`);
|
|
2691
|
+
}
|
|
2692
|
+
const parsed = JSON.parse(fs3.readFileSync(absolute, "utf-8"));
|
|
2693
|
+
if (parsed.version !== 1 || typeof parsed.issues !== "object" || parsed.issues === null) {
|
|
2694
|
+
throw new Error(`Unrecognized baseline format in ${absolute}`);
|
|
2695
|
+
}
|
|
2696
|
+
return parsed;
|
|
2697
|
+
}
|
|
2698
|
+
function saveBaseline(filePath, baseline) {
|
|
2699
|
+
fs3.writeFileSync(path4.resolve(filePath), JSON.stringify(baseline, null, 2) + "\n");
|
|
2700
|
+
}
|
|
2701
|
+
function applyBaseline(failures, baseline, basePath) {
|
|
2702
|
+
const remaining = new Map(Object.entries(baseline.issues));
|
|
2703
|
+
const fresh = [];
|
|
2704
|
+
let suppressed = 0;
|
|
2705
|
+
for (const failure of failures) {
|
|
2706
|
+
const key = issueKey(failure, basePath);
|
|
2707
|
+
const allowance = remaining.get(key) || 0;
|
|
2708
|
+
if (allowance > 0) {
|
|
2709
|
+
remaining.set(key, allowance - 1);
|
|
2710
|
+
suppressed++;
|
|
2711
|
+
} else {
|
|
2712
|
+
fresh.push(failure);
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
return { fresh, suppressed };
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// src/lib/ci-env.ts
|
|
2256
2719
|
import { execSync } from "child_process";
|
|
2720
|
+
function collectCiMetadata(env = process.env, cwd) {
|
|
2721
|
+
if (env.GITHUB_ACTIONS === "true") {
|
|
2722
|
+
const prMatch = env.GITHUB_REF?.match(/^refs\/pull\/(\d+)\//);
|
|
2723
|
+
return withoutEmpty({
|
|
2724
|
+
provider: "github-actions",
|
|
2725
|
+
commit: env.GITHUB_SHA,
|
|
2726
|
+
branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
|
|
2727
|
+
pullRequest: prMatch ? Number(prMatch[1]) : void 0,
|
|
2728
|
+
buildUrl: env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : void 0
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
if (env.GITLAB_CI === "true") {
|
|
2732
|
+
return withoutEmpty({
|
|
2733
|
+
provider: "gitlab-ci",
|
|
2734
|
+
commit: env.CI_COMMIT_SHA,
|
|
2735
|
+
branch: env.CI_COMMIT_REF_NAME,
|
|
2736
|
+
pullRequest: env.CI_MERGE_REQUEST_IID ? Number(env.CI_MERGE_REQUEST_IID) : void 0,
|
|
2737
|
+
buildUrl: env.CI_JOB_URL
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
if (env.VERCEL === "1") {
|
|
2741
|
+
return withoutEmpty({
|
|
2742
|
+
provider: "vercel",
|
|
2743
|
+
commit: env.VERCEL_GIT_COMMIT_SHA,
|
|
2744
|
+
branch: env.VERCEL_GIT_COMMIT_REF
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
try {
|
|
2748
|
+
const git = (args) => execSync(`git ${args}`, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2749
|
+
const commit = git("rev-parse HEAD");
|
|
2750
|
+
const branch = git("rev-parse --abbrev-ref HEAD");
|
|
2751
|
+
if (commit) {
|
|
2752
|
+
return { provider: "git", commit, branch: branch !== "HEAD" ? branch : void 0 };
|
|
2753
|
+
}
|
|
2754
|
+
} catch {
|
|
2755
|
+
}
|
|
2756
|
+
return void 0;
|
|
2757
|
+
}
|
|
2758
|
+
function withoutEmpty(meta) {
|
|
2759
|
+
return Object.fromEntries(
|
|
2760
|
+
Object.entries(meta).filter(([, value]) => value !== void 0 && value !== "")
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
// src/lib/static-server.ts
|
|
2765
|
+
import * as fs4 from "fs";
|
|
2766
|
+
import * as http from "http";
|
|
2767
|
+
import * as path5 from "path";
|
|
2768
|
+
var CONTENT_TYPES = {
|
|
2769
|
+
".css": "text/css; charset=utf-8",
|
|
2770
|
+
".html": "text/html; charset=utf-8",
|
|
2771
|
+
".js": "text/javascript; charset=utf-8",
|
|
2772
|
+
".json": "application/json; charset=utf-8",
|
|
2773
|
+
".svg": "image/svg+xml",
|
|
2774
|
+
".txt": "text/plain; charset=utf-8",
|
|
2775
|
+
".xml": "application/xml; charset=utf-8"
|
|
2776
|
+
};
|
|
2777
|
+
function resolveRequest(root, pathname) {
|
|
2778
|
+
let decoded;
|
|
2779
|
+
try {
|
|
2780
|
+
decoded = decodeURIComponent(pathname);
|
|
2781
|
+
} catch {
|
|
2782
|
+
return void 0;
|
|
2783
|
+
}
|
|
2784
|
+
const relative6 = decoded.replace(/^\/+/, "");
|
|
2785
|
+
const candidates = decoded.endsWith("/") ? [path5.join(relative6, "index.html")] : [relative6, `${relative6}.html`, path5.join(relative6, "index.html")];
|
|
2786
|
+
for (const candidate of candidates) {
|
|
2787
|
+
const resolved = path5.resolve(root, candidate || "index.html");
|
|
2788
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path5.sep}`)) continue;
|
|
2789
|
+
if (fs4.existsSync(resolved) && fs4.statSync(resolved).isFile()) return resolved;
|
|
2790
|
+
}
|
|
2791
|
+
return void 0;
|
|
2792
|
+
}
|
|
2793
|
+
async function startStaticServer(rootPath, port = 0) {
|
|
2794
|
+
const root = path5.resolve(rootPath);
|
|
2795
|
+
const server = http.createServer((request, response) => {
|
|
2796
|
+
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
2797
|
+
const file = resolveRequest(root, pathname);
|
|
2798
|
+
if (!file) {
|
|
2799
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2800
|
+
response.end("Not found");
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
response.writeHead(200, {
|
|
2804
|
+
"Content-Type": CONTENT_TYPES[path5.extname(file).toLowerCase()] ?? "application/octet-stream",
|
|
2805
|
+
"Content-Length": fs4.statSync(file).size
|
|
2806
|
+
});
|
|
2807
|
+
if (request.method === "HEAD") response.end();
|
|
2808
|
+
else fs4.createReadStream(file).pipe(response);
|
|
2809
|
+
});
|
|
2810
|
+
await new Promise((resolve9, reject) => {
|
|
2811
|
+
server.once("error", reject);
|
|
2812
|
+
server.listen(port, "127.0.0.1", resolve9);
|
|
2813
|
+
});
|
|
2814
|
+
const address = server.address();
|
|
2815
|
+
if (!address || typeof address === "string") throw new Error("Unable to determine local server address");
|
|
2816
|
+
return {
|
|
2817
|
+
origin: `http://127.0.0.1:${address.port}`,
|
|
2818
|
+
close: () => new Promise((resolve9, reject) => server.close((error) => error ? reject(error) : resolve9()))
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2821
|
+
function pageUrlForFile(filePath, rootPath, origin) {
|
|
2822
|
+
let relative6 = path5.relative(rootPath, filePath).split(path5.sep).join("/");
|
|
2823
|
+
if (relative6 === "index.html") relative6 = "";
|
|
2824
|
+
else if (relative6.endsWith("/index.html")) relative6 = relative6.slice(0, -"index.html".length);
|
|
2825
|
+
return new URL(`/${relative6}`, origin).href;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// src/lib/api-client.ts
|
|
2829
|
+
import { createHash } from "crypto";
|
|
2830
|
+
|
|
2831
|
+
// src/lib/credentials.ts
|
|
2832
|
+
import * as fs5 from "fs";
|
|
2833
|
+
import * as os from "os";
|
|
2834
|
+
import * as path6 from "path";
|
|
2835
|
+
function credentialFile(env = process.env) {
|
|
2836
|
+
const base = env.RANKTURE_CONFIG_DIR || env.XDG_CONFIG_HOME || (process.platform === "win32" ? env.APPDATA : path6.join(os.homedir(), ".config"));
|
|
2837
|
+
return path6.join(base || path6.join(os.homedir(), ".config"), "rankture", "credentials.json");
|
|
2838
|
+
}
|
|
2839
|
+
function saveCredentials(credentials, env = process.env) {
|
|
2840
|
+
const file = credentialFile(env);
|
|
2841
|
+
fs5.mkdirSync(path6.dirname(file), { recursive: true, mode: 448 });
|
|
2842
|
+
fs5.writeFileSync(file, `${JSON.stringify(credentials, null, 2)}
|
|
2843
|
+
`, { mode: 384 });
|
|
2844
|
+
fs5.chmodSync(file, 384);
|
|
2845
|
+
return file;
|
|
2846
|
+
}
|
|
2847
|
+
function loadCredentials(env = process.env) {
|
|
2848
|
+
if (env.RANKTURE_API_KEY) {
|
|
2849
|
+
return { apiKey: env.RANKTURE_API_KEY, apiUrl: env.RANKTURE_API_URL || "https://rankture.com" };
|
|
2850
|
+
}
|
|
2851
|
+
const file = credentialFile(env);
|
|
2852
|
+
if (!fs5.existsSync(file)) return void 0;
|
|
2853
|
+
const value = JSON.parse(fs5.readFileSync(file, "utf8"));
|
|
2854
|
+
if (!value.apiKey || !value.apiUrl) return void 0;
|
|
2855
|
+
return { apiKey: value.apiKey, apiUrl: value.apiUrl.replace(/\/$/, "") };
|
|
2856
|
+
}
|
|
2857
|
+
function clearCredentials(env = process.env) {
|
|
2858
|
+
const file = credentialFile(env);
|
|
2859
|
+
if (!fs5.existsSync(file)) return false;
|
|
2860
|
+
fs5.unlinkSync(file);
|
|
2861
|
+
return true;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// src/lib/api-client.ts
|
|
2865
|
+
function credentialsOrThrow(override) {
|
|
2866
|
+
const stored = loadCredentials();
|
|
2867
|
+
const apiKey = override?.apiKey || stored?.apiKey;
|
|
2868
|
+
const apiUrl = (override?.apiUrl || stored?.apiUrl || "https://rankture.com").replace(/\/$/, "");
|
|
2869
|
+
if (!apiKey) throw new Error("Not logged in. Run: rankture login --api-key <key>");
|
|
2870
|
+
return { apiKey, apiUrl };
|
|
2871
|
+
}
|
|
2872
|
+
async function cliApiRequest(pathname, init = {}, override) {
|
|
2873
|
+
const credentials = credentialsOrThrow(override);
|
|
2874
|
+
const response = await fetch(`${credentials.apiUrl}${pathname}`, {
|
|
2875
|
+
...init,
|
|
2876
|
+
headers: {
|
|
2877
|
+
Authorization: `Bearer ${credentials.apiKey}`,
|
|
2878
|
+
"Content-Type": "application/json",
|
|
2879
|
+
"User-Agent": "rankture-cli",
|
|
2880
|
+
...init.headers
|
|
2881
|
+
}
|
|
2882
|
+
});
|
|
2883
|
+
const body = await response.json().catch(() => ({}));
|
|
2884
|
+
if (!response.ok) throw new Error(String(body.error || body.message || `Rankture API returned ${response.status}`));
|
|
2885
|
+
return body;
|
|
2886
|
+
}
|
|
2887
|
+
var getIdentity = (override) => cliApiRequest("/api/cli/me", {}, override);
|
|
2888
|
+
var getWebsites = (override) => cliApiRequest("/api/cli/websites", {}, override);
|
|
2889
|
+
var syncCheckReport = (report, websiteId, override) => cliApiRequest("/api/cli/sync", {
|
|
2890
|
+
method: "POST",
|
|
2891
|
+
headers: { "X-Idempotency-Key": createHash("sha256").update(`${websiteId || ""}:${report.timestamp}:${report.ci?.commit || ""}`).digest("hex") },
|
|
2892
|
+
body: JSON.stringify({ schemaVersion: 1, report, websiteId })
|
|
2893
|
+
}, override);
|
|
2894
|
+
var startRemoteAudit = (url, options = {}, override) => cliApiRequest("/api/cli/audits", {
|
|
2895
|
+
method: "POST",
|
|
2896
|
+
body: JSON.stringify({ url, pages: options.pages })
|
|
2897
|
+
}, override);
|
|
2898
|
+
var getRemoteAudit = (auditId, override, full = false) => cliApiRequest(`/api/cli/audits/${encodeURIComponent(auditId)}${full ? "?full=true" : ""}`, {}, override);
|
|
2899
|
+
|
|
2900
|
+
// src/commands/check.ts
|
|
2257
2901
|
function getGitChangedFiles(basePath, stagedOnly) {
|
|
2258
2902
|
try {
|
|
2259
|
-
const cwd =
|
|
2260
|
-
const stagedOutput =
|
|
2903
|
+
const cwd = path7.resolve(basePath);
|
|
2904
|
+
const stagedOutput = execSync2("git diff --cached --name-only --diff-filter=ACMR", {
|
|
2261
2905
|
cwd,
|
|
2262
2906
|
encoding: "utf-8"
|
|
2263
2907
|
}).trim();
|
|
2264
2908
|
const stagedFiles = stagedOutput ? stagedOutput.split("\n") : [];
|
|
2265
2909
|
if (stagedOnly) {
|
|
2266
|
-
return stagedFiles.map((f) =>
|
|
2910
|
+
return stagedFiles.map((f) => path7.join(cwd, f));
|
|
2267
2911
|
}
|
|
2268
|
-
const unstagedOutput =
|
|
2912
|
+
const unstagedOutput = execSync2("git diff --name-only --diff-filter=ACMR", {
|
|
2269
2913
|
cwd,
|
|
2270
2914
|
encoding: "utf-8"
|
|
2271
2915
|
}).trim();
|
|
2272
2916
|
const unstagedFiles = unstagedOutput ? unstagedOutput.split("\n") : [];
|
|
2273
2917
|
const allFiles = [.../* @__PURE__ */ new Set([...stagedFiles, ...unstagedFiles])];
|
|
2274
|
-
return allFiles.map((f) =>
|
|
2918
|
+
return allFiles.map((f) => path7.join(cwd, f));
|
|
2275
2919
|
} catch {
|
|
2276
2920
|
return [];
|
|
2277
2921
|
}
|
|
2278
2922
|
}
|
|
2279
2923
|
async function checkCommand(options) {
|
|
2280
|
-
const spinner = ora(
|
|
2924
|
+
const spinner = ora({
|
|
2925
|
+
text: "Scanning files...",
|
|
2926
|
+
isEnabled: Boolean(process.stdout.isTTY && !options.quiet)
|
|
2927
|
+
}).start();
|
|
2281
2928
|
try {
|
|
2282
|
-
const basePath =
|
|
2283
|
-
|
|
2929
|
+
const basePath = path7.resolve(options.path);
|
|
2930
|
+
const formats = ["terminal", "json", "html", "sarif", "markdown"];
|
|
2931
|
+
const failLevels = ["none", "critical", "warning", "any", "new"];
|
|
2932
|
+
if (!formats.includes(options.format)) {
|
|
2933
|
+
spinner.fail(`Invalid format: ${options.format}. Use ${formats.join(", ")}`);
|
|
2934
|
+
return 1;
|
|
2935
|
+
}
|
|
2936
|
+
if (!failLevels.includes(options.failOn)) {
|
|
2937
|
+
spinner.fail(`Invalid fail-on level: ${options.failOn}. Use ${failLevels.join(", ")}`);
|
|
2938
|
+
return 1;
|
|
2939
|
+
}
|
|
2940
|
+
for (const [name, value, min, max] of [
|
|
2941
|
+
["max-warnings", options.maxWarnings, 0, Number.MAX_SAFE_INTEGER],
|
|
2942
|
+
["max-critical", options.maxCritical, 0, Number.MAX_SAFE_INTEGER],
|
|
2943
|
+
["min-score", options.minScore, 0, 100],
|
|
2944
|
+
["serve-port", options.servePort, 0, 65535]
|
|
2945
|
+
]) {
|
|
2946
|
+
if (value !== void 0 && (!Number.isFinite(value) || value < min || value > max)) {
|
|
2947
|
+
spinner.fail(`--${name} must be between ${min} and ${max}`);
|
|
2948
|
+
return 1;
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
if ((options.failOn === "new" || options.updateBaseline) && !options.baseline) {
|
|
2952
|
+
spinner.fail(`${options.failOn === "new" ? "--fail-on new" : "--update-baseline"} requires --baseline <file>`);
|
|
2953
|
+
return 1;
|
|
2954
|
+
}
|
|
2955
|
+
if (!fs6.existsSync(basePath)) {
|
|
2284
2956
|
spinner.fail(`Path not found: ${basePath}`);
|
|
2285
2957
|
return 1;
|
|
2286
2958
|
}
|
|
2287
|
-
const stats =
|
|
2959
|
+
const stats = fs6.statSync(basePath);
|
|
2960
|
+
const config = await loadConfig(basePath, options.config);
|
|
2961
|
+
const availableChecks = getAvailableChecks();
|
|
2962
|
+
const unknownChecks = Object.keys(config.checks ?? {}).filter((id) => !availableChecks.includes(id));
|
|
2963
|
+
if (unknownChecks.length > 0) {
|
|
2964
|
+
spinner.fail(`Unknown check ID${unknownChecks.length > 1 ? "s" : ""}: ${unknownChecks.join(", ")}`);
|
|
2965
|
+
console.error(`Available checks: ${availableChecks.join(", ")}`);
|
|
2966
|
+
return 1;
|
|
2967
|
+
}
|
|
2968
|
+
const enabledChecks = getEnabledChecks(config, availableChecks);
|
|
2969
|
+
const effectiveIgnore = [.../* @__PURE__ */ new Set([...config.ignore ?? [], ...options.ignore])];
|
|
2288
2970
|
let targetFiles;
|
|
2289
2971
|
const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
|
|
2290
2972
|
if (options.changed || options.staged) {
|
|
2291
2973
|
spinner.text = options.staged ? "Getting staged files..." : "Getting changed files...";
|
|
2292
2974
|
const gitFiles = getGitChangedFiles(basePath, !!options.staged);
|
|
2293
2975
|
targetFiles = gitFiles.filter((f) => {
|
|
2294
|
-
const ext =
|
|
2976
|
+
const ext = path7.extname(f).toLowerCase();
|
|
2295
2977
|
return allExtensions.includes(ext);
|
|
2296
2978
|
});
|
|
2297
2979
|
if (targetFiles.length === 0) {
|
|
@@ -2300,7 +2982,7 @@ async function checkCommand(options) {
|
|
|
2300
2982
|
}
|
|
2301
2983
|
spinner.text = `Found ${targetFiles.length} ${options.staged ? "staged" : "changed"} file(s)`;
|
|
2302
2984
|
} else if (stats.isFile()) {
|
|
2303
|
-
const ext =
|
|
2985
|
+
const ext = path7.extname(basePath).toLowerCase();
|
|
2304
2986
|
if (!allExtensions.includes(ext)) {
|
|
2305
2987
|
spinner.fail(`Unsupported file type: ${ext}. Supported: ${allExtensions.join(", ")}`);
|
|
2306
2988
|
return 1;
|
|
@@ -2316,7 +2998,7 @@ async function checkCommand(options) {
|
|
|
2316
2998
|
"**/.svelte-kit/**",
|
|
2317
2999
|
"**/dist/**/*.js",
|
|
2318
3000
|
"**/dist/**/*.css",
|
|
2319
|
-
...
|
|
3001
|
+
...effectiveIgnore
|
|
2320
3002
|
];
|
|
2321
3003
|
const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
|
|
2322
3004
|
const globPattern = `**/*.{${extensionPattern}}`;
|
|
@@ -2330,11 +3012,12 @@ async function checkCommand(options) {
|
|
|
2330
3012
|
return 1;
|
|
2331
3013
|
}
|
|
2332
3014
|
}
|
|
3015
|
+
targetFiles.sort((a, b) => a.localeCompare(b));
|
|
2333
3016
|
spinner.text = `Parsing ${targetFiles.length} file${targetFiles.length > 1 ? "s" : ""}...`;
|
|
2334
3017
|
const pages = [];
|
|
2335
3018
|
for (const filePath of targetFiles) {
|
|
2336
|
-
const content =
|
|
2337
|
-
const ext =
|
|
3019
|
+
const content = fs6.readFileSync(filePath, "utf-8");
|
|
3020
|
+
const ext = path7.extname(filePath).toLowerCase();
|
|
2338
3021
|
let page;
|
|
2339
3022
|
if (SUPPORTED_EXTENSIONS.html.includes(ext)) {
|
|
2340
3023
|
page = parseHtml(filePath, content);
|
|
@@ -2344,55 +3027,129 @@ async function checkCommand(options) {
|
|
|
2344
3027
|
pages.push(page);
|
|
2345
3028
|
}
|
|
2346
3029
|
spinner.text = "Running checks...";
|
|
2347
|
-
const effectiveBasePath = stats.isFile() ?
|
|
3030
|
+
const effectiveBasePath = stats.isFile() ? path7.dirname(basePath) : basePath;
|
|
3031
|
+
const useHttpLinks = Boolean(options.serve && enabledChecks.has("internal-links"));
|
|
3032
|
+
const registryChecks = new Set(enabledChecks);
|
|
3033
|
+
if (useHttpLinks) registryChecks.delete("internal-links");
|
|
2348
3034
|
const allResults = [];
|
|
2349
3035
|
for (const page of pages) {
|
|
2350
|
-
const results = runChecks(page, pages, effectiveBasePath);
|
|
3036
|
+
const results = runChecks(page, pages, effectiveBasePath, registryChecks, config.thresholds);
|
|
3037
|
+
for (const result of results) {
|
|
3038
|
+
const override = config.rules?.[result.checkId] ?? Object.entries(config.rules ?? {}).find(([id]) => result.checkId.startsWith(`${id}-`))?.[1];
|
|
3039
|
+
if (override) result.severity = override;
|
|
3040
|
+
}
|
|
2351
3041
|
allResults.push(...results);
|
|
2352
3042
|
}
|
|
3043
|
+
if (useHttpLinks) {
|
|
3044
|
+
const htmlPages = pages.filter((page) => !page.isSourceFile);
|
|
3045
|
+
if (htmlPages.length === 0) {
|
|
3046
|
+
spinner.fail("--serve requires at least one built .html file");
|
|
3047
|
+
return 1;
|
|
3048
|
+
}
|
|
3049
|
+
spinner.text = "Validating links through a local HTTP server...";
|
|
3050
|
+
const server = await startStaticServer(effectiveBasePath, options.servePort ?? 0);
|
|
3051
|
+
try {
|
|
3052
|
+
for (const page of htmlPages) {
|
|
3053
|
+
const findings = await checkInternalLinksHttp(
|
|
3054
|
+
page,
|
|
3055
|
+
pageUrlForFile(page.filePath, effectiveBasePath, server.origin)
|
|
3056
|
+
);
|
|
3057
|
+
if (findings.length === 0) {
|
|
3058
|
+
allResults.push({
|
|
3059
|
+
checkId: "internal-links",
|
|
3060
|
+
name: "Internal Links",
|
|
3061
|
+
severity: "critical",
|
|
3062
|
+
passed: true,
|
|
3063
|
+
file: page.filePath,
|
|
3064
|
+
message: "Internal Links: no issues found"
|
|
3065
|
+
});
|
|
3066
|
+
} else {
|
|
3067
|
+
allResults.push(...findings.map((result) => locateFinding(page, result)));
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
} finally {
|
|
3071
|
+
await server.close();
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
|
3075
|
+
allResults.sort(
|
|
3076
|
+
(a, b) => (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER) || severityOrder[a.severity] - severityOrder[b.severity] || a.checkId.localeCompare(b.checkId) || a.message.localeCompare(b.message)
|
|
3077
|
+
);
|
|
2353
3078
|
spinner.stop();
|
|
3079
|
+
const failures = allResults.filter((result) => !result.passed);
|
|
3080
|
+
let reportResults = failures;
|
|
3081
|
+
let baselineMeta;
|
|
3082
|
+
if (options.baseline && options.updateBaseline) {
|
|
3083
|
+
saveBaseline(options.baseline, buildBaseline(failures, effectiveBasePath, (/* @__PURE__ */ new Date()).toISOString()));
|
|
3084
|
+
baselineMeta = { file: options.baseline, suppressed: failures.length, fresh: 0 };
|
|
3085
|
+
reportResults = [];
|
|
3086
|
+
} else if (options.baseline) {
|
|
3087
|
+
const comparison = applyBaseline(failures, loadBaseline(options.baseline), effectiveBasePath);
|
|
3088
|
+
reportResults = comparison.fresh;
|
|
3089
|
+
baselineMeta = { file: options.baseline, suppressed: comparison.suppressed, fresh: comparison.fresh.length };
|
|
3090
|
+
}
|
|
3091
|
+
const passedCount = allResults.filter((result) => result.passed).length;
|
|
3092
|
+
const score = allResults.length > 0 ? Math.round(passedCount / allResults.length * 100) : 100;
|
|
2354
3093
|
const report = {
|
|
2355
|
-
version:
|
|
3094
|
+
version: VERSION,
|
|
2356
3095
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2357
3096
|
path: options.path,
|
|
2358
3097
|
summary: {
|
|
2359
3098
|
totalFiles: pages.length,
|
|
2360
|
-
passed:
|
|
2361
|
-
critical:
|
|
2362
|
-
warning:
|
|
2363
|
-
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
|
|
2364
3104
|
},
|
|
2365
|
-
results:
|
|
2366
|
-
|
|
3105
|
+
results: reportResults,
|
|
3106
|
+
ci: collectCiMetadata(process.env, effectiveBasePath),
|
|
3107
|
+
baseline: baselineMeta
|
|
2367
3108
|
};
|
|
2368
|
-
|
|
2369
|
-
|
|
3109
|
+
const shouldSync = options.sync ?? config.sync ?? false;
|
|
3110
|
+
if (shouldSync) {
|
|
3111
|
+
const websiteId = options.websiteId || config.websiteId;
|
|
3112
|
+
const remote = await syncCheckReport(reportForSync(report, effectiveBasePath), websiteId, {
|
|
3113
|
+
apiKey: options.apiKey || config.apiKey,
|
|
3114
|
+
apiUrl: options.apiUrl || config.apiUrl
|
|
3115
|
+
});
|
|
3116
|
+
report.sync = { runId: remote.runId, websiteId };
|
|
3117
|
+
if (!options.quiet && options.format === "terminal") console.log(`Synced Rankture run ${remote.runId}`);
|
|
3118
|
+
}
|
|
3119
|
+
if (options.format === "json" || options.format === "sarif" || options.format === "markdown") {
|
|
3120
|
+
const output = options.format === "json" ? formatJson(report) : options.format === "sarif" ? formatSarif(report, effectiveBasePath) : formatMarkdown(report);
|
|
2370
3121
|
if (options.output) {
|
|
2371
|
-
|
|
2372
|
-
console.log(`Report written to ${options.output}`);
|
|
3122
|
+
fs6.writeFileSync(options.output, output);
|
|
3123
|
+
if (!options.quiet) console.log(`Report written to ${options.output}`);
|
|
2373
3124
|
} else {
|
|
2374
|
-
console.log(
|
|
3125
|
+
console.log(output);
|
|
2375
3126
|
}
|
|
2376
3127
|
} else if (options.format === "html") {
|
|
2377
3128
|
const html = formatHtml(report);
|
|
3129
|
+
let reportPath;
|
|
2378
3130
|
if (options.output) {
|
|
2379
|
-
|
|
3131
|
+
fs6.writeFileSync(options.output, html);
|
|
3132
|
+
reportPath = path7.resolve(options.output);
|
|
2380
3133
|
console.log(`
|
|
2381
3134
|
\u2705 HTML report written to ${options.output}`);
|
|
2382
|
-
console.log(` Open in browser: file://${
|
|
3135
|
+
console.log(` Open in browser: file://${reportPath}
|
|
2383
3136
|
`);
|
|
2384
3137
|
} else {
|
|
2385
3138
|
const outputFile = `seo-report-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.html`;
|
|
2386
|
-
|
|
3139
|
+
fs6.writeFileSync(outputFile, html);
|
|
3140
|
+
reportPath = path7.resolve(outputFile);
|
|
2387
3141
|
console.log(`
|
|
2388
3142
|
\u2705 HTML report written to ${outputFile}`);
|
|
2389
|
-
console.log(` Open in browser: file://${
|
|
3143
|
+
console.log(` Open in browser: file://${reportPath}
|
|
2390
3144
|
`);
|
|
2391
3145
|
}
|
|
3146
|
+
if (options.open) openGeneratedFile(reportPath);
|
|
2392
3147
|
} else {
|
|
2393
|
-
printReport(report);
|
|
3148
|
+
printReport(report, { quiet: options.quiet, noColor: options.noColor });
|
|
2394
3149
|
}
|
|
3150
|
+
if (options.annotations) printGitHubAnnotations(report.results, effectiveBasePath);
|
|
2395
3151
|
let exitCode = 0;
|
|
3152
|
+
if (options.updateBaseline) return 0;
|
|
2396
3153
|
switch (options.failOn) {
|
|
2397
3154
|
case "critical":
|
|
2398
3155
|
if (report.summary.critical > 0) exitCode = 1;
|
|
@@ -2405,23 +3162,54 @@ async function checkCommand(options) {
|
|
|
2405
3162
|
exitCode = 1;
|
|
2406
3163
|
}
|
|
2407
3164
|
break;
|
|
3165
|
+
case "new":
|
|
3166
|
+
if ((report.baseline?.fresh ?? 0) > 0) exitCode = 1;
|
|
3167
|
+
break;
|
|
2408
3168
|
case "none":
|
|
2409
3169
|
default:
|
|
2410
3170
|
exitCode = 0;
|
|
2411
3171
|
}
|
|
3172
|
+
if (options.maxWarnings !== void 0 && report.summary.warning > options.maxWarnings) exitCode = 1;
|
|
3173
|
+
if (options.maxCritical !== void 0 && report.summary.critical > options.maxCritical) exitCode = 1;
|
|
3174
|
+
if (options.minScore !== void 0 && score < options.minScore) exitCode = 1;
|
|
2412
3175
|
return exitCode;
|
|
2413
3176
|
} catch (error) {
|
|
2414
3177
|
spinner.fail("Error running checks");
|
|
2415
3178
|
console.error(error);
|
|
2416
|
-
return 1;
|
|
3179
|
+
return error instanceof Error && /Invalid Rankture config|Unknown check ID|Baseline file not found|Unrecognized baseline format/.test(error.message) ? 1 : 2;
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
function openGeneratedFile(filePath) {
|
|
3183
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
3184
|
+
const args = process.platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
|
|
3185
|
+
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
3186
|
+
}
|
|
3187
|
+
function reportForSync(report, basePath) {
|
|
3188
|
+
return {
|
|
3189
|
+
...report,
|
|
3190
|
+
results: report.results.map((result) => ({
|
|
3191
|
+
...result,
|
|
3192
|
+
file: result.file ? path7.relative(basePath, result.file).split(path7.sep).join("/") : void 0
|
|
3193
|
+
}))
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3196
|
+
function printGitHubAnnotations(results, basePath) {
|
|
3197
|
+
const escape = (value) => value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
3198
|
+
for (const result of results) {
|
|
3199
|
+
const command = result.severity === "critical" ? "error" : result.severity === "warning" ? "warning" : "notice";
|
|
3200
|
+
const file = result.file ? path7.relative(basePath, result.file).split(path7.sep).join("/") : "";
|
|
3201
|
+
const properties = [file && `file=${escape(file)}`, result.line && `line=${result.line}`, result.column && `col=${result.column}`, `title=${escape(result.checkId)}`].filter(Boolean).join(",");
|
|
3202
|
+
console.log(`::${command} ${properties}::${escape(result.message)}`);
|
|
2417
3203
|
}
|
|
2418
3204
|
}
|
|
2419
3205
|
async function watchCommand(options) {
|
|
2420
|
-
const basePath =
|
|
2421
|
-
if (!
|
|
3206
|
+
const basePath = path7.resolve(options.path);
|
|
3207
|
+
if (!fs6.existsSync(basePath)) {
|
|
2422
3208
|
console.error(`Path not found: ${basePath}`);
|
|
2423
3209
|
process.exit(1);
|
|
2424
3210
|
}
|
|
3211
|
+
const config = await loadConfig(basePath, options.config);
|
|
3212
|
+
const watchIgnore = [.../* @__PURE__ */ new Set([...config.ignore ?? [], ...options.ignore])];
|
|
2425
3213
|
console.log("");
|
|
2426
3214
|
console.log(" \u{1F440} Watch mode enabled");
|
|
2427
3215
|
console.log(` Watching: ${basePath}`);
|
|
@@ -2430,7 +3218,7 @@ async function watchCommand(options) {
|
|
|
2430
3218
|
await checkCommand(options);
|
|
2431
3219
|
const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
|
|
2432
3220
|
const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
|
|
2433
|
-
const watchPattern =
|
|
3221
|
+
const watchPattern = path7.join(basePath, `**/*.{${extensionPattern}}`);
|
|
2434
3222
|
const chokidar = await import("chokidar");
|
|
2435
3223
|
let debounceTimer = null;
|
|
2436
3224
|
let isRunning = false;
|
|
@@ -2460,7 +3248,7 @@ async function watchCommand(options) {
|
|
|
2460
3248
|
"**/.next/**",
|
|
2461
3249
|
"**/.nuxt/**",
|
|
2462
3250
|
"**/.svelte-kit/**",
|
|
2463
|
-
...
|
|
3251
|
+
...watchIgnore
|
|
2464
3252
|
],
|
|
2465
3253
|
persistent: true,
|
|
2466
3254
|
ignoreInitial: true
|
|
@@ -2475,10 +3263,65 @@ async function watchCommand(options) {
|
|
|
2475
3263
|
}
|
|
2476
3264
|
|
|
2477
3265
|
// src/commands/validate.ts
|
|
2478
|
-
import * as
|
|
2479
|
-
import * as
|
|
3266
|
+
import * as fs7 from "fs";
|
|
3267
|
+
import * as path8 from "path";
|
|
2480
3268
|
import chalk2 from "chalk";
|
|
2481
3269
|
import { XMLParser } from "fast-xml-parser";
|
|
3270
|
+
async function validateSchema(filePath) {
|
|
3271
|
+
const result = { valid: true, errors: [], warnings: [], info: [] };
|
|
3272
|
+
const absolutePath = path8.resolve(filePath);
|
|
3273
|
+
if (!fs7.existsSync(absolutePath)) {
|
|
3274
|
+
return { ...result, valid: false, errors: [`File not found: ${absolutePath}`] };
|
|
3275
|
+
}
|
|
3276
|
+
const content = fs7.readFileSync(absolutePath, "utf-8");
|
|
3277
|
+
const ext = path8.extname(absolutePath).toLowerCase();
|
|
3278
|
+
if (ext === ".json" || ext === ".jsonld") {
|
|
3279
|
+
try {
|
|
3280
|
+
const parsed = JSON.parse(content);
|
|
3281
|
+
const nodes = Array.isArray(parsed) ? parsed : [parsed];
|
|
3282
|
+
for (const [index, node] of nodes.entries()) {
|
|
3283
|
+
validateSchemaNode(node, `Schema ${index + 1}`, result);
|
|
3284
|
+
}
|
|
3285
|
+
result.info.push(`${nodes.length} JSON-LD schema object(s) checked`);
|
|
3286
|
+
} catch (error) {
|
|
3287
|
+
result.valid = false;
|
|
3288
|
+
result.errors.push(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
3289
|
+
}
|
|
3290
|
+
return result;
|
|
3291
|
+
}
|
|
3292
|
+
const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
|
|
3293
|
+
if (!allExtensions.includes(ext)) {
|
|
3294
|
+
result.valid = false;
|
|
3295
|
+
result.errors.push(`Unsupported schema input ${ext || "(no extension)"}. Use JSON, HTML, or a supported source template.`);
|
|
3296
|
+
return result;
|
|
3297
|
+
}
|
|
3298
|
+
const page = SUPPORTED_EXTENSIONS.html.includes(ext) ? parseHtml(absolutePath, content) : parseSourceFile(absolutePath, content);
|
|
3299
|
+
const findings = [...checkSchemaJsonValidity(page), ...checkSchema(page)].filter((finding) => !finding.passed);
|
|
3300
|
+
for (const finding of findings) {
|
|
3301
|
+
const message = `${finding.checkId}: ${finding.message}`;
|
|
3302
|
+
if (finding.severity === "critical") {
|
|
3303
|
+
result.valid = false;
|
|
3304
|
+
result.errors.push(message);
|
|
3305
|
+
} else {
|
|
3306
|
+
result.warnings.push(message);
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
result.info.push(`${page.jsonLd.length} parseable JSON-LD schema object(s) found`);
|
|
3310
|
+
return result;
|
|
3311
|
+
}
|
|
3312
|
+
function validateSchemaNode(node, label, result) {
|
|
3313
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
3314
|
+
result.valid = false;
|
|
3315
|
+
result.errors.push(`${label} must be a JSON object`);
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
const schema = node;
|
|
3319
|
+
if (!schema["@context"]) result.warnings.push(`${label} is missing @context`);
|
|
3320
|
+
if (!schema["@type"] && !Array.isArray(schema["@graph"])) result.warnings.push(`${label} is missing @type`);
|
|
3321
|
+
if (Array.isArray(schema["@graph"])) {
|
|
3322
|
+
schema["@graph"].forEach((entry, index) => validateSchemaNode(entry, `${label} @graph item ${index + 1}`, result));
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
2482
3325
|
async function validateRobotsTxt(filePath) {
|
|
2483
3326
|
const result = {
|
|
2484
3327
|
valid: true,
|
|
@@ -2486,13 +3329,13 @@ async function validateRobotsTxt(filePath) {
|
|
|
2486
3329
|
warnings: [],
|
|
2487
3330
|
info: []
|
|
2488
3331
|
};
|
|
2489
|
-
const absolutePath =
|
|
2490
|
-
if (!
|
|
3332
|
+
const absolutePath = path8.resolve(filePath);
|
|
3333
|
+
if (!fs7.existsSync(absolutePath)) {
|
|
2491
3334
|
result.valid = false;
|
|
2492
3335
|
result.errors.push(`File not found: ${absolutePath}`);
|
|
2493
3336
|
return result;
|
|
2494
3337
|
}
|
|
2495
|
-
const content =
|
|
3338
|
+
const content = fs7.readFileSync(absolutePath, "utf-8");
|
|
2496
3339
|
const lines = content.split("\n");
|
|
2497
3340
|
let hasUserAgent = false;
|
|
2498
3341
|
let hasSitemap = false;
|
|
@@ -2593,13 +3436,13 @@ async function validateSitemap(filePath) {
|
|
|
2593
3436
|
warnings: [],
|
|
2594
3437
|
info: []
|
|
2595
3438
|
};
|
|
2596
|
-
const absolutePath =
|
|
2597
|
-
if (!
|
|
3439
|
+
const absolutePath = path8.resolve(filePath);
|
|
3440
|
+
if (!fs7.existsSync(absolutePath)) {
|
|
2598
3441
|
result.valid = false;
|
|
2599
3442
|
result.errors.push(`File not found: ${absolutePath}`);
|
|
2600
3443
|
return result;
|
|
2601
3444
|
}
|
|
2602
|
-
const content =
|
|
3445
|
+
const content = fs7.readFileSync(absolutePath, "utf-8");
|
|
2603
3446
|
if (!content.trim().startsWith("<?xml")) {
|
|
2604
3447
|
result.warnings.push('Missing XML declaration (<?xml version="1.0" encoding="UTF-8"?>)');
|
|
2605
3448
|
}
|
|
@@ -2754,10 +3597,155 @@ function printValidationResult(type, filePath, result) {
|
|
|
2754
3597
|
console.log("");
|
|
2755
3598
|
}
|
|
2756
3599
|
|
|
3600
|
+
// src/commands/audit.ts
|
|
3601
|
+
async function auditCommand(target, options) {
|
|
3602
|
+
try {
|
|
3603
|
+
const parsed = new URL(target);
|
|
3604
|
+
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Audit URL must use http or https");
|
|
3605
|
+
if (options.pages !== void 0 && (!Number.isInteger(options.pages) || options.pages < 1)) {
|
|
3606
|
+
throw new Error("--pages must be a positive integer");
|
|
3607
|
+
}
|
|
3608
|
+
if (options.timeout !== void 0 && (!Number.isFinite(options.timeout) || options.timeout < 1)) {
|
|
3609
|
+
throw new Error("--timeout must be a positive number of seconds");
|
|
3610
|
+
}
|
|
3611
|
+
if (options.format && !["terminal", "json"].includes(options.format)) {
|
|
3612
|
+
throw new Error("--format must be terminal or json");
|
|
3613
|
+
}
|
|
3614
|
+
const credentials = { apiKey: options.apiKey, apiUrl: options.apiUrl };
|
|
3615
|
+
const started = await startRemoteAudit(parsed.href, { pages: options.pages }, credentials);
|
|
3616
|
+
if (!options.wait) {
|
|
3617
|
+
print({ auditId: started.auditId, status: "queued" }, options.format);
|
|
3618
|
+
return 0;
|
|
3619
|
+
}
|
|
3620
|
+
const timeoutMs = Math.max(1, options.timeout ?? 900) * 1e3;
|
|
3621
|
+
const deadline = Date.now() + timeoutMs;
|
|
3622
|
+
while (Date.now() < deadline) {
|
|
3623
|
+
const audit = await getRemoteAudit(started.auditId, credentials);
|
|
3624
|
+
const status = String(audit.status || "unknown");
|
|
3625
|
+
if (status === "completed") {
|
|
3626
|
+
const full = await getRemoteAudit(started.auditId, credentials, true);
|
|
3627
|
+
print(full, options.format);
|
|
3628
|
+
return 0;
|
|
3629
|
+
}
|
|
3630
|
+
if (["failed", "cancelled"].includes(status)) {
|
|
3631
|
+
print(audit, options.format);
|
|
3632
|
+
return 1;
|
|
3633
|
+
}
|
|
3634
|
+
await new Promise((resolve9) => setTimeout(resolve9, 3e3));
|
|
3635
|
+
}
|
|
3636
|
+
console.error(`Audit ${started.auditId} is still running after ${options.timeout ?? 900} seconds`);
|
|
3637
|
+
return 2;
|
|
3638
|
+
} catch (error) {
|
|
3639
|
+
console.error(error instanceof Error ? error.message : "Unable to run audit");
|
|
3640
|
+
return 2;
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
function print(value, format) {
|
|
3644
|
+
if (format === "json") console.log(JSON.stringify(value, null, 2));
|
|
3645
|
+
else {
|
|
3646
|
+
console.log(`Audit: ${String(value.auditId || value.id || "")}`);
|
|
3647
|
+
console.log(`Status: ${String(value.status || "unknown")}`);
|
|
3648
|
+
if (value.score !== void 0 && value.score !== null) console.log(`Score: ${String(value.score)}`);
|
|
3649
|
+
if (value.failureReason) console.log(`Failure: ${String(value.failureReason)}`);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
// src/commands/auth.ts
|
|
3654
|
+
async function loginCommand(apiKey, apiUrl = "https://rankture.com") {
|
|
3655
|
+
const key = apiKey || process.env.RANKTURE_API_KEY;
|
|
3656
|
+
if (!key) {
|
|
3657
|
+
console.error("An API key is required. Create one in Rankture and run: rankture login --api-key <key>");
|
|
3658
|
+
return 1;
|
|
3659
|
+
}
|
|
3660
|
+
try {
|
|
3661
|
+
const identity = await getIdentity({ apiKey: key, apiUrl });
|
|
3662
|
+
saveCredentials({ apiKey: key, apiUrl: apiUrl.replace(/\/$/, "") });
|
|
3663
|
+
console.log(`Logged in as ${identity.user.email}`);
|
|
3664
|
+
return 0;
|
|
3665
|
+
} catch (error) {
|
|
3666
|
+
console.error(error instanceof Error ? error.message : "Unable to validate API key");
|
|
3667
|
+
return 1;
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
function logoutCommand() {
|
|
3671
|
+
console.log(clearCredentials() ? "Logged out of Rankture" : "No stored Rankture login found");
|
|
3672
|
+
return 0;
|
|
3673
|
+
}
|
|
3674
|
+
async function whoamiCommand() {
|
|
3675
|
+
try {
|
|
3676
|
+
const identity = await getIdentity();
|
|
3677
|
+
console.log(`${identity.user.email}
|
|
3678
|
+
Scopes: ${identity.scopes.join(", ")}`);
|
|
3679
|
+
return 0;
|
|
3680
|
+
} catch (error) {
|
|
3681
|
+
console.error(error instanceof Error ? error.message : "Unable to load Rankture identity");
|
|
3682
|
+
return 1;
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
// src/mcp/server.ts
|
|
3687
|
+
import { spawnSync } from "child_process";
|
|
3688
|
+
import * as path9 from "path";
|
|
3689
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3690
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3691
|
+
import * as z from "zod/v4";
|
|
3692
|
+
function safePath(root, requested) {
|
|
3693
|
+
const resolved = path9.resolve(root, requested);
|
|
3694
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path9.sep}`)) {
|
|
3695
|
+
throw new Error(`Path must stay inside MCP root: ${root}`);
|
|
3696
|
+
}
|
|
3697
|
+
return resolved;
|
|
3698
|
+
}
|
|
3699
|
+
var text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
3700
|
+
function createMcpServer(rootPath = process.cwd(), cliEntry = process.argv[1]) {
|
|
3701
|
+
const root = path9.resolve(rootPath);
|
|
3702
|
+
const server = new McpServer({ name: "rankture", version: VERSION });
|
|
3703
|
+
server.registerTool("check_path", {
|
|
3704
|
+
description: "Run Rankture SEO checks against a file or directory inside the configured project root.",
|
|
3705
|
+
inputSchema: {
|
|
3706
|
+
path: z.string().default("."),
|
|
3707
|
+
config: z.string().optional(),
|
|
3708
|
+
serve: z.boolean().default(false)
|
|
3709
|
+
},
|
|
3710
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
3711
|
+
}, async (input) => {
|
|
3712
|
+
const target = safePath(root, input.path);
|
|
3713
|
+
const args = [cliEntry, "check", target, "--format", "json"];
|
|
3714
|
+
if (input.config) args.push("--config", safePath(root, input.config));
|
|
3715
|
+
if (input.serve) args.push("--serve");
|
|
3716
|
+
const result = spawnSync(process.execPath, args, { cwd: root, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } });
|
|
3717
|
+
if (result.error) throw result.error;
|
|
3718
|
+
if (!result.stdout.trim()) throw new Error(result.stderr.trim() || `Rankture exited ${result.status}`);
|
|
3719
|
+
return text(JSON.parse(result.stdout));
|
|
3720
|
+
});
|
|
3721
|
+
server.registerTool("validate_schema", {
|
|
3722
|
+
description: "Validate a local JSON-LD file or HTML document inside the configured project root.",
|
|
3723
|
+
inputSchema: { file: z.string() },
|
|
3724
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
3725
|
+
}, async ({ file }) => text(await validateSchema(safePath(root, file))));
|
|
3726
|
+
server.registerTool("list_rules", {
|
|
3727
|
+
description: "List Rankture rule groups with their default severity and purpose.",
|
|
3728
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
3729
|
+
}, async () => text(checks.map(({ id, name, description, severity }) => ({ id, name, description, severity }))));
|
|
3730
|
+
server.registerTool("explain_rule", {
|
|
3731
|
+
description: "Explain a Rankture rule group by its stable ID.",
|
|
3732
|
+
inputSchema: { ruleId: z.string() },
|
|
3733
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
3734
|
+
}, async ({ ruleId }) => {
|
|
3735
|
+
const rule = checks.find((candidate) => candidate.id === ruleId || ruleId.startsWith(`${candidate.id}-`));
|
|
3736
|
+
if (!rule) throw new Error(`Unknown Rankture rule: ${ruleId}`);
|
|
3737
|
+
return text({ id: rule.id, name: rule.name, severity: rule.severity, description: rule.description });
|
|
3738
|
+
});
|
|
3739
|
+
return server;
|
|
3740
|
+
}
|
|
3741
|
+
async function startMcpServer(rootPath) {
|
|
3742
|
+
await createMcpServer(rootPath).connect(new StdioServerTransport());
|
|
3743
|
+
}
|
|
3744
|
+
|
|
2757
3745
|
// src/cli.ts
|
|
2758
3746
|
var program = new Command();
|
|
2759
|
-
program.name("rankture").description("SEO audit tool for developers - check your sites before deploy").version(
|
|
2760
|
-
program.command("check <path>").description("Run SEO checks on local HTML files").option("-f, --format <format>", "Output format: terminal, json, html", "terminal").option("-o, --output <file>", "Write output to file").option("--fail-on <level>", "Exit with code 1 on: none, critical, warning, any", "none").option("--ignore <patterns...>", "Glob patterns to ignore", []).option("--config <file>", "Config file path").option("-w, --watch", "Watch for file changes and re-run checks").option("--changed", "Only check files changed in git (staged + unstaged)").option("--staged", "Only check git staged files").action(async (pathArg, opts) => {
|
|
3747
|
+
program.name("rankture").description("SEO audit tool for developers - check your sites before deploy").version(VERSION);
|
|
3748
|
+
program.command("check <path>").description("Run SEO checks on local HTML files").option("-f, --format <format>", "Output format: terminal, json, html, sarif, markdown", "terminal").option("-o, --output <file>", "Write output to file").option("--fail-on <level>", "Exit with code 1 on: none, critical, warning, any, new", "none").option("--ignore <patterns...>", "Glob patterns to ignore", []).option("--config <file>", "Config file path").option("-w, --watch", "Watch for file changes and re-run checks").option("--changed", "Only check files changed in git (staged + unstaged)").option("--staged", "Only check git staged files").option("--quiet", "Suppress progress and informational findings").option("--no-color", "Disable ANSI colors").option("--baseline <file>", "Compare against a committed Rankture baseline").option("--update-baseline", "Write current failures to --baseline and exit successfully").option("--max-warnings <number>", "Fail when warnings exceed this count").option("--max-critical <number>", "Fail when critical issues exceed this count").option("--min-score <number>", "Fail when score is below this percentage").option("--annotations", "Emit GitHub Actions workflow annotations").option("--serve", "Validate built-site links through an ephemeral local HTTP server").option("--serve-port <number>", "Preferred local server port (0 selects an available port)").option("--open", "Open a generated HTML report in the system browser").option("--sync", "Upload the report to Rankture").option("--website-id <id>", "Associate a synced report with a Rankture website").option("--api-key <key>", "One-run API key override (prefer login or RANKTURE_API_KEY)").option("--api-url <url>", "Rankture API origin override").action(async (pathArg, opts) => {
|
|
2761
3749
|
const options = {
|
|
2762
3750
|
path: pathArg,
|
|
2763
3751
|
format: opts.format,
|
|
@@ -2767,7 +3755,22 @@ program.command("check <path>").description("Run SEO checks on local HTML files"
|
|
|
2767
3755
|
config: opts.config,
|
|
2768
3756
|
watch: opts.watch,
|
|
2769
3757
|
changed: opts.changed,
|
|
2770
|
-
staged: opts.staged
|
|
3758
|
+
staged: opts.staged,
|
|
3759
|
+
quiet: opts.quiet,
|
|
3760
|
+
noColor: opts.color === false,
|
|
3761
|
+
baseline: opts.baseline,
|
|
3762
|
+
updateBaseline: opts.updateBaseline,
|
|
3763
|
+
maxWarnings: opts.maxWarnings === void 0 ? void 0 : Number(opts.maxWarnings),
|
|
3764
|
+
maxCritical: opts.maxCritical === void 0 ? void 0 : Number(opts.maxCritical),
|
|
3765
|
+
minScore: opts.minScore === void 0 ? void 0 : Number(opts.minScore),
|
|
3766
|
+
annotations: opts.annotations,
|
|
3767
|
+
serve: opts.serve,
|
|
3768
|
+
servePort: opts.servePort === void 0 ? void 0 : Number(opts.servePort),
|
|
3769
|
+
open: opts.open,
|
|
3770
|
+
sync: opts.sync,
|
|
3771
|
+
websiteId: opts.websiteId,
|
|
3772
|
+
apiKey: opts.apiKey,
|
|
3773
|
+
apiUrl: opts.apiUrl
|
|
2771
3774
|
};
|
|
2772
3775
|
if (opts.watch) {
|
|
2773
3776
|
await watchCommand(options);
|
|
@@ -2776,22 +3779,37 @@ program.command("check <path>").description("Run SEO checks on local HTML files"
|
|
|
2776
3779
|
process.exit(exitCode);
|
|
2777
3780
|
}
|
|
2778
3781
|
});
|
|
2779
|
-
program.command("audit <url>").description("Audit a live URL (requires API key for full features)").option("--api-key <key>", "Rankture API key for Pro features").option("--
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
3782
|
+
program.command("audit <url>").description("Audit a live URL (requires API key for full features)").option("--api-key <key>", "Rankture API key for Pro features").option("--pages <number>", "Max pages to audit (Pro only)", "1").option("-f, --format <format>", "Output format: terminal, json", "terminal").option("--wait", "Wait for the audit to finish").option("--timeout <seconds>", "Maximum wait time", "900").option("--api-url <url>", "Rankture API origin override").action(async (url, opts) => {
|
|
3783
|
+
process.exit(await auditCommand(url, {
|
|
3784
|
+
apiKey: opts.apiKey,
|
|
3785
|
+
apiUrl: opts.apiUrl,
|
|
3786
|
+
pages: Number(opts.pages),
|
|
3787
|
+
wait: opts.wait,
|
|
3788
|
+
timeout: Number(opts.timeout),
|
|
3789
|
+
format: opts.format
|
|
3790
|
+
}));
|
|
3791
|
+
});
|
|
3792
|
+
program.command("login").description("Validate and securely store a Rankture API key").option("--api-key <key>", "Rankture CLI API key").option("--api-url <url>", "Rankture API origin", "https://rankture.com").action(async (opts) => process.exit(await loginCommand(opts.apiKey, opts.apiUrl)));
|
|
3793
|
+
program.command("logout").description("Remove the locally stored Rankture API key").action(() => process.exit(logoutCommand()));
|
|
3794
|
+
program.command("whoami").description("Show the authenticated Rankture account and scopes").action(async () => process.exit(await whoamiCommand()));
|
|
3795
|
+
program.command("websites").description("List websites available to the authenticated CLI account").option("--json", "Output machine-readable JSON").action(async (opts) => {
|
|
3796
|
+
try {
|
|
3797
|
+
const result = await getWebsites();
|
|
3798
|
+
if (opts.json) console.log(JSON.stringify(result.websites, null, 2));
|
|
3799
|
+
else for (const website of result.websites) console.log(`${website.id} ${website.url}`);
|
|
3800
|
+
process.exit(0);
|
|
3801
|
+
} catch (error) {
|
|
3802
|
+
console.error(error instanceof Error ? error.message : "Unable to list websites");
|
|
3803
|
+
process.exit(1);
|
|
3804
|
+
}
|
|
3805
|
+
});
|
|
3806
|
+
program.command("mcp").description("Start the local Rankture MCP server over stdio").option("--root <path>", "Restrict file access to this project root", process.cwd()).action(async (opts) => {
|
|
3807
|
+
await startMcpServer(opts.root);
|
|
2790
3808
|
});
|
|
2791
3809
|
program.command("init").description("Create a rankture.config.json file").option("--force", "Overwrite existing config file").action(async (opts) => {
|
|
2792
|
-
const
|
|
3810
|
+
const fs8 = await import("fs");
|
|
2793
3811
|
const configPath = "./rankture.config.json";
|
|
2794
|
-
if (
|
|
3812
|
+
if (fs8.existsSync(configPath) && !opts.force) {
|
|
2795
3813
|
console.log("");
|
|
2796
3814
|
console.log(" \u26A0\uFE0F rankture.config.json already exists");
|
|
2797
3815
|
console.log(" Use --force to overwrite");
|
|
@@ -2833,7 +3851,7 @@ program.command("init").description("Create a rankture.config.json file").option
|
|
|
2833
3851
|
descriptionLength: { min: 120, max: 160 }
|
|
2834
3852
|
}
|
|
2835
3853
|
};
|
|
2836
|
-
|
|
3854
|
+
fs8.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
2837
3855
|
console.log("");
|
|
2838
3856
|
console.log(" \u2705 Created rankture.config.json");
|
|
2839
3857
|
console.log("");
|
|
@@ -2863,8 +3881,9 @@ program.command("validate <type> <file>").description("Validate sitemap.xml, rob
|
|
|
2863
3881
|
printValidationResult("sitemap.xml", file, result);
|
|
2864
3882
|
process.exit(result.valid ? 0 : 1);
|
|
2865
3883
|
} else if (normalizedType === "schema") {
|
|
2866
|
-
|
|
2867
|
-
|
|
3884
|
+
const result = await validateSchema(file);
|
|
3885
|
+
printValidationResult("schema", file, result);
|
|
3886
|
+
process.exit(result.valid ? 0 : 1);
|
|
2868
3887
|
}
|
|
2869
3888
|
} catch (error) {
|
|
2870
3889
|
console.error(`
|
|
@@ -2874,4 +3893,3 @@ program.command("validate <type> <file>").description("Validate sitemap.xml, rob
|
|
|
2874
3893
|
}
|
|
2875
3894
|
});
|
|
2876
3895
|
program.parse();
|
|
2877
|
-
//# sourceMappingURL=cli.mjs.map
|