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