@rankture/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +637 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.mjs +2877 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.mjs +2648 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +72 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,2877 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/check.ts
|
|
7
|
+
import * as fs2 from "fs";
|
|
8
|
+
import * as path2 from "path";
|
|
9
|
+
import fg from "fast-glob";
|
|
10
|
+
import ora from "ora";
|
|
11
|
+
|
|
12
|
+
// src/parsers/html.ts
|
|
13
|
+
import * as cheerio from "cheerio";
|
|
14
|
+
var SUPPORTED_EXTENSIONS = {
|
|
15
|
+
// Built HTML output
|
|
16
|
+
html: [".html", ".htm"],
|
|
17
|
+
// Source files that can contain HTML-like content
|
|
18
|
+
source: [
|
|
19
|
+
".astro",
|
|
20
|
+
// Astro
|
|
21
|
+
".jsx",
|
|
22
|
+
".tsx",
|
|
23
|
+
// React/Next.js
|
|
24
|
+
".vue",
|
|
25
|
+
// Vue/Nuxt
|
|
26
|
+
".svelte",
|
|
27
|
+
// Svelte/SvelteKit
|
|
28
|
+
".md",
|
|
29
|
+
".mdx",
|
|
30
|
+
// Markdown
|
|
31
|
+
".njk",
|
|
32
|
+
// Nunjucks
|
|
33
|
+
".ejs",
|
|
34
|
+
// EJS
|
|
35
|
+
".hbs",
|
|
36
|
+
// Handlebars
|
|
37
|
+
".pug",
|
|
38
|
+
// Pug/Jade
|
|
39
|
+
".liquid"
|
|
40
|
+
// Liquid (Shopify, 11ty)
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
function parseHtml(filePath, html) {
|
|
44
|
+
const $ = cheerio.load(html);
|
|
45
|
+
const title = $("title").first().text().trim() || void 0;
|
|
46
|
+
const metaDescription = $('meta[name="description"]').attr("content")?.trim() || void 0;
|
|
47
|
+
const canonical = $('link[rel="canonical"]').attr("href")?.trim() || void 0;
|
|
48
|
+
const viewport = $('meta[name="viewport"]').attr("content")?.trim() || void 0;
|
|
49
|
+
const lang = $("html").attr("lang")?.trim() || void 0;
|
|
50
|
+
const h1s = [];
|
|
51
|
+
$("h1").each((_, el) => {
|
|
52
|
+
const text = $(el).text().trim();
|
|
53
|
+
if (text) h1s.push(text);
|
|
54
|
+
});
|
|
55
|
+
const headings = [];
|
|
56
|
+
$("h1, h2, h3, h4, h5, h6").each((_, el) => {
|
|
57
|
+
const tagName = el.tagName.toLowerCase();
|
|
58
|
+
const level = parseInt(tagName.replace("h", ""), 10);
|
|
59
|
+
const text = $(el).text().trim();
|
|
60
|
+
if (text) headings.push({ level, text });
|
|
61
|
+
});
|
|
62
|
+
const images = [];
|
|
63
|
+
$("img").each((_, el) => {
|
|
64
|
+
const src = $(el).attr("src") || "";
|
|
65
|
+
const alt = $(el).attr("alt");
|
|
66
|
+
images.push({ src, alt: alt !== void 0 ? alt : void 0 });
|
|
67
|
+
});
|
|
68
|
+
const internalLinks = [];
|
|
69
|
+
const externalLinks = [];
|
|
70
|
+
$("a[href]").each((_, el) => {
|
|
71
|
+
const href = $(el).attr("href") || "";
|
|
72
|
+
const text = $(el).text().trim();
|
|
73
|
+
if (href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) {
|
|
77
|
+
externalLinks.push({ href, text });
|
|
78
|
+
} else {
|
|
79
|
+
internalLinks.push({ href, text });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const jsonLd = [];
|
|
83
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
84
|
+
try {
|
|
85
|
+
const content = $(el).html();
|
|
86
|
+
if (content) {
|
|
87
|
+
const parsed = JSON.parse(content);
|
|
88
|
+
jsonLd.push(parsed);
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
const ogTags = {};
|
|
94
|
+
$('meta[property^="og:"]').each((_, el) => {
|
|
95
|
+
const property = $(el).attr("property");
|
|
96
|
+
const content = $(el).attr("content");
|
|
97
|
+
if (property && content) {
|
|
98
|
+
ogTags[property] = content;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const twitterTags = {};
|
|
102
|
+
$('meta[name^="twitter:"], meta[property^="twitter:"]').each((_, el) => {
|
|
103
|
+
const name = $(el).attr("name") || $(el).attr("property");
|
|
104
|
+
const content = $(el).attr("content");
|
|
105
|
+
if (name && content) {
|
|
106
|
+
twitterTags[name] = content;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const robots = $('meta[name="robots"]').attr("content")?.trim() || void 0;
|
|
110
|
+
return {
|
|
111
|
+
filePath,
|
|
112
|
+
html,
|
|
113
|
+
title,
|
|
114
|
+
metaDescription,
|
|
115
|
+
canonical,
|
|
116
|
+
viewport,
|
|
117
|
+
lang,
|
|
118
|
+
h1s,
|
|
119
|
+
headings,
|
|
120
|
+
images,
|
|
121
|
+
internalLinks,
|
|
122
|
+
externalLinks,
|
|
123
|
+
jsonLd,
|
|
124
|
+
ogTags,
|
|
125
|
+
twitterTags,
|
|
126
|
+
robots,
|
|
127
|
+
isSourceFile: false
|
|
128
|
+
// This is a built HTML file
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function parseSourceFile(filePath, content) {
|
|
132
|
+
const ext = filePath.toLowerCase().split(".").pop() || "";
|
|
133
|
+
let htmlContent = content;
|
|
134
|
+
switch (ext) {
|
|
135
|
+
case "astro":
|
|
136
|
+
htmlContent = parseAstroFile(content);
|
|
137
|
+
break;
|
|
138
|
+
case "vue":
|
|
139
|
+
htmlContent = parseVueFile(content);
|
|
140
|
+
break;
|
|
141
|
+
case "svelte":
|
|
142
|
+
htmlContent = parseSvelteFile(content);
|
|
143
|
+
break;
|
|
144
|
+
case "md":
|
|
145
|
+
case "mdx":
|
|
146
|
+
htmlContent = parseMarkdownFile(content);
|
|
147
|
+
break;
|
|
148
|
+
case "jsx":
|
|
149
|
+
case "tsx":
|
|
150
|
+
htmlContent = extractJsxContent(content);
|
|
151
|
+
break;
|
|
152
|
+
case "njk":
|
|
153
|
+
htmlContent = parseNunjucksFile(content);
|
|
154
|
+
break;
|
|
155
|
+
case "ejs":
|
|
156
|
+
htmlContent = parseEjsFile(content);
|
|
157
|
+
break;
|
|
158
|
+
case "hbs":
|
|
159
|
+
htmlContent = parseHandlebarsFile(content);
|
|
160
|
+
break;
|
|
161
|
+
case "pug":
|
|
162
|
+
htmlContent = parsePugFile(content);
|
|
163
|
+
break;
|
|
164
|
+
case "liquid":
|
|
165
|
+
htmlContent = parseLiquidFile(content);
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
htmlContent = content;
|
|
169
|
+
}
|
|
170
|
+
return parseHtmlLikeContent(filePath, content, htmlContent);
|
|
171
|
+
}
|
|
172
|
+
function parseAstroFile(content) {
|
|
173
|
+
const frontmatterMatch = content.match(/^---[\s\S]*?---\n?([\s\S]*)$/);
|
|
174
|
+
let htmlContent = frontmatterMatch ? frontmatterMatch[1] : content;
|
|
175
|
+
htmlContent = normalizeImageComponents(htmlContent);
|
|
176
|
+
htmlContent = normalizeLinkComponents(htmlContent);
|
|
177
|
+
return htmlContent;
|
|
178
|
+
}
|
|
179
|
+
function parseVueFile(content) {
|
|
180
|
+
let htmlContent = "";
|
|
181
|
+
const templateMatch = content.match(/<template[^>]*>([\s\S]*?)<\/template>/i);
|
|
182
|
+
if (templateMatch) {
|
|
183
|
+
htmlContent = templateMatch[1];
|
|
184
|
+
}
|
|
185
|
+
const scriptSetupMatch = content.match(/<script\s+setup[^>]*>([\s\S]*?)<\/script>/i);
|
|
186
|
+
if (scriptSetupMatch) {
|
|
187
|
+
const seoMetaMatch = scriptSetupMatch[1].match(/useSeoMeta\s*\(\s*{([^}]+)}/);
|
|
188
|
+
if (seoMetaMatch) {
|
|
189
|
+
const titleMatch = seoMetaMatch[1].match(/title:\s*['"`]([^'"`]+)['"`]/);
|
|
190
|
+
const descMatch = seoMetaMatch[1].match(/description:\s*['"`]([^'"`]+)['"`]/);
|
|
191
|
+
if (titleMatch) {
|
|
192
|
+
htmlContent = `<title>${titleMatch[1]}</title>` + htmlContent;
|
|
193
|
+
}
|
|
194
|
+
if (descMatch) {
|
|
195
|
+
htmlContent = `<meta name="description" content="${descMatch[1]}">` + htmlContent;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const useHeadMatch = scriptSetupMatch[1].match(/useHead\s*\(\s*{([^}]+)}/);
|
|
199
|
+
if (useHeadMatch) {
|
|
200
|
+
const titleMatch = useHeadMatch[1].match(/title:\s*['"`]([^'"`]+)['"`]/);
|
|
201
|
+
if (titleMatch) {
|
|
202
|
+
htmlContent = `<title>${titleMatch[1]}</title>` + htmlContent;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const pageMetaMatch = content.match(/definePageMeta\s*\(\s*{([^}]+)}/);
|
|
207
|
+
if (pageMetaMatch) {
|
|
208
|
+
const titleMatch = pageMetaMatch[1].match(/title:\s*['"`]([^'"`]+)['"`]/);
|
|
209
|
+
if (titleMatch) {
|
|
210
|
+
htmlContent = `<title>${titleMatch[1]}</title>` + htmlContent;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
htmlContent = normalizeImageComponents(htmlContent);
|
|
214
|
+
htmlContent = normalizeLinkComponents(htmlContent);
|
|
215
|
+
htmlContent = htmlContent.replace(
|
|
216
|
+
/<NuxtImg\s+([^>]*?)\s*\/>/gi,
|
|
217
|
+
(match, attrs) => `<img ${attrs}>`
|
|
218
|
+
);
|
|
219
|
+
htmlContent = htmlContent.replace(
|
|
220
|
+
/<NuxtImg\s+([^>]*?)>([\s\S]*?)<\/NuxtImg>/gi,
|
|
221
|
+
(match, attrs) => `<img ${attrs}>`
|
|
222
|
+
);
|
|
223
|
+
return htmlContent;
|
|
224
|
+
}
|
|
225
|
+
function parseSvelteFile(content) {
|
|
226
|
+
let htmlContent = content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
|
|
227
|
+
const headMatch = content.match(/<svelte:head[^>]*>([\s\S]*?)<\/svelte:head>/i);
|
|
228
|
+
if (headMatch) {
|
|
229
|
+
htmlContent = headMatch[1] + htmlContent;
|
|
230
|
+
}
|
|
231
|
+
const scriptMatch = content.match(/<script[^>]*>([\s\S]*?)<\/script>/i);
|
|
232
|
+
if (scriptMatch) {
|
|
233
|
+
scriptMatch[1].match(/export\s+const\s+\w+\s*=\s*{([^}]+)}/g);
|
|
234
|
+
}
|
|
235
|
+
htmlContent = normalizeImageComponents(htmlContent);
|
|
236
|
+
htmlContent = normalizeLinkComponents(htmlContent);
|
|
237
|
+
htmlContent = htmlContent.replace(/{#if\s+[^}]+}/g, "").replace(/{:else\s*(?:if\s+[^}]+)?}/g, "").replace(/{\/if}/g, "").replace(/{#each\s+[^}]+}/g, "").replace(/{\/each}/g, "").replace(/{#await\s+[^}]+}/g, "").replace(/{:then\s*[^}]*}/g, "").replace(/{:catch\s*[^}]*}/g, "").replace(/{\/await}/g, "").replace(/{@html\s+[^}]+}/g, "").replace(/{@const\s+[^}]+}/g, "");
|
|
238
|
+
return htmlContent;
|
|
239
|
+
}
|
|
240
|
+
function parseMarkdownFile(content) {
|
|
241
|
+
const frontmatterMatch = content.match(/^---[\s\S]*?---\n?([\s\S]*)$/);
|
|
242
|
+
let htmlContent = frontmatterMatch ? frontmatterMatch[1] : content;
|
|
243
|
+
htmlContent = convertMarkdownHeadings(htmlContent);
|
|
244
|
+
htmlContent = convertMarkdownImages(htmlContent);
|
|
245
|
+
htmlContent = convertMarkdownLinks(htmlContent);
|
|
246
|
+
return htmlContent;
|
|
247
|
+
}
|
|
248
|
+
function parseNunjucksFile(content) {
|
|
249
|
+
let htmlContent = content;
|
|
250
|
+
htmlContent = htmlContent.replace(/{%\s*block\s+\w+\s*%}/g, "").replace(/{%\s*endblock\s*%}/g, "").replace(/{%\s*extends\s+['"][^'"]+['"]\s*%}/g, "").replace(/{%\s*include\s+['"][^'"]+['"]\s*%}/g, "").replace(/{%\s*import\s+['"][^'"]+['"].*?%}/g, "").replace(/{%\s*from\s+['"][^'"]+['"].*?%}/g, "");
|
|
251
|
+
htmlContent = htmlContent.replace(/{%\s*if\s+.*?%}/g, "").replace(/{%\s*elif\s+.*?%}/g, "").replace(/{%\s*else\s*%}/g, "").replace(/{%\s*endif\s*%}/g, "").replace(/{%\s*for\s+.*?%}/g, "").replace(/{%\s*endfor\s*%}/g, "").replace(/{%\s*set\s+.*?%}/g, "");
|
|
252
|
+
htmlContent = htmlContent.replace(/{{\s*([^}]+)\s*}}/g, "[dynamic: $1]");
|
|
253
|
+
return htmlContent;
|
|
254
|
+
}
|
|
255
|
+
function parseEjsFile(content) {
|
|
256
|
+
let htmlContent = content;
|
|
257
|
+
htmlContent = htmlContent.replace(/<%[-_]?\s*[\s\S]*?[-_]?%>/g, "").replace(/<%[-_]?=\s*([^%]+)[-_]?%>/g, "[dynamic: $1]");
|
|
258
|
+
return htmlContent;
|
|
259
|
+
}
|
|
260
|
+
function parseHandlebarsFile(content) {
|
|
261
|
+
let htmlContent = content;
|
|
262
|
+
htmlContent = htmlContent.replace(/{{#\s*\w+.*?}}/g, "").replace(/{{\/\s*\w+\s*}}/g, "").replace(/{{>\s*[^}]+}}/g, "");
|
|
263
|
+
htmlContent = htmlContent.replace(/{{\s*([^}#/]+)\s*}}/g, "[dynamic: $1]");
|
|
264
|
+
return htmlContent;
|
|
265
|
+
}
|
|
266
|
+
function parsePugFile(content) {
|
|
267
|
+
let htmlContent = "";
|
|
268
|
+
const lines = content.split("\n");
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
const trimmed = line.trim();
|
|
271
|
+
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("-")) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const tagMatch = trimmed.match(/^(h[1-6]|p|a|img|title|meta)([.#(][^\s]*)?\s*(.*)?$/);
|
|
275
|
+
if (tagMatch) {
|
|
276
|
+
const [, tag, attrs, text] = tagMatch;
|
|
277
|
+
if (tag === "img") {
|
|
278
|
+
const srcMatch = (attrs || "").match(/src=['"]([^'"]+)['"]/);
|
|
279
|
+
const altMatch = (attrs || "").match(/alt=['"]([^'"]*)['"]/);
|
|
280
|
+
htmlContent += `<img src="${srcMatch?.[1] || ""}" ${altMatch ? `alt="${altMatch[1]}"` : ""}>
|
|
281
|
+
`;
|
|
282
|
+
} else if (tag === "a") {
|
|
283
|
+
const hrefMatch = (attrs || "").match(/href=['"]([^'"]+)['"]/);
|
|
284
|
+
htmlContent += `<a href="${hrefMatch?.[1] || ""}">${text || ""}</a>
|
|
285
|
+
`;
|
|
286
|
+
} else if (tag === "title") {
|
|
287
|
+
htmlContent += `<title>${text || ""}</title>
|
|
288
|
+
`;
|
|
289
|
+
} else if (tag === "meta") {
|
|
290
|
+
htmlContent += `<meta ${attrs?.replace(/[()]/g, "") || ""}>
|
|
291
|
+
`;
|
|
292
|
+
} else {
|
|
293
|
+
htmlContent += `<${tag}>${text || ""}</${tag}>
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return htmlContent;
|
|
299
|
+
}
|
|
300
|
+
function parseLiquidFile(content) {
|
|
301
|
+
let htmlContent = content;
|
|
302
|
+
htmlContent = htmlContent.replace(/{%\s*comment\s*%}[\s\S]*?{%\s*endcomment\s*%}/g, "").replace(/{%\s*schema\s*%}[\s\S]*?{%\s*endschema\s*%}/g, "").replace(/{%\s*style\s*%}[\s\S]*?{%\s*endstyle\s*%}/g, "").replace(/{%\s*javascript\s*%}[\s\S]*?{%\s*endjavascript\s*%}/g, "").replace(/{%\s*capture\s+\w+\s*%}/g, "").replace(/{%\s*endcapture\s*%}/g, "").replace(/{%\s*if\s+.*?%}/g, "").replace(/{%\s*elsif\s+.*?%}/g, "").replace(/{%\s*else\s*%}/g, "").replace(/{%\s*endif\s*%}/g, "").replace(/{%\s*for\s+.*?%}/g, "").replace(/{%\s*endfor\s*%}/g, "").replace(/{%\s*unless\s+.*?%}/g, "").replace(/{%\s*endunless\s*%}/g, "").replace(/{%\s*assign\s+.*?%}/g, "").replace(/{%\s*render\s+.*?%}/g, "").replace(/{%\s*include\s+.*?%}/g, "").replace(/{%\s*section\s+.*?%}/g, "");
|
|
303
|
+
htmlContent = htmlContent.replace(/{{\s*([^}|]+)(?:\|[^}]*)?\s*}}/g, "[dynamic: $1]");
|
|
304
|
+
return htmlContent;
|
|
305
|
+
}
|
|
306
|
+
function convertMarkdownHeadings(content) {
|
|
307
|
+
return content.replace(/^######\s+(.+)$/gm, "<h6>$1</h6>").replace(/^#####\s+(.+)$/gm, "<h5>$1</h5>").replace(/^####\s+(.+)$/gm, "<h4>$1</h4>").replace(/^###\s+(.+)$/gm, "<h3>$1</h3>").replace(/^##\s+(.+)$/gm, "<h2>$1</h2>").replace(/^#\s+(.+)$/gm, "<h1>$1</h1>");
|
|
308
|
+
}
|
|
309
|
+
function convertMarkdownImages(content) {
|
|
310
|
+
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2">');
|
|
311
|
+
}
|
|
312
|
+
function convertMarkdownLinks(content) {
|
|
313
|
+
return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
314
|
+
}
|
|
315
|
+
function extractJsxContent(content) {
|
|
316
|
+
let jsxContent = "";
|
|
317
|
+
const withoutImports = content.replace(/^import\s+.*?;?\s*$/gm, "");
|
|
318
|
+
const returnBlocks = [];
|
|
319
|
+
const standardReturns = withoutImports.match(/return\s*\([\s\S]*?\n\s*\);/g);
|
|
320
|
+
if (standardReturns) {
|
|
321
|
+
returnBlocks.push(...standardReturns);
|
|
322
|
+
}
|
|
323
|
+
const directReturns = withoutImports.match(/return\s+<[A-Z][a-zA-Z]*[\s\S]*?<\/[A-Z][a-zA-Z]*>/g);
|
|
324
|
+
if (directReturns) {
|
|
325
|
+
returnBlocks.push(...directReturns);
|
|
326
|
+
}
|
|
327
|
+
const arrowReturns = withoutImports.match(/=>\s*\([\s\S]*?\n\s*\)/g);
|
|
328
|
+
if (arrowReturns) {
|
|
329
|
+
returnBlocks.push(...arrowReturns);
|
|
330
|
+
}
|
|
331
|
+
const arrowDirectReturns = withoutImports.match(/=>\s*<[a-zA-Z][^;]+/g);
|
|
332
|
+
if (arrowDirectReturns) {
|
|
333
|
+
returnBlocks.push(...arrowDirectReturns);
|
|
334
|
+
}
|
|
335
|
+
if (returnBlocks.length > 0) {
|
|
336
|
+
jsxContent = returnBlocks.join("\n");
|
|
337
|
+
} else {
|
|
338
|
+
jsxContent = withoutImports;
|
|
339
|
+
}
|
|
340
|
+
const headMatch = content.match(/<Head[^>]*>([\s\S]*?)<\/Head>/gi);
|
|
341
|
+
if (headMatch) {
|
|
342
|
+
jsxContent = headMatch.join("\n") + jsxContent;
|
|
343
|
+
}
|
|
344
|
+
const metadataMatch = content.match(/export\s+const\s+metadata(?:\s*:\s*\w+)?\s*=\s*\{([\s\S]*?)\n\}/);
|
|
345
|
+
if (metadataMatch) {
|
|
346
|
+
const metaContent = metadataMatch[1];
|
|
347
|
+
const titleMatch = metaContent.match(/title:\s*['"`]([^'"`]+)['"`]/);
|
|
348
|
+
const descMatch = metaContent.match(/description:\s*['"`]([^'"`]+)['"`]/);
|
|
349
|
+
if (titleMatch) {
|
|
350
|
+
jsxContent = `<title>${titleMatch[1]}</title>` + jsxContent;
|
|
351
|
+
}
|
|
352
|
+
if (descMatch) {
|
|
353
|
+
jsxContent = `<meta name="description" content="${descMatch[1]}">` + jsxContent;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const generateMetadataMatch = content.match(/export\s+(?:async\s+)?function\s+generateMetadata[\s\S]*?return\s*\{([\s\S]*?)\n\s*\}/);
|
|
357
|
+
if (generateMetadataMatch) {
|
|
358
|
+
const metaContent = generateMetadataMatch[1];
|
|
359
|
+
const titleMatch = metaContent.match(/title:\s*['"`]([^'"`]+)['"`]/);
|
|
360
|
+
const descMatch = metaContent.match(/description:\s*['"`]([^'"`]+)['"`]/);
|
|
361
|
+
if (titleMatch) {
|
|
362
|
+
jsxContent = `<title>${titleMatch[1]}</title>` + jsxContent;
|
|
363
|
+
}
|
|
364
|
+
if (descMatch) {
|
|
365
|
+
jsxContent = `<meta name="description" content="${descMatch[1]}">` + jsxContent;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
jsxContent = jsxContent.replace(/className=/g, "class=");
|
|
369
|
+
jsxContent = jsxContent.replace(/<(\w+)([^>]*)\s*\/>/g, "<$1$2></$1>");
|
|
370
|
+
jsxContent = normalizeImageComponents(jsxContent);
|
|
371
|
+
jsxContent = normalizeLinkComponents(jsxContent);
|
|
372
|
+
return jsxContent;
|
|
373
|
+
}
|
|
374
|
+
function normalizeImageComponents(content) {
|
|
375
|
+
let normalized = content.replace(
|
|
376
|
+
/<Image\s+([^>]*?)\s*\/>/gi,
|
|
377
|
+
(match, attrs) => {
|
|
378
|
+
return `<img ${attrs}>`;
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
normalized = normalized.replace(
|
|
382
|
+
/<Image\s+([^>]*?)>([\s\S]*?)<\/Image>/gi,
|
|
383
|
+
(match, attrs, _children) => {
|
|
384
|
+
return `<img ${attrs}>`;
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
normalized = normalized.replace(
|
|
388
|
+
/<Img\s+([^>]*?)\s*\/>/gi,
|
|
389
|
+
(match, attrs) => {
|
|
390
|
+
return `<img ${attrs}>`;
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
normalized = normalized.replace(
|
|
394
|
+
/<StaticImage\s+([^>]*?)\s*\/>/gi,
|
|
395
|
+
(match, attrs) => {
|
|
396
|
+
return `<img ${attrs}>`;
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
normalized = normalized.replace(
|
|
400
|
+
/<GatsbyImage\s+([^>]*?)\s*\/>/gi,
|
|
401
|
+
(match, attrs) => {
|
|
402
|
+
return `<img ${attrs}>`;
|
|
403
|
+
}
|
|
404
|
+
);
|
|
405
|
+
const pictureMatch = normalized.match(/<picture[^>]*>[\s\S]*?<img\s+([^>]*?)>[\s\S]*?<\/picture>/gi);
|
|
406
|
+
if (pictureMatch) {
|
|
407
|
+
}
|
|
408
|
+
return normalized;
|
|
409
|
+
}
|
|
410
|
+
function normalizeLinkComponents(content) {
|
|
411
|
+
let normalized = content.replace(
|
|
412
|
+
/<Link\s+([^>]*?)>([\s\S]*?)<\/Link>/gi,
|
|
413
|
+
(match, attrs, children) => {
|
|
414
|
+
const normalizedAttrs = attrs.replace(/\bto=/g, "href=");
|
|
415
|
+
return `<a ${normalizedAttrs}>${children}</a>`;
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
normalized = normalized.replace(
|
|
419
|
+
/<Link\s+([^>]*?)\s*\/>/gi,
|
|
420
|
+
(match, attrs) => {
|
|
421
|
+
const normalizedAttrs = attrs.replace(/\bto=/g, "href=");
|
|
422
|
+
return `<a ${normalizedAttrs}></a>`;
|
|
423
|
+
}
|
|
424
|
+
);
|
|
425
|
+
normalized = normalized.replace(/client:\w+/g, "");
|
|
426
|
+
normalized = normalized.replace(
|
|
427
|
+
/<NuxtLink\s+([^>]*?)>([\s\S]*?)<\/NuxtLink>/gi,
|
|
428
|
+
(match, attrs, children) => {
|
|
429
|
+
const normalizedAttrs = attrs.replace(/\bto=/g, "href=");
|
|
430
|
+
return `<a ${normalizedAttrs}>${children}</a>`;
|
|
431
|
+
}
|
|
432
|
+
);
|
|
433
|
+
normalized = normalized.replace(/sveltekit:\w+/g, "");
|
|
434
|
+
normalized = normalized.replace(/data-sveltekit-\w+="[^"]*"/g, "");
|
|
435
|
+
return normalized;
|
|
436
|
+
}
|
|
437
|
+
function parseHtmlLikeContent(filePath, originalContent, htmlContent) {
|
|
438
|
+
const $ = cheerio.load(htmlContent, { xml: false });
|
|
439
|
+
const h1s = [];
|
|
440
|
+
$("h1").each((_, el) => {
|
|
441
|
+
let text = $(el).text().trim();
|
|
442
|
+
const html = $(el).html() || "";
|
|
443
|
+
if (html.includes("{") && html.includes("}")) {
|
|
444
|
+
text = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
|
|
445
|
+
}
|
|
446
|
+
if (text) h1s.push(text);
|
|
447
|
+
});
|
|
448
|
+
const mdH1Match = originalContent.match(/^#\s+(.+)$/m);
|
|
449
|
+
if (mdH1Match && h1s.length === 0) {
|
|
450
|
+
h1s.push(mdH1Match[1]);
|
|
451
|
+
}
|
|
452
|
+
const headings = [];
|
|
453
|
+
$("h1, h2, h3, h4, h5, h6").each((_, el) => {
|
|
454
|
+
const tagName = el.tagName.toLowerCase();
|
|
455
|
+
const level = parseInt(tagName.replace("h", ""), 10);
|
|
456
|
+
let text = $(el).text().trim();
|
|
457
|
+
const html = $(el).html() || "";
|
|
458
|
+
if (html.includes("{") && html.includes("}")) {
|
|
459
|
+
text = `[dynamic: ${html.replace(/<[^>]+>/g, "").trim()}]`;
|
|
460
|
+
}
|
|
461
|
+
if (text) headings.push({ level, text });
|
|
462
|
+
});
|
|
463
|
+
const images = [];
|
|
464
|
+
$("img").each((_, el) => {
|
|
465
|
+
let src = $(el).attr("src") || "";
|
|
466
|
+
const alt = $(el).attr("alt");
|
|
467
|
+
if (!src) {
|
|
468
|
+
const srcAttr = $(el).attr(":src") || $(el).attr("v-bind:src");
|
|
469
|
+
if (srcAttr) src = `[dynamic: ${srcAttr}]`;
|
|
470
|
+
}
|
|
471
|
+
images.push({ src, alt: alt !== void 0 ? alt : void 0 });
|
|
472
|
+
});
|
|
473
|
+
$("Image").each((_, el) => {
|
|
474
|
+
const src = $(el).attr("src") || "[dynamic]";
|
|
475
|
+
const alt = $(el).attr("alt");
|
|
476
|
+
images.push({ src, alt: alt !== void 0 ? alt : void 0 });
|
|
477
|
+
});
|
|
478
|
+
const internalLinks = [];
|
|
479
|
+
const externalLinks = [];
|
|
480
|
+
$("a[href], Link[href], Link[to]").each((_, el) => {
|
|
481
|
+
let href = $(el).attr("href") || $(el).attr("to") || "";
|
|
482
|
+
const text = $(el).text().trim();
|
|
483
|
+
if (!href || href === "#" || href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (href.startsWith("{") || href.includes("${")) {
|
|
487
|
+
href = `[dynamic: ${href}]`;
|
|
488
|
+
}
|
|
489
|
+
if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) {
|
|
490
|
+
externalLinks.push({ href, text });
|
|
491
|
+
} else {
|
|
492
|
+
internalLinks.push({ href, text });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
let title;
|
|
496
|
+
const parsedTitle = $("title").first().text().trim();
|
|
497
|
+
if (parsedTitle) {
|
|
498
|
+
title = parsedTitle;
|
|
499
|
+
}
|
|
500
|
+
if (!title) {
|
|
501
|
+
const titleMatch = originalContent.match(/^title:\s*["']?([^"'\n]+)["']?/m);
|
|
502
|
+
if (titleMatch) {
|
|
503
|
+
title = titleMatch[1].trim();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
let metaDescription;
|
|
507
|
+
const parsedDesc = $('meta[name="description"]').attr("content")?.trim();
|
|
508
|
+
if (parsedDesc) {
|
|
509
|
+
metaDescription = parsedDesc;
|
|
510
|
+
}
|
|
511
|
+
if (!metaDescription) {
|
|
512
|
+
const descMatch = originalContent.match(/^description:\s*["']?([^"'\n]+)["']?/m);
|
|
513
|
+
if (descMatch) {
|
|
514
|
+
metaDescription = descMatch[1].trim();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
filePath,
|
|
519
|
+
html: originalContent,
|
|
520
|
+
title,
|
|
521
|
+
metaDescription,
|
|
522
|
+
canonical: void 0,
|
|
523
|
+
viewport: void 0,
|
|
524
|
+
// Source files usually don't have viewport
|
|
525
|
+
lang: void 0,
|
|
526
|
+
h1s,
|
|
527
|
+
headings,
|
|
528
|
+
images,
|
|
529
|
+
internalLinks,
|
|
530
|
+
externalLinks,
|
|
531
|
+
jsonLd: [],
|
|
532
|
+
ogTags: {},
|
|
533
|
+
twitterTags: {},
|
|
534
|
+
robots: void 0,
|
|
535
|
+
isSourceFile: true
|
|
536
|
+
// This is a source file
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/checks/meta-tags.ts
|
|
541
|
+
function checkMetaTitle(page, allPages) {
|
|
542
|
+
const results = [];
|
|
543
|
+
if (!page.title) {
|
|
544
|
+
results.push({
|
|
545
|
+
checkId: "meta-title-missing",
|
|
546
|
+
name: "Missing Title Tag",
|
|
547
|
+
severity: "critical",
|
|
548
|
+
passed: false,
|
|
549
|
+
file: page.filePath,
|
|
550
|
+
message: "Page has no <title> tag",
|
|
551
|
+
fix: "Add a unique, descriptive <title> tag in the <head> section"
|
|
552
|
+
});
|
|
553
|
+
return results;
|
|
554
|
+
}
|
|
555
|
+
const titleLength = page.title.length;
|
|
556
|
+
if (titleLength < 30) {
|
|
557
|
+
results.push({
|
|
558
|
+
checkId: "meta-title-length",
|
|
559
|
+
name: "Title Too Short",
|
|
560
|
+
severity: "warning",
|
|
561
|
+
passed: false,
|
|
562
|
+
file: page.filePath,
|
|
563
|
+
message: `Title is too short (${titleLength} chars, recommended 30-60)`,
|
|
564
|
+
fix: "Expand your title to be more descriptive (30-60 characters)"
|
|
565
|
+
});
|
|
566
|
+
} else if (titleLength > 60) {
|
|
567
|
+
results.push({
|
|
568
|
+
checkId: "meta-title-length",
|
|
569
|
+
name: "Title Too Long",
|
|
570
|
+
severity: "warning",
|
|
571
|
+
passed: false,
|
|
572
|
+
file: page.filePath,
|
|
573
|
+
message: `Title is too long (${titleLength} chars, recommended 30-60)`,
|
|
574
|
+
fix: "Shorten your title to under 60 characters to prevent truncation in search results"
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
const duplicates = allPages.filter(
|
|
578
|
+
(p) => p.filePath !== page.filePath && p.title === page.title
|
|
579
|
+
);
|
|
580
|
+
if (duplicates.length > 0) {
|
|
581
|
+
results.push({
|
|
582
|
+
checkId: "meta-title-duplicate",
|
|
583
|
+
name: "Duplicate Title",
|
|
584
|
+
severity: "warning",
|
|
585
|
+
passed: false,
|
|
586
|
+
file: page.filePath,
|
|
587
|
+
message: `Title is duplicated on ${duplicates.length} other page(s)`,
|
|
588
|
+
fix: "Each page should have a unique title",
|
|
589
|
+
details: duplicates.map((p) => p.filePath)
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return results;
|
|
593
|
+
}
|
|
594
|
+
function checkMetaDescription(page, allPages) {
|
|
595
|
+
const results = [];
|
|
596
|
+
if (!page.metaDescription) {
|
|
597
|
+
results.push({
|
|
598
|
+
checkId: "meta-description-missing",
|
|
599
|
+
name: "Missing Meta Description",
|
|
600
|
+
severity: "warning",
|
|
601
|
+
passed: false,
|
|
602
|
+
file: page.filePath,
|
|
603
|
+
message: "Page has no meta description",
|
|
604
|
+
fix: 'Add a <meta name="description" content="..."> tag in the <head> section'
|
|
605
|
+
});
|
|
606
|
+
return results;
|
|
607
|
+
}
|
|
608
|
+
const descLength = page.metaDescription.length;
|
|
609
|
+
if (descLength < 120) {
|
|
610
|
+
results.push({
|
|
611
|
+
checkId: "meta-description-length",
|
|
612
|
+
name: "Meta Description Too Short",
|
|
613
|
+
severity: "warning",
|
|
614
|
+
passed: false,
|
|
615
|
+
file: page.filePath,
|
|
616
|
+
message: `Meta description is too short (${descLength} chars, recommended 120-160)`,
|
|
617
|
+
fix: "Expand your meta description to be more descriptive (120-160 characters)"
|
|
618
|
+
});
|
|
619
|
+
} else if (descLength > 160) {
|
|
620
|
+
results.push({
|
|
621
|
+
checkId: "meta-description-length",
|
|
622
|
+
name: "Meta Description Too Long",
|
|
623
|
+
severity: "warning",
|
|
624
|
+
passed: false,
|
|
625
|
+
file: page.filePath,
|
|
626
|
+
message: `Meta description is too long (${descLength} chars, recommended 120-160)`,
|
|
627
|
+
fix: "Shorten your meta description to under 160 characters to prevent truncation"
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
const duplicates = allPages.filter(
|
|
631
|
+
(p) => p.filePath !== page.filePath && p.metaDescription === page.metaDescription
|
|
632
|
+
);
|
|
633
|
+
if (duplicates.length > 0) {
|
|
634
|
+
results.push({
|
|
635
|
+
checkId: "meta-description-duplicate",
|
|
636
|
+
name: "Duplicate Meta Description",
|
|
637
|
+
severity: "warning",
|
|
638
|
+
passed: false,
|
|
639
|
+
file: page.filePath,
|
|
640
|
+
message: `Meta description is duplicated on ${duplicates.length} other page(s)`,
|
|
641
|
+
fix: "Each page should have a unique meta description",
|
|
642
|
+
details: duplicates.map((p) => p.filePath)
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return results;
|
|
646
|
+
}
|
|
647
|
+
function checkViewport(page) {
|
|
648
|
+
const results = [];
|
|
649
|
+
if (page.isSourceFile) {
|
|
650
|
+
return results;
|
|
651
|
+
}
|
|
652
|
+
if (!page.viewport) {
|
|
653
|
+
results.push({
|
|
654
|
+
checkId: "viewport-missing",
|
|
655
|
+
name: "Missing Viewport Meta Tag",
|
|
656
|
+
severity: "critical",
|
|
657
|
+
passed: false,
|
|
658
|
+
file: page.filePath,
|
|
659
|
+
message: "Page has no viewport meta tag",
|
|
660
|
+
fix: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> in the <head>'
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return results;
|
|
664
|
+
}
|
|
665
|
+
function checkLang(page) {
|
|
666
|
+
const results = [];
|
|
667
|
+
if (page.isSourceFile) {
|
|
668
|
+
return results;
|
|
669
|
+
}
|
|
670
|
+
if (!page.lang) {
|
|
671
|
+
results.push({
|
|
672
|
+
checkId: "lang-missing",
|
|
673
|
+
name: "Missing Language Attribute",
|
|
674
|
+
severity: "warning",
|
|
675
|
+
passed: false,
|
|
676
|
+
file: page.filePath,
|
|
677
|
+
message: "Page has no lang attribute on <html> tag",
|
|
678
|
+
fix: 'Add lang="en" (or appropriate language code) to the <html> tag'
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return results;
|
|
682
|
+
}
|
|
683
|
+
function checkCanonical(page) {
|
|
684
|
+
const results = [];
|
|
685
|
+
if (page.isSourceFile) {
|
|
686
|
+
return results;
|
|
687
|
+
}
|
|
688
|
+
if (!page.canonical) {
|
|
689
|
+
results.push({
|
|
690
|
+
checkId: "canonical-missing",
|
|
691
|
+
name: "Missing Canonical URL",
|
|
692
|
+
severity: "warning",
|
|
693
|
+
passed: false,
|
|
694
|
+
file: page.filePath,
|
|
695
|
+
message: "Page has no canonical URL defined",
|
|
696
|
+
fix: 'Add <link rel="canonical" href="..."> in the <head> section'
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return results;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/checks/headings.ts
|
|
703
|
+
function checkH1(page) {
|
|
704
|
+
const results = [];
|
|
705
|
+
if (page.h1s.length === 0) {
|
|
706
|
+
results.push({
|
|
707
|
+
checkId: "h1-missing",
|
|
708
|
+
name: "Missing H1 Tag",
|
|
709
|
+
severity: "critical",
|
|
710
|
+
passed: false,
|
|
711
|
+
file: page.filePath,
|
|
712
|
+
message: "Page has no H1 tag",
|
|
713
|
+
fix: "Add a single H1 tag that describes the main content of the page"
|
|
714
|
+
});
|
|
715
|
+
} else if (page.h1s.length > 1) {
|
|
716
|
+
results.push({
|
|
717
|
+
checkId: "h1-multiple",
|
|
718
|
+
name: "Multiple H1 Tags",
|
|
719
|
+
severity: "warning",
|
|
720
|
+
passed: false,
|
|
721
|
+
file: page.filePath,
|
|
722
|
+
message: `Page has ${page.h1s.length} H1 tags (should have exactly 1)`,
|
|
723
|
+
fix: "Use only one H1 tag per page. Use H2-H6 for subheadings.",
|
|
724
|
+
details: page.h1s
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
return results;
|
|
728
|
+
}
|
|
729
|
+
function checkHeadingHierarchy(page) {
|
|
730
|
+
const results = [];
|
|
731
|
+
if (page.headings.length === 0) {
|
|
732
|
+
return results;
|
|
733
|
+
}
|
|
734
|
+
let prevLevel = 0;
|
|
735
|
+
const skippedLevels = [];
|
|
736
|
+
for (const heading of page.headings) {
|
|
737
|
+
if (prevLevel > 0 && heading.level > prevLevel + 1) {
|
|
738
|
+
skippedLevels.push(`H${prevLevel} \u2192 H${heading.level} ("${heading.text.substring(0, 30)}...")`);
|
|
739
|
+
}
|
|
740
|
+
prevLevel = heading.level;
|
|
741
|
+
}
|
|
742
|
+
if (skippedLevels.length > 0) {
|
|
743
|
+
results.push({
|
|
744
|
+
checkId: "heading-hierarchy",
|
|
745
|
+
name: "Skipped Heading Levels",
|
|
746
|
+
severity: "warning",
|
|
747
|
+
passed: false,
|
|
748
|
+
file: page.filePath,
|
|
749
|
+
message: `Heading hierarchy skips levels in ${skippedLevels.length} place(s)`,
|
|
750
|
+
fix: "Use headings in order (H1 \u2192 H2 \u2192 H3). Don't skip levels.",
|
|
751
|
+
details: skippedLevels
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return results;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/checks/images.ts
|
|
758
|
+
function checkImageAlt(page) {
|
|
759
|
+
const results = [];
|
|
760
|
+
const missingAlt = page.images.filter((img) => img.alt === void 0);
|
|
761
|
+
if (missingAlt.length > 0) {
|
|
762
|
+
results.push({
|
|
763
|
+
checkId: "image-alt-missing",
|
|
764
|
+
name: "Images Missing Alt Text",
|
|
765
|
+
severity: "warning",
|
|
766
|
+
passed: false,
|
|
767
|
+
file: page.filePath,
|
|
768
|
+
message: `${missingAlt.length} image(s) missing alt attribute`,
|
|
769
|
+
fix: "Add descriptive alt text to all images for accessibility and SEO",
|
|
770
|
+
details: missingAlt.map((img) => img.src)
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
const emptyAlt = page.images.filter((img) => img.alt === "");
|
|
774
|
+
if (emptyAlt.length > 0) {
|
|
775
|
+
results.push({
|
|
776
|
+
checkId: "image-alt-empty",
|
|
777
|
+
name: "Images With Empty Alt Text",
|
|
778
|
+
severity: "info",
|
|
779
|
+
passed: true,
|
|
780
|
+
// Empty alt is valid for decorative images
|
|
781
|
+
file: page.filePath,
|
|
782
|
+
message: `${emptyAlt.length} image(s) have empty alt="" (okay for decorative images)`,
|
|
783
|
+
details: emptyAlt.map((img) => img.src)
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
return results;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/checks/links.ts
|
|
790
|
+
import * as path from "path";
|
|
791
|
+
import * as fs from "fs";
|
|
792
|
+
function checkInternalLinks(page, allPages, basePath) {
|
|
793
|
+
const results = [];
|
|
794
|
+
const brokenLinks = [];
|
|
795
|
+
const existingPaths = new Set(
|
|
796
|
+
allPages.map((p) => {
|
|
797
|
+
const rel = path.relative(basePath, p.filePath);
|
|
798
|
+
return "/" + rel.replace(/\\/g, "/").replace(/index\.html$/, "").replace(/\.html$/, "");
|
|
799
|
+
})
|
|
800
|
+
);
|
|
801
|
+
allPages.forEach((p) => {
|
|
802
|
+
const rel = path.relative(basePath, p.filePath);
|
|
803
|
+
existingPaths.add("/" + rel.replace(/\\/g, "/"));
|
|
804
|
+
});
|
|
805
|
+
for (const link of page.internalLinks) {
|
|
806
|
+
let href = link.href;
|
|
807
|
+
if (!href || href.startsWith("#") || href.startsWith("javascript:")) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (!href.startsWith("/")) {
|
|
811
|
+
const pageDir = path.dirname(page.filePath);
|
|
812
|
+
const absolutePath = path.resolve(pageDir, href);
|
|
813
|
+
href = "/" + path.relative(basePath, absolutePath).replace(/\\/g, "/");
|
|
814
|
+
}
|
|
815
|
+
const normalizedHref = href.split("#")[0].split("?")[0].replace(/\/$/, "");
|
|
816
|
+
const targetExists = existingPaths.has(normalizedHref) || existingPaths.has(normalizedHref + "/") || existingPaths.has(normalizedHref + "/index.html") || existingPaths.has(normalizedHref + ".html") || // Check if actual file exists (for non-HTML assets)
|
|
817
|
+
fs.existsSync(path.join(basePath, normalizedHref));
|
|
818
|
+
if (!targetExists && normalizedHref !== "" && normalizedHref !== "/") {
|
|
819
|
+
brokenLinks.push(link.href);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if (brokenLinks.length > 0) {
|
|
823
|
+
results.push({
|
|
824
|
+
checkId: "link-broken-internal",
|
|
825
|
+
name: "Broken Internal Links",
|
|
826
|
+
severity: "critical",
|
|
827
|
+
passed: false,
|
|
828
|
+
file: page.filePath,
|
|
829
|
+
message: `${brokenLinks.length} internal link(s) point to non-existent pages`,
|
|
830
|
+
fix: "Fix or remove links to pages that don't exist",
|
|
831
|
+
details: [...new Set(brokenLinks)]
|
|
832
|
+
// Dedupe
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
const emptyLinks = page.internalLinks.filter(
|
|
836
|
+
(link) => !link.href || link.href === "#"
|
|
837
|
+
);
|
|
838
|
+
if (emptyLinks.length > 0) {
|
|
839
|
+
results.push({
|
|
840
|
+
checkId: "link-empty-href",
|
|
841
|
+
name: "Empty Link Href",
|
|
842
|
+
severity: "warning",
|
|
843
|
+
passed: false,
|
|
844
|
+
file: page.filePath,
|
|
845
|
+
message: `${emptyLinks.length} link(s) have empty or "#" href`,
|
|
846
|
+
fix: "Add proper href values to all links or use buttons for non-navigation actions"
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
return results;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/checks/schema.ts
|
|
853
|
+
function checkSchema(page) {
|
|
854
|
+
const results = [];
|
|
855
|
+
for (const schema of page.jsonLd) {
|
|
856
|
+
if (!("@context" in schema)) {
|
|
857
|
+
results.push({
|
|
858
|
+
checkId: "schema-missing-context",
|
|
859
|
+
name: "Schema Missing @context",
|
|
860
|
+
severity: "warning",
|
|
861
|
+
passed: false,
|
|
862
|
+
file: page.filePath,
|
|
863
|
+
message: "JSON-LD is missing @context property",
|
|
864
|
+
fix: 'Add "@context": "https://schema.org" to your JSON-LD'
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
if (!("@type" in schema)) {
|
|
868
|
+
results.push({
|
|
869
|
+
checkId: "schema-missing-type",
|
|
870
|
+
name: "Schema Missing @type",
|
|
871
|
+
severity: "warning",
|
|
872
|
+
passed: false,
|
|
873
|
+
file: page.filePath,
|
|
874
|
+
message: "JSON-LD is missing @type property",
|
|
875
|
+
fix: 'Add "@type": "YourSchemaType" to your JSON-LD'
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return results;
|
|
880
|
+
}
|
|
881
|
+
function checkSchemaJsonValidity(page) {
|
|
882
|
+
const results = [];
|
|
883
|
+
const jsonLdRegex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
884
|
+
let match;
|
|
885
|
+
let index = 0;
|
|
886
|
+
while ((match = jsonLdRegex.exec(page.html)) !== null) {
|
|
887
|
+
index++;
|
|
888
|
+
const content = match[1].trim();
|
|
889
|
+
if (!content) {
|
|
890
|
+
results.push({
|
|
891
|
+
checkId: "schema-empty",
|
|
892
|
+
name: "Empty JSON-LD Script",
|
|
893
|
+
severity: "warning",
|
|
894
|
+
passed: false,
|
|
895
|
+
file: page.filePath,
|
|
896
|
+
message: `JSON-LD script #${index} is empty`,
|
|
897
|
+
fix: "Add valid JSON-LD content or remove the empty script tag"
|
|
898
|
+
});
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
JSON.parse(content);
|
|
903
|
+
} catch (error) {
|
|
904
|
+
results.push({
|
|
905
|
+
checkId: "schema-invalid-json",
|
|
906
|
+
name: "Invalid JSON-LD Syntax",
|
|
907
|
+
severity: "critical",
|
|
908
|
+
passed: false,
|
|
909
|
+
file: page.filePath,
|
|
910
|
+
message: `JSON-LD script #${index} has invalid JSON syntax`,
|
|
911
|
+
fix: "Fix the JSON syntax error in your structured data",
|
|
912
|
+
details: [error instanceof Error ? error.message : "Unknown error"]
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return results;
|
|
917
|
+
}
|
|
918
|
+
function checkSpeakableSchema(page) {
|
|
919
|
+
const results = [];
|
|
920
|
+
const schemas = page.jsonLd;
|
|
921
|
+
const hasSpeakable = schemas.some((schema) => {
|
|
922
|
+
if ("speakable" in schema) return true;
|
|
923
|
+
if (typeof schema === "object") {
|
|
924
|
+
return JSON.stringify(schema).includes('"speakable"');
|
|
925
|
+
}
|
|
926
|
+
return false;
|
|
927
|
+
});
|
|
928
|
+
const isContentPage = schemas.some((schema) => {
|
|
929
|
+
const type = schema["@type"];
|
|
930
|
+
return ["Article", "NewsArticle", "BlogPosting", "WebPage", "FAQPage", "Product", "Service"].includes(type);
|
|
931
|
+
});
|
|
932
|
+
if (isContentPage && !hasSpeakable) {
|
|
933
|
+
results.push({
|
|
934
|
+
checkId: "schema-speakable-missing",
|
|
935
|
+
name: "Speakable Schema Missing",
|
|
936
|
+
severity: "info",
|
|
937
|
+
passed: false,
|
|
938
|
+
file: page.filePath,
|
|
939
|
+
message: "Content page lacks speakable schema for voice search/AI optimization",
|
|
940
|
+
fix: 'Add speakable specification to help voice assistants and AI find key content:\n "speakable": {\n "@type": "SpeakableSpecification",\n "cssSelector": ["h1", ".main-content", ".key-points"]\n }'
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
for (const schema of schemas) {
|
|
944
|
+
if ("speakable" in schema) {
|
|
945
|
+
const speakable = schema.speakable;
|
|
946
|
+
if (!speakable["@type"] || speakable["@type"] !== "SpeakableSpecification") {
|
|
947
|
+
results.push({
|
|
948
|
+
checkId: "schema-speakable-invalid-type",
|
|
949
|
+
name: "Invalid Speakable Type",
|
|
950
|
+
severity: "warning",
|
|
951
|
+
passed: false,
|
|
952
|
+
file: page.filePath,
|
|
953
|
+
message: "Speakable schema has incorrect or missing @type",
|
|
954
|
+
fix: 'Set "@type": "SpeakableSpecification" in the speakable object'
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
if (!speakable.cssSelector && !speakable.xpath) {
|
|
958
|
+
results.push({
|
|
959
|
+
checkId: "schema-speakable-no-selector",
|
|
960
|
+
name: "Speakable Missing Selector",
|
|
961
|
+
severity: "warning",
|
|
962
|
+
passed: false,
|
|
963
|
+
file: page.filePath,
|
|
964
|
+
message: "Speakable schema needs cssSelector or xpath to identify content",
|
|
965
|
+
fix: 'Add "cssSelector": ["h1", ".content"] or "xpath": ["/html/body/article"]'
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return results;
|
|
971
|
+
}
|
|
972
|
+
function checkOfferSchema(page) {
|
|
973
|
+
const results = [];
|
|
974
|
+
const schemas = page.jsonLd;
|
|
975
|
+
const productSchemas = schemas.filter((schema) => {
|
|
976
|
+
const type = schema["@type"];
|
|
977
|
+
return ["Product", "SoftwareApplication", "Service"].includes(type);
|
|
978
|
+
});
|
|
979
|
+
for (const product of productSchemas) {
|
|
980
|
+
if (!product.offers) {
|
|
981
|
+
results.push({
|
|
982
|
+
checkId: "schema-product-no-offers",
|
|
983
|
+
name: "Product Missing Offers",
|
|
984
|
+
severity: "warning",
|
|
985
|
+
passed: false,
|
|
986
|
+
file: page.filePath,
|
|
987
|
+
message: `${product["@type"]} schema is missing offers/pricing information`,
|
|
988
|
+
fix: 'Add "offers" property with price, priceCurrency, and availability'
|
|
989
|
+
});
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const offersRaw = product.offers;
|
|
993
|
+
const offers = Array.isArray(offersRaw) ? offersRaw : [offersRaw];
|
|
994
|
+
for (const offer of offers) {
|
|
995
|
+
if (!offer.price && offer.price !== 0) {
|
|
996
|
+
results.push({
|
|
997
|
+
checkId: "schema-offer-no-price",
|
|
998
|
+
name: "Offer Missing Price",
|
|
999
|
+
severity: "warning",
|
|
1000
|
+
passed: false,
|
|
1001
|
+
file: page.filePath,
|
|
1002
|
+
message: "Offer schema is missing price property",
|
|
1003
|
+
fix: 'Add "price": "29.99" or "price": "0" for free items'
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
if (!offer.priceCurrency) {
|
|
1007
|
+
results.push({
|
|
1008
|
+
checkId: "schema-offer-no-currency",
|
|
1009
|
+
name: "Offer Missing Currency",
|
|
1010
|
+
severity: "warning",
|
|
1011
|
+
passed: false,
|
|
1012
|
+
file: page.filePath,
|
|
1013
|
+
message: "Offer schema is missing priceCurrency property",
|
|
1014
|
+
fix: 'Add "priceCurrency": "USD" (use ISO 4217 currency code)'
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const hasSaleSchema = schemas.some((schema) => schema["@type"] === "Sale");
|
|
1020
|
+
const hasDiscountedOffers = schemas.some((schema) => {
|
|
1021
|
+
if (schema.offers) {
|
|
1022
|
+
const offersRaw = schema.offers;
|
|
1023
|
+
const offers = Array.isArray(offersRaw) ? offersRaw : [offersRaw];
|
|
1024
|
+
return offers.some((o) => o.priceSpecification || o.discount);
|
|
1025
|
+
}
|
|
1026
|
+
return false;
|
|
1027
|
+
});
|
|
1028
|
+
const htmlLower = page.html.toLowerCase();
|
|
1029
|
+
const hasDiscountContent = /sale|discount|% off|save \$|was \$|original price|strike|line-through/i.test(htmlLower);
|
|
1030
|
+
if (hasDiscountContent && !hasSaleSchema && !hasDiscountedOffers) {
|
|
1031
|
+
results.push({
|
|
1032
|
+
checkId: "schema-sale-missing",
|
|
1033
|
+
name: "Sale Schema Recommended",
|
|
1034
|
+
severity: "info",
|
|
1035
|
+
passed: false,
|
|
1036
|
+
file: page.filePath,
|
|
1037
|
+
message: "Page appears to have discounted pricing but no Sale or discount schema",
|
|
1038
|
+
fix: 'Add Sale schema or priceSpecification with validThrough date:\n {\n "@type": "Sale",\n "name": "Holiday Sale",\n "description": "20% off all plans",\n "validThrough": "2025-12-31"\n }'
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
return results;
|
|
1042
|
+
}
|
|
1043
|
+
function checkFAQSchema(page) {
|
|
1044
|
+
const results = [];
|
|
1045
|
+
const schemas = page.jsonLd;
|
|
1046
|
+
const faqSchema = schemas.find((schema) => schema["@type"] === "FAQPage");
|
|
1047
|
+
const hasFAQContent = /<(dt|summary|h[2-4])[^>]*>[^<]*(faq|question|q:|q\.|ask|how|what|why|when|where|who)/i.test(page.html) || /class=["'][^"']*faq/i.test(page.html);
|
|
1048
|
+
if (hasFAQContent && !faqSchema) {
|
|
1049
|
+
results.push({
|
|
1050
|
+
checkId: "schema-faq-missing",
|
|
1051
|
+
name: "FAQ Schema Recommended",
|
|
1052
|
+
severity: "info",
|
|
1053
|
+
passed: false,
|
|
1054
|
+
file: page.filePath,
|
|
1055
|
+
message: "Page has FAQ content but no FAQPage schema",
|
|
1056
|
+
fix: 'Add FAQPage schema to get rich results and improve AI discoverability:\n {\n "@type": "FAQPage",\n "mainEntity": [{\n "@type": "Question",\n "name": "Your question?",\n "acceptedAnswer": {\n "@type": "Answer",\n "text": "Your answer"\n }\n }]\n }'
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
if (faqSchema) {
|
|
1060
|
+
const mainEntity = faqSchema.mainEntity;
|
|
1061
|
+
if (!mainEntity || !Array.isArray(mainEntity) || mainEntity.length === 0) {
|
|
1062
|
+
results.push({
|
|
1063
|
+
checkId: "schema-faq-empty",
|
|
1064
|
+
name: "FAQ Schema Empty",
|
|
1065
|
+
severity: "warning",
|
|
1066
|
+
passed: false,
|
|
1067
|
+
file: page.filePath,
|
|
1068
|
+
message: "FAQPage schema has no questions in mainEntity",
|
|
1069
|
+
fix: "Add Question/Answer pairs to the mainEntity array"
|
|
1070
|
+
});
|
|
1071
|
+
} else {
|
|
1072
|
+
for (let i = 0; i < mainEntity.length; i++) {
|
|
1073
|
+
const qa = mainEntity[i];
|
|
1074
|
+
if (qa["@type"] !== "Question") {
|
|
1075
|
+
results.push({
|
|
1076
|
+
checkId: "schema-faq-invalid-question",
|
|
1077
|
+
name: "FAQ Invalid Question Type",
|
|
1078
|
+
severity: "warning",
|
|
1079
|
+
passed: false,
|
|
1080
|
+
file: page.filePath,
|
|
1081
|
+
message: `FAQ item ${i + 1} is not a Question type`,
|
|
1082
|
+
fix: 'Set "@type": "Question" for each FAQ item'
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
if (!qa.name) {
|
|
1086
|
+
results.push({
|
|
1087
|
+
checkId: "schema-faq-no-question-text",
|
|
1088
|
+
name: "FAQ Missing Question Text",
|
|
1089
|
+
severity: "warning",
|
|
1090
|
+
passed: false,
|
|
1091
|
+
file: page.filePath,
|
|
1092
|
+
message: `FAQ Question ${i + 1} is missing the question text (name property)`,
|
|
1093
|
+
fix: 'Add "name": "Your question?" to each Question'
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
const answer = qa.acceptedAnswer;
|
|
1097
|
+
if (!answer || !answer.text) {
|
|
1098
|
+
results.push({
|
|
1099
|
+
checkId: "schema-faq-no-answer",
|
|
1100
|
+
name: "FAQ Missing Answer",
|
|
1101
|
+
severity: "warning",
|
|
1102
|
+
passed: false,
|
|
1103
|
+
file: page.filePath,
|
|
1104
|
+
message: `FAQ Question ${i + 1} is missing an answer`,
|
|
1105
|
+
fix: 'Add acceptedAnswer with @type "Answer" and "text" property'
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return results;
|
|
1112
|
+
}
|
|
1113
|
+
function checkHowToSchema(page) {
|
|
1114
|
+
const results = [];
|
|
1115
|
+
const schemas = page.jsonLd;
|
|
1116
|
+
const howToSchema = schemas.find((schema) => schema["@type"] === "HowTo");
|
|
1117
|
+
const hasHowToContent = /how to|step[s]? [0-9]|guide:|tutorial|instruction/i.test(page.html) && /<(ol|ul)[^>]*class=["'][^"']*step/i.test(page.html);
|
|
1118
|
+
if (hasHowToContent && !howToSchema) {
|
|
1119
|
+
results.push({
|
|
1120
|
+
checkId: "schema-howto-missing",
|
|
1121
|
+
name: "HowTo Schema Recommended",
|
|
1122
|
+
severity: "info",
|
|
1123
|
+
passed: false,
|
|
1124
|
+
file: page.filePath,
|
|
1125
|
+
message: "Page has step-by-step content but no HowTo schema",
|
|
1126
|
+
fix: 'Add HowTo schema for rich results:\n {\n "@type": "HowTo",\n "name": "How to ...",\n "step": [{\n "@type": "HowToStep",\n "name": "Step 1",\n "text": "Description..."\n }]\n }'
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
return results;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/checks/social.ts
|
|
1133
|
+
function checkOpenGraph(page) {
|
|
1134
|
+
const results = [];
|
|
1135
|
+
if (page.isSourceFile) {
|
|
1136
|
+
return results;
|
|
1137
|
+
}
|
|
1138
|
+
const og = page.ogTags;
|
|
1139
|
+
if (!og["og:title"]) {
|
|
1140
|
+
results.push({
|
|
1141
|
+
checkId: "og-title-missing",
|
|
1142
|
+
name: "Missing og:title",
|
|
1143
|
+
severity: "warning",
|
|
1144
|
+
passed: false,
|
|
1145
|
+
file: page.filePath,
|
|
1146
|
+
message: "Page is missing og:title meta tag",
|
|
1147
|
+
fix: 'Add <meta property="og:title" content="Your Page Title" /> for better social sharing'
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
if (!og["og:description"]) {
|
|
1151
|
+
results.push({
|
|
1152
|
+
checkId: "og-description-missing",
|
|
1153
|
+
name: "Missing og:description",
|
|
1154
|
+
severity: "warning",
|
|
1155
|
+
passed: false,
|
|
1156
|
+
file: page.filePath,
|
|
1157
|
+
message: "Page is missing og:description meta tag",
|
|
1158
|
+
fix: 'Add <meta property="og:description" content="Your description" /> for better social sharing'
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
if (!og["og:image"]) {
|
|
1162
|
+
results.push({
|
|
1163
|
+
checkId: "og-image-missing",
|
|
1164
|
+
name: "Missing og:image",
|
|
1165
|
+
severity: "warning",
|
|
1166
|
+
passed: false,
|
|
1167
|
+
file: page.filePath,
|
|
1168
|
+
message: "Page is missing og:image meta tag - shared links will have no preview image",
|
|
1169
|
+
fix: 'Add <meta property="og:image" content="https://example.com/image.jpg" /> (use absolute URL, min 1200x630px recommended)'
|
|
1170
|
+
});
|
|
1171
|
+
} else {
|
|
1172
|
+
const ogImage = og["og:image"];
|
|
1173
|
+
if (!ogImage.startsWith("http://") && !ogImage.startsWith("https://")) {
|
|
1174
|
+
results.push({
|
|
1175
|
+
checkId: "og-image-relative",
|
|
1176
|
+
name: "Relative og:image URL",
|
|
1177
|
+
severity: "warning",
|
|
1178
|
+
passed: false,
|
|
1179
|
+
file: page.filePath,
|
|
1180
|
+
message: "og:image should use an absolute URL",
|
|
1181
|
+
fix: "Change to absolute URL: https://yourdomain.com/path/to/image.jpg"
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (!og["og:type"]) {
|
|
1186
|
+
results.push({
|
|
1187
|
+
checkId: "og-type-missing",
|
|
1188
|
+
name: "Missing og:type",
|
|
1189
|
+
severity: "info",
|
|
1190
|
+
passed: false,
|
|
1191
|
+
file: page.filePath,
|
|
1192
|
+
message: 'Page is missing og:type meta tag (defaults to "website")',
|
|
1193
|
+
fix: 'Add <meta property="og:type" content="website" /> or "article" for blog posts'
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
if (!og["og:url"]) {
|
|
1197
|
+
results.push({
|
|
1198
|
+
checkId: "og-url-missing",
|
|
1199
|
+
name: "Missing og:url",
|
|
1200
|
+
severity: "info",
|
|
1201
|
+
passed: false,
|
|
1202
|
+
file: page.filePath,
|
|
1203
|
+
message: "Page is missing og:url meta tag",
|
|
1204
|
+
fix: 'Add <meta property="og:url" content="https://example.com/page" /> with the canonical URL'
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
return results;
|
|
1208
|
+
}
|
|
1209
|
+
function checkTwitterCards(page) {
|
|
1210
|
+
const results = [];
|
|
1211
|
+
if (page.isSourceFile) {
|
|
1212
|
+
return results;
|
|
1213
|
+
}
|
|
1214
|
+
const twitter = page.twitterTags;
|
|
1215
|
+
const og = page.ogTags;
|
|
1216
|
+
if (!twitter["twitter:card"]) {
|
|
1217
|
+
results.push({
|
|
1218
|
+
checkId: "twitter-card-missing",
|
|
1219
|
+
name: "Missing twitter:card",
|
|
1220
|
+
severity: "warning",
|
|
1221
|
+
passed: false,
|
|
1222
|
+
file: page.filePath,
|
|
1223
|
+
message: "Page is missing twitter:card meta tag",
|
|
1224
|
+
fix: 'Add <meta name="twitter:card" content="summary_large_image" /> for large preview, or "summary" for small'
|
|
1225
|
+
});
|
|
1226
|
+
} else {
|
|
1227
|
+
const validTypes = ["summary", "summary_large_image", "app", "player"];
|
|
1228
|
+
if (!validTypes.includes(twitter["twitter:card"])) {
|
|
1229
|
+
results.push({
|
|
1230
|
+
checkId: "twitter-card-invalid",
|
|
1231
|
+
name: "Invalid twitter:card value",
|
|
1232
|
+
severity: "warning",
|
|
1233
|
+
passed: false,
|
|
1234
|
+
file: page.filePath,
|
|
1235
|
+
message: `Invalid twitter:card value: "${twitter["twitter:card"]}"`,
|
|
1236
|
+
fix: `Use one of: ${validTypes.join(", ")}`
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (!twitter["twitter:title"] && !og["og:title"]) {
|
|
1241
|
+
results.push({
|
|
1242
|
+
checkId: "twitter-title-missing",
|
|
1243
|
+
name: "Missing twitter:title",
|
|
1244
|
+
severity: "info",
|
|
1245
|
+
passed: false,
|
|
1246
|
+
file: page.filePath,
|
|
1247
|
+
message: "Page has no twitter:title or og:title to fall back on",
|
|
1248
|
+
fix: 'Add <meta name="twitter:title" content="Your Title" /> or og:title'
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
if (!twitter["twitter:description"] && !og["og:description"]) {
|
|
1252
|
+
results.push({
|
|
1253
|
+
checkId: "twitter-description-missing",
|
|
1254
|
+
name: "Missing twitter:description",
|
|
1255
|
+
severity: "info",
|
|
1256
|
+
passed: false,
|
|
1257
|
+
file: page.filePath,
|
|
1258
|
+
message: "Page has no twitter:description or og:description to fall back on",
|
|
1259
|
+
fix: 'Add <meta name="twitter:description" content="Your description" /> or og:description'
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
if (!twitter["twitter:image"] && !og["og:image"]) {
|
|
1263
|
+
results.push({
|
|
1264
|
+
checkId: "twitter-image-missing",
|
|
1265
|
+
name: "Missing twitter:image",
|
|
1266
|
+
severity: "info",
|
|
1267
|
+
passed: false,
|
|
1268
|
+
file: page.filePath,
|
|
1269
|
+
message: "Page has no twitter:image or og:image to fall back on",
|
|
1270
|
+
fix: 'Add <meta name="twitter:image" content="https://example.com/image.jpg" /> or og:image'
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
return results;
|
|
1274
|
+
}
|
|
1275
|
+
function checkRobotsMeta(page) {
|
|
1276
|
+
const results = [];
|
|
1277
|
+
if (page.isSourceFile) {
|
|
1278
|
+
return results;
|
|
1279
|
+
}
|
|
1280
|
+
if (page.robots) {
|
|
1281
|
+
const robotsLower = page.robots.toLowerCase();
|
|
1282
|
+
if (robotsLower.includes("noindex")) {
|
|
1283
|
+
results.push({
|
|
1284
|
+
checkId: "robots-noindex",
|
|
1285
|
+
name: "Page blocked from indexing",
|
|
1286
|
+
severity: "warning",
|
|
1287
|
+
passed: false,
|
|
1288
|
+
file: page.filePath,
|
|
1289
|
+
message: 'Page has "noindex" in robots meta tag - it will not appear in search results',
|
|
1290
|
+
fix: "Remove noindex if you want this page to be indexed, or ignore if intentional"
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
if (robotsLower.includes("nofollow")) {
|
|
1294
|
+
results.push({
|
|
1295
|
+
checkId: "robots-nofollow",
|
|
1296
|
+
name: "Links blocked from following",
|
|
1297
|
+
severity: "info",
|
|
1298
|
+
passed: false,
|
|
1299
|
+
file: page.filePath,
|
|
1300
|
+
message: 'Page has "nofollow" in robots meta tag - links will not pass PageRank',
|
|
1301
|
+
fix: "Remove nofollow unless intentional (e.g., user-generated content pages)"
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
if (robotsLower.includes("noindex") && robotsLower.includes("index")) {
|
|
1305
|
+
results.push({
|
|
1306
|
+
checkId: "robots-conflicting",
|
|
1307
|
+
name: "Conflicting robots directives",
|
|
1308
|
+
severity: "critical",
|
|
1309
|
+
passed: false,
|
|
1310
|
+
file: page.filePath,
|
|
1311
|
+
message: 'Page has both "index" and "noindex" directives - this is confusing for crawlers',
|
|
1312
|
+
fix: 'Use either "index" or "noindex", not both'
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return results;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// src/checks/accessibility.ts
|
|
1320
|
+
import * as cheerio2 from "cheerio";
|
|
1321
|
+
function checkAccessibility(page) {
|
|
1322
|
+
const results = [];
|
|
1323
|
+
const $ = cheerio2.load(page.html);
|
|
1324
|
+
const skipLink = $('a[href="#main"], a[href="#content"], a[href="#main-content"], .skip-link, .skip-to-content');
|
|
1325
|
+
if (skipLink.length === 0) {
|
|
1326
|
+
results.push({
|
|
1327
|
+
checkId: "a11y-skip-link",
|
|
1328
|
+
name: "Missing Skip Link",
|
|
1329
|
+
severity: "info",
|
|
1330
|
+
passed: false,
|
|
1331
|
+
file: page.filePath,
|
|
1332
|
+
message: "Page has no skip navigation link for keyboard users",
|
|
1333
|
+
fix: 'Add a skip link as the first focusable element: <a href="#main" class="sr-only focus:not-sr-only">Skip to content</a>'
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
const mainLandmark = $('main, [role="main"]');
|
|
1337
|
+
if (mainLandmark.length === 0) {
|
|
1338
|
+
results.push({
|
|
1339
|
+
checkId: "a11y-main-landmark",
|
|
1340
|
+
name: "Missing Main Landmark",
|
|
1341
|
+
severity: "warning",
|
|
1342
|
+
passed: false,
|
|
1343
|
+
file: page.filePath,
|
|
1344
|
+
message: 'Page has no <main> element or role="main"',
|
|
1345
|
+
fix: "Wrap your main content in a <main> element for screen reader navigation"
|
|
1346
|
+
});
|
|
1347
|
+
} else if (mainLandmark.length > 1) {
|
|
1348
|
+
results.push({
|
|
1349
|
+
checkId: "a11y-multiple-main",
|
|
1350
|
+
name: "Multiple Main Landmarks",
|
|
1351
|
+
severity: "warning",
|
|
1352
|
+
passed: false,
|
|
1353
|
+
file: page.filePath,
|
|
1354
|
+
message: `Page has ${mainLandmark.length} main landmarks - there should only be one`,
|
|
1355
|
+
fix: "Ensure only one <main> element exists per page"
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
const navLandmark = $('nav, [role="navigation"]');
|
|
1359
|
+
if (navLandmark.length === 0) {
|
|
1360
|
+
results.push({
|
|
1361
|
+
checkId: "a11y-nav-landmark",
|
|
1362
|
+
name: "Missing Nav Landmark",
|
|
1363
|
+
severity: "info",
|
|
1364
|
+
passed: false,
|
|
1365
|
+
file: page.filePath,
|
|
1366
|
+
message: "Page has no <nav> element for navigation",
|
|
1367
|
+
fix: "Wrap your navigation in a <nav> element"
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
const buttonsWithoutText = [];
|
|
1371
|
+
$("button").each((_, el) => {
|
|
1372
|
+
const $btn = $(el);
|
|
1373
|
+
const text = $btn.text().trim();
|
|
1374
|
+
const ariaLabel = $btn.attr("aria-label");
|
|
1375
|
+
const ariaLabelledby = $btn.attr("aria-labelledby");
|
|
1376
|
+
const title = $btn.attr("title");
|
|
1377
|
+
if (!text && !ariaLabel && !ariaLabelledby && !title) {
|
|
1378
|
+
const className = $btn.attr("class") || "";
|
|
1379
|
+
const id = $btn.attr("id") || "";
|
|
1380
|
+
buttonsWithoutText.push(className || id || "unnamed button");
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
if (buttonsWithoutText.length > 0) {
|
|
1384
|
+
results.push({
|
|
1385
|
+
checkId: "a11y-button-name",
|
|
1386
|
+
name: "Buttons Without Accessible Name",
|
|
1387
|
+
severity: "critical",
|
|
1388
|
+
passed: false,
|
|
1389
|
+
file: page.filePath,
|
|
1390
|
+
message: `${buttonsWithoutText.length} button(s) have no accessible name`,
|
|
1391
|
+
fix: "Add text content, aria-label, or aria-labelledby to buttons",
|
|
1392
|
+
details: buttonsWithoutText.slice(0, 10)
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
const linksWithoutText = [];
|
|
1396
|
+
$("a[href]").each((_, el) => {
|
|
1397
|
+
const $link = $(el);
|
|
1398
|
+
const text = $link.text().trim();
|
|
1399
|
+
const ariaLabel = $link.attr("aria-label");
|
|
1400
|
+
const ariaLabelledby = $link.attr("aria-labelledby");
|
|
1401
|
+
const title = $link.attr("title");
|
|
1402
|
+
const hasImg = $link.find("img[alt]").length > 0;
|
|
1403
|
+
if (!text && !ariaLabel && !ariaLabelledby && !title && !hasImg) {
|
|
1404
|
+
const href = $link.attr("href") || "";
|
|
1405
|
+
linksWithoutText.push(href.substring(0, 50));
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
if (linksWithoutText.length > 0) {
|
|
1409
|
+
results.push({
|
|
1410
|
+
checkId: "a11y-link-name",
|
|
1411
|
+
name: "Links Without Accessible Name",
|
|
1412
|
+
severity: "critical",
|
|
1413
|
+
passed: false,
|
|
1414
|
+
file: page.filePath,
|
|
1415
|
+
message: `${linksWithoutText.length} link(s) have no accessible name`,
|
|
1416
|
+
fix: "Add text content, aria-label, or an image with alt text to links",
|
|
1417
|
+
details: linksWithoutText.slice(0, 10)
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
const inputsWithoutLabels = [];
|
|
1421
|
+
$('input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]), textarea, select').each((_, el) => {
|
|
1422
|
+
const $input = $(el);
|
|
1423
|
+
const id = $input.attr("id");
|
|
1424
|
+
const ariaLabel = $input.attr("aria-label");
|
|
1425
|
+
const ariaLabelledby = $input.attr("aria-labelledby");
|
|
1426
|
+
const placeholder = $input.attr("placeholder");
|
|
1427
|
+
const title = $input.attr("title");
|
|
1428
|
+
let hasLabel = false;
|
|
1429
|
+
if (id) {
|
|
1430
|
+
hasLabel = $(`label[for="${id}"]`).length > 0;
|
|
1431
|
+
}
|
|
1432
|
+
if (!hasLabel) {
|
|
1433
|
+
hasLabel = $input.closest("label").length > 0;
|
|
1434
|
+
}
|
|
1435
|
+
if (!hasLabel && !ariaLabel && !ariaLabelledby && !title) {
|
|
1436
|
+
const name = $input.attr("name") || $input.attr("id") || "unnamed";
|
|
1437
|
+
const type = $input.attr("type") || "text";
|
|
1438
|
+
inputsWithoutLabels.push(`${type}: ${name}${placeholder ? ` (placeholder: ${placeholder})` : ""}`);
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
if (inputsWithoutLabels.length > 0) {
|
|
1442
|
+
results.push({
|
|
1443
|
+
checkId: "a11y-input-label",
|
|
1444
|
+
name: "Form Inputs Without Labels",
|
|
1445
|
+
severity: "critical",
|
|
1446
|
+
passed: false,
|
|
1447
|
+
file: page.filePath,
|
|
1448
|
+
message: `${inputsWithoutLabels.length} form input(s) have no associated label`,
|
|
1449
|
+
fix: 'Add <label for="inputId"> or wrap inputs in <label> elements. Placeholder is not a substitute for labels.',
|
|
1450
|
+
details: inputsWithoutLabels.slice(0, 10)
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
const positiveTabindex = [];
|
|
1454
|
+
$("[tabindex]").each((_, el) => {
|
|
1455
|
+
const tabindex = parseInt($(el).attr("tabindex") || "0", 10);
|
|
1456
|
+
if (tabindex > 0) {
|
|
1457
|
+
const tagName = el.tagName;
|
|
1458
|
+
positiveTabindex.push(`${tagName} (tabindex=${tabindex})`);
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
if (positiveTabindex.length > 0) {
|
|
1462
|
+
results.push({
|
|
1463
|
+
checkId: "a11y-tabindex-positive",
|
|
1464
|
+
name: "Positive Tabindex Values",
|
|
1465
|
+
severity: "warning",
|
|
1466
|
+
passed: false,
|
|
1467
|
+
file: page.filePath,
|
|
1468
|
+
message: `${positiveTabindex.length} element(s) have tabindex > 0, which disrupts natural tab order`,
|
|
1469
|
+
fix: 'Use tabindex="0" to add to natural tab order, or tabindex="-1" to make focusable via JS only',
|
|
1470
|
+
details: positiveTabindex.slice(0, 10)
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
const autoplayVideos = [];
|
|
1474
|
+
$("video[autoplay]").each((_, el) => {
|
|
1475
|
+
const $video = $(el);
|
|
1476
|
+
if (!$video.attr("muted")) {
|
|
1477
|
+
const src = $video.attr("src") || $video.find("source").first().attr("src") || "unknown";
|
|
1478
|
+
autoplayVideos.push(src.substring(0, 50));
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
if (autoplayVideos.length > 0) {
|
|
1482
|
+
results.push({
|
|
1483
|
+
checkId: "a11y-autoplay-audio",
|
|
1484
|
+
name: "Autoplay Video With Audio",
|
|
1485
|
+
severity: "warning",
|
|
1486
|
+
passed: false,
|
|
1487
|
+
file: page.filePath,
|
|
1488
|
+
message: `${autoplayVideos.length} video(s) autoplay without being muted`,
|
|
1489
|
+
fix: "Add the muted attribute to autoplay videos: <video autoplay muted>",
|
|
1490
|
+
details: autoplayVideos
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
const emptyTableHeaders = [];
|
|
1494
|
+
$("th").each((index, el) => {
|
|
1495
|
+
const text = $(el).text().trim();
|
|
1496
|
+
if (!text) {
|
|
1497
|
+
emptyTableHeaders.push(index + 1);
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
if (emptyTableHeaders.length > 0) {
|
|
1501
|
+
results.push({
|
|
1502
|
+
checkId: "a11y-empty-th",
|
|
1503
|
+
name: "Empty Table Headers",
|
|
1504
|
+
severity: "warning",
|
|
1505
|
+
passed: false,
|
|
1506
|
+
file: page.filePath,
|
|
1507
|
+
message: `${emptyTableHeaders.length} table header(s) are empty`,
|
|
1508
|
+
fix: "Add descriptive text to all <th> elements, or use scope attribute"
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
return results;
|
|
1512
|
+
}
|
|
1513
|
+
function checkColorContrast(page) {
|
|
1514
|
+
const results = [];
|
|
1515
|
+
const $ = cheerio2.load(page.html);
|
|
1516
|
+
const potentialIssues = [];
|
|
1517
|
+
$('[style*="color"]').each((_, el) => {
|
|
1518
|
+
const style = $(el).attr("style") || "";
|
|
1519
|
+
const colorMatch = style.match(/color:\s*(#[a-fA-F0-9]{3,6}|rgb[a]?\([^)]+\)|[a-z]+)/i);
|
|
1520
|
+
if (colorMatch) {
|
|
1521
|
+
const color = colorMatch[1].toLowerCase();
|
|
1522
|
+
if (color.match(/^#[c-f][c-f][c-f]$/i) || // Light hex shorthand
|
|
1523
|
+
color.match(/^#[c-f]{2}[c-f]{2}[c-f]{2}$/i) || // Light hex
|
|
1524
|
+
color === "lightgray" || color === "lightgrey" || color === "silver" || color === "gainsboro") {
|
|
1525
|
+
potentialIssues.push(`Light text color: ${color}`);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
if (potentialIssues.length > 0) {
|
|
1530
|
+
results.push({
|
|
1531
|
+
checkId: "a11y-color-contrast",
|
|
1532
|
+
name: "Potential Color Contrast Issues",
|
|
1533
|
+
severity: "info",
|
|
1534
|
+
passed: false,
|
|
1535
|
+
file: page.filePath,
|
|
1536
|
+
message: `${potentialIssues.length} element(s) may have low color contrast`,
|
|
1537
|
+
fix: "Ensure text has at least 4.5:1 contrast ratio (3:1 for large text). Use a contrast checker tool.",
|
|
1538
|
+
details: potentialIssues.slice(0, 5)
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
return results;
|
|
1542
|
+
}
|
|
1543
|
+
function checkFocusIndicators(page) {
|
|
1544
|
+
const results = [];
|
|
1545
|
+
const $ = cheerio2.load(page.html);
|
|
1546
|
+
const outlineNone = [];
|
|
1547
|
+
$('[style*="outline"]').each((_, el) => {
|
|
1548
|
+
const style = $(el).attr("style") || "";
|
|
1549
|
+
if (style.match(/outline:\s*(none|0)/i)) {
|
|
1550
|
+
const tagName = el.tagName;
|
|
1551
|
+
outlineNone.push(tagName);
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
if (outlineNone.length > 0) {
|
|
1555
|
+
results.push({
|
|
1556
|
+
checkId: "a11y-focus-outline",
|
|
1557
|
+
name: "Focus Outline Removed",
|
|
1558
|
+
severity: "warning",
|
|
1559
|
+
passed: false,
|
|
1560
|
+
file: page.filePath,
|
|
1561
|
+
message: `${outlineNone.length} element(s) have outline:none which may hide focus indicators`,
|
|
1562
|
+
fix: "Provide custom focus styles instead of removing outlines: :focus { outline: 2px solid blue; }",
|
|
1563
|
+
details: outlineNone
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
return results;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// src/checks/index.ts
|
|
1570
|
+
var checks = [
|
|
1571
|
+
// Meta tags
|
|
1572
|
+
{
|
|
1573
|
+
id: "meta-title",
|
|
1574
|
+
name: "Title Tag",
|
|
1575
|
+
description: "Check for missing, short, long, or duplicate title tags",
|
|
1576
|
+
severity: "critical",
|
|
1577
|
+
run: (page, allPages) => checkMetaTitle(page, allPages)
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
id: "meta-description",
|
|
1581
|
+
name: "Meta Description",
|
|
1582
|
+
description: "Check for missing, short, long, or duplicate meta descriptions",
|
|
1583
|
+
severity: "warning",
|
|
1584
|
+
run: (page, allPages) => checkMetaDescription(page, allPages)
|
|
1585
|
+
},
|
|
1586
|
+
{
|
|
1587
|
+
id: "viewport",
|
|
1588
|
+
name: "Viewport Meta Tag",
|
|
1589
|
+
description: "Check for missing viewport meta tag",
|
|
1590
|
+
severity: "critical",
|
|
1591
|
+
run: (page) => checkViewport(page)
|
|
1592
|
+
},
|
|
1593
|
+
{
|
|
1594
|
+
id: "lang",
|
|
1595
|
+
name: "Language Attribute",
|
|
1596
|
+
description: "Check for missing lang attribute on html tag",
|
|
1597
|
+
severity: "warning",
|
|
1598
|
+
run: (page) => checkLang(page)
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
id: "canonical",
|
|
1602
|
+
name: "Canonical URL",
|
|
1603
|
+
description: "Check for missing canonical URL",
|
|
1604
|
+
severity: "warning",
|
|
1605
|
+
run: (page) => checkCanonical(page)
|
|
1606
|
+
},
|
|
1607
|
+
// Headings
|
|
1608
|
+
{
|
|
1609
|
+
id: "h1",
|
|
1610
|
+
name: "H1 Tag",
|
|
1611
|
+
description: "Check for missing or multiple H1 tags",
|
|
1612
|
+
severity: "critical",
|
|
1613
|
+
run: (page) => checkH1(page)
|
|
1614
|
+
},
|
|
1615
|
+
{
|
|
1616
|
+
id: "heading-hierarchy",
|
|
1617
|
+
name: "Heading Hierarchy",
|
|
1618
|
+
description: "Check for skipped heading levels",
|
|
1619
|
+
severity: "warning",
|
|
1620
|
+
run: (page) => checkHeadingHierarchy(page)
|
|
1621
|
+
},
|
|
1622
|
+
// Images
|
|
1623
|
+
{
|
|
1624
|
+
id: "image-alt",
|
|
1625
|
+
name: "Image Alt Text",
|
|
1626
|
+
description: "Check for images missing alt attributes",
|
|
1627
|
+
severity: "warning",
|
|
1628
|
+
run: (page) => checkImageAlt(page)
|
|
1629
|
+
},
|
|
1630
|
+
// Links
|
|
1631
|
+
{
|
|
1632
|
+
id: "internal-links",
|
|
1633
|
+
name: "Internal Links",
|
|
1634
|
+
description: "Check for broken internal links",
|
|
1635
|
+
severity: "critical",
|
|
1636
|
+
run: (page, allPages, basePath) => checkInternalLinks(page, allPages, basePath)
|
|
1637
|
+
},
|
|
1638
|
+
// Schema
|
|
1639
|
+
{
|
|
1640
|
+
id: "schema",
|
|
1641
|
+
name: "Structured Data",
|
|
1642
|
+
description: "Check JSON-LD structured data for validity",
|
|
1643
|
+
severity: "warning",
|
|
1644
|
+
run: (page) => [...checkSchemaJsonValidity(page), ...checkSchema(page)]
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
id: "schema-speakable",
|
|
1648
|
+
name: "Speakable Schema (AI/Voice)",
|
|
1649
|
+
description: "Check for speakable schema for voice search and AI discoverability",
|
|
1650
|
+
severity: "info",
|
|
1651
|
+
run: (page) => checkSpeakableSchema(page)
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
id: "schema-offers",
|
|
1655
|
+
name: "Offer/Pricing Schema",
|
|
1656
|
+
description: "Check Product/Service schemas for proper pricing markup",
|
|
1657
|
+
severity: "warning",
|
|
1658
|
+
run: (page) => checkOfferSchema(page)
|
|
1659
|
+
},
|
|
1660
|
+
{
|
|
1661
|
+
id: "schema-faq",
|
|
1662
|
+
name: "FAQ Schema",
|
|
1663
|
+
description: "Check for FAQ schema on pages with Q&A content",
|
|
1664
|
+
severity: "info",
|
|
1665
|
+
run: (page) => checkFAQSchema(page)
|
|
1666
|
+
},
|
|
1667
|
+
{
|
|
1668
|
+
id: "schema-howto",
|
|
1669
|
+
name: "HowTo Schema",
|
|
1670
|
+
description: "Check for HowTo schema on tutorial/guide pages",
|
|
1671
|
+
severity: "info",
|
|
1672
|
+
run: (page) => checkHowToSchema(page)
|
|
1673
|
+
},
|
|
1674
|
+
// Social / Open Graph
|
|
1675
|
+
{
|
|
1676
|
+
id: "open-graph",
|
|
1677
|
+
name: "Open Graph Tags",
|
|
1678
|
+
description: "Check for Open Graph meta tags for social sharing",
|
|
1679
|
+
severity: "warning",
|
|
1680
|
+
run: (page) => checkOpenGraph(page)
|
|
1681
|
+
},
|
|
1682
|
+
{
|
|
1683
|
+
id: "twitter-cards",
|
|
1684
|
+
name: "Twitter Cards",
|
|
1685
|
+
description: "Check for Twitter Card meta tags",
|
|
1686
|
+
severity: "warning",
|
|
1687
|
+
run: (page) => checkTwitterCards(page)
|
|
1688
|
+
},
|
|
1689
|
+
{
|
|
1690
|
+
id: "robots-meta",
|
|
1691
|
+
name: "Robots Meta",
|
|
1692
|
+
description: "Check for robots meta tag issues",
|
|
1693
|
+
severity: "warning",
|
|
1694
|
+
run: (page) => checkRobotsMeta(page)
|
|
1695
|
+
},
|
|
1696
|
+
// Accessibility
|
|
1697
|
+
{
|
|
1698
|
+
id: "accessibility",
|
|
1699
|
+
name: "Accessibility",
|
|
1700
|
+
description: "Check for common accessibility issues (WCAG)",
|
|
1701
|
+
severity: "warning",
|
|
1702
|
+
run: (page) => checkAccessibility(page)
|
|
1703
|
+
},
|
|
1704
|
+
{
|
|
1705
|
+
id: "color-contrast",
|
|
1706
|
+
name: "Color Contrast",
|
|
1707
|
+
description: "Check for potential color contrast issues",
|
|
1708
|
+
severity: "info",
|
|
1709
|
+
run: (page) => checkColorContrast(page)
|
|
1710
|
+
},
|
|
1711
|
+
{
|
|
1712
|
+
id: "focus-indicators",
|
|
1713
|
+
name: "Focus Indicators",
|
|
1714
|
+
description: "Check for removed focus outlines",
|
|
1715
|
+
severity: "warning",
|
|
1716
|
+
run: (page) => checkFocusIndicators(page)
|
|
1717
|
+
}
|
|
1718
|
+
];
|
|
1719
|
+
function runChecks(page, allPages, basePath, enabledChecks) {
|
|
1720
|
+
const results = [];
|
|
1721
|
+
for (const check of checks) {
|
|
1722
|
+
if (enabledChecks && !enabledChecks.has(check.id)) {
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
const checkResults = check.run(page, allPages, basePath);
|
|
1726
|
+
if (checkResults.every((r) => r.passed)) {
|
|
1727
|
+
results.push({
|
|
1728
|
+
checkId: check.id,
|
|
1729
|
+
name: check.name,
|
|
1730
|
+
severity: check.severity,
|
|
1731
|
+
passed: true,
|
|
1732
|
+
file: page.filePath,
|
|
1733
|
+
message: `${check.name}: no issues found`
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
results.push(...checkResults);
|
|
1737
|
+
}
|
|
1738
|
+
return results;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// src/reporters/terminal.ts
|
|
1742
|
+
import chalk from "chalk";
|
|
1743
|
+
function formatTerminal(report) {
|
|
1744
|
+
const lines = [];
|
|
1745
|
+
lines.push("");
|
|
1746
|
+
lines.push(chalk.bold.cyan(" Rankture SEO Check"));
|
|
1747
|
+
lines.push(chalk.gray(" " + "\u2500".repeat(40)));
|
|
1748
|
+
lines.push("");
|
|
1749
|
+
lines.push(chalk.gray(` Checking ${report.path} (${report.summary.totalFiles} files)...`));
|
|
1750
|
+
lines.push("");
|
|
1751
|
+
const critical = report.results.filter((r) => !r.passed && r.severity === "critical");
|
|
1752
|
+
const warnings = report.results.filter((r) => !r.passed && r.severity === "warning");
|
|
1753
|
+
const info = report.results.filter((r) => !r.passed && r.severity === "info");
|
|
1754
|
+
if (critical.length > 0) {
|
|
1755
|
+
for (const result of critical) {
|
|
1756
|
+
lines.push(formatResult(result));
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
if (warnings.length > 0) {
|
|
1760
|
+
for (const result of warnings) {
|
|
1761
|
+
lines.push(formatResult(result));
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (info.length > 0) {
|
|
1765
|
+
for (const result of info) {
|
|
1766
|
+
lines.push(formatResult(result));
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
lines.push("");
|
|
1770
|
+
lines.push(chalk.gray(" " + "\u2500".repeat(40)));
|
|
1771
|
+
const summaryParts = [];
|
|
1772
|
+
if (report.summary.critical > 0) {
|
|
1773
|
+
summaryParts.push(chalk.red.bold(`${report.summary.critical} critical`));
|
|
1774
|
+
}
|
|
1775
|
+
if (report.summary.warning > 0) {
|
|
1776
|
+
summaryParts.push(chalk.yellow.bold(`${report.summary.warning} warnings`));
|
|
1777
|
+
}
|
|
1778
|
+
if (report.summary.info > 0) {
|
|
1779
|
+
summaryParts.push(chalk.blue(`${report.summary.info} info`));
|
|
1780
|
+
}
|
|
1781
|
+
summaryParts.push(chalk.green(`${report.summary.passed} passed`));
|
|
1782
|
+
lines.push(` Results: ${summaryParts.join(", ")}`);
|
|
1783
|
+
lines.push("");
|
|
1784
|
+
if (report.summary.critical > 0) {
|
|
1785
|
+
lines.push(chalk.red.bold(" \u2717 Check failed (critical issues found)"));
|
|
1786
|
+
} else if (report.summary.warning > 0) {
|
|
1787
|
+
lines.push(chalk.yellow(" \u26A0 Check completed with warnings"));
|
|
1788
|
+
} else {
|
|
1789
|
+
lines.push(chalk.green.bold(" \u2713 All checks passed!"));
|
|
1790
|
+
}
|
|
1791
|
+
lines.push("");
|
|
1792
|
+
return lines.join("\n");
|
|
1793
|
+
}
|
|
1794
|
+
function formatResult(result) {
|
|
1795
|
+
const lines = [];
|
|
1796
|
+
let icon;
|
|
1797
|
+
let color;
|
|
1798
|
+
switch (result.severity) {
|
|
1799
|
+
case "critical":
|
|
1800
|
+
icon = "\u2717";
|
|
1801
|
+
color = chalk.red;
|
|
1802
|
+
break;
|
|
1803
|
+
case "warning":
|
|
1804
|
+
icon = "\u26A0";
|
|
1805
|
+
color = chalk.yellow;
|
|
1806
|
+
break;
|
|
1807
|
+
case "info":
|
|
1808
|
+
default:
|
|
1809
|
+
icon = "\u2139";
|
|
1810
|
+
color = chalk.blue;
|
|
1811
|
+
break;
|
|
1812
|
+
}
|
|
1813
|
+
lines.push(
|
|
1814
|
+
` ${color(icon)} ${color.bold(result.severity.toUpperCase().padEnd(8))} ${chalk.gray(result.checkId)}`
|
|
1815
|
+
);
|
|
1816
|
+
if (result.file) {
|
|
1817
|
+
lines.push(` ${chalk.cyan(result.file)} - ${result.message}`);
|
|
1818
|
+
} else {
|
|
1819
|
+
lines.push(` ${result.message}`);
|
|
1820
|
+
}
|
|
1821
|
+
if (result.details && result.details.length > 0) {
|
|
1822
|
+
const maxDetails = 5;
|
|
1823
|
+
const showDetails = result.details.slice(0, maxDetails);
|
|
1824
|
+
for (const detail of showDetails) {
|
|
1825
|
+
lines.push(chalk.gray(` - ${detail}`));
|
|
1826
|
+
}
|
|
1827
|
+
if (result.details.length > maxDetails) {
|
|
1828
|
+
lines.push(chalk.gray(` ... and ${result.details.length - maxDetails} more`));
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
lines.push("");
|
|
1832
|
+
return lines.join("\n");
|
|
1833
|
+
}
|
|
1834
|
+
function printReport(report) {
|
|
1835
|
+
console.log(formatTerminal(report));
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/reporters/json.ts
|
|
1839
|
+
function formatJson(report) {
|
|
1840
|
+
return JSON.stringify(report, null, 2);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// src/reporters/html.ts
|
|
1844
|
+
function formatHtml(report) {
|
|
1845
|
+
const { summary, results } = report;
|
|
1846
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1847
|
+
results.forEach((r) => {
|
|
1848
|
+
const file = r.file || "Unknown";
|
|
1849
|
+
if (!byFile.has(file)) {
|
|
1850
|
+
byFile.set(file, []);
|
|
1851
|
+
}
|
|
1852
|
+
byFile.get(file).push(r);
|
|
1853
|
+
});
|
|
1854
|
+
const totalChecks = summary.passed + summary.critical + summary.warning + summary.info;
|
|
1855
|
+
const score = totalChecks > 0 ? Math.round(summary.passed / totalChecks * 100) : 100;
|
|
1856
|
+
const scoreColor = score >= 90 ? "#22c55e" : score >= 70 ? "#eab308" : score >= 50 ? "#f97316" : "#ef4444";
|
|
1857
|
+
const html = `<!DOCTYPE html>
|
|
1858
|
+
<html lang="en">
|
|
1859
|
+
<head>
|
|
1860
|
+
<meta charset="UTF-8">
|
|
1861
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1862
|
+
<title>SEO Audit Report - ${new Date(report.timestamp).toLocaleDateString()}</title>
|
|
1863
|
+
<style>
|
|
1864
|
+
* {
|
|
1865
|
+
margin: 0;
|
|
1866
|
+
padding: 0;
|
|
1867
|
+
box-sizing: border-box;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
body {
|
|
1871
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
1872
|
+
background: #f8fafc;
|
|
1873
|
+
color: #1e293b;
|
|
1874
|
+
line-height: 1.6;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
.container {
|
|
1878
|
+
max-width: 1200px;
|
|
1879
|
+
margin: 0 auto;
|
|
1880
|
+
padding: 2rem;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
header {
|
|
1884
|
+
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
|
1885
|
+
color: white;
|
|
1886
|
+
padding: 2rem;
|
|
1887
|
+
border-radius: 1rem;
|
|
1888
|
+
margin-bottom: 2rem;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
header h1 {
|
|
1892
|
+
font-size: 1.875rem;
|
|
1893
|
+
margin-bottom: 0.5rem;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
header p {
|
|
1897
|
+
opacity: 0.9;
|
|
1898
|
+
font-size: 0.875rem;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
.score-ring {
|
|
1902
|
+
display: flex;
|
|
1903
|
+
align-items: center;
|
|
1904
|
+
gap: 2rem;
|
|
1905
|
+
margin-top: 1.5rem;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
.score-circle {
|
|
1909
|
+
width: 120px;
|
|
1910
|
+
height: 120px;
|
|
1911
|
+
border-radius: 50%;
|
|
1912
|
+
background: conic-gradient(${scoreColor} ${score * 3.6}deg, rgba(255,255,255,0.2) 0deg);
|
|
1913
|
+
display: flex;
|
|
1914
|
+
align-items: center;
|
|
1915
|
+
justify-content: center;
|
|
1916
|
+
position: relative;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
.score-circle::before {
|
|
1920
|
+
content: '';
|
|
1921
|
+
position: absolute;
|
|
1922
|
+
width: 90px;
|
|
1923
|
+
height: 90px;
|
|
1924
|
+
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
|
1925
|
+
border-radius: 50%;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
.score-value {
|
|
1929
|
+
position: relative;
|
|
1930
|
+
font-size: 2rem;
|
|
1931
|
+
font-weight: bold;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
.score-stats {
|
|
1935
|
+
display: flex;
|
|
1936
|
+
gap: 1.5rem;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
.stat {
|
|
1940
|
+
text-align: center;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
.stat-value {
|
|
1944
|
+
font-size: 1.5rem;
|
|
1945
|
+
font-weight: bold;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
.stat-label {
|
|
1949
|
+
font-size: 0.75rem;
|
|
1950
|
+
opacity: 0.8;
|
|
1951
|
+
text-transform: uppercase;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.summary-cards {
|
|
1955
|
+
display: grid;
|
|
1956
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
1957
|
+
gap: 1rem;
|
|
1958
|
+
margin-bottom: 2rem;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
.card {
|
|
1962
|
+
background: white;
|
|
1963
|
+
border-radius: 0.75rem;
|
|
1964
|
+
padding: 1.5rem;
|
|
1965
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
.card-critical {
|
|
1969
|
+
border-left: 4px solid #ef4444;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
.card-warning {
|
|
1973
|
+
border-left: 4px solid #eab308;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
.card-info {
|
|
1977
|
+
border-left: 4px solid #3b82f6;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
.card-passed {
|
|
1981
|
+
border-left: 4px solid #22c55e;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
.card h3 {
|
|
1985
|
+
font-size: 2rem;
|
|
1986
|
+
margin-bottom: 0.25rem;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
.card p {
|
|
1990
|
+
color: #64748b;
|
|
1991
|
+
font-size: 0.875rem;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
.section {
|
|
1995
|
+
background: white;
|
|
1996
|
+
border-radius: 0.75rem;
|
|
1997
|
+
padding: 1.5rem;
|
|
1998
|
+
margin-bottom: 1.5rem;
|
|
1999
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
.section h2 {
|
|
2003
|
+
font-size: 1.25rem;
|
|
2004
|
+
margin-bottom: 1rem;
|
|
2005
|
+
padding-bottom: 0.75rem;
|
|
2006
|
+
border-bottom: 1px solid #e2e8f0;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
.file-group {
|
|
2010
|
+
margin-bottom: 1.5rem;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
.file-group:last-child {
|
|
2014
|
+
margin-bottom: 0;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
.file-name {
|
|
2018
|
+
font-weight: 600;
|
|
2019
|
+
color: #475569;
|
|
2020
|
+
font-size: 0.875rem;
|
|
2021
|
+
margin-bottom: 0.75rem;
|
|
2022
|
+
padding: 0.5rem;
|
|
2023
|
+
background: #f1f5f9;
|
|
2024
|
+
border-radius: 0.375rem;
|
|
2025
|
+
font-family: monospace;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
.issue {
|
|
2029
|
+
display: flex;
|
|
2030
|
+
gap: 1rem;
|
|
2031
|
+
padding: 1rem;
|
|
2032
|
+
border-radius: 0.5rem;
|
|
2033
|
+
margin-bottom: 0.5rem;
|
|
2034
|
+
background: #fafafa;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
.issue:hover {
|
|
2038
|
+
background: #f1f5f9;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
.issue-severity {
|
|
2042
|
+
flex-shrink: 0;
|
|
2043
|
+
width: 80px;
|
|
2044
|
+
font-size: 0.75rem;
|
|
2045
|
+
font-weight: 600;
|
|
2046
|
+
text-transform: uppercase;
|
|
2047
|
+
padding: 0.25rem 0.5rem;
|
|
2048
|
+
border-radius: 0.25rem;
|
|
2049
|
+
text-align: center;
|
|
2050
|
+
height: fit-content;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
.severity-critical {
|
|
2054
|
+
background: #fef2f2;
|
|
2055
|
+
color: #dc2626;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
.severity-warning {
|
|
2059
|
+
background: #fefce8;
|
|
2060
|
+
color: #ca8a04;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
.severity-info {
|
|
2064
|
+
background: #eff6ff;
|
|
2065
|
+
color: #2563eb;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
.issue-content {
|
|
2069
|
+
flex: 1;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
.issue-name {
|
|
2073
|
+
font-weight: 600;
|
|
2074
|
+
margin-bottom: 0.25rem;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
.issue-message {
|
|
2078
|
+
color: #64748b;
|
|
2079
|
+
font-size: 0.875rem;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
.issue-fix {
|
|
2083
|
+
margin-top: 0.5rem;
|
|
2084
|
+
padding: 0.75rem;
|
|
2085
|
+
background: #f0fdf4;
|
|
2086
|
+
border-radius: 0.375rem;
|
|
2087
|
+
font-size: 0.875rem;
|
|
2088
|
+
color: #166534;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
.issue-fix strong {
|
|
2092
|
+
display: block;
|
|
2093
|
+
margin-bottom: 0.25rem;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
.issue-details {
|
|
2097
|
+
margin-top: 0.5rem;
|
|
2098
|
+
font-size: 0.75rem;
|
|
2099
|
+
color: #94a3b8;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
.issue-details ul {
|
|
2103
|
+
margin-left: 1rem;
|
|
2104
|
+
margin-top: 0.25rem;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
footer {
|
|
2108
|
+
text-align: center;
|
|
2109
|
+
padding: 2rem;
|
|
2110
|
+
color: #94a3b8;
|
|
2111
|
+
font-size: 0.875rem;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
footer a {
|
|
2115
|
+
color: #3b82f6;
|
|
2116
|
+
text-decoration: none;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
footer a:hover {
|
|
2120
|
+
text-decoration: underline;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
.no-issues {
|
|
2124
|
+
text-align: center;
|
|
2125
|
+
padding: 3rem;
|
|
2126
|
+
color: #22c55e;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
.no-issues svg {
|
|
2130
|
+
width: 64px;
|
|
2131
|
+
height: 64px;
|
|
2132
|
+
margin-bottom: 1rem;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
@media (max-width: 640px) {
|
|
2136
|
+
.container {
|
|
2137
|
+
padding: 1rem;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
.score-ring {
|
|
2141
|
+
flex-direction: column;
|
|
2142
|
+
align-items: flex-start;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
.summary-cards {
|
|
2146
|
+
grid-template-columns: 1fr 1fr;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
</style>
|
|
2150
|
+
</head>
|
|
2151
|
+
<body>
|
|
2152
|
+
<div class="container">
|
|
2153
|
+
<header>
|
|
2154
|
+
<h1>\u{1F50D} SEO Audit Report</h1>
|
|
2155
|
+
<p>Generated on ${new Date(report.timestamp).toLocaleString()} \u2022 Path: ${report.path}</p>
|
|
2156
|
+
|
|
2157
|
+
<div class="score-ring">
|
|
2158
|
+
<div class="score-circle">
|
|
2159
|
+
<span class="score-value">${score}</span>
|
|
2160
|
+
</div>
|
|
2161
|
+
<div class="score-stats">
|
|
2162
|
+
<div class="stat">
|
|
2163
|
+
<div class="stat-value">${summary.totalFiles}</div>
|
|
2164
|
+
<div class="stat-label">Files</div>
|
|
2165
|
+
</div>
|
|
2166
|
+
<div class="stat">
|
|
2167
|
+
<div class="stat-value">${totalChecks}</div>
|
|
2168
|
+
<div class="stat-label">Checks</div>
|
|
2169
|
+
</div>
|
|
2170
|
+
<div class="stat">
|
|
2171
|
+
<div class="stat-value">${summary.passed}</div>
|
|
2172
|
+
<div class="stat-label">Passed</div>
|
|
2173
|
+
</div>
|
|
2174
|
+
</div>
|
|
2175
|
+
</div>
|
|
2176
|
+
</header>
|
|
2177
|
+
|
|
2178
|
+
<div class="summary-cards">
|
|
2179
|
+
<div class="card card-critical">
|
|
2180
|
+
<h3>${summary.critical}</h3>
|
|
2181
|
+
<p>Critical Issues</p>
|
|
2182
|
+
</div>
|
|
2183
|
+
<div class="card card-warning">
|
|
2184
|
+
<h3>${summary.warning}</h3>
|
|
2185
|
+
<p>Warnings</p>
|
|
2186
|
+
</div>
|
|
2187
|
+
<div class="card card-info">
|
|
2188
|
+
<h3>${summary.info}</h3>
|
|
2189
|
+
<p>Info</p>
|
|
2190
|
+
</div>
|
|
2191
|
+
<div class="card card-passed">
|
|
2192
|
+
<h3>${summary.passed}</h3>
|
|
2193
|
+
<p>Passed</p>
|
|
2194
|
+
</div>
|
|
2195
|
+
</div>
|
|
2196
|
+
|
|
2197
|
+
${results.length === 0 ? `
|
|
2198
|
+
<div class="section">
|
|
2199
|
+
<div class="no-issues">
|
|
2200
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
2201
|
+
<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" />
|
|
2202
|
+
</svg>
|
|
2203
|
+
<h2>All Checks Passed!</h2>
|
|
2204
|
+
<p>No SEO issues found. Great job!</p>
|
|
2205
|
+
</div>
|
|
2206
|
+
</div>
|
|
2207
|
+
` : `
|
|
2208
|
+
<div class="section">
|
|
2209
|
+
<h2>Issues Found</h2>
|
|
2210
|
+
${Array.from(byFile.entries()).map(([file, issues]) => `
|
|
2211
|
+
<div class="file-group">
|
|
2212
|
+
<div class="file-name">${escapeHtml(file)}</div>
|
|
2213
|
+
${issues.map((issue) => `
|
|
2214
|
+
<div class="issue">
|
|
2215
|
+
<span class="issue-severity severity-${issue.severity}">${issue.severity}</span>
|
|
2216
|
+
<div class="issue-content">
|
|
2217
|
+
<div class="issue-name">${escapeHtml(issue.name)}</div>
|
|
2218
|
+
<div class="issue-message">${escapeHtml(issue.message)}</div>
|
|
2219
|
+
${issue.fix ? `
|
|
2220
|
+
<div class="issue-fix">
|
|
2221
|
+
<strong>How to fix:</strong>
|
|
2222
|
+
${escapeHtml(issue.fix)}
|
|
2223
|
+
</div>
|
|
2224
|
+
` : ""}
|
|
2225
|
+
${issue.details && issue.details.length > 0 ? `
|
|
2226
|
+
<div class="issue-details">
|
|
2227
|
+
<strong>Affected:</strong>
|
|
2228
|
+
<ul>
|
|
2229
|
+
${issue.details.slice(0, 5).map((d) => `<li>${escapeHtml(d)}</li>`).join("")}
|
|
2230
|
+
${issue.details.length > 5 ? `<li>...and ${issue.details.length - 5} more</li>` : ""}
|
|
2231
|
+
</ul>
|
|
2232
|
+
</div>
|
|
2233
|
+
` : ""}
|
|
2234
|
+
</div>
|
|
2235
|
+
</div>
|
|
2236
|
+
`).join("")}
|
|
2237
|
+
</div>
|
|
2238
|
+
`).join("")}
|
|
2239
|
+
</div>
|
|
2240
|
+
`}
|
|
2241
|
+
|
|
2242
|
+
<footer>
|
|
2243
|
+
<p>Generated by <a href="https://rankture.com">Rankture CLI</a> v${report.version}</p>
|
|
2244
|
+
<p>For full audit with Core Web Vitals and AI suggestions, visit <a href="https://rankture.com/free-seo-audit/">rankture.com</a></p>
|
|
2245
|
+
</footer>
|
|
2246
|
+
</div>
|
|
2247
|
+
</body>
|
|
2248
|
+
</html>`;
|
|
2249
|
+
return html;
|
|
2250
|
+
}
|
|
2251
|
+
function escapeHtml(text) {
|
|
2252
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// src/commands/check.ts
|
|
2256
|
+
import { execSync } from "child_process";
|
|
2257
|
+
function getGitChangedFiles(basePath, stagedOnly) {
|
|
2258
|
+
try {
|
|
2259
|
+
const cwd = path2.resolve(basePath);
|
|
2260
|
+
const stagedOutput = execSync("git diff --cached --name-only --diff-filter=ACMR", {
|
|
2261
|
+
cwd,
|
|
2262
|
+
encoding: "utf-8"
|
|
2263
|
+
}).trim();
|
|
2264
|
+
const stagedFiles = stagedOutput ? stagedOutput.split("\n") : [];
|
|
2265
|
+
if (stagedOnly) {
|
|
2266
|
+
return stagedFiles.map((f) => path2.join(cwd, f));
|
|
2267
|
+
}
|
|
2268
|
+
const unstagedOutput = execSync("git diff --name-only --diff-filter=ACMR", {
|
|
2269
|
+
cwd,
|
|
2270
|
+
encoding: "utf-8"
|
|
2271
|
+
}).trim();
|
|
2272
|
+
const unstagedFiles = unstagedOutput ? unstagedOutput.split("\n") : [];
|
|
2273
|
+
const allFiles = [.../* @__PURE__ */ new Set([...stagedFiles, ...unstagedFiles])];
|
|
2274
|
+
return allFiles.map((f) => path2.join(cwd, f));
|
|
2275
|
+
} catch {
|
|
2276
|
+
return [];
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
async function checkCommand(options) {
|
|
2280
|
+
const spinner = ora("Scanning files...").start();
|
|
2281
|
+
try {
|
|
2282
|
+
const basePath = path2.resolve(options.path);
|
|
2283
|
+
if (!fs2.existsSync(basePath)) {
|
|
2284
|
+
spinner.fail(`Path not found: ${basePath}`);
|
|
2285
|
+
return 1;
|
|
2286
|
+
}
|
|
2287
|
+
const stats = fs2.statSync(basePath);
|
|
2288
|
+
let targetFiles;
|
|
2289
|
+
const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
|
|
2290
|
+
if (options.changed || options.staged) {
|
|
2291
|
+
spinner.text = options.staged ? "Getting staged files..." : "Getting changed files...";
|
|
2292
|
+
const gitFiles = getGitChangedFiles(basePath, !!options.staged);
|
|
2293
|
+
targetFiles = gitFiles.filter((f) => {
|
|
2294
|
+
const ext = path2.extname(f).toLowerCase();
|
|
2295
|
+
return allExtensions.includes(ext);
|
|
2296
|
+
});
|
|
2297
|
+
if (targetFiles.length === 0) {
|
|
2298
|
+
spinner.succeed(options.staged ? "No staged files to check" : "No changed files to check");
|
|
2299
|
+
return 0;
|
|
2300
|
+
}
|
|
2301
|
+
spinner.text = `Found ${targetFiles.length} ${options.staged ? "staged" : "changed"} file(s)`;
|
|
2302
|
+
} else if (stats.isFile()) {
|
|
2303
|
+
const ext = path2.extname(basePath).toLowerCase();
|
|
2304
|
+
if (!allExtensions.includes(ext)) {
|
|
2305
|
+
spinner.fail(`Unsupported file type: ${ext}. Supported: ${allExtensions.join(", ")}`);
|
|
2306
|
+
return 1;
|
|
2307
|
+
}
|
|
2308
|
+
targetFiles = [basePath];
|
|
2309
|
+
} else {
|
|
2310
|
+
const ignorePatterns = [
|
|
2311
|
+
"**/node_modules/**",
|
|
2312
|
+
"**/.git/**",
|
|
2313
|
+
"**/.astro/**",
|
|
2314
|
+
"**/.next/**",
|
|
2315
|
+
"**/.nuxt/**",
|
|
2316
|
+
"**/.svelte-kit/**",
|
|
2317
|
+
"**/dist/**/*.js",
|
|
2318
|
+
"**/dist/**/*.css",
|
|
2319
|
+
...options.ignore
|
|
2320
|
+
];
|
|
2321
|
+
const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
|
|
2322
|
+
const globPattern = `**/*.{${extensionPattern}}`;
|
|
2323
|
+
targetFiles = await fg(globPattern, {
|
|
2324
|
+
cwd: basePath,
|
|
2325
|
+
ignore: ignorePatterns,
|
|
2326
|
+
absolute: true
|
|
2327
|
+
});
|
|
2328
|
+
if (targetFiles.length === 0) {
|
|
2329
|
+
spinner.fail(`No supported files found in ${basePath}. Looking for: ${allExtensions.join(", ")}`);
|
|
2330
|
+
return 1;
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
spinner.text = `Parsing ${targetFiles.length} file${targetFiles.length > 1 ? "s" : ""}...`;
|
|
2334
|
+
const pages = [];
|
|
2335
|
+
for (const filePath of targetFiles) {
|
|
2336
|
+
const content = fs2.readFileSync(filePath, "utf-8");
|
|
2337
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
2338
|
+
let page;
|
|
2339
|
+
if (SUPPORTED_EXTENSIONS.html.includes(ext)) {
|
|
2340
|
+
page = parseHtml(filePath, content);
|
|
2341
|
+
} else {
|
|
2342
|
+
page = parseSourceFile(filePath, content);
|
|
2343
|
+
}
|
|
2344
|
+
pages.push(page);
|
|
2345
|
+
}
|
|
2346
|
+
spinner.text = "Running checks...";
|
|
2347
|
+
const effectiveBasePath = stats.isFile() ? path2.dirname(basePath) : basePath;
|
|
2348
|
+
const allResults = [];
|
|
2349
|
+
for (const page of pages) {
|
|
2350
|
+
const results = runChecks(page, pages, effectiveBasePath);
|
|
2351
|
+
allResults.push(...results);
|
|
2352
|
+
}
|
|
2353
|
+
spinner.stop();
|
|
2354
|
+
const report = {
|
|
2355
|
+
version: "0.1.0",
|
|
2356
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2357
|
+
path: options.path,
|
|
2358
|
+
summary: {
|
|
2359
|
+
totalFiles: pages.length,
|
|
2360
|
+
passed: allResults.filter((r) => r.passed).length,
|
|
2361
|
+
critical: allResults.filter((r) => !r.passed && r.severity === "critical").length,
|
|
2362
|
+
warning: allResults.filter((r) => !r.passed && r.severity === "warning").length,
|
|
2363
|
+
info: allResults.filter((r) => !r.passed && r.severity === "info").length
|
|
2364
|
+
},
|
|
2365
|
+
results: allResults.filter((r) => !r.passed)
|
|
2366
|
+
// Only include failures
|
|
2367
|
+
};
|
|
2368
|
+
if (options.format === "json") {
|
|
2369
|
+
const json = formatJson(report);
|
|
2370
|
+
if (options.output) {
|
|
2371
|
+
fs2.writeFileSync(options.output, json);
|
|
2372
|
+
console.log(`Report written to ${options.output}`);
|
|
2373
|
+
} else {
|
|
2374
|
+
console.log(json);
|
|
2375
|
+
}
|
|
2376
|
+
} else if (options.format === "html") {
|
|
2377
|
+
const html = formatHtml(report);
|
|
2378
|
+
if (options.output) {
|
|
2379
|
+
fs2.writeFileSync(options.output, html);
|
|
2380
|
+
console.log(`
|
|
2381
|
+
\u2705 HTML report written to ${options.output}`);
|
|
2382
|
+
console.log(` Open in browser: file://${path2.resolve(options.output)}
|
|
2383
|
+
`);
|
|
2384
|
+
} else {
|
|
2385
|
+
const outputFile = `seo-report-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.html`;
|
|
2386
|
+
fs2.writeFileSync(outputFile, html);
|
|
2387
|
+
console.log(`
|
|
2388
|
+
\u2705 HTML report written to ${outputFile}`);
|
|
2389
|
+
console.log(` Open in browser: file://${path2.resolve(outputFile)}
|
|
2390
|
+
`);
|
|
2391
|
+
}
|
|
2392
|
+
} else {
|
|
2393
|
+
printReport(report);
|
|
2394
|
+
}
|
|
2395
|
+
let exitCode = 0;
|
|
2396
|
+
switch (options.failOn) {
|
|
2397
|
+
case "critical":
|
|
2398
|
+
if (report.summary.critical > 0) exitCode = 1;
|
|
2399
|
+
break;
|
|
2400
|
+
case "warning":
|
|
2401
|
+
if (report.summary.critical > 0 || report.summary.warning > 0) exitCode = 1;
|
|
2402
|
+
break;
|
|
2403
|
+
case "any":
|
|
2404
|
+
if (report.summary.critical > 0 || report.summary.warning > 0 || report.summary.info > 0) {
|
|
2405
|
+
exitCode = 1;
|
|
2406
|
+
}
|
|
2407
|
+
break;
|
|
2408
|
+
case "none":
|
|
2409
|
+
default:
|
|
2410
|
+
exitCode = 0;
|
|
2411
|
+
}
|
|
2412
|
+
return exitCode;
|
|
2413
|
+
} catch (error) {
|
|
2414
|
+
spinner.fail("Error running checks");
|
|
2415
|
+
console.error(error);
|
|
2416
|
+
return 1;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
async function watchCommand(options) {
|
|
2420
|
+
const basePath = path2.resolve(options.path);
|
|
2421
|
+
if (!fs2.existsSync(basePath)) {
|
|
2422
|
+
console.error(`Path not found: ${basePath}`);
|
|
2423
|
+
process.exit(1);
|
|
2424
|
+
}
|
|
2425
|
+
console.log("");
|
|
2426
|
+
console.log(" \u{1F440} Watch mode enabled");
|
|
2427
|
+
console.log(` Watching: ${basePath}`);
|
|
2428
|
+
console.log(" Press Ctrl+C to stop");
|
|
2429
|
+
console.log("");
|
|
2430
|
+
await checkCommand(options);
|
|
2431
|
+
const allExtensions = [...SUPPORTED_EXTENSIONS.html, ...SUPPORTED_EXTENSIONS.source];
|
|
2432
|
+
const extensionPattern = allExtensions.map((e) => e.slice(1)).join(",");
|
|
2433
|
+
const watchPattern = path2.join(basePath, `**/*.{${extensionPattern}}`);
|
|
2434
|
+
const chokidar = await import("chokidar");
|
|
2435
|
+
let debounceTimer = null;
|
|
2436
|
+
let isRunning = false;
|
|
2437
|
+
const runChecks2 = async () => {
|
|
2438
|
+
if (isRunning) return;
|
|
2439
|
+
isRunning = true;
|
|
2440
|
+
console.clear();
|
|
2441
|
+
console.log("");
|
|
2442
|
+
console.log(" \u{1F504} File change detected, re-running checks...");
|
|
2443
|
+
console.log("");
|
|
2444
|
+
await checkCommand(options);
|
|
2445
|
+
console.log("");
|
|
2446
|
+
console.log(" \u{1F440} Watching for changes...");
|
|
2447
|
+
isRunning = false;
|
|
2448
|
+
};
|
|
2449
|
+
const debouncedRun = () => {
|
|
2450
|
+
if (debounceTimer) {
|
|
2451
|
+
clearTimeout(debounceTimer);
|
|
2452
|
+
}
|
|
2453
|
+
debounceTimer = setTimeout(runChecks2, 300);
|
|
2454
|
+
};
|
|
2455
|
+
const watcher = chokidar.watch(watchPattern, {
|
|
2456
|
+
ignored: [
|
|
2457
|
+
"**/node_modules/**",
|
|
2458
|
+
"**/.git/**",
|
|
2459
|
+
"**/.astro/**",
|
|
2460
|
+
"**/.next/**",
|
|
2461
|
+
"**/.nuxt/**",
|
|
2462
|
+
"**/.svelte-kit/**",
|
|
2463
|
+
...options.ignore.map((p) => `**/${p}`)
|
|
2464
|
+
],
|
|
2465
|
+
persistent: true,
|
|
2466
|
+
ignoreInitial: true
|
|
2467
|
+
});
|
|
2468
|
+
watcher.on("change", debouncedRun).on("add", debouncedRun).on("unlink", debouncedRun);
|
|
2469
|
+
process.on("SIGINT", () => {
|
|
2470
|
+
console.log("");
|
|
2471
|
+
console.log(" \u{1F44B} Watch mode stopped");
|
|
2472
|
+
watcher.close();
|
|
2473
|
+
process.exit(0);
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// src/commands/validate.ts
|
|
2478
|
+
import * as fs3 from "fs";
|
|
2479
|
+
import * as path3 from "path";
|
|
2480
|
+
import chalk2 from "chalk";
|
|
2481
|
+
import { XMLParser } from "fast-xml-parser";
|
|
2482
|
+
async function validateRobotsTxt(filePath) {
|
|
2483
|
+
const result = {
|
|
2484
|
+
valid: true,
|
|
2485
|
+
errors: [],
|
|
2486
|
+
warnings: [],
|
|
2487
|
+
info: []
|
|
2488
|
+
};
|
|
2489
|
+
const absolutePath = path3.resolve(filePath);
|
|
2490
|
+
if (!fs3.existsSync(absolutePath)) {
|
|
2491
|
+
result.valid = false;
|
|
2492
|
+
result.errors.push(`File not found: ${absolutePath}`);
|
|
2493
|
+
return result;
|
|
2494
|
+
}
|
|
2495
|
+
const content = fs3.readFileSync(absolutePath, "utf-8");
|
|
2496
|
+
const lines = content.split("\n");
|
|
2497
|
+
let hasUserAgent = false;
|
|
2498
|
+
let hasSitemap = false;
|
|
2499
|
+
let currentUserAgent = "";
|
|
2500
|
+
let lineNum = 0;
|
|
2501
|
+
for (const rawLine of lines) {
|
|
2502
|
+
lineNum++;
|
|
2503
|
+
const line = rawLine.trim();
|
|
2504
|
+
if (!line || line.startsWith("#")) {
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
const colonIndex = line.indexOf(":");
|
|
2508
|
+
if (colonIndex === -1) {
|
|
2509
|
+
result.warnings.push(`Line ${lineNum}: Invalid format (missing colon): "${line}"`);
|
|
2510
|
+
continue;
|
|
2511
|
+
}
|
|
2512
|
+
const directive = line.substring(0, colonIndex).trim().toLowerCase();
|
|
2513
|
+
const value = line.substring(colonIndex + 1).trim();
|
|
2514
|
+
switch (directive) {
|
|
2515
|
+
case "user-agent":
|
|
2516
|
+
hasUserAgent = true;
|
|
2517
|
+
currentUserAgent = value;
|
|
2518
|
+
if (!value) {
|
|
2519
|
+
result.errors.push(`Line ${lineNum}: User-agent directive has no value`);
|
|
2520
|
+
result.valid = false;
|
|
2521
|
+
}
|
|
2522
|
+
break;
|
|
2523
|
+
case "disallow":
|
|
2524
|
+
if (!currentUserAgent) {
|
|
2525
|
+
result.errors.push(`Line ${lineNum}: Disallow before User-agent directive`);
|
|
2526
|
+
result.valid = false;
|
|
2527
|
+
}
|
|
2528
|
+
if (value && !value.startsWith("/") && value !== "*") {
|
|
2529
|
+
result.warnings.push(`Line ${lineNum}: Disallow path should start with /`);
|
|
2530
|
+
}
|
|
2531
|
+
break;
|
|
2532
|
+
case "allow":
|
|
2533
|
+
if (!currentUserAgent) {
|
|
2534
|
+
result.errors.push(`Line ${lineNum}: Allow before User-agent directive`);
|
|
2535
|
+
result.valid = false;
|
|
2536
|
+
}
|
|
2537
|
+
if (value && !value.startsWith("/") && value !== "*") {
|
|
2538
|
+
result.warnings.push(`Line ${lineNum}: Allow path should start with /`);
|
|
2539
|
+
}
|
|
2540
|
+
break;
|
|
2541
|
+
case "sitemap":
|
|
2542
|
+
hasSitemap = true;
|
|
2543
|
+
if (!value.startsWith("http://") && !value.startsWith("https://")) {
|
|
2544
|
+
result.errors.push(`Line ${lineNum}: Sitemap URL must be absolute (start with http:// or https://)`);
|
|
2545
|
+
result.valid = false;
|
|
2546
|
+
}
|
|
2547
|
+
break;
|
|
2548
|
+
case "crawl-delay": {
|
|
2549
|
+
const delay = parseFloat(value);
|
|
2550
|
+
if (isNaN(delay) || delay < 0) {
|
|
2551
|
+
result.warnings.push(`Line ${lineNum}: Invalid crawl-delay value: "${value}"`);
|
|
2552
|
+
} else if (delay > 10) {
|
|
2553
|
+
result.warnings.push(
|
|
2554
|
+
`Line ${lineNum}: Crawl-delay of ${delay}s is very high - may slow indexing`
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
break;
|
|
2558
|
+
}
|
|
2559
|
+
case "host":
|
|
2560
|
+
result.info.push(`Line ${lineNum}: Host directive (Yandex-specific)`);
|
|
2561
|
+
break;
|
|
2562
|
+
case "clean-param":
|
|
2563
|
+
result.info.push(`Line ${lineNum}: Clean-param directive (Yandex-specific)`);
|
|
2564
|
+
break;
|
|
2565
|
+
default:
|
|
2566
|
+
result.warnings.push(`Line ${lineNum}: Unknown directive: "${directive}"`);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
if (!hasUserAgent) {
|
|
2570
|
+
result.errors.push("Missing User-agent directive (required)");
|
|
2571
|
+
result.valid = false;
|
|
2572
|
+
}
|
|
2573
|
+
if (!hasSitemap) {
|
|
2574
|
+
result.warnings.push("No Sitemap directive found - recommended to include sitemap URL");
|
|
2575
|
+
}
|
|
2576
|
+
if (content.includes("Disallow: /") && !content.includes("Allow:")) {
|
|
2577
|
+
const disallowAll = lines.some((l) => {
|
|
2578
|
+
const trimmed = l.trim().toLowerCase();
|
|
2579
|
+
return trimmed === "disallow: /" || trimmed === "disallow:/";
|
|
2580
|
+
});
|
|
2581
|
+
if (disallowAll) {
|
|
2582
|
+
result.warnings.push('Found "Disallow: /" which blocks all crawling - ensure this is intentional');
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
result.info.push(`Total lines: ${lines.length}`);
|
|
2586
|
+
result.info.push(`User-agents defined: ${lines.filter((l) => l.trim().toLowerCase().startsWith("user-agent")).length}`);
|
|
2587
|
+
return result;
|
|
2588
|
+
}
|
|
2589
|
+
async function validateSitemap(filePath) {
|
|
2590
|
+
const result = {
|
|
2591
|
+
valid: true,
|
|
2592
|
+
errors: [],
|
|
2593
|
+
warnings: [],
|
|
2594
|
+
info: []
|
|
2595
|
+
};
|
|
2596
|
+
const absolutePath = path3.resolve(filePath);
|
|
2597
|
+
if (!fs3.existsSync(absolutePath)) {
|
|
2598
|
+
result.valid = false;
|
|
2599
|
+
result.errors.push(`File not found: ${absolutePath}`);
|
|
2600
|
+
return result;
|
|
2601
|
+
}
|
|
2602
|
+
const content = fs3.readFileSync(absolutePath, "utf-8");
|
|
2603
|
+
if (!content.trim().startsWith("<?xml")) {
|
|
2604
|
+
result.warnings.push('Missing XML declaration (<?xml version="1.0" encoding="UTF-8"?>)');
|
|
2605
|
+
}
|
|
2606
|
+
const parser = new XMLParser({
|
|
2607
|
+
ignoreAttributes: false,
|
|
2608
|
+
attributeNamePrefix: "@_"
|
|
2609
|
+
});
|
|
2610
|
+
let parsed;
|
|
2611
|
+
try {
|
|
2612
|
+
parsed = parser.parse(content);
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
result.valid = false;
|
|
2615
|
+
result.errors.push(`Invalid XML: ${error}`);
|
|
2616
|
+
return result;
|
|
2617
|
+
}
|
|
2618
|
+
const isSitemapIndex = !!parsed.sitemapindex;
|
|
2619
|
+
const isUrlset = !!parsed.urlset;
|
|
2620
|
+
if (!isSitemapIndex && !isUrlset) {
|
|
2621
|
+
result.valid = false;
|
|
2622
|
+
result.errors.push("Missing root element: expected <urlset> or <sitemapindex>");
|
|
2623
|
+
return result;
|
|
2624
|
+
}
|
|
2625
|
+
if (isSitemapIndex) {
|
|
2626
|
+
const sitemaps = parsed.sitemapindex.sitemap;
|
|
2627
|
+
const sitemapArray = Array.isArray(sitemaps) ? sitemaps : sitemaps ? [sitemaps] : [];
|
|
2628
|
+
result.info.push(`Sitemap index with ${sitemapArray.length} child sitemap(s)`);
|
|
2629
|
+
for (let i = 0; i < sitemapArray.length; i++) {
|
|
2630
|
+
const sitemap = sitemapArray[i];
|
|
2631
|
+
if (!sitemap.loc) {
|
|
2632
|
+
result.errors.push(`Sitemap ${i + 1}: Missing <loc> element (required)`);
|
|
2633
|
+
result.valid = false;
|
|
2634
|
+
} else if (!sitemap.loc.startsWith("http")) {
|
|
2635
|
+
result.errors.push(`Sitemap ${i + 1}: <loc> must be absolute URL`);
|
|
2636
|
+
result.valid = false;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
} else {
|
|
2640
|
+
const urls = parsed.urlset.url;
|
|
2641
|
+
const urlArray = Array.isArray(urls) ? urls : urls ? [urls] : [];
|
|
2642
|
+
result.info.push(`Sitemap with ${urlArray.length} URL(s)`);
|
|
2643
|
+
if (urlArray.length === 0) {
|
|
2644
|
+
result.warnings.push("Sitemap is empty (no URLs)");
|
|
2645
|
+
}
|
|
2646
|
+
if (urlArray.length > 5e4) {
|
|
2647
|
+
result.errors.push(`Sitemap has ${urlArray.length} URLs - maximum allowed is 50,000`);
|
|
2648
|
+
result.valid = false;
|
|
2649
|
+
}
|
|
2650
|
+
const fileSize = Buffer.byteLength(content, "utf-8");
|
|
2651
|
+
const fileSizeMB = fileSize / (1024 * 1024);
|
|
2652
|
+
if (fileSizeMB > 50) {
|
|
2653
|
+
result.errors.push(`Sitemap is ${fileSizeMB.toFixed(1)}MB - maximum allowed is 50MB`);
|
|
2654
|
+
result.valid = false;
|
|
2655
|
+
}
|
|
2656
|
+
const priorities = /* @__PURE__ */ new Set();
|
|
2657
|
+
const changefreqs = /* @__PURE__ */ new Set();
|
|
2658
|
+
let urlsWithoutLoc = 0;
|
|
2659
|
+
let urlsWithRelativeLoc = 0;
|
|
2660
|
+
let urlsWithLastmod = 0;
|
|
2661
|
+
let urlsWithPriority = 0;
|
|
2662
|
+
let urlsWithChangefreq = 0;
|
|
2663
|
+
const validChangefreqs = ["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"];
|
|
2664
|
+
const seenUrls = /* @__PURE__ */ new Set();
|
|
2665
|
+
const duplicateUrls = [];
|
|
2666
|
+
for (let i = 0; i < Math.min(urlArray.length, 1e3); i++) {
|
|
2667
|
+
const url = urlArray[i];
|
|
2668
|
+
if (!url.loc) {
|
|
2669
|
+
urlsWithoutLoc++;
|
|
2670
|
+
} else {
|
|
2671
|
+
if (seenUrls.has(url.loc)) {
|
|
2672
|
+
duplicateUrls.push(url.loc);
|
|
2673
|
+
}
|
|
2674
|
+
seenUrls.add(url.loc);
|
|
2675
|
+
if (!url.loc.startsWith("http")) {
|
|
2676
|
+
urlsWithRelativeLoc++;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
if (url.lastmod) {
|
|
2680
|
+
urlsWithLastmod++;
|
|
2681
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)?)?$/;
|
|
2682
|
+
if (!datePattern.test(url.lastmod)) {
|
|
2683
|
+
result.warnings.push(`URL ${i + 1}: Invalid lastmod date format: "${url.lastmod}"`);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
if (url.priority !== void 0) {
|
|
2687
|
+
urlsWithPriority++;
|
|
2688
|
+
priorities.add(String(url.priority));
|
|
2689
|
+
const priority = parseFloat(url.priority);
|
|
2690
|
+
if (isNaN(priority) || priority < 0 || priority > 1) {
|
|
2691
|
+
result.warnings.push(`URL ${i + 1}: Invalid priority value: "${url.priority}" (must be 0.0-1.0)`);
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
if (url.changefreq) {
|
|
2695
|
+
urlsWithChangefreq++;
|
|
2696
|
+
changefreqs.add(url.changefreq);
|
|
2697
|
+
if (!validChangefreqs.includes(url.changefreq.toLowerCase())) {
|
|
2698
|
+
result.warnings.push(`URL ${i + 1}: Invalid changefreq: "${url.changefreq}"`);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
if (urlsWithoutLoc > 0) {
|
|
2703
|
+
result.errors.push(`${urlsWithoutLoc} URL(s) missing required <loc> element`);
|
|
2704
|
+
result.valid = false;
|
|
2705
|
+
}
|
|
2706
|
+
if (urlsWithRelativeLoc > 0) {
|
|
2707
|
+
result.errors.push(`${urlsWithRelativeLoc} URL(s) have relative URLs - must be absolute`);
|
|
2708
|
+
result.valid = false;
|
|
2709
|
+
}
|
|
2710
|
+
if (duplicateUrls.length > 0) {
|
|
2711
|
+
result.warnings.push(`${duplicateUrls.length} duplicate URL(s) found`);
|
|
2712
|
+
if (duplicateUrls.length <= 5) {
|
|
2713
|
+
duplicateUrls.forEach((url) => result.warnings.push(` Duplicate: ${url}`));
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
if (urlsWithLastmod === 0) {
|
|
2717
|
+
result.info.push("No lastmod dates - consider adding for better crawling");
|
|
2718
|
+
} else {
|
|
2719
|
+
result.info.push(`${urlsWithLastmod} URL(s) have lastmod dates`);
|
|
2720
|
+
}
|
|
2721
|
+
if (priorities.size === 1) {
|
|
2722
|
+
result.info.push(`All URLs have same priority (${[...priorities][0]}) - consider varying for important pages`);
|
|
2723
|
+
}
|
|
2724
|
+
if (urlsWithPriority > 0 || urlsWithChangefreq > 0) {
|
|
2725
|
+
result.info.push("Note: Google largely ignores priority and changefreq - focus on lastmod instead");
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
return result;
|
|
2729
|
+
}
|
|
2730
|
+
function printValidationResult(type, filePath, result) {
|
|
2731
|
+
console.log("");
|
|
2732
|
+
console.log(chalk2.bold(` ${type.toUpperCase()} Validation: ${filePath}`));
|
|
2733
|
+
console.log(" " + "\u2500".repeat(50));
|
|
2734
|
+
if (result.valid) {
|
|
2735
|
+
console.log(chalk2.green(" \u2713 Valid"));
|
|
2736
|
+
} else {
|
|
2737
|
+
console.log(chalk2.red(" \u2717 Invalid"));
|
|
2738
|
+
}
|
|
2739
|
+
if (result.errors.length > 0) {
|
|
2740
|
+
console.log("");
|
|
2741
|
+
console.log(chalk2.red(" Errors:"));
|
|
2742
|
+
result.errors.forEach((e) => console.log(chalk2.red(` \u2022 ${e}`)));
|
|
2743
|
+
}
|
|
2744
|
+
if (result.warnings.length > 0) {
|
|
2745
|
+
console.log("");
|
|
2746
|
+
console.log(chalk2.yellow(" Warnings:"));
|
|
2747
|
+
result.warnings.forEach((w) => console.log(chalk2.yellow(` \u2022 ${w}`)));
|
|
2748
|
+
}
|
|
2749
|
+
if (result.info.length > 0) {
|
|
2750
|
+
console.log("");
|
|
2751
|
+
console.log(chalk2.blue(" Info:"));
|
|
2752
|
+
result.info.forEach((i) => console.log(chalk2.blue(` \u2022 ${i}`)));
|
|
2753
|
+
}
|
|
2754
|
+
console.log("");
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// src/cli.ts
|
|
2758
|
+
var program = new Command();
|
|
2759
|
+
program.name("rankture").description("SEO audit tool for developers - check your sites before deploy").version("0.1.0");
|
|
2760
|
+
program.command("check <path>").description("Run SEO checks on local HTML files").option("-f, --format <format>", "Output format: terminal, json, html", "terminal").option("-o, --output <file>", "Write output to file").option("--fail-on <level>", "Exit with code 1 on: none, critical, warning, any", "none").option("--ignore <patterns...>", "Glob patterns to ignore", []).option("--config <file>", "Config file path").option("-w, --watch", "Watch for file changes and re-run checks").option("--changed", "Only check files changed in git (staged + unstaged)").option("--staged", "Only check git staged files").action(async (pathArg, opts) => {
|
|
2761
|
+
const options = {
|
|
2762
|
+
path: pathArg,
|
|
2763
|
+
format: opts.format,
|
|
2764
|
+
output: opts.output,
|
|
2765
|
+
failOn: opts.failOn,
|
|
2766
|
+
ignore: opts.ignore,
|
|
2767
|
+
config: opts.config,
|
|
2768
|
+
watch: opts.watch,
|
|
2769
|
+
changed: opts.changed,
|
|
2770
|
+
staged: opts.staged
|
|
2771
|
+
};
|
|
2772
|
+
if (opts.watch) {
|
|
2773
|
+
await watchCommand(options);
|
|
2774
|
+
} else {
|
|
2775
|
+
const exitCode = await checkCommand(options);
|
|
2776
|
+
process.exit(exitCode);
|
|
2777
|
+
}
|
|
2778
|
+
});
|
|
2779
|
+
program.command("audit <url>").description("Audit a live URL (requires API key for full features)").option("--api-key <key>", "Rankture API key for Pro features").option("--sync", "Sync results to Rankture dashboard").option("--mobile", "Run mobile audit", true).option("--desktop", "Run desktop audit", false).option("--pages <number>", "Max pages to audit (Pro only)", "1").option("-f, --format <format>", "Output format: terminal, json, html", "terminal").option("--fail-on <level>", "Exit with code 1 on: none, critical, warning, any", "none").action(async (_url, _opts) => {
|
|
2780
|
+
console.log("");
|
|
2781
|
+
console.log(" The audit command is coming soon!");
|
|
2782
|
+
console.log("");
|
|
2783
|
+
console.log(" For now, use the check command on your built files:");
|
|
2784
|
+
console.log(" rankture check ./dist");
|
|
2785
|
+
console.log("");
|
|
2786
|
+
console.log(" Or run a free audit at:");
|
|
2787
|
+
console.log(" https://rankture.com/free-seo-audit/");
|
|
2788
|
+
console.log("");
|
|
2789
|
+
process.exit(0);
|
|
2790
|
+
});
|
|
2791
|
+
program.command("init").description("Create a rankture.config.json file").option("--force", "Overwrite existing config file").action(async (opts) => {
|
|
2792
|
+
const fs4 = await import("fs");
|
|
2793
|
+
const configPath = "./rankture.config.json";
|
|
2794
|
+
if (fs4.existsSync(configPath) && !opts.force) {
|
|
2795
|
+
console.log("");
|
|
2796
|
+
console.log(" \u26A0\uFE0F rankture.config.json already exists");
|
|
2797
|
+
console.log(" Use --force to overwrite");
|
|
2798
|
+
console.log("");
|
|
2799
|
+
process.exit(1);
|
|
2800
|
+
}
|
|
2801
|
+
const config = {
|
|
2802
|
+
checks: {
|
|
2803
|
+
"meta-title": true,
|
|
2804
|
+
"meta-description": true,
|
|
2805
|
+
"viewport": true,
|
|
2806
|
+
"lang": true,
|
|
2807
|
+
"canonical": true,
|
|
2808
|
+
"h1": true,
|
|
2809
|
+
"heading-hierarchy": true,
|
|
2810
|
+
"image-alt": true,
|
|
2811
|
+
"internal-links": true,
|
|
2812
|
+
"schema": true,
|
|
2813
|
+
// AI/Voice optimization checks
|
|
2814
|
+
"schema-speakable": true,
|
|
2815
|
+
"schema-offers": true,
|
|
2816
|
+
"schema-faq": true,
|
|
2817
|
+
"schema-howto": true,
|
|
2818
|
+
"open-graph": true,
|
|
2819
|
+
"twitter-cards": true,
|
|
2820
|
+
"robots-meta": true,
|
|
2821
|
+
"accessibility": true,
|
|
2822
|
+
"color-contrast": true,
|
|
2823
|
+
"focus-indicators": true
|
|
2824
|
+
},
|
|
2825
|
+
ignore: [
|
|
2826
|
+
"**/node_modules/**",
|
|
2827
|
+
"**/.git/**",
|
|
2828
|
+
"**/404.html",
|
|
2829
|
+
"**/500.html"
|
|
2830
|
+
],
|
|
2831
|
+
thresholds: {
|
|
2832
|
+
titleLength: { min: 30, max: 60 },
|
|
2833
|
+
descriptionLength: { min: 120, max: 160 }
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
fs4.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
2837
|
+
console.log("");
|
|
2838
|
+
console.log(" \u2705 Created rankture.config.json");
|
|
2839
|
+
console.log("");
|
|
2840
|
+
console.log(" Next steps:");
|
|
2841
|
+
console.log(" 1. Edit the config to customize checks");
|
|
2842
|
+
console.log(" 2. Run: rankture check ./dist");
|
|
2843
|
+
console.log("");
|
|
2844
|
+
process.exit(0);
|
|
2845
|
+
});
|
|
2846
|
+
program.command("validate <type> <file>").description("Validate sitemap.xml, robots.txt, or schema.json").action(async (type, file) => {
|
|
2847
|
+
const validTypes = ["robots", "robots.txt", "sitemap", "sitemap.xml", "schema"];
|
|
2848
|
+
if (!validTypes.includes(type.toLowerCase())) {
|
|
2849
|
+
console.error(`
|
|
2850
|
+
Invalid type: "${type}"`);
|
|
2851
|
+
console.error(` Valid types: robots, sitemap, schema
|
|
2852
|
+
`);
|
|
2853
|
+
process.exit(1);
|
|
2854
|
+
}
|
|
2855
|
+
const normalizedType = type.toLowerCase().replace(".txt", "").replace(".xml", "").replace(".json", "");
|
|
2856
|
+
try {
|
|
2857
|
+
if (normalizedType === "robots") {
|
|
2858
|
+
const result = await validateRobotsTxt(file);
|
|
2859
|
+
printValidationResult("robots.txt", file, result);
|
|
2860
|
+
process.exit(result.valid ? 0 : 1);
|
|
2861
|
+
} else if (normalizedType === "sitemap") {
|
|
2862
|
+
const result = await validateSitemap(file);
|
|
2863
|
+
printValidationResult("sitemap.xml", file, result);
|
|
2864
|
+
process.exit(result.valid ? 0 : 1);
|
|
2865
|
+
} else if (normalizedType === "schema") {
|
|
2866
|
+
console.log("\n Schema validation coming soon!\n");
|
|
2867
|
+
process.exit(0);
|
|
2868
|
+
}
|
|
2869
|
+
} catch (error) {
|
|
2870
|
+
console.error(`
|
|
2871
|
+
Error validating ${type}: ${error}
|
|
2872
|
+
`);
|
|
2873
|
+
process.exit(1);
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
program.parse();
|
|
2877
|
+
//# sourceMappingURL=cli.mjs.map
|