@ijonis/geo-lint 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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +692 -0
- package/dist/cli.cjs +2716 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2689 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +2691 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +267 -0
- package/dist/index.d.ts +267 -0
- package/dist/index.js +2646 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2691 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
createAdapter: () => createAdapter,
|
|
34
|
+
defineConfig: () => defineConfig,
|
|
35
|
+
lint: () => lint,
|
|
36
|
+
lintQuiet: () => lintQuiet,
|
|
37
|
+
loadContentItems: () => loadContentItems
|
|
38
|
+
});
|
|
39
|
+
module.exports = __toCommonJS(src_exports);
|
|
40
|
+
|
|
41
|
+
// node_modules/tsup/assets/cjs_shims.js
|
|
42
|
+
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
43
|
+
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
44
|
+
|
|
45
|
+
// src/index.ts
|
|
46
|
+
var import_node_path4 = require("path");
|
|
47
|
+
|
|
48
|
+
// src/config/loader.ts
|
|
49
|
+
var import_node_fs = require("fs");
|
|
50
|
+
var import_node_fs2 = require("fs");
|
|
51
|
+
var import_node_path = require("path");
|
|
52
|
+
|
|
53
|
+
// src/config/defaults.ts
|
|
54
|
+
var DEFAULT_CONFIG = {
|
|
55
|
+
contentPaths: [
|
|
56
|
+
{ dir: "content/blog", type: "blog", urlPrefix: "/blog/" },
|
|
57
|
+
{ dir: "content/pages", type: "page", urlPrefix: "/" },
|
|
58
|
+
{ dir: "content/projects", type: "project", urlPrefix: "/projects/" }
|
|
59
|
+
],
|
|
60
|
+
staticRoutes: [],
|
|
61
|
+
imageDirectories: ["public/images"],
|
|
62
|
+
categories: [],
|
|
63
|
+
excludeSlugs: [],
|
|
64
|
+
excludeCategories: ["legal"],
|
|
65
|
+
geo: {
|
|
66
|
+
brandName: "",
|
|
67
|
+
brandCity: "",
|
|
68
|
+
keywordsPath: ""
|
|
69
|
+
},
|
|
70
|
+
rules: {},
|
|
71
|
+
thresholds: {
|
|
72
|
+
title: { minLength: 30, maxLength: 60, warnLength: 55 },
|
|
73
|
+
description: { minLength: 70, maxLength: 160, warnLength: 150 },
|
|
74
|
+
slug: { maxLength: 75 },
|
|
75
|
+
content: { minWordCount: 300, minReadabilityScore: 30 }
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/config/loader.ts
|
|
80
|
+
var CONFIG_FILENAMES = [
|
|
81
|
+
"geo-lint.config.ts",
|
|
82
|
+
"geo-lint.config.mts",
|
|
83
|
+
"geo-lint.config.mjs",
|
|
84
|
+
"geo-lint.config.js"
|
|
85
|
+
];
|
|
86
|
+
function defineConfig(config) {
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
async function tryLoadConfigFile(projectRoot) {
|
|
90
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
91
|
+
const configPath = (0, import_node_path.join)(projectRoot, filename);
|
|
92
|
+
if (!(0, import_node_fs.existsSync)(configPath)) continue;
|
|
93
|
+
try {
|
|
94
|
+
const { createJiti } = await import("jiti");
|
|
95
|
+
const jiti = createJiti(importMetaUrl);
|
|
96
|
+
const mod = await jiti.import(configPath);
|
|
97
|
+
const config = mod.default ?? mod;
|
|
98
|
+
if (config && typeof config === "object" && "siteUrl" in config) {
|
|
99
|
+
return config;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
function tryLoadPackageJsonConfig(projectRoot) {
|
|
107
|
+
try {
|
|
108
|
+
const pkgPath = (0, import_node_path.join)(projectRoot, "package.json");
|
|
109
|
+
if (!(0, import_node_fs.existsSync)(pkgPath)) return null;
|
|
110
|
+
const pkg = JSON.parse((0, import_node_fs2.readFileSync)(pkgPath, "utf-8"));
|
|
111
|
+
const config = pkg.geoLint;
|
|
112
|
+
if (config && typeof config === "object" && "siteUrl" in config) {
|
|
113
|
+
return config;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function mergeWithDefaults(user) {
|
|
121
|
+
return {
|
|
122
|
+
siteUrl: user.siteUrl,
|
|
123
|
+
contentPaths: user.contentPaths ?? DEFAULT_CONFIG.contentPaths,
|
|
124
|
+
staticRoutes: user.staticRoutes ?? DEFAULT_CONFIG.staticRoutes,
|
|
125
|
+
imageDirectories: user.imageDirectories ?? DEFAULT_CONFIG.imageDirectories,
|
|
126
|
+
categories: user.categories ?? DEFAULT_CONFIG.categories,
|
|
127
|
+
excludeSlugs: user.excludeSlugs ?? DEFAULT_CONFIG.excludeSlugs,
|
|
128
|
+
excludeCategories: user.excludeCategories ?? DEFAULT_CONFIG.excludeCategories,
|
|
129
|
+
geo: {
|
|
130
|
+
brandName: user.geo?.brandName ?? DEFAULT_CONFIG.geo.brandName,
|
|
131
|
+
brandCity: user.geo?.brandCity ?? DEFAULT_CONFIG.geo.brandCity,
|
|
132
|
+
keywordsPath: user.geo?.keywordsPath ?? DEFAULT_CONFIG.geo.keywordsPath
|
|
133
|
+
},
|
|
134
|
+
rules: { ...DEFAULT_CONFIG.rules, ...user.rules ?? {} },
|
|
135
|
+
thresholds: {
|
|
136
|
+
title: { ...DEFAULT_CONFIG.thresholds.title, ...user.thresholds?.title ?? {} },
|
|
137
|
+
description: { ...DEFAULT_CONFIG.thresholds.description, ...user.thresholds?.description ?? {} },
|
|
138
|
+
slug: { ...DEFAULT_CONFIG.thresholds.slug, ...user.thresholds?.slug ?? {} },
|
|
139
|
+
content: { ...DEFAULT_CONFIG.thresholds.content, ...user.thresholds?.content ?? {} }
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async function loadConfig(projectRoot) {
|
|
144
|
+
const root = (0, import_node_path.resolve)(projectRoot ?? process.cwd());
|
|
145
|
+
const userConfig = await tryLoadConfigFile(root) ?? tryLoadPackageJsonConfig(root);
|
|
146
|
+
if (!userConfig) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'geo-lint: No configuration found.\nCreate a geo-lint.config.ts in your project root with at least:\n\n import { defineConfig } from "@ijonis/geo-lint";\n export default defineConfig({ siteUrl: "https://example.com" });\n\nSee https://github.com/ijonis/geo-lint#configuration'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return mergeWithDefaults(userConfig);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/adapters/mdx.ts
|
|
155
|
+
var import_node_fs3 = require("fs");
|
|
156
|
+
var import_node_path2 = require("path");
|
|
157
|
+
var import_gray_matter = __toESM(require("gray-matter"), 1);
|
|
158
|
+
function derivePermalink(slug, locale, pathConfig) {
|
|
159
|
+
const prefix = pathConfig.urlPrefix ?? "/";
|
|
160
|
+
if (pathConfig.type === "page") {
|
|
161
|
+
return `/${slug}`.replace(/\/\/+/g, "/");
|
|
162
|
+
}
|
|
163
|
+
const defaultLocale = pathConfig.defaultLocale ?? "de";
|
|
164
|
+
if (locale && locale !== defaultLocale) {
|
|
165
|
+
return `/${locale}${prefix}${slug}`.replace(/\/\/+/g, "/");
|
|
166
|
+
}
|
|
167
|
+
return `${prefix}${slug}`.replace(/\/\/+/g, "/");
|
|
168
|
+
}
|
|
169
|
+
function findContentFiles(dir) {
|
|
170
|
+
const files = [];
|
|
171
|
+
if (!(0, import_node_fs3.existsSync)(dir)) return files;
|
|
172
|
+
for (const entry of (0, import_node_fs3.readdirSync)(dir)) {
|
|
173
|
+
const fullPath = (0, import_node_path2.join)(dir, entry);
|
|
174
|
+
try {
|
|
175
|
+
const stat = (0, import_node_fs3.statSync)(fullPath);
|
|
176
|
+
if (stat.isDirectory()) {
|
|
177
|
+
files.push(...findContentFiles(fullPath));
|
|
178
|
+
} else if (entry.endsWith(".mdx") || entry.endsWith(".md")) {
|
|
179
|
+
files.push(fullPath);
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return files;
|
|
185
|
+
}
|
|
186
|
+
function parseContentFile(filePath, pathConfig) {
|
|
187
|
+
try {
|
|
188
|
+
const { data: fm, content: body, orig } = import_gray_matter.default.read(filePath);
|
|
189
|
+
const slug = fm.slug;
|
|
190
|
+
if (!slug) return null;
|
|
191
|
+
if (fm.draft === true) return null;
|
|
192
|
+
const locale = fm.locale;
|
|
193
|
+
const permalink = fm.permalink ?? derivePermalink(slug, locale, pathConfig);
|
|
194
|
+
return {
|
|
195
|
+
title: fm.title ?? "",
|
|
196
|
+
slug,
|
|
197
|
+
description: fm.description ?? "",
|
|
198
|
+
permalink,
|
|
199
|
+
image: fm.image ?? fm.thumbnail,
|
|
200
|
+
imageAlt: fm.imageAlt,
|
|
201
|
+
categories: fm.categories,
|
|
202
|
+
date: fm.date ? String(fm.date) : void 0,
|
|
203
|
+
category: fm.category,
|
|
204
|
+
locale,
|
|
205
|
+
translationKey: fm.translationKey,
|
|
206
|
+
updatedAt: fm.updatedAt ? String(fm.updatedAt) : void 0,
|
|
207
|
+
noindex: fm.noindex,
|
|
208
|
+
draft: fm.draft,
|
|
209
|
+
contentType: pathConfig.type,
|
|
210
|
+
filePath,
|
|
211
|
+
rawContent: orig?.toString() ?? "",
|
|
212
|
+
body
|
|
213
|
+
};
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function loadContentItems(contentPaths, projectRoot) {
|
|
219
|
+
const items = [];
|
|
220
|
+
for (const pathConfig of contentPaths) {
|
|
221
|
+
const fullDir = (0, import_node_path2.join)(projectRoot, pathConfig.dir);
|
|
222
|
+
const files = findContentFiles(fullDir);
|
|
223
|
+
for (const filePath of files) {
|
|
224
|
+
const item = parseContentFile(filePath, pathConfig);
|
|
225
|
+
if (item) items.push(item);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return items;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/utils/link-extractor.ts
|
|
232
|
+
function createLinkExtractor(siteUrl) {
|
|
233
|
+
const { hostname } = new URL(siteUrl);
|
|
234
|
+
const escapedDomain = hostname.replace(/\./g, "\\.");
|
|
235
|
+
const internalPatterns = [
|
|
236
|
+
new RegExp(`^https?:\\/\\/(www\\.)?${escapedDomain}(\\/|$)`, "i"),
|
|
237
|
+
new RegExp(`^\\/\\/${escapedDomain}(\\/|$)`, "i"),
|
|
238
|
+
/^\/(?!\/)/
|
|
239
|
+
// Relative paths starting with single /
|
|
240
|
+
];
|
|
241
|
+
function isInternalUrl(url) {
|
|
242
|
+
return internalPatterns.some((pattern) => pattern.test(url));
|
|
243
|
+
}
|
|
244
|
+
function normalizeInternalUrl(url) {
|
|
245
|
+
let normalized = url;
|
|
246
|
+
normalized = normalized.replace(new RegExp(`^https?:\\/\\/(www\\.)?${escapedDomain}`, "i"), "");
|
|
247
|
+
normalized = normalized.replace(new RegExp(`^\\/\\/${escapedDomain}`, "i"), "");
|
|
248
|
+
if (!normalized.startsWith("/")) {
|
|
249
|
+
normalized = "/" + normalized;
|
|
250
|
+
}
|
|
251
|
+
normalized = normalized.split("?")[0].split("#")[0];
|
|
252
|
+
if (normalized.length > 1 && normalized.endsWith("/")) {
|
|
253
|
+
normalized = normalized.slice(0, -1);
|
|
254
|
+
}
|
|
255
|
+
return normalized;
|
|
256
|
+
}
|
|
257
|
+
function isAbsoluteInternalLink(url) {
|
|
258
|
+
return new RegExp(`^https?:\\/\\/(www\\.)?${escapedDomain}`, "i").test(url);
|
|
259
|
+
}
|
|
260
|
+
function isInCodeBlock3(lines, lineIndex) {
|
|
261
|
+
let inCodeBlock = false;
|
|
262
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
263
|
+
const line = lines[i].trim();
|
|
264
|
+
if (line.startsWith("```") || line.startsWith("~~~")) {
|
|
265
|
+
inCodeBlock = !inCodeBlock;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return inCodeBlock;
|
|
269
|
+
}
|
|
270
|
+
function extractLinks(mdxBody) {
|
|
271
|
+
const links = [];
|
|
272
|
+
const lines = mdxBody.split("\n");
|
|
273
|
+
const markdownLinkRegex = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
|
|
274
|
+
const hrefRegex = /href=["']([^"']+)["']/g;
|
|
275
|
+
for (let i = 0; i < lines.length; i++) {
|
|
276
|
+
const line = lines[i];
|
|
277
|
+
if (isInCodeBlock3(lines, i)) continue;
|
|
278
|
+
let match;
|
|
279
|
+
while ((match = markdownLinkRegex.exec(line)) !== null) {
|
|
280
|
+
const [, text, url] = match;
|
|
281
|
+
if (url.startsWith("mailto:") || url.startsWith("tel:") || url === "#") continue;
|
|
282
|
+
const internal = isInternalUrl(url);
|
|
283
|
+
links.push({
|
|
284
|
+
text: text.trim(),
|
|
285
|
+
url: internal ? normalizeInternalUrl(url) : url,
|
|
286
|
+
originalUrl: url,
|
|
287
|
+
line: i + 1,
|
|
288
|
+
isInternal: internal
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
markdownLinkRegex.lastIndex = 0;
|
|
292
|
+
while ((match = hrefRegex.exec(line)) !== null) {
|
|
293
|
+
const [, url] = match;
|
|
294
|
+
if (url.startsWith("mailto:") || url.startsWith("tel:") || url === "#") continue;
|
|
295
|
+
const normalized = isInternalUrl(url) ? normalizeInternalUrl(url) : url;
|
|
296
|
+
const alreadyCaptured = links.some(
|
|
297
|
+
(l) => l.line === i + 1 && (l.url === normalized || l.originalUrl === url)
|
|
298
|
+
);
|
|
299
|
+
if (!alreadyCaptured) {
|
|
300
|
+
const internal = isInternalUrl(url);
|
|
301
|
+
links.push({
|
|
302
|
+
text: "",
|
|
303
|
+
url: internal ? normalizeInternalUrl(url) : url,
|
|
304
|
+
originalUrl: url,
|
|
305
|
+
line: i + 1,
|
|
306
|
+
isInternal: internal
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
hrefRegex.lastIndex = 0;
|
|
311
|
+
}
|
|
312
|
+
return links;
|
|
313
|
+
}
|
|
314
|
+
function getInternalLinks(links) {
|
|
315
|
+
return links.filter((l) => l.isInternal);
|
|
316
|
+
}
|
|
317
|
+
return { isInternalUrl, normalizeInternalUrl, isAbsoluteInternalLink, extractLinks, getInternalLinks };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/utils/slug-resolver.ts
|
|
321
|
+
var import_node_fs4 = require("fs");
|
|
322
|
+
var import_node_path3 = require("path");
|
|
323
|
+
function extractSlugFromFile(filePath) {
|
|
324
|
+
try {
|
|
325
|
+
const content = (0, import_node_fs4.readFileSync)(filePath, "utf-8");
|
|
326
|
+
if (!content.startsWith("---")) return null;
|
|
327
|
+
const endIndex = content.indexOf("---", 3);
|
|
328
|
+
if (endIndex === -1) return null;
|
|
329
|
+
const frontmatter = content.slice(3, endIndex);
|
|
330
|
+
const slugMatch = frontmatter.match(/^slug:\s*["']?([^"'\n]+)["']?\s*$/m);
|
|
331
|
+
if (!slugMatch) return null;
|
|
332
|
+
const localeMatch = frontmatter.match(/^locale:\s*["']?([^"'\n]+)["']?\s*$/m);
|
|
333
|
+
const draftMatch = frontmatter.match(/^draft:\s*true\s*$/m);
|
|
334
|
+
if (draftMatch) return null;
|
|
335
|
+
return {
|
|
336
|
+
slug: slugMatch[1].trim(),
|
|
337
|
+
locale: localeMatch ? localeMatch[1].trim() : "de"
|
|
338
|
+
};
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function findFilesInDir(dir, ext) {
|
|
344
|
+
const results = [];
|
|
345
|
+
try {
|
|
346
|
+
for (const entry of (0, import_node_fs4.readdirSync)(dir)) {
|
|
347
|
+
const fullPath = (0, import_node_path3.join)(dir, entry);
|
|
348
|
+
const stat = (0, import_node_fs4.statSync)(fullPath);
|
|
349
|
+
if (stat.isDirectory()) {
|
|
350
|
+
results.push(...findFilesInDir(fullPath, ext));
|
|
351
|
+
} else if (entry.endsWith(ext)) {
|
|
352
|
+
results.push(fullPath);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
function scanRawContentPermalinks(knownSlugs, contentPaths) {
|
|
360
|
+
const projectRoot = process.cwd();
|
|
361
|
+
const additionalPermalinks = [];
|
|
362
|
+
for (const pathConfig of contentPaths) {
|
|
363
|
+
const contentDir = (0, import_node_path3.join)(projectRoot, pathConfig.dir);
|
|
364
|
+
if (!(0, import_node_fs4.existsSync)(contentDir)) continue;
|
|
365
|
+
const urlPrefix = pathConfig.urlPrefix ?? "/";
|
|
366
|
+
for (const file of findFilesInDir(contentDir, ".mdx")) {
|
|
367
|
+
const meta = extractSlugFromFile(file);
|
|
368
|
+
if (!meta) continue;
|
|
369
|
+
let permalink;
|
|
370
|
+
const defaultLocale = pathConfig.defaultLocale ?? "de";
|
|
371
|
+
if (meta.locale !== defaultLocale && meta.locale !== "de") {
|
|
372
|
+
permalink = `/${meta.locale}${urlPrefix}${meta.slug}`.replace(/\/+/g, "/");
|
|
373
|
+
} else {
|
|
374
|
+
permalink = `${urlPrefix}${meta.slug}`.replace(/\/+/g, "/");
|
|
375
|
+
}
|
|
376
|
+
if (!permalink.startsWith("/")) {
|
|
377
|
+
permalink = "/" + permalink;
|
|
378
|
+
}
|
|
379
|
+
if (permalink.length > 1 && permalink.endsWith("/")) {
|
|
380
|
+
permalink = permalink.slice(0, -1);
|
|
381
|
+
}
|
|
382
|
+
if (!knownSlugs.has(permalink)) {
|
|
383
|
+
additionalPermalinks.push(permalink);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return additionalPermalinks;
|
|
388
|
+
}
|
|
389
|
+
function buildSlugRegistry(allContent, staticRoutes, contentPaths) {
|
|
390
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
391
|
+
for (const route of staticRoutes) {
|
|
392
|
+
slugs.add(route);
|
|
393
|
+
}
|
|
394
|
+
for (const item of allContent) {
|
|
395
|
+
slugs.add(item.permalink);
|
|
396
|
+
if (item.permalink.endsWith("/") && item.permalink.length > 1) {
|
|
397
|
+
slugs.add(item.permalink.slice(0, -1));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const additional = scanRawContentPermalinks(slugs, contentPaths);
|
|
401
|
+
for (const permalink of additional) {
|
|
402
|
+
slugs.add(permalink);
|
|
403
|
+
}
|
|
404
|
+
return slugs;
|
|
405
|
+
}
|
|
406
|
+
function isValidInternalLink(url, validSlugs) {
|
|
407
|
+
if (validSlugs.has(url)) {
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
if (validSlugs.has(url + "/")) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
if (url.endsWith("/") && validSlugs.has(url.slice(0, -1))) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
function scanDirectory(dir) {
|
|
419
|
+
const files = [];
|
|
420
|
+
try {
|
|
421
|
+
const entries = (0, import_node_fs4.readdirSync)(dir);
|
|
422
|
+
for (const entry of entries) {
|
|
423
|
+
const fullPath = (0, import_node_path3.join)(dir, entry);
|
|
424
|
+
const stat = (0, import_node_fs4.statSync)(fullPath);
|
|
425
|
+
if (stat.isDirectory()) {
|
|
426
|
+
files.push(...scanDirectory(fullPath));
|
|
427
|
+
} else {
|
|
428
|
+
files.push(fullPath);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
}
|
|
433
|
+
return files;
|
|
434
|
+
}
|
|
435
|
+
function buildImageRegistry(imageDirectories) {
|
|
436
|
+
const images = /* @__PURE__ */ new Set();
|
|
437
|
+
const projectRoot = process.cwd();
|
|
438
|
+
for (const imageDir of imageDirectories) {
|
|
439
|
+
const fullDir = (0, import_node_path3.join)(projectRoot, imageDir);
|
|
440
|
+
if (!(0, import_node_fs4.existsSync)(fullDir)) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const files = scanDirectory(fullDir);
|
|
444
|
+
for (const file of files) {
|
|
445
|
+
const relativePath = file.replace((0, import_node_path3.join)(projectRoot, "public"), "");
|
|
446
|
+
const normalizedPath = relativePath.replace(/\\/g, "/");
|
|
447
|
+
images.add(normalizedPath.startsWith("/") ? normalizedPath : "/" + normalizedPath);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return images;
|
|
451
|
+
}
|
|
452
|
+
function isValidImagePath(imagePath, validImages) {
|
|
453
|
+
let normalized = imagePath;
|
|
454
|
+
if (!normalized.startsWith("/")) {
|
|
455
|
+
normalized = "/" + normalized;
|
|
456
|
+
}
|
|
457
|
+
if (validImages.has(normalized)) {
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
const decoded = decodeURIComponent(normalized);
|
|
462
|
+
if (validImages.has(decoded)) {
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/utils/display-path.ts
|
|
471
|
+
function getDisplayPath(item) {
|
|
472
|
+
return `${item.contentType}/${item.slug}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/rules/title-rules.ts
|
|
476
|
+
var TITLE_MIN_LENGTH = 30;
|
|
477
|
+
var TITLE_MAX_LENGTH = 60;
|
|
478
|
+
var TITLE_WARN_LENGTH = 55;
|
|
479
|
+
var titleMissing = {
|
|
480
|
+
name: "title-missing",
|
|
481
|
+
severity: "error",
|
|
482
|
+
category: "seo",
|
|
483
|
+
fixStrategy: "Add a title field to the frontmatter",
|
|
484
|
+
run: (item) => {
|
|
485
|
+
if (!item.title || item.title.trim().length === 0) {
|
|
486
|
+
return [{
|
|
487
|
+
file: getDisplayPath(item),
|
|
488
|
+
field: "title",
|
|
489
|
+
rule: "title-missing",
|
|
490
|
+
severity: "error",
|
|
491
|
+
message: "Missing title",
|
|
492
|
+
suggestion: "Add a title field to the frontmatter"
|
|
493
|
+
}];
|
|
494
|
+
}
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
var titleTooShort = {
|
|
499
|
+
name: "title-too-short",
|
|
500
|
+
severity: "warning",
|
|
501
|
+
category: "seo",
|
|
502
|
+
fixStrategy: "Expand the title to meet minimum length",
|
|
503
|
+
run: (item) => {
|
|
504
|
+
if (!item.title) return [];
|
|
505
|
+
const length = item.title.length;
|
|
506
|
+
if (length > 0 && length < TITLE_MIN_LENGTH) {
|
|
507
|
+
return [{
|
|
508
|
+
file: getDisplayPath(item),
|
|
509
|
+
field: "title",
|
|
510
|
+
rule: "title-too-short",
|
|
511
|
+
severity: "warning",
|
|
512
|
+
message: `Title is too short (${length}/${TITLE_MIN_LENGTH} chars minimum)`,
|
|
513
|
+
suggestion: `Expand the title to at least ${TITLE_MIN_LENGTH} characters for better SEO`
|
|
514
|
+
}];
|
|
515
|
+
}
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
var titleTooLong = {
|
|
520
|
+
name: "title-too-long",
|
|
521
|
+
severity: "error",
|
|
522
|
+
category: "seo",
|
|
523
|
+
fixStrategy: "Shorten the title to avoid truncation in search results",
|
|
524
|
+
run: (item) => {
|
|
525
|
+
if (!item.title) return [];
|
|
526
|
+
const length = item.title.length;
|
|
527
|
+
if (length > TITLE_MAX_LENGTH) {
|
|
528
|
+
return [{
|
|
529
|
+
file: getDisplayPath(item),
|
|
530
|
+
field: "title",
|
|
531
|
+
rule: "title-too-long",
|
|
532
|
+
severity: "error",
|
|
533
|
+
message: `Title is too long (${length}/${TITLE_MAX_LENGTH} chars)`,
|
|
534
|
+
suggestion: `Shorten the title to ${TITLE_MAX_LENGTH} characters or less to avoid truncation in search results`
|
|
535
|
+
}];
|
|
536
|
+
}
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
var titleApproachingLimit = {
|
|
541
|
+
name: "title-approaching-limit",
|
|
542
|
+
severity: "warning",
|
|
543
|
+
category: "seo",
|
|
544
|
+
fixStrategy: "Consider shortening to leave room for site name suffix",
|
|
545
|
+
run: (item) => {
|
|
546
|
+
if (!item.title) return [];
|
|
547
|
+
const length = item.title.length;
|
|
548
|
+
if (length > TITLE_WARN_LENGTH && length <= TITLE_MAX_LENGTH) {
|
|
549
|
+
return [{
|
|
550
|
+
file: getDisplayPath(item),
|
|
551
|
+
field: "title",
|
|
552
|
+
rule: "title-approaching-limit",
|
|
553
|
+
severity: "warning",
|
|
554
|
+
message: `Title is approaching maximum length (${length}/${TITLE_MAX_LENGTH} chars)`,
|
|
555
|
+
suggestion: "Consider shortening to leave room for site name suffix in search results"
|
|
556
|
+
}];
|
|
557
|
+
}
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
var titleRules = [
|
|
562
|
+
titleMissing,
|
|
563
|
+
titleTooShort,
|
|
564
|
+
titleTooLong,
|
|
565
|
+
titleApproachingLimit
|
|
566
|
+
];
|
|
567
|
+
|
|
568
|
+
// src/rules/description-rules.ts
|
|
569
|
+
var DESC_MIN_LENGTH = 70;
|
|
570
|
+
var DESC_MAX_LENGTH = 160;
|
|
571
|
+
var DESC_WARN_LENGTH = 150;
|
|
572
|
+
var descriptionMissing = {
|
|
573
|
+
name: "description-missing",
|
|
574
|
+
severity: "error",
|
|
575
|
+
category: "seo",
|
|
576
|
+
fixStrategy: "Add a description field to the frontmatter (max 160 chars)",
|
|
577
|
+
run: (item) => {
|
|
578
|
+
if (!item.description || item.description.trim().length === 0) {
|
|
579
|
+
return [{
|
|
580
|
+
file: getDisplayPath(item),
|
|
581
|
+
field: "description",
|
|
582
|
+
rule: "description-missing",
|
|
583
|
+
severity: "error",
|
|
584
|
+
message: "Missing description",
|
|
585
|
+
suggestion: "Add a description field to the frontmatter (max 160 chars)"
|
|
586
|
+
}];
|
|
587
|
+
}
|
|
588
|
+
return [];
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
var descriptionTooLong = {
|
|
592
|
+
name: "description-too-long",
|
|
593
|
+
severity: "error",
|
|
594
|
+
category: "seo",
|
|
595
|
+
fixStrategy: "Shorten the description to avoid truncation in search results",
|
|
596
|
+
run: (item) => {
|
|
597
|
+
if (!item.description) return [];
|
|
598
|
+
const length = item.description.length;
|
|
599
|
+
if (length > DESC_MAX_LENGTH) {
|
|
600
|
+
return [{
|
|
601
|
+
file: getDisplayPath(item),
|
|
602
|
+
field: "description",
|
|
603
|
+
rule: "description-too-long",
|
|
604
|
+
severity: "error",
|
|
605
|
+
message: `Description is too long (${length}/${DESC_MAX_LENGTH} chars)`,
|
|
606
|
+
suggestion: `Shorten to ${DESC_MAX_LENGTH} characters to avoid truncation in search results`
|
|
607
|
+
}];
|
|
608
|
+
}
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
var descriptionApproachingLimit = {
|
|
613
|
+
name: "description-approaching-limit",
|
|
614
|
+
severity: "warning",
|
|
615
|
+
category: "seo",
|
|
616
|
+
fixStrategy: "Consider shortening to ensure full display in search results",
|
|
617
|
+
run: (item) => {
|
|
618
|
+
if (!item.description) return [];
|
|
619
|
+
const length = item.description.length;
|
|
620
|
+
if (length > DESC_WARN_LENGTH && length <= DESC_MAX_LENGTH) {
|
|
621
|
+
return [{
|
|
622
|
+
file: getDisplayPath(item),
|
|
623
|
+
field: "description",
|
|
624
|
+
rule: "description-approaching-limit",
|
|
625
|
+
severity: "warning",
|
|
626
|
+
message: `Description is approaching maximum length (${length}/${DESC_MAX_LENGTH} chars)`,
|
|
627
|
+
suggestion: "Consider shortening to ensure full display in search results"
|
|
628
|
+
}];
|
|
629
|
+
}
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
var descriptionTooShort = {
|
|
634
|
+
name: "description-too-short",
|
|
635
|
+
severity: "warning",
|
|
636
|
+
category: "seo",
|
|
637
|
+
fixStrategy: "Expand the description for better search result snippets",
|
|
638
|
+
run: (item) => {
|
|
639
|
+
if (!item.description || item.description.trim().length === 0) return [];
|
|
640
|
+
const length = item.description.length;
|
|
641
|
+
if (length < DESC_MIN_LENGTH) {
|
|
642
|
+
return [{
|
|
643
|
+
file: getDisplayPath(item),
|
|
644
|
+
field: "description",
|
|
645
|
+
rule: "description-too-short",
|
|
646
|
+
severity: "warning",
|
|
647
|
+
message: `Description is too short (${length}/${DESC_MIN_LENGTH} chars minimum)`,
|
|
648
|
+
suggestion: `Expand the description to at least ${DESC_MIN_LENGTH} characters for better search result snippets`
|
|
649
|
+
}];
|
|
650
|
+
}
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
var descriptionRules = [
|
|
655
|
+
descriptionMissing,
|
|
656
|
+
descriptionTooLong,
|
|
657
|
+
descriptionApproachingLimit,
|
|
658
|
+
descriptionTooShort
|
|
659
|
+
];
|
|
660
|
+
|
|
661
|
+
// src/rules/duplicate-rules.ts
|
|
662
|
+
function buildDuplicateMap(items, getValue) {
|
|
663
|
+
const map = /* @__PURE__ */ new Map();
|
|
664
|
+
for (const item of items) {
|
|
665
|
+
const value = getValue(item);
|
|
666
|
+
if (!value || value.trim().length === 0) continue;
|
|
667
|
+
const normalized = value.toLowerCase().trim();
|
|
668
|
+
const files = map.get(normalized) || [];
|
|
669
|
+
files.push(getDisplayPath(item));
|
|
670
|
+
map.set(normalized, files);
|
|
671
|
+
}
|
|
672
|
+
return map;
|
|
673
|
+
}
|
|
674
|
+
var duplicateTitle = {
|
|
675
|
+
name: "duplicate-title",
|
|
676
|
+
severity: "error",
|
|
677
|
+
category: "seo",
|
|
678
|
+
fixStrategy: "Use a unique title that differentiates this content from others",
|
|
679
|
+
run: (item, context) => {
|
|
680
|
+
const titleMap = buildDuplicateMap(context.allContent, (c) => c.title);
|
|
681
|
+
const normalizedTitle = item.title?.toLowerCase().trim();
|
|
682
|
+
if (!normalizedTitle) return [];
|
|
683
|
+
const filesWithSameTitle = titleMap.get(normalizedTitle);
|
|
684
|
+
if (!filesWithSameTitle || filesWithSameTitle.length <= 1) return [];
|
|
685
|
+
const otherFiles = filesWithSameTitle.filter((f) => f !== getDisplayPath(item));
|
|
686
|
+
if (otherFiles.length === 0) return [];
|
|
687
|
+
return [{
|
|
688
|
+
file: getDisplayPath(item),
|
|
689
|
+
field: "title",
|
|
690
|
+
rule: "duplicate-title",
|
|
691
|
+
severity: "error",
|
|
692
|
+
message: `Duplicate title found`,
|
|
693
|
+
suggestion: `Also used by: ${otherFiles.join(", ")}`
|
|
694
|
+
}];
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
var duplicateDescription = {
|
|
698
|
+
name: "duplicate-description",
|
|
699
|
+
severity: "error",
|
|
700
|
+
category: "seo",
|
|
701
|
+
fixStrategy: "Write a unique description that differentiates this content",
|
|
702
|
+
run: (item, context) => {
|
|
703
|
+
const descMap = buildDuplicateMap(context.allContent, (c) => c.description);
|
|
704
|
+
const normalizedDesc = item.description?.toLowerCase().trim();
|
|
705
|
+
if (!normalizedDesc) return [];
|
|
706
|
+
const filesWithSameDesc = descMap.get(normalizedDesc);
|
|
707
|
+
if (!filesWithSameDesc || filesWithSameDesc.length <= 1) return [];
|
|
708
|
+
const otherFiles = filesWithSameDesc.filter((f) => f !== getDisplayPath(item));
|
|
709
|
+
if (otherFiles.length === 0) return [];
|
|
710
|
+
return [{
|
|
711
|
+
file: getDisplayPath(item),
|
|
712
|
+
field: "description",
|
|
713
|
+
rule: "duplicate-description",
|
|
714
|
+
severity: "error",
|
|
715
|
+
message: `Duplicate description found`,
|
|
716
|
+
suggestion: `Also used by: ${otherFiles.join(", ")}`
|
|
717
|
+
}];
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
var duplicateRules = [
|
|
721
|
+
duplicateTitle,
|
|
722
|
+
duplicateDescription
|
|
723
|
+
];
|
|
724
|
+
|
|
725
|
+
// src/utils/heading-extractor.ts
|
|
726
|
+
function isInCodeBlock(lines, lineIndex) {
|
|
727
|
+
let inCodeBlock = false;
|
|
728
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
729
|
+
const line = lines[i].trim();
|
|
730
|
+
if (line.startsWith("```") || line.startsWith("~~~")) {
|
|
731
|
+
inCodeBlock = !inCodeBlock;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return inCodeBlock;
|
|
735
|
+
}
|
|
736
|
+
function extractHeadings(mdxBody) {
|
|
737
|
+
const headings = [];
|
|
738
|
+
const lines = mdxBody.split("\n");
|
|
739
|
+
const headingRegex = /^(#{1,6})\s+(.+)$/;
|
|
740
|
+
for (let i = 0; i < lines.length; i++) {
|
|
741
|
+
const line = lines[i];
|
|
742
|
+
const match = line.match(headingRegex);
|
|
743
|
+
if (match && !isInCodeBlock(lines, i)) {
|
|
744
|
+
const [, hashes, text] = match;
|
|
745
|
+
headings.push({
|
|
746
|
+
level: hashes.length,
|
|
747
|
+
text: text.trim(),
|
|
748
|
+
line: i + 1
|
|
749
|
+
// 1-indexed for display
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return headings;
|
|
754
|
+
}
|
|
755
|
+
function countH1s(headings) {
|
|
756
|
+
return headings.filter((h) => h.level === 1).length;
|
|
757
|
+
}
|
|
758
|
+
function findH1s(headings) {
|
|
759
|
+
return headings.filter((h) => h.level === 1);
|
|
760
|
+
}
|
|
761
|
+
function findHierarchyViolations(headings) {
|
|
762
|
+
const violations = [];
|
|
763
|
+
let previousLevel = 0;
|
|
764
|
+
for (const heading of headings) {
|
|
765
|
+
if (heading.level > previousLevel + 1 && previousLevel > 0) {
|
|
766
|
+
violations.push({
|
|
767
|
+
heading,
|
|
768
|
+
previousLevel,
|
|
769
|
+
expectedMaxLevel: previousLevel + 1
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
previousLevel = heading.level;
|
|
773
|
+
}
|
|
774
|
+
return violations;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// src/rules/heading-rules.ts
|
|
778
|
+
var missingH1 = {
|
|
779
|
+
name: "missing-h1",
|
|
780
|
+
severity: "warning",
|
|
781
|
+
category: "seo",
|
|
782
|
+
fixStrategy: "Add an H1 heading (# Heading) at the start of the content",
|
|
783
|
+
run: (item) => {
|
|
784
|
+
if (item.contentType === "blog") {
|
|
785
|
+
return [];
|
|
786
|
+
}
|
|
787
|
+
if (item.category === "legal" || item.category === "service") {
|
|
788
|
+
return [];
|
|
789
|
+
}
|
|
790
|
+
const headings = extractHeadings(item.body);
|
|
791
|
+
const h1Count = countH1s(headings);
|
|
792
|
+
if (h1Count === 0) {
|
|
793
|
+
return [{
|
|
794
|
+
file: getDisplayPath(item),
|
|
795
|
+
field: "body",
|
|
796
|
+
rule: "missing-h1",
|
|
797
|
+
severity: "warning",
|
|
798
|
+
message: "No H1 heading found in content",
|
|
799
|
+
suggestion: "Add an H1 heading (# Heading) at the start of your content"
|
|
800
|
+
}];
|
|
801
|
+
}
|
|
802
|
+
return [];
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
var multipleH1 = {
|
|
806
|
+
name: "multiple-h1",
|
|
807
|
+
severity: "error",
|
|
808
|
+
category: "seo",
|
|
809
|
+
fixStrategy: "Convert extra H1s to H2s",
|
|
810
|
+
run: (item) => {
|
|
811
|
+
if (item.contentType === "blog") {
|
|
812
|
+
return [];
|
|
813
|
+
}
|
|
814
|
+
const headings = extractHeadings(item.body);
|
|
815
|
+
const h1s = findH1s(headings);
|
|
816
|
+
if (h1s.length > 1) {
|
|
817
|
+
const locations = h1s.map((h) => `line ${h.line}`).join(", ");
|
|
818
|
+
return [{
|
|
819
|
+
file: getDisplayPath(item),
|
|
820
|
+
field: "body",
|
|
821
|
+
rule: "multiple-h1",
|
|
822
|
+
severity: "error",
|
|
823
|
+
message: `Found ${h1s.length} H1 headings (expected 1)`,
|
|
824
|
+
suggestion: `H1 headings at: ${locations}. Convert extra H1s to H2s.`
|
|
825
|
+
}];
|
|
826
|
+
}
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
var headingHierarchySkip = {
|
|
831
|
+
name: "heading-hierarchy-skip",
|
|
832
|
+
severity: "warning",
|
|
833
|
+
category: "seo",
|
|
834
|
+
fixStrategy: "Adjust heading levels to avoid skipping (e.g., H2 before H3)",
|
|
835
|
+
run: (item) => {
|
|
836
|
+
const headings = extractHeadings(item.body);
|
|
837
|
+
const violations = findHierarchyViolations(headings);
|
|
838
|
+
return violations.map((v) => ({
|
|
839
|
+
file: getDisplayPath(item),
|
|
840
|
+
field: "body",
|
|
841
|
+
rule: "heading-hierarchy-skip",
|
|
842
|
+
severity: "warning",
|
|
843
|
+
message: `Heading level skipped: H${v.previousLevel} \u2192 H${v.heading.level}`,
|
|
844
|
+
suggestion: `At line ${v.heading.line}: "${v.heading.text}". Expected H${v.expectedMaxLevel} or lower.`,
|
|
845
|
+
line: v.heading.line
|
|
846
|
+
}));
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
var duplicateHeadingText = {
|
|
850
|
+
name: "duplicate-heading-text",
|
|
851
|
+
severity: "warning",
|
|
852
|
+
category: "seo",
|
|
853
|
+
fixStrategy: "Use unique heading text for each section",
|
|
854
|
+
run: (item) => {
|
|
855
|
+
const results = [];
|
|
856
|
+
const headings = extractHeadings(item.body);
|
|
857
|
+
const seen = /* @__PURE__ */ new Map();
|
|
858
|
+
for (const heading of headings) {
|
|
859
|
+
const normalized = heading.text.toLowerCase();
|
|
860
|
+
const previousLine = seen.get(normalized);
|
|
861
|
+
if (previousLine !== void 0) {
|
|
862
|
+
results.push({
|
|
863
|
+
file: getDisplayPath(item),
|
|
864
|
+
field: "body",
|
|
865
|
+
rule: "duplicate-heading-text",
|
|
866
|
+
severity: "warning",
|
|
867
|
+
message: `Duplicate heading text: "${heading.text}"`,
|
|
868
|
+
suggestion: `Same heading appears at line ${previousLine} and line ${heading.line}. Use unique headings for better structure.`,
|
|
869
|
+
line: heading.line
|
|
870
|
+
});
|
|
871
|
+
} else {
|
|
872
|
+
seen.set(normalized, heading.line);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return results;
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
var headingRules = [
|
|
879
|
+
missingH1,
|
|
880
|
+
multipleH1,
|
|
881
|
+
headingHierarchySkip,
|
|
882
|
+
duplicateHeadingText
|
|
883
|
+
];
|
|
884
|
+
|
|
885
|
+
// src/utils/image-extractor.ts
|
|
886
|
+
function isInCodeBlock2(lines, lineIndex) {
|
|
887
|
+
let inCodeBlock = false;
|
|
888
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
889
|
+
const line = lines[i].trim();
|
|
890
|
+
if (line.startsWith("```") || line.startsWith("~~~")) {
|
|
891
|
+
inCodeBlock = !inCodeBlock;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return inCodeBlock;
|
|
895
|
+
}
|
|
896
|
+
function extractImages(mdxBody) {
|
|
897
|
+
const images = [];
|
|
898
|
+
const lines = mdxBody.split("\n");
|
|
899
|
+
const markdownImageRegex = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
900
|
+
const jsxImageRegex = /<(?:img|Image)\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
|
|
901
|
+
const altAttrRegex = /alt=["']([^"']*)["']/i;
|
|
902
|
+
for (let i = 0; i < lines.length; i++) {
|
|
903
|
+
const line = lines[i];
|
|
904
|
+
if (isInCodeBlock2(lines, i)) {
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
let match;
|
|
908
|
+
while ((match = markdownImageRegex.exec(line)) !== null) {
|
|
909
|
+
const [, alt, src] = match;
|
|
910
|
+
images.push({
|
|
911
|
+
src,
|
|
912
|
+
alt: alt.trim(),
|
|
913
|
+
line: i + 1,
|
|
914
|
+
hasAlt: alt.trim().length > 0,
|
|
915
|
+
source: "inline"
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
markdownImageRegex.lastIndex = 0;
|
|
919
|
+
while ((match = jsxImageRegex.exec(line)) !== null) {
|
|
920
|
+
const [fullMatch, src] = match;
|
|
921
|
+
const altMatch = fullMatch.match(altAttrRegex);
|
|
922
|
+
const alt = altMatch ? altMatch[1] : "";
|
|
923
|
+
const alreadyCaptured = images.some(
|
|
924
|
+
(img) => img.line === i + 1 && img.src === src
|
|
925
|
+
);
|
|
926
|
+
if (!alreadyCaptured) {
|
|
927
|
+
images.push({
|
|
928
|
+
src,
|
|
929
|
+
alt: alt.trim(),
|
|
930
|
+
line: i + 1,
|
|
931
|
+
hasAlt: alt.trim().length > 0,
|
|
932
|
+
source: "inline"
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
jsxImageRegex.lastIndex = 0;
|
|
937
|
+
}
|
|
938
|
+
return images;
|
|
939
|
+
}
|
|
940
|
+
function normalizeImagePath(src) {
|
|
941
|
+
let normalized = src;
|
|
942
|
+
if (!normalized.startsWith("/") && !normalized.startsWith("http")) {
|
|
943
|
+
normalized = "/" + normalized;
|
|
944
|
+
}
|
|
945
|
+
normalized = normalized.split("?")[0];
|
|
946
|
+
return normalized;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// src/rules/image-rules.ts
|
|
950
|
+
var inlineImageMissingAlt = {
|
|
951
|
+
name: "inline-image-missing-alt",
|
|
952
|
+
severity: "error",
|
|
953
|
+
category: "seo",
|
|
954
|
+
fixStrategy: "Add descriptive alt text for accessibility and SEO",
|
|
955
|
+
run: (item) => {
|
|
956
|
+
const results = [];
|
|
957
|
+
const images = extractImages(item.body);
|
|
958
|
+
for (const img of images) {
|
|
959
|
+
if (!img.hasAlt) {
|
|
960
|
+
results.push({
|
|
961
|
+
file: getDisplayPath(item),
|
|
962
|
+
field: "body",
|
|
963
|
+
rule: "inline-image-missing-alt",
|
|
964
|
+
severity: "error",
|
|
965
|
+
message: `Image missing alt text: ${img.src}`,
|
|
966
|
+
suggestion: "Add descriptive alt text for accessibility and SEO",
|
|
967
|
+
line: img.line
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return results;
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
var frontmatterImageMissingAlt = {
|
|
975
|
+
name: "frontmatter-image-missing-alt",
|
|
976
|
+
severity: "warning",
|
|
977
|
+
category: "seo",
|
|
978
|
+
fixStrategy: "Add an imageAlt field to the frontmatter for the featured image",
|
|
979
|
+
run: (item) => {
|
|
980
|
+
if (item.image && (!item.imageAlt || item.imageAlt.trim().length === 0)) {
|
|
981
|
+
return [{
|
|
982
|
+
file: getDisplayPath(item),
|
|
983
|
+
field: "imageAlt",
|
|
984
|
+
rule: "frontmatter-image-missing-alt",
|
|
985
|
+
severity: "warning",
|
|
986
|
+
message: "Featured image missing alt text",
|
|
987
|
+
suggestion: "Add an imageAlt field to the frontmatter for the featured image"
|
|
988
|
+
}];
|
|
989
|
+
}
|
|
990
|
+
return [];
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
var imageNotFound = {
|
|
994
|
+
name: "image-not-found",
|
|
995
|
+
severity: "warning",
|
|
996
|
+
category: "technical",
|
|
997
|
+
fixStrategy: "Check that the image path is correct and the file exists in public/",
|
|
998
|
+
run: (item, context) => {
|
|
999
|
+
const results = [];
|
|
1000
|
+
if (item.image) {
|
|
1001
|
+
const normalizedPath = normalizeImagePath(item.image);
|
|
1002
|
+
if (!item.image.startsWith("http") && !isValidImagePath(normalizedPath, context.validImages)) {
|
|
1003
|
+
results.push({
|
|
1004
|
+
file: getDisplayPath(item),
|
|
1005
|
+
field: "image",
|
|
1006
|
+
rule: "image-not-found",
|
|
1007
|
+
severity: "warning",
|
|
1008
|
+
message: `Featured image not found: ${item.image}`,
|
|
1009
|
+
suggestion: "Check that the image path is correct and the file exists in public/"
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const images = extractImages(item.body);
|
|
1014
|
+
for (const img of images) {
|
|
1015
|
+
if (!img.src.startsWith("http")) {
|
|
1016
|
+
const normalizedPath = normalizeImagePath(img.src);
|
|
1017
|
+
if (!isValidImagePath(normalizedPath, context.validImages)) {
|
|
1018
|
+
results.push({
|
|
1019
|
+
file: getDisplayPath(item),
|
|
1020
|
+
field: "body",
|
|
1021
|
+
rule: "image-not-found",
|
|
1022
|
+
severity: "warning",
|
|
1023
|
+
message: `Inline image not found: ${img.src}`,
|
|
1024
|
+
suggestion: "Check that the image path is correct and the file exists in public/",
|
|
1025
|
+
line: img.line
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return results;
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
var imageRules = [
|
|
1034
|
+
inlineImageMissingAlt,
|
|
1035
|
+
frontmatterImageMissingAlt,
|
|
1036
|
+
imageNotFound
|
|
1037
|
+
];
|
|
1038
|
+
|
|
1039
|
+
// src/utils/word-counter.ts
|
|
1040
|
+
function stripMarkdown(text) {
|
|
1041
|
+
let result = text;
|
|
1042
|
+
result = result.replace(/```[\s\S]*?```/g, "");
|
|
1043
|
+
result = result.replace(/`[^`]+`/g, "");
|
|
1044
|
+
result = result.replace(/!\[[^\]]*\]\([^)]+\)/g, "");
|
|
1045
|
+
result = result.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
1046
|
+
result = result.replace(/<[^>]+>/g, "");
|
|
1047
|
+
result = result.replace(/^#{1,6}\s+/gm, "");
|
|
1048
|
+
result = result.replace(/\*\*([^*]+)\*\*/g, "$1");
|
|
1049
|
+
result = result.replace(/\*([^*]+)\*/g, "$1");
|
|
1050
|
+
result = result.replace(/__([^_]+)__/g, "$1");
|
|
1051
|
+
result = result.replace(/_([^_]+)_/g, "$1");
|
|
1052
|
+
result = result.replace(/^>\s+/gm, "");
|
|
1053
|
+
result = result.replace(/^[-*_]{3,}$/gm, "");
|
|
1054
|
+
result = result.replace(/^[\s]*[-*+]\s+/gm, "");
|
|
1055
|
+
result = result.replace(/^[\s]*\d+\.\s+/gm, "");
|
|
1056
|
+
result = result.replace(/^import\s+.*$/gm, "");
|
|
1057
|
+
result = result.replace(/^export\s+.*$/gm, "");
|
|
1058
|
+
result = result.replace(/<\w+[^>]*>[\s\S]*?<\/\w+>/g, (match) => {
|
|
1059
|
+
return match.replace(/<[^>]+>/g, " ");
|
|
1060
|
+
});
|
|
1061
|
+
return result;
|
|
1062
|
+
}
|
|
1063
|
+
function countWords(text) {
|
|
1064
|
+
const stripped = stripMarkdown(text);
|
|
1065
|
+
const words = stripped.split(/\s+/).filter((word) => word.length > 0).filter((word) => /\w/.test(word));
|
|
1066
|
+
return words.length;
|
|
1067
|
+
}
|
|
1068
|
+
function countSentences(text) {
|
|
1069
|
+
const stripped = stripMarkdown(text);
|
|
1070
|
+
const sentences = stripped.match(/[.!?]+(?:\s|$)/g);
|
|
1071
|
+
return sentences ? sentences.length : 0;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/utils/readability.ts
|
|
1075
|
+
function estimateSyllables(word) {
|
|
1076
|
+
const lower = word.toLowerCase();
|
|
1077
|
+
const vowelPattern = /[aeiouyäöü]+/gi;
|
|
1078
|
+
const matches = lower.match(vowelPattern);
|
|
1079
|
+
if (!matches) {
|
|
1080
|
+
return 1;
|
|
1081
|
+
}
|
|
1082
|
+
let count = matches.length;
|
|
1083
|
+
if (lower.endsWith("e") && count > 1) {
|
|
1084
|
+
count -= 0.5;
|
|
1085
|
+
}
|
|
1086
|
+
if (word.length > 12) {
|
|
1087
|
+
count = Math.max(count, Math.ceil(word.length / 4));
|
|
1088
|
+
}
|
|
1089
|
+
return Math.max(1, Math.round(count));
|
|
1090
|
+
}
|
|
1091
|
+
function countSyllables(text) {
|
|
1092
|
+
const words = text.split(/\s+/).filter((word) => word.length > 0).filter((word) => /\w/.test(word));
|
|
1093
|
+
return words.reduce((total, word) => total + estimateSyllables(word), 0);
|
|
1094
|
+
}
|
|
1095
|
+
function averageWordLength(text) {
|
|
1096
|
+
const words = text.split(/\s+/).filter((word) => word.length > 0).filter((word) => /\w/.test(word));
|
|
1097
|
+
if (words.length === 0) return 0;
|
|
1098
|
+
const totalLength = words.reduce((sum, word) => sum + word.length, 0);
|
|
1099
|
+
return totalLength / words.length;
|
|
1100
|
+
}
|
|
1101
|
+
function calculateReadability(mdxBody) {
|
|
1102
|
+
const plainText = stripMarkdown(mdxBody);
|
|
1103
|
+
const wordCount = countWords(mdxBody);
|
|
1104
|
+
const sentenceCount = countSentences(mdxBody);
|
|
1105
|
+
const syllableCount = countSyllables(plainText);
|
|
1106
|
+
if (wordCount === 0 || sentenceCount === 0) {
|
|
1107
|
+
return {
|
|
1108
|
+
score: 0,
|
|
1109
|
+
avgSentenceLength: 0,
|
|
1110
|
+
avgSyllablesPerWord: 0,
|
|
1111
|
+
avgWordLength: 0,
|
|
1112
|
+
interpretation: "No content to analyze"
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
const avgSentenceLength = wordCount / sentenceCount;
|
|
1116
|
+
const avgSyllablesPerWord = syllableCount / wordCount;
|
|
1117
|
+
const avgWordLen = averageWordLength(plainText);
|
|
1118
|
+
const score = Math.round(180 - avgSentenceLength - 58.5 * avgSyllablesPerWord);
|
|
1119
|
+
const clampedScore = Math.max(0, Math.min(100, score));
|
|
1120
|
+
return {
|
|
1121
|
+
score: clampedScore,
|
|
1122
|
+
avgSentenceLength: Math.round(avgSentenceLength * 10) / 10,
|
|
1123
|
+
avgSyllablesPerWord: Math.round(avgSyllablesPerWord * 100) / 100,
|
|
1124
|
+
avgWordLength: Math.round(avgWordLen * 10) / 10,
|
|
1125
|
+
interpretation: getInterpretation(clampedScore)
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function getInterpretation(score) {
|
|
1129
|
+
if (score >= 70) return "Very easy to read";
|
|
1130
|
+
if (score >= 60) return "Easy to read";
|
|
1131
|
+
if (score >= 50) return "Fairly easy to read";
|
|
1132
|
+
if (score >= 40) return "Standard";
|
|
1133
|
+
if (score >= 30) return "Fairly difficult";
|
|
1134
|
+
if (score >= 20) return "Difficult";
|
|
1135
|
+
return "Very difficult";
|
|
1136
|
+
}
|
|
1137
|
+
function isReadable(score, threshold) {
|
|
1138
|
+
return score >= threshold;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// src/rules/content-rules.ts
|
|
1142
|
+
var MIN_WORD_COUNT = 300;
|
|
1143
|
+
var MIN_READABILITY_SCORE = 30;
|
|
1144
|
+
var contentTooShort = {
|
|
1145
|
+
name: "content-too-short",
|
|
1146
|
+
severity: "warning",
|
|
1147
|
+
category: "content",
|
|
1148
|
+
fixStrategy: "Expand the content for better SEO performance",
|
|
1149
|
+
run: (item) => {
|
|
1150
|
+
const wordCount = countWords(item.body);
|
|
1151
|
+
if (wordCount < MIN_WORD_COUNT) {
|
|
1152
|
+
return [{
|
|
1153
|
+
file: getDisplayPath(item),
|
|
1154
|
+
field: "body",
|
|
1155
|
+
rule: "content-too-short",
|
|
1156
|
+
severity: "warning",
|
|
1157
|
+
message: `Content is too short (${wordCount} words, minimum ${MIN_WORD_COUNT})`,
|
|
1158
|
+
suggestion: "Consider expanding the content for better SEO performance"
|
|
1159
|
+
}];
|
|
1160
|
+
}
|
|
1161
|
+
return [];
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
var lowReadability = {
|
|
1165
|
+
name: "low-readability",
|
|
1166
|
+
severity: "warning",
|
|
1167
|
+
category: "content",
|
|
1168
|
+
fixStrategy: "Use shorter sentences for easier reading",
|
|
1169
|
+
run: (item) => {
|
|
1170
|
+
const wordCount = countWords(item.body);
|
|
1171
|
+
if (wordCount < 100) {
|
|
1172
|
+
return [];
|
|
1173
|
+
}
|
|
1174
|
+
const readability = calculateReadability(item.body);
|
|
1175
|
+
if (!isReadable(readability.score, MIN_READABILITY_SCORE)) {
|
|
1176
|
+
return [{
|
|
1177
|
+
file: getDisplayPath(item),
|
|
1178
|
+
field: "body",
|
|
1179
|
+
rule: "low-readability",
|
|
1180
|
+
severity: "warning",
|
|
1181
|
+
message: `Low readability score (${readability.score}/100 - ${readability.interpretation})`,
|
|
1182
|
+
suggestion: `Avg sentence length: ${readability.avgSentenceLength} words. Consider shorter sentences for easier reading.`
|
|
1183
|
+
}];
|
|
1184
|
+
}
|
|
1185
|
+
return [];
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
var contentRules = [
|
|
1189
|
+
contentTooShort,
|
|
1190
|
+
lowReadability
|
|
1191
|
+
];
|
|
1192
|
+
|
|
1193
|
+
// src/rules/og-rules.ts
|
|
1194
|
+
var blogMissingOgImage = {
|
|
1195
|
+
name: "blog-missing-og-image",
|
|
1196
|
+
severity: "warning",
|
|
1197
|
+
category: "seo",
|
|
1198
|
+
fixStrategy: "Add an image field to the frontmatter for social sharing previews",
|
|
1199
|
+
run: (item) => {
|
|
1200
|
+
if (item.contentType !== "blog") {
|
|
1201
|
+
return [];
|
|
1202
|
+
}
|
|
1203
|
+
if (!item.image || item.image.trim().length === 0) {
|
|
1204
|
+
return [{
|
|
1205
|
+
file: getDisplayPath(item),
|
|
1206
|
+
field: "image",
|
|
1207
|
+
rule: "blog-missing-og-image",
|
|
1208
|
+
severity: "warning",
|
|
1209
|
+
message: "Blog post missing featured image",
|
|
1210
|
+
suggestion: "Add an image field for better social media sharing previews."
|
|
1211
|
+
}];
|
|
1212
|
+
}
|
|
1213
|
+
return [];
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
var projectMissingOgImage = {
|
|
1217
|
+
name: "project-missing-og-image",
|
|
1218
|
+
severity: "warning",
|
|
1219
|
+
category: "seo",
|
|
1220
|
+
fixStrategy: "Add a thumbnail image for social media sharing previews",
|
|
1221
|
+
run: (item) => {
|
|
1222
|
+
if (item.contentType !== "project") {
|
|
1223
|
+
return [];
|
|
1224
|
+
}
|
|
1225
|
+
if (!item.image || item.image.trim().length === 0) {
|
|
1226
|
+
return [{
|
|
1227
|
+
file: getDisplayPath(item),
|
|
1228
|
+
field: "image",
|
|
1229
|
+
rule: "project-missing-og-image",
|
|
1230
|
+
severity: "warning",
|
|
1231
|
+
message: "Project missing thumbnail/featured image",
|
|
1232
|
+
suggestion: "Add a thumbnail image for better social media sharing previews."
|
|
1233
|
+
}];
|
|
1234
|
+
}
|
|
1235
|
+
return [];
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
var ogRules = [
|
|
1239
|
+
blogMissingOgImage,
|
|
1240
|
+
projectMissingOgImage
|
|
1241
|
+
];
|
|
1242
|
+
|
|
1243
|
+
// src/rules/performance-rules.ts
|
|
1244
|
+
var import_fs = require("fs");
|
|
1245
|
+
var import_path = require("path");
|
|
1246
|
+
var MAX_IMAGE_SIZE_KB = 500;
|
|
1247
|
+
var imageFileTooLarge = {
|
|
1248
|
+
name: "image-file-too-large",
|
|
1249
|
+
severity: "warning",
|
|
1250
|
+
category: "technical",
|
|
1251
|
+
fixStrategy: "Compress the image or convert to WebP/AVIF format",
|
|
1252
|
+
run: (item) => {
|
|
1253
|
+
const results = [];
|
|
1254
|
+
const maxBytes = MAX_IMAGE_SIZE_KB * 1024;
|
|
1255
|
+
const imagesToCheck = [];
|
|
1256
|
+
if (item.image && !item.image.startsWith("http")) {
|
|
1257
|
+
imagesToCheck.push({ src: item.image, source: "frontmatter" });
|
|
1258
|
+
}
|
|
1259
|
+
const inlineImages = extractImages(item.body);
|
|
1260
|
+
for (const img of inlineImages) {
|
|
1261
|
+
if (!img.src.startsWith("http")) {
|
|
1262
|
+
imagesToCheck.push({ src: img.src, line: img.line, source: "inline" });
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
for (const image of imagesToCheck) {
|
|
1266
|
+
try {
|
|
1267
|
+
const imagePath = (0, import_path.join)(process.cwd(), "public", image.src);
|
|
1268
|
+
const stats = (0, import_fs.statSync)(imagePath);
|
|
1269
|
+
const sizeKB = Math.round(stats.size / 1024);
|
|
1270
|
+
if (stats.size > maxBytes) {
|
|
1271
|
+
results.push({
|
|
1272
|
+
file: getDisplayPath(item),
|
|
1273
|
+
field: image.source === "frontmatter" ? "image" : "body",
|
|
1274
|
+
rule: "image-file-too-large",
|
|
1275
|
+
severity: "warning",
|
|
1276
|
+
message: `Image file too large: ${image.src} (${sizeKB}KB > ${MAX_IMAGE_SIZE_KB}KB)`,
|
|
1277
|
+
suggestion: "Compress the image or convert to WebP/AVIF format to improve page load speed",
|
|
1278
|
+
line: image.line
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
} catch {
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return results;
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
var performanceRules = [
|
|
1288
|
+
imageFileTooLarge
|
|
1289
|
+
];
|
|
1290
|
+
|
|
1291
|
+
// src/rules/robots-rules.ts
|
|
1292
|
+
var publishedNoindex = {
|
|
1293
|
+
name: "published-noindex",
|
|
1294
|
+
severity: "warning",
|
|
1295
|
+
category: "seo",
|
|
1296
|
+
fixStrategy: "Remove noindex if content should be discoverable, or set draft: true to hide it",
|
|
1297
|
+
run: (item) => {
|
|
1298
|
+
if (!item.draft && item.noindex === true) {
|
|
1299
|
+
return [{
|
|
1300
|
+
file: getDisplayPath(item),
|
|
1301
|
+
field: "noindex",
|
|
1302
|
+
rule: "published-noindex",
|
|
1303
|
+
severity: "warning",
|
|
1304
|
+
message: "Published content has noindex set \u2014 it will not appear in search results",
|
|
1305
|
+
suggestion: "Remove noindex if this content should be discoverable, or set draft: true if it should be hidden entirely"
|
|
1306
|
+
}];
|
|
1307
|
+
}
|
|
1308
|
+
return [];
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
var robotsRules = [
|
|
1312
|
+
publishedNoindex
|
|
1313
|
+
];
|
|
1314
|
+
|
|
1315
|
+
// src/rules/slug-rules.ts
|
|
1316
|
+
var SLUG_MAX_LENGTH = 75;
|
|
1317
|
+
var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1318
|
+
var slugInvalidCharacters = {
|
|
1319
|
+
name: "slug-invalid-characters",
|
|
1320
|
+
severity: "error",
|
|
1321
|
+
category: "seo",
|
|
1322
|
+
fixStrategy: 'Use lowercase alphanumeric characters with hyphens only (e.g., "my-blog-post")',
|
|
1323
|
+
run: (item) => {
|
|
1324
|
+
if (!item.slug) return [];
|
|
1325
|
+
const hasUppercase = /[A-Z]/.test(item.slug);
|
|
1326
|
+
const matchesPattern = SLUG_PATTERN.test(item.slug);
|
|
1327
|
+
if (hasUppercase || !matchesPattern) {
|
|
1328
|
+
return [{
|
|
1329
|
+
file: getDisplayPath(item),
|
|
1330
|
+
field: "slug",
|
|
1331
|
+
rule: "slug-invalid-characters",
|
|
1332
|
+
severity: "error",
|
|
1333
|
+
message: `Slug "${item.slug}" contains invalid characters`,
|
|
1334
|
+
suggestion: 'Slugs must be lowercase alphanumeric with hyphens only (e.g., "my-blog-post")'
|
|
1335
|
+
}];
|
|
1336
|
+
}
|
|
1337
|
+
return [];
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
var slugTooLong = {
|
|
1341
|
+
name: "slug-too-long",
|
|
1342
|
+
severity: "warning",
|
|
1343
|
+
category: "seo",
|
|
1344
|
+
fixStrategy: "Shorten the slug for better URL readability",
|
|
1345
|
+
run: (item) => {
|
|
1346
|
+
if (!item.slug) return [];
|
|
1347
|
+
const length = item.slug.length;
|
|
1348
|
+
if (length > SLUG_MAX_LENGTH) {
|
|
1349
|
+
return [{
|
|
1350
|
+
file: getDisplayPath(item),
|
|
1351
|
+
field: "slug",
|
|
1352
|
+
rule: "slug-too-long",
|
|
1353
|
+
severity: "warning",
|
|
1354
|
+
message: `Slug is too long (${length}/${SLUG_MAX_LENGTH} chars)`,
|
|
1355
|
+
suggestion: `Shorten the slug to ${SLUG_MAX_LENGTH} characters or less for better URL readability`
|
|
1356
|
+
}];
|
|
1357
|
+
}
|
|
1358
|
+
return [];
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
var slugRules = [
|
|
1362
|
+
slugInvalidCharacters,
|
|
1363
|
+
slugTooLong
|
|
1364
|
+
];
|
|
1365
|
+
|
|
1366
|
+
// src/rules/i18n-rules.ts
|
|
1367
|
+
var translationPairMissing = {
|
|
1368
|
+
name: "translation-pair-missing",
|
|
1369
|
+
severity: "warning",
|
|
1370
|
+
category: "i18n",
|
|
1371
|
+
fixStrategy: "Create the missing translation version with the same translationKey",
|
|
1372
|
+
run: (item, context) => {
|
|
1373
|
+
if (item.contentType === "page") return [];
|
|
1374
|
+
if (!item.translationKey) return [];
|
|
1375
|
+
const siblings = context.allContent.filter(
|
|
1376
|
+
(c) => c.translationKey === item.translationKey && c.slug !== item.slug
|
|
1377
|
+
);
|
|
1378
|
+
const locales = /* @__PURE__ */ new Set([item.locale, ...siblings.map((s) => s.locale)]);
|
|
1379
|
+
const results = [];
|
|
1380
|
+
if (item.locale === "de" && !locales.has("en")) {
|
|
1381
|
+
results.push({
|
|
1382
|
+
file: getDisplayPath(item),
|
|
1383
|
+
field: "translationKey",
|
|
1384
|
+
rule: "translation-pair-missing",
|
|
1385
|
+
severity: "warning",
|
|
1386
|
+
message: `Missing English translation for translationKey "${item.translationKey}"`,
|
|
1387
|
+
suggestion: "Create an English (.en.mdx) version with the same translationKey"
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
if (item.locale === "en" && !locales.has("de")) {
|
|
1391
|
+
results.push({
|
|
1392
|
+
file: getDisplayPath(item),
|
|
1393
|
+
field: "translationKey",
|
|
1394
|
+
rule: "translation-pair-missing",
|
|
1395
|
+
severity: "warning",
|
|
1396
|
+
message: `Missing German translation for translationKey "${item.translationKey}"`,
|
|
1397
|
+
suggestion: "Create a German (.de.mdx) version with the same translationKey"
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
return results;
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
var missingLocale = {
|
|
1404
|
+
name: "missing-locale",
|
|
1405
|
+
severity: "warning",
|
|
1406
|
+
category: "i18n",
|
|
1407
|
+
fixStrategy: 'Add a locale field ("de" or "en") to the frontmatter',
|
|
1408
|
+
run: (item) => {
|
|
1409
|
+
if (item.contentType === "page") return [];
|
|
1410
|
+
if (!item.locale) {
|
|
1411
|
+
return [{
|
|
1412
|
+
file: getDisplayPath(item),
|
|
1413
|
+
field: "locale",
|
|
1414
|
+
rule: "missing-locale",
|
|
1415
|
+
severity: "warning",
|
|
1416
|
+
message: "Missing locale field",
|
|
1417
|
+
suggestion: 'Add a locale field ("de" or "en") to the frontmatter for proper i18n support'
|
|
1418
|
+
}];
|
|
1419
|
+
}
|
|
1420
|
+
return [];
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
var i18nRules = [
|
|
1424
|
+
translationPairMissing,
|
|
1425
|
+
missingLocale
|
|
1426
|
+
];
|
|
1427
|
+
|
|
1428
|
+
// src/rules/date-rules.ts
|
|
1429
|
+
var missingDate = {
|
|
1430
|
+
name: "missing-date",
|
|
1431
|
+
severity: "error",
|
|
1432
|
+
category: "content",
|
|
1433
|
+
fixStrategy: 'Add a date field (e.g., "2025-01-15") to the frontmatter',
|
|
1434
|
+
run: (item) => {
|
|
1435
|
+
if (item.contentType === "page") return [];
|
|
1436
|
+
if (!item.date) {
|
|
1437
|
+
return [{
|
|
1438
|
+
file: getDisplayPath(item),
|
|
1439
|
+
field: "date",
|
|
1440
|
+
rule: "missing-date",
|
|
1441
|
+
severity: "error",
|
|
1442
|
+
message: "Missing date field",
|
|
1443
|
+
suggestion: 'Add a date field (e.g., "2025-01-15") to the frontmatter'
|
|
1444
|
+
}];
|
|
1445
|
+
}
|
|
1446
|
+
return [];
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
var futureDate = {
|
|
1450
|
+
name: "future-date",
|
|
1451
|
+
severity: "warning",
|
|
1452
|
+
category: "content",
|
|
1453
|
+
fixStrategy: "Verify the publish date is correct or set it to today",
|
|
1454
|
+
run: (item) => {
|
|
1455
|
+
if (!item.date) return [];
|
|
1456
|
+
const dateValue = new Date(item.date);
|
|
1457
|
+
const now = /* @__PURE__ */ new Date();
|
|
1458
|
+
if (dateValue > now) {
|
|
1459
|
+
return [{
|
|
1460
|
+
file: getDisplayPath(item),
|
|
1461
|
+
field: "date",
|
|
1462
|
+
rule: "future-date",
|
|
1463
|
+
severity: "warning",
|
|
1464
|
+
message: `Date "${item.date}" is in the future`,
|
|
1465
|
+
suggestion: "Verify the publish date is correct or set it to today"
|
|
1466
|
+
}];
|
|
1467
|
+
}
|
|
1468
|
+
return [];
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
var missingUpdatedAt = {
|
|
1472
|
+
name: "missing-updated-at",
|
|
1473
|
+
severity: "warning",
|
|
1474
|
+
category: "content",
|
|
1475
|
+
fixStrategy: "Add an updatedAt field to help search engines identify fresh content",
|
|
1476
|
+
run: (item) => {
|
|
1477
|
+
if (item.contentType === "page") return [];
|
|
1478
|
+
if (!item.updatedAt) {
|
|
1479
|
+
return [{
|
|
1480
|
+
file: getDisplayPath(item),
|
|
1481
|
+
field: "updatedAt",
|
|
1482
|
+
rule: "missing-updated-at",
|
|
1483
|
+
severity: "warning",
|
|
1484
|
+
message: "Missing updatedAt field",
|
|
1485
|
+
suggestion: "Add an updatedAt field to help search engines identify fresh content"
|
|
1486
|
+
}];
|
|
1487
|
+
}
|
|
1488
|
+
return [];
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
var dateRules = [
|
|
1492
|
+
missingDate,
|
|
1493
|
+
futureDate,
|
|
1494
|
+
missingUpdatedAt
|
|
1495
|
+
];
|
|
1496
|
+
|
|
1497
|
+
// src/rules/schema-rules.ts
|
|
1498
|
+
var blogMissingSchemaFields = {
|
|
1499
|
+
name: "blog-missing-schema-fields",
|
|
1500
|
+
severity: "warning",
|
|
1501
|
+
category: "seo",
|
|
1502
|
+
fixStrategy: "Add updatedAt and image fields to frontmatter for complete schema.org data",
|
|
1503
|
+
run: (item) => {
|
|
1504
|
+
if (item.contentType !== "blog") {
|
|
1505
|
+
return [];
|
|
1506
|
+
}
|
|
1507
|
+
const results = [];
|
|
1508
|
+
if (!item.updatedAt) {
|
|
1509
|
+
results.push({
|
|
1510
|
+
file: getDisplayPath(item),
|
|
1511
|
+
field: "updatedAt",
|
|
1512
|
+
rule: "blog-missing-schema-fields",
|
|
1513
|
+
severity: "warning",
|
|
1514
|
+
message: "Missing updatedAt \u2014 BlogPosting schema dateModified will fall back to datePublished",
|
|
1515
|
+
suggestion: "Add updatedAt field to frontmatter for accurate schema.org dateModified."
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
if (!item.image || item.image.trim().length === 0) {
|
|
1519
|
+
results.push({
|
|
1520
|
+
file: getDisplayPath(item),
|
|
1521
|
+
field: "image",
|
|
1522
|
+
rule: "blog-missing-schema-fields",
|
|
1523
|
+
severity: "warning",
|
|
1524
|
+
message: "Missing featured image \u2014 BlogPosting schema image will be empty",
|
|
1525
|
+
suggestion: "Add an image field for complete BlogPosting structured data."
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
return results;
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
var schemaRules = [
|
|
1532
|
+
blogMissingSchemaFields
|
|
1533
|
+
];
|
|
1534
|
+
|
|
1535
|
+
// src/rules/keyword-coherence-rules.ts
|
|
1536
|
+
var MIN_SIGNIFICANT_WORDS = 2;
|
|
1537
|
+
var MIN_WORD_LENGTH = 3;
|
|
1538
|
+
var MIN_CONTENT_WORDS = 300;
|
|
1539
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
1540
|
+
// German
|
|
1541
|
+
"der",
|
|
1542
|
+
"die",
|
|
1543
|
+
"das",
|
|
1544
|
+
"ein",
|
|
1545
|
+
"eine",
|
|
1546
|
+
"einer",
|
|
1547
|
+
"eines",
|
|
1548
|
+
"einem",
|
|
1549
|
+
"einen",
|
|
1550
|
+
"und",
|
|
1551
|
+
"oder",
|
|
1552
|
+
"aber",
|
|
1553
|
+
"als",
|
|
1554
|
+
"auch",
|
|
1555
|
+
"auf",
|
|
1556
|
+
"aus",
|
|
1557
|
+
"bei",
|
|
1558
|
+
"bis",
|
|
1559
|
+
"f\xFCr",
|
|
1560
|
+
"mit",
|
|
1561
|
+
"nach",
|
|
1562
|
+
"\xFCber",
|
|
1563
|
+
"von",
|
|
1564
|
+
"vor",
|
|
1565
|
+
"wie",
|
|
1566
|
+
"zum",
|
|
1567
|
+
"zur",
|
|
1568
|
+
"den",
|
|
1569
|
+
"dem",
|
|
1570
|
+
"des",
|
|
1571
|
+
"ist",
|
|
1572
|
+
"sind",
|
|
1573
|
+
"war",
|
|
1574
|
+
"hat",
|
|
1575
|
+
"ihr",
|
|
1576
|
+
"wir",
|
|
1577
|
+
"sie",
|
|
1578
|
+
"ich",
|
|
1579
|
+
"nicht",
|
|
1580
|
+
"sich",
|
|
1581
|
+
"man",
|
|
1582
|
+
"nur",
|
|
1583
|
+
"noch",
|
|
1584
|
+
"mehr",
|
|
1585
|
+
// English
|
|
1586
|
+
"the",
|
|
1587
|
+
"and",
|
|
1588
|
+
"for",
|
|
1589
|
+
"are",
|
|
1590
|
+
"but",
|
|
1591
|
+
"not",
|
|
1592
|
+
"you",
|
|
1593
|
+
"all",
|
|
1594
|
+
"can",
|
|
1595
|
+
"her",
|
|
1596
|
+
"was",
|
|
1597
|
+
"one",
|
|
1598
|
+
"our",
|
|
1599
|
+
"out",
|
|
1600
|
+
"has",
|
|
1601
|
+
"had",
|
|
1602
|
+
"its",
|
|
1603
|
+
"his",
|
|
1604
|
+
"how",
|
|
1605
|
+
"who",
|
|
1606
|
+
"what",
|
|
1607
|
+
"why",
|
|
1608
|
+
"when",
|
|
1609
|
+
"where",
|
|
1610
|
+
"which",
|
|
1611
|
+
"with",
|
|
1612
|
+
"this",
|
|
1613
|
+
"that",
|
|
1614
|
+
"from",
|
|
1615
|
+
"they",
|
|
1616
|
+
"been",
|
|
1617
|
+
"have",
|
|
1618
|
+
"will",
|
|
1619
|
+
"your",
|
|
1620
|
+
"into",
|
|
1621
|
+
"than",
|
|
1622
|
+
"them",
|
|
1623
|
+
"then",
|
|
1624
|
+
"some",
|
|
1625
|
+
"each",
|
|
1626
|
+
"make"
|
|
1627
|
+
]);
|
|
1628
|
+
function extractSignificantWords(text) {
|
|
1629
|
+
return text.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, " ").split(/\s+/).filter(
|
|
1630
|
+
(word) => word.length >= MIN_WORD_LENGTH && !STOPWORDS.has(word)
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
function findMatchingKeywords(keywords, targetText) {
|
|
1634
|
+
const normalizedTarget = targetText.toLowerCase();
|
|
1635
|
+
return keywords.filter((keyword) => normalizedTarget.includes(keyword));
|
|
1636
|
+
}
|
|
1637
|
+
var keywordNotInDescription = {
|
|
1638
|
+
name: "keyword-not-in-description",
|
|
1639
|
+
severity: "warning",
|
|
1640
|
+
category: "seo",
|
|
1641
|
+
fixStrategy: "Include at least one title keyword in the meta description",
|
|
1642
|
+
run: (item) => {
|
|
1643
|
+
if (!item.title || !item.description) return [];
|
|
1644
|
+
const keywords = extractSignificantWords(item.title);
|
|
1645
|
+
if (keywords.length < MIN_SIGNIFICANT_WORDS) return [];
|
|
1646
|
+
const matches = findMatchingKeywords(keywords, item.description);
|
|
1647
|
+
if (matches.length === 0) {
|
|
1648
|
+
return [{
|
|
1649
|
+
file: getDisplayPath(item),
|
|
1650
|
+
field: "description",
|
|
1651
|
+
rule: "keyword-not-in-description",
|
|
1652
|
+
severity: "warning",
|
|
1653
|
+
message: `Description contains none of the title keywords: ${keywords.join(", ")}`,
|
|
1654
|
+
suggestion: "Include at least one keyword from the title in the meta description for better SEO coherence"
|
|
1655
|
+
}];
|
|
1656
|
+
}
|
|
1657
|
+
return [];
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
var keywordNotInHeadings = {
|
|
1661
|
+
name: "keyword-not-in-headings",
|
|
1662
|
+
severity: "warning",
|
|
1663
|
+
category: "seo",
|
|
1664
|
+
fixStrategy: "Include at least one title keyword in subheadings",
|
|
1665
|
+
run: (item) => {
|
|
1666
|
+
if (item.contentType !== "blog" && item.contentType !== "project") return [];
|
|
1667
|
+
if (!item.title || !item.body) return [];
|
|
1668
|
+
const wordCount = countWords(item.body);
|
|
1669
|
+
if (wordCount < MIN_CONTENT_WORDS) return [];
|
|
1670
|
+
const keywords = extractSignificantWords(item.title);
|
|
1671
|
+
if (keywords.length < MIN_SIGNIFICANT_WORDS) return [];
|
|
1672
|
+
const headings = extractHeadings(item.body);
|
|
1673
|
+
const subHeadings = headings.filter((h) => h.level === 2 || h.level === 3);
|
|
1674
|
+
if (subHeadings.length === 0) return [];
|
|
1675
|
+
const allHeadingText = subHeadings.map((h) => h.text).join(" ");
|
|
1676
|
+
const matches = findMatchingKeywords(keywords, allHeadingText);
|
|
1677
|
+
if (matches.length === 0) {
|
|
1678
|
+
return [{
|
|
1679
|
+
file: getDisplayPath(item),
|
|
1680
|
+
field: "body",
|
|
1681
|
+
rule: "keyword-not-in-headings",
|
|
1682
|
+
severity: "warning",
|
|
1683
|
+
message: `No title keywords found in H2/H3 headings: ${keywords.join(", ")}`,
|
|
1684
|
+
suggestion: "Include at least one title keyword in your subheadings to reinforce topical relevance"
|
|
1685
|
+
}];
|
|
1686
|
+
}
|
|
1687
|
+
return [];
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
var titleDescriptionNoOverlap = {
|
|
1691
|
+
name: "title-description-no-overlap",
|
|
1692
|
+
severity: "warning",
|
|
1693
|
+
category: "seo",
|
|
1694
|
+
fixStrategy: "Ensure title and description share at least one keyword",
|
|
1695
|
+
run: (item) => {
|
|
1696
|
+
if (!item.title || !item.description) return [];
|
|
1697
|
+
const titleWords = extractSignificantWords(item.title);
|
|
1698
|
+
const descWords = new Set(extractSignificantWords(item.description));
|
|
1699
|
+
if (titleWords.length < MIN_SIGNIFICANT_WORDS) return [];
|
|
1700
|
+
if (descWords.size < MIN_SIGNIFICANT_WORDS) return [];
|
|
1701
|
+
const overlap = titleWords.filter((word) => descWords.has(word));
|
|
1702
|
+
if (overlap.length === 0) {
|
|
1703
|
+
return [{
|
|
1704
|
+
file: getDisplayPath(item),
|
|
1705
|
+
field: "description",
|
|
1706
|
+
rule: "title-description-no-overlap",
|
|
1707
|
+
severity: "warning",
|
|
1708
|
+
message: "Title and description share no significant words",
|
|
1709
|
+
suggestion: "Title and description should be thematically connected. Ensure they share at least one keyword."
|
|
1710
|
+
}];
|
|
1711
|
+
}
|
|
1712
|
+
return [];
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
var keywordCoherenceRules = [
|
|
1716
|
+
keywordNotInDescription,
|
|
1717
|
+
keywordNotInHeadings,
|
|
1718
|
+
titleDescriptionNoOverlap
|
|
1719
|
+
];
|
|
1720
|
+
|
|
1721
|
+
// src/rules/link-rules.ts
|
|
1722
|
+
function createLinkRules(linkExtractor) {
|
|
1723
|
+
const { extractLinks, getInternalLinks, isAbsoluteInternalLink } = linkExtractor;
|
|
1724
|
+
const brokenInternalLink = {
|
|
1725
|
+
name: "broken-internal-link",
|
|
1726
|
+
severity: "error",
|
|
1727
|
+
category: "technical",
|
|
1728
|
+
fixStrategy: "Fix the link target to point to an existing page",
|
|
1729
|
+
run: (item, context) => {
|
|
1730
|
+
const results = [];
|
|
1731
|
+
const links = extractLinks(item.body);
|
|
1732
|
+
const internalLinks = getInternalLinks(links);
|
|
1733
|
+
for (const link of internalLinks) {
|
|
1734
|
+
if (!isValidInternalLink(link.url, context.validSlugs)) {
|
|
1735
|
+
results.push({
|
|
1736
|
+
file: getDisplayPath(item),
|
|
1737
|
+
field: "body",
|
|
1738
|
+
rule: "broken-internal-link",
|
|
1739
|
+
severity: "error",
|
|
1740
|
+
message: `Broken internal link: ${link.url}`,
|
|
1741
|
+
suggestion: `Link "${link.originalUrl}" does not resolve to any known page`,
|
|
1742
|
+
line: link.line
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return results;
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
const absoluteInternalLink = {
|
|
1750
|
+
name: "absolute-internal-link",
|
|
1751
|
+
severity: "warning",
|
|
1752
|
+
category: "technical",
|
|
1753
|
+
fixStrategy: "Use a relative path instead of an absolute URL",
|
|
1754
|
+
run: (item) => {
|
|
1755
|
+
const results = [];
|
|
1756
|
+
const links = extractLinks(item.body);
|
|
1757
|
+
for (const link of links) {
|
|
1758
|
+
if (link.isInternal && isAbsoluteInternalLink(link.originalUrl)) {
|
|
1759
|
+
results.push({
|
|
1760
|
+
file: getDisplayPath(item),
|
|
1761
|
+
field: "body",
|
|
1762
|
+
rule: "absolute-internal-link",
|
|
1763
|
+
severity: "warning",
|
|
1764
|
+
message: `Absolute internal URL: ${link.originalUrl}`,
|
|
1765
|
+
suggestion: `Use relative path "${link.url}" instead for better portability`,
|
|
1766
|
+
line: link.line
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
return results;
|
|
1771
|
+
}
|
|
1772
|
+
};
|
|
1773
|
+
const draftLinkLeak = {
|
|
1774
|
+
name: "draft-link-leak",
|
|
1775
|
+
severity: "error",
|
|
1776
|
+
category: "technical",
|
|
1777
|
+
fixStrategy: "Remove or update link \u2014 the target page is not publicly visible",
|
|
1778
|
+
run: (item, context) => {
|
|
1779
|
+
if (item.draft || item.noindex) {
|
|
1780
|
+
return [];
|
|
1781
|
+
}
|
|
1782
|
+
const results = [];
|
|
1783
|
+
const links = extractLinks(item.body);
|
|
1784
|
+
const internalLinks = getInternalLinks(links);
|
|
1785
|
+
const draftPermalinks = new Set(
|
|
1786
|
+
context.allContent.filter((c) => c.draft === true || c.noindex === true).map((c) => c.permalink)
|
|
1787
|
+
);
|
|
1788
|
+
for (const link of internalLinks) {
|
|
1789
|
+
if (draftPermalinks.has(link.url)) {
|
|
1790
|
+
results.push({
|
|
1791
|
+
file: getDisplayPath(item),
|
|
1792
|
+
field: "body",
|
|
1793
|
+
rule: "draft-link-leak",
|
|
1794
|
+
severity: "error",
|
|
1795
|
+
message: `Internal link points to draft/noindex page: ${link.url}`,
|
|
1796
|
+
suggestion: `Remove or update link "${link.originalUrl}" \u2014 the target page is not publicly visible`,
|
|
1797
|
+
line: link.line
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
return results;
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
const trailingSlashInconsistency = {
|
|
1805
|
+
name: "trailing-slash-inconsistency",
|
|
1806
|
+
severity: "warning",
|
|
1807
|
+
category: "technical",
|
|
1808
|
+
fixStrategy: "Remove the trailing slash from the internal link",
|
|
1809
|
+
run: (item) => {
|
|
1810
|
+
const results = [];
|
|
1811
|
+
const links = extractLinks(item.body);
|
|
1812
|
+
const internalLinks = getInternalLinks(links);
|
|
1813
|
+
for (const link of internalLinks) {
|
|
1814
|
+
const originalPath = link.originalUrl.split("?")[0].split("#")[0];
|
|
1815
|
+
if (originalPath !== "/" && originalPath.endsWith("/")) {
|
|
1816
|
+
results.push({
|
|
1817
|
+
file: getDisplayPath(item),
|
|
1818
|
+
field: "body",
|
|
1819
|
+
rule: "trailing-slash-inconsistency",
|
|
1820
|
+
severity: "warning",
|
|
1821
|
+
message: `Internal link has trailing slash: ${link.originalUrl}`,
|
|
1822
|
+
suggestion: `Remove trailing slash: "${originalPath.slice(0, -1)}"`,
|
|
1823
|
+
line: link.line
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return results;
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
return [
|
|
1831
|
+
brokenInternalLink,
|
|
1832
|
+
absoluteInternalLink,
|
|
1833
|
+
draftLinkLeak,
|
|
1834
|
+
trailingSlashInconsistency
|
|
1835
|
+
];
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/rules/external-link-rules.ts
|
|
1839
|
+
var OUTBOUND_MIN_BASE = 3;
|
|
1840
|
+
var OUTBOUND_WORDS_PER_EXTRA_LINK = 500;
|
|
1841
|
+
var OUTBOUND_MIN_WORDS_TO_APPLY = 300;
|
|
1842
|
+
function createExternalLinkRules(linkExtractor) {
|
|
1843
|
+
const { extractLinks } = linkExtractor;
|
|
1844
|
+
const externalLinkMalformed = {
|
|
1845
|
+
name: "external-link-malformed",
|
|
1846
|
+
severity: "warning",
|
|
1847
|
+
category: "technical",
|
|
1848
|
+
fixStrategy: "Check the URL for typos or missing protocol (https://)",
|
|
1849
|
+
run: (item) => {
|
|
1850
|
+
const results = [];
|
|
1851
|
+
const links = extractLinks(item.body);
|
|
1852
|
+
for (const link of links) {
|
|
1853
|
+
if (link.isInternal) {
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
try {
|
|
1857
|
+
new URL(link.url);
|
|
1858
|
+
} catch {
|
|
1859
|
+
results.push({
|
|
1860
|
+
file: getDisplayPath(item),
|
|
1861
|
+
field: "body",
|
|
1862
|
+
rule: "external-link-malformed",
|
|
1863
|
+
severity: "warning",
|
|
1864
|
+
message: `Malformed external URL: ${link.url}`,
|
|
1865
|
+
suggestion: "Check the URL for typos or missing protocol (https://)",
|
|
1866
|
+
line: link.line
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
return results;
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1873
|
+
const externalLinkHttp = {
|
|
1874
|
+
name: "external-link-http",
|
|
1875
|
+
severity: "warning",
|
|
1876
|
+
category: "technical",
|
|
1877
|
+
fixStrategy: "Replace http:// with https://",
|
|
1878
|
+
run: (item) => {
|
|
1879
|
+
const results = [];
|
|
1880
|
+
const links = extractLinks(item.body);
|
|
1881
|
+
for (const link of links) {
|
|
1882
|
+
if (link.isInternal) {
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
if (link.url.startsWith("http://") && !link.url.startsWith("http://localhost")) {
|
|
1886
|
+
results.push({
|
|
1887
|
+
file: getDisplayPath(item),
|
|
1888
|
+
field: "body",
|
|
1889
|
+
rule: "external-link-http",
|
|
1890
|
+
severity: "warning",
|
|
1891
|
+
message: `Insecure HTTP link: ${link.url}`,
|
|
1892
|
+
suggestion: `Use HTTPS instead: "${link.url.replace("http://", "https://")}"`,
|
|
1893
|
+
line: link.line
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
return results;
|
|
1898
|
+
}
|
|
1899
|
+
};
|
|
1900
|
+
const externalLinkMinDensity = {
|
|
1901
|
+
name: "external-link-low-density",
|
|
1902
|
+
severity: "warning",
|
|
1903
|
+
category: "seo",
|
|
1904
|
+
fixStrategy: "Add outbound links to cite sources, reference tools, or link to studies",
|
|
1905
|
+
run: (item) => {
|
|
1906
|
+
if (item.contentType !== "blog") {
|
|
1907
|
+
return [];
|
|
1908
|
+
}
|
|
1909
|
+
const wordCount = countWords(item.body);
|
|
1910
|
+
if (wordCount < OUTBOUND_MIN_WORDS_TO_APPLY) {
|
|
1911
|
+
return [];
|
|
1912
|
+
}
|
|
1913
|
+
const links = extractLinks(item.body);
|
|
1914
|
+
const externalLinks = links.filter((link) => !link.isInternal);
|
|
1915
|
+
const externalCount = externalLinks.length;
|
|
1916
|
+
const extraLinks = Math.max(
|
|
1917
|
+
0,
|
|
1918
|
+
Math.floor((wordCount - OUTBOUND_MIN_WORDS_TO_APPLY) / OUTBOUND_WORDS_PER_EXTRA_LINK)
|
|
1919
|
+
);
|
|
1920
|
+
const requiredMin = OUTBOUND_MIN_BASE + extraLinks;
|
|
1921
|
+
if (externalCount < requiredMin) {
|
|
1922
|
+
return [{
|
|
1923
|
+
file: getDisplayPath(item),
|
|
1924
|
+
field: "body",
|
|
1925
|
+
rule: "external-link-low-density",
|
|
1926
|
+
severity: "warning",
|
|
1927
|
+
message: `Only ${externalCount} outbound link(s) for ${wordCount} words (minimum: ${requiredMin})`,
|
|
1928
|
+
suggestion: `Add ${requiredMin - externalCount} more outbound link(s) to cite sources, reference tools, or link to studies that support your claims.`
|
|
1929
|
+
}];
|
|
1930
|
+
}
|
|
1931
|
+
return [];
|
|
1932
|
+
}
|
|
1933
|
+
};
|
|
1934
|
+
return [
|
|
1935
|
+
externalLinkMalformed,
|
|
1936
|
+
externalLinkHttp,
|
|
1937
|
+
externalLinkMinDensity
|
|
1938
|
+
];
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// src/rules/orphan-rules.ts
|
|
1942
|
+
function normalizePermalink(permalink) {
|
|
1943
|
+
if (permalink.length > 1 && permalink.endsWith("/")) {
|
|
1944
|
+
return permalink.slice(0, -1);
|
|
1945
|
+
}
|
|
1946
|
+
return permalink;
|
|
1947
|
+
}
|
|
1948
|
+
function createOrphanRules(linkExtractor) {
|
|
1949
|
+
const { extractLinks, getInternalLinks } = linkExtractor;
|
|
1950
|
+
const orphanContent = {
|
|
1951
|
+
name: "orphan-content",
|
|
1952
|
+
severity: "warning",
|
|
1953
|
+
category: "seo",
|
|
1954
|
+
fixStrategy: "Add an internal link to this page from a related page",
|
|
1955
|
+
run: (item, context) => {
|
|
1956
|
+
if (item.contentType !== "blog" && item.contentType !== "project") {
|
|
1957
|
+
return [];
|
|
1958
|
+
}
|
|
1959
|
+
if (item.draft || item.noindex) {
|
|
1960
|
+
return [];
|
|
1961
|
+
}
|
|
1962
|
+
const itemPermalink = normalizePermalink(item.permalink);
|
|
1963
|
+
for (const other of context.allContent) {
|
|
1964
|
+
if (other.slug === item.slug && other.contentType === item.contentType) {
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
const links = extractLinks(other.body);
|
|
1968
|
+
const internalLinks = getInternalLinks(links);
|
|
1969
|
+
for (const link of internalLinks) {
|
|
1970
|
+
const normalizedLink = normalizePermalink(link.url);
|
|
1971
|
+
if (normalizedLink === itemPermalink) {
|
|
1972
|
+
return [];
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
return [{
|
|
1977
|
+
file: getDisplayPath(item),
|
|
1978
|
+
field: "body",
|
|
1979
|
+
rule: "orphan-content",
|
|
1980
|
+
severity: "warning",
|
|
1981
|
+
message: `No other content links to this ${item.contentType} post`,
|
|
1982
|
+
suggestion: `Add an internal link to "${item.permalink}" from a related page to improve discoverability`
|
|
1983
|
+
}];
|
|
1984
|
+
}
|
|
1985
|
+
};
|
|
1986
|
+
return [orphanContent];
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// src/utils/geo-analyzer.ts
|
|
1990
|
+
var QUESTION_WORDS = [
|
|
1991
|
+
"was",
|
|
1992
|
+
"wie",
|
|
1993
|
+
"warum",
|
|
1994
|
+
"wann",
|
|
1995
|
+
"wo",
|
|
1996
|
+
"wer",
|
|
1997
|
+
"welche",
|
|
1998
|
+
"how",
|
|
1999
|
+
"what",
|
|
2000
|
+
"why",
|
|
2001
|
+
"when",
|
|
2002
|
+
"where",
|
|
2003
|
+
"who",
|
|
2004
|
+
"which",
|
|
2005
|
+
"can",
|
|
2006
|
+
"do",
|
|
2007
|
+
"does",
|
|
2008
|
+
"is",
|
|
2009
|
+
"are",
|
|
2010
|
+
"should"
|
|
2011
|
+
];
|
|
2012
|
+
var WEAK_LEAD_STARTS = [
|
|
2013
|
+
"in this",
|
|
2014
|
+
"the following",
|
|
2015
|
+
"diesem",
|
|
2016
|
+
"let's",
|
|
2017
|
+
"lass uns",
|
|
2018
|
+
"this section",
|
|
2019
|
+
"dieser abschnitt"
|
|
2020
|
+
];
|
|
2021
|
+
var TABLE_SEPARATOR_PATTERN = /\|\s*:?-{2,}/;
|
|
2022
|
+
function countQuestionHeadings(body) {
|
|
2023
|
+
const headings = extractHeadings(body);
|
|
2024
|
+
let count = 0;
|
|
2025
|
+
for (const heading of headings) {
|
|
2026
|
+
const text = heading.text.trim();
|
|
2027
|
+
if (text.endsWith("?")) {
|
|
2028
|
+
count++;
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
const firstWord = text.split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
2032
|
+
if (QUESTION_WORDS.includes(firstWord)) {
|
|
2033
|
+
count++;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
return count;
|
|
2037
|
+
}
|
|
2038
|
+
function analyzeLeadSentences(body) {
|
|
2039
|
+
const sectionPattern = /^#{2,3}\s+.+$/m;
|
|
2040
|
+
const sections = body.split(sectionPattern);
|
|
2041
|
+
const contentSections = sections.slice(1);
|
|
2042
|
+
let totalSections = 0;
|
|
2043
|
+
let sectionsWithDirectAnswers = 0;
|
|
2044
|
+
for (const section of contentSections) {
|
|
2045
|
+
const trimmed = section.trim();
|
|
2046
|
+
if (!trimmed) continue;
|
|
2047
|
+
totalSections++;
|
|
2048
|
+
const sentenceMatch = trimmed.match(/^([^.!?]+[.!?])/);
|
|
2049
|
+
if (!sentenceMatch) continue;
|
|
2050
|
+
const firstSentence = sentenceMatch[1].trim();
|
|
2051
|
+
if (firstSentence.length <= 20) continue;
|
|
2052
|
+
if (firstSentence.endsWith("?")) continue;
|
|
2053
|
+
const lowerSentence = firstSentence.toLowerCase();
|
|
2054
|
+
const isWeakLead = WEAK_LEAD_STARTS.some(
|
|
2055
|
+
(pattern) => lowerSentence.startsWith(pattern)
|
|
2056
|
+
);
|
|
2057
|
+
if (!isWeakLead) {
|
|
2058
|
+
sectionsWithDirectAnswers++;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
return { totalSections, sectionsWithDirectAnswers };
|
|
2062
|
+
}
|
|
2063
|
+
function countStatistics(body) {
|
|
2064
|
+
const patterns = [
|
|
2065
|
+
/\d+\s*%/g,
|
|
2066
|
+
// Percentages: 50%, 23 %
|
|
2067
|
+
/\d+(?:\.\d+)?\s*(?:x|mal|times)/gi,
|
|
2068
|
+
// Multipliers: 3x, 2.5 mal, 10 times
|
|
2069
|
+
/(?:€|\$|USD|EUR)\s*\d+/g,
|
|
2070
|
+
// Currency: €500, $1000, USD 50
|
|
2071
|
+
/\d{4,}/g,
|
|
2072
|
+
// Large numbers: 10000, 2024 (4+ digits)
|
|
2073
|
+
/\d+(?:\.\d+)?\s*(?:million|billion|mrd|mio)/gi
|
|
2074
|
+
// Scales: 5 million, 2.3 Mrd
|
|
2075
|
+
];
|
|
2076
|
+
const matches = /* @__PURE__ */ new Set();
|
|
2077
|
+
for (const pattern of patterns) {
|
|
2078
|
+
const found = body.matchAll(pattern);
|
|
2079
|
+
for (const match of found) {
|
|
2080
|
+
matches.add(`${match.index}:${match[0]}`);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
return matches.size;
|
|
2084
|
+
}
|
|
2085
|
+
function hasFAQSection(body) {
|
|
2086
|
+
const faqPattern = /#{2,3}\s*(FAQ|Häufige Fragen|Frequently Asked|Fragen und Antworten)/i;
|
|
2087
|
+
return faqPattern.test(body);
|
|
2088
|
+
}
|
|
2089
|
+
function hasMarkdownTable(body) {
|
|
2090
|
+
return TABLE_SEPARATOR_PATTERN.test(body);
|
|
2091
|
+
}
|
|
2092
|
+
function countEntityMentions(body, entity) {
|
|
2093
|
+
const escapedEntity = entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2094
|
+
const pattern = new RegExp(escapedEntity, "gi");
|
|
2095
|
+
const matches = body.match(pattern);
|
|
2096
|
+
return matches ? matches.length : 0;
|
|
2097
|
+
}
|
|
2098
|
+
function analyzeCitationBlocks(body) {
|
|
2099
|
+
const h2Pattern = /^##\s+.+$/gm;
|
|
2100
|
+
const h2Matches = [...body.matchAll(h2Pattern)];
|
|
2101
|
+
if (h2Matches.length === 0) {
|
|
2102
|
+
return { totalSections: 0, sectionsWithAdequateBlocks: 0 };
|
|
2103
|
+
}
|
|
2104
|
+
let totalSections = 0;
|
|
2105
|
+
let sectionsWithAdequateBlocks = 0;
|
|
2106
|
+
for (let i = 0; i < h2Matches.length; i++) {
|
|
2107
|
+
const matchStart = h2Matches[i].index + h2Matches[i][0].length;
|
|
2108
|
+
const nextHeadingStart = i + 1 < h2Matches.length ? h2Matches[i + 1].index : body.length;
|
|
2109
|
+
const sectionContent = body.slice(matchStart, nextHeadingStart).trim();
|
|
2110
|
+
if (!sectionContent) continue;
|
|
2111
|
+
totalSections++;
|
|
2112
|
+
const firstParagraph = extractFirstParagraph(sectionContent);
|
|
2113
|
+
if (!firstParagraph) continue;
|
|
2114
|
+
const wordCount = countWords(firstParagraph);
|
|
2115
|
+
if (wordCount >= 40) {
|
|
2116
|
+
sectionsWithAdequateBlocks++;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
return { totalSections, sectionsWithAdequateBlocks };
|
|
2120
|
+
}
|
|
2121
|
+
function extractFirstParagraph(sectionContent) {
|
|
2122
|
+
const lines = sectionContent.split("\n");
|
|
2123
|
+
const paragraphLines = [];
|
|
2124
|
+
let foundContent = false;
|
|
2125
|
+
for (const line of lines) {
|
|
2126
|
+
const trimmedLine = line.trim();
|
|
2127
|
+
if (!foundContent && !trimmedLine) continue;
|
|
2128
|
+
if (trimmedLine.startsWith("#")) break;
|
|
2129
|
+
if (foundContent && !trimmedLine) break;
|
|
2130
|
+
if (trimmedLine.startsWith("import ") || trimmedLine.startsWith("export ")) continue;
|
|
2131
|
+
if (trimmedLine.startsWith("<") && !trimmedLine.startsWith("<a")) continue;
|
|
2132
|
+
foundContent = true;
|
|
2133
|
+
paragraphLines.push(trimmedLine);
|
|
2134
|
+
}
|
|
2135
|
+
return paragraphLines.join(" ");
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// src/rules/geo-rules.ts
|
|
2139
|
+
var GEO_MIN_WORDS = 500;
|
|
2140
|
+
var FAQ_MIN_WORDS = 800;
|
|
2141
|
+
var QUESTION_HEADING_THRESHOLD = 0.2;
|
|
2142
|
+
var DIRECT_ANSWER_THRESHOLD = 0.5;
|
|
2143
|
+
var CITATION_WORDS_PER_STAT = 500;
|
|
2144
|
+
var TABLE_MIN_WORDS = 1e3;
|
|
2145
|
+
var ENTITY_MIN_WORDS = 800;
|
|
2146
|
+
var CITATION_BLOCK_MIN_WORDS = 800;
|
|
2147
|
+
var CITATION_BLOCK_WORD_THRESHOLD = 40;
|
|
2148
|
+
var CITATION_BLOCK_SECTION_THRESHOLD = 0.5;
|
|
2149
|
+
var geoNoQuestionHeadings = {
|
|
2150
|
+
name: "geo-no-question-headings",
|
|
2151
|
+
severity: "warning",
|
|
2152
|
+
category: "geo",
|
|
2153
|
+
fixStrategy: 'Rephrase some headings as questions (e.g., "How does X work?")',
|
|
2154
|
+
run: (item) => {
|
|
2155
|
+
if (item.contentType !== "blog") return [];
|
|
2156
|
+
const wordCount = countWords(item.body);
|
|
2157
|
+
if (wordCount < GEO_MIN_WORDS) return [];
|
|
2158
|
+
const headings = extractHeadings(item.body);
|
|
2159
|
+
const subHeadings = headings.filter((h) => h.level === 2 || h.level === 3);
|
|
2160
|
+
if (subHeadings.length === 0) return [];
|
|
2161
|
+
const questionCount = countQuestionHeadings(item.body);
|
|
2162
|
+
const ratio = questionCount / subHeadings.length;
|
|
2163
|
+
if (ratio < QUESTION_HEADING_THRESHOLD) {
|
|
2164
|
+
return [{
|
|
2165
|
+
file: getDisplayPath(item),
|
|
2166
|
+
field: "body",
|
|
2167
|
+
rule: "geo-no-question-headings",
|
|
2168
|
+
severity: "warning",
|
|
2169
|
+
message: `Only ${questionCount}/${subHeadings.length} (${Math.round(ratio * 100)}%) H2/H3 headings are question-formatted \u2014 aim for at least ${Math.round(QUESTION_HEADING_THRESHOLD * 100)}%`,
|
|
2170
|
+
suggestion: 'Rephrase some headings as questions (e.g., "How does X work?" / "Was ist X?") to improve LLM snippet extraction.'
|
|
2171
|
+
}];
|
|
2172
|
+
}
|
|
2173
|
+
return [];
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
var geoWeakLeadSentences = {
|
|
2177
|
+
name: "geo-weak-lead-sentences",
|
|
2178
|
+
severity: "warning",
|
|
2179
|
+
category: "geo",
|
|
2180
|
+
fixStrategy: "Start each section with a concise factual sentence that directly answers the heading",
|
|
2181
|
+
run: (item) => {
|
|
2182
|
+
if (item.contentType !== "blog") return [];
|
|
2183
|
+
const wordCount = countWords(item.body);
|
|
2184
|
+
if (wordCount < GEO_MIN_WORDS) return [];
|
|
2185
|
+
const { totalSections, sectionsWithDirectAnswers } = analyzeLeadSentences(item.body);
|
|
2186
|
+
if (totalSections === 0) return [];
|
|
2187
|
+
const ratio = sectionsWithDirectAnswers / totalSections;
|
|
2188
|
+
if (ratio < DIRECT_ANSWER_THRESHOLD) {
|
|
2189
|
+
return [{
|
|
2190
|
+
file: getDisplayPath(item),
|
|
2191
|
+
field: "body",
|
|
2192
|
+
rule: "geo-weak-lead-sentences",
|
|
2193
|
+
severity: "warning",
|
|
2194
|
+
message: `Only ${sectionsWithDirectAnswers}/${totalSections} (${Math.round(ratio * 100)}%) sections start with direct answers \u2014 aim for at least ${Math.round(DIRECT_ANSWER_THRESHOLD * 100)}%`,
|
|
2195
|
+
suggestion: `Start each section with a concise factual sentence that directly answers the heading. Avoid filler like "In this section..." or "Let's look at...".`
|
|
2196
|
+
}];
|
|
2197
|
+
}
|
|
2198
|
+
return [];
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
var geoLowCitationDensity = {
|
|
2202
|
+
name: "geo-low-citation-density",
|
|
2203
|
+
severity: "warning",
|
|
2204
|
+
category: "geo",
|
|
2205
|
+
fixStrategy: "Add statistics, percentages, or concrete numbers to increase citation potential",
|
|
2206
|
+
run: (item) => {
|
|
2207
|
+
if (item.contentType !== "blog") return [];
|
|
2208
|
+
const wordCount = countWords(item.body);
|
|
2209
|
+
if (wordCount < GEO_MIN_WORDS) return [];
|
|
2210
|
+
const statCount = countStatistics(item.body);
|
|
2211
|
+
const expectedMin = Math.floor(wordCount / CITATION_WORDS_PER_STAT);
|
|
2212
|
+
if (statCount < expectedMin) {
|
|
2213
|
+
return [{
|
|
2214
|
+
file: getDisplayPath(item),
|
|
2215
|
+
field: "body",
|
|
2216
|
+
rule: "geo-low-citation-density",
|
|
2217
|
+
severity: "warning",
|
|
2218
|
+
message: `Found ${statCount} data points but expected at least ${expectedMin} (1 per ${CITATION_WORDS_PER_STAT} words in ${wordCount}-word post)`,
|
|
2219
|
+
suggestion: "Add statistics, percentages, or concrete numbers to increase citation potential in AI-generated answers."
|
|
2220
|
+
}];
|
|
2221
|
+
}
|
|
2222
|
+
return [];
|
|
2223
|
+
}
|
|
2224
|
+
};
|
|
2225
|
+
var geoMissingFaqSection = {
|
|
2226
|
+
name: "geo-missing-faq-section",
|
|
2227
|
+
severity: "warning",
|
|
2228
|
+
category: "geo",
|
|
2229
|
+
fixStrategy: 'Add an "## FAQ" or "## Frequently Asked Questions" section with Q&A pairs',
|
|
2230
|
+
run: (item) => {
|
|
2231
|
+
if (item.contentType !== "blog") return [];
|
|
2232
|
+
const wordCount = countWords(item.body);
|
|
2233
|
+
if (wordCount < FAQ_MIN_WORDS) return [];
|
|
2234
|
+
if (!hasFAQSection(item.body)) {
|
|
2235
|
+
return [{
|
|
2236
|
+
file: getDisplayPath(item),
|
|
2237
|
+
field: "body",
|
|
2238
|
+
rule: "geo-missing-faq-section",
|
|
2239
|
+
severity: "warning",
|
|
2240
|
+
message: "No FAQ section detected in long-form content",
|
|
2241
|
+
suggestion: 'Add an "## FAQ" or "## H\xE4ufige Fragen" section with Q&A pairs. FAQ sections are frequently extracted by LLMs and featured in AI answers.'
|
|
2242
|
+
}];
|
|
2243
|
+
}
|
|
2244
|
+
return [];
|
|
2245
|
+
}
|
|
2246
|
+
};
|
|
2247
|
+
var geoMissingTable = {
|
|
2248
|
+
name: "geo-missing-table",
|
|
2249
|
+
severity: "warning",
|
|
2250
|
+
category: "geo",
|
|
2251
|
+
fixStrategy: "Add a comparison table, feature matrix, or data summary table",
|
|
2252
|
+
run: (item) => {
|
|
2253
|
+
if (item.contentType !== "blog") return [];
|
|
2254
|
+
const wordCount = countWords(item.body);
|
|
2255
|
+
if (wordCount < TABLE_MIN_WORDS) return [];
|
|
2256
|
+
if (!hasMarkdownTable(item.body)) {
|
|
2257
|
+
return [{
|
|
2258
|
+
file: getDisplayPath(item),
|
|
2259
|
+
field: "body",
|
|
2260
|
+
rule: "geo-missing-table",
|
|
2261
|
+
severity: "warning",
|
|
2262
|
+
message: "No data table found in long-form content (tables increase AI citation rates by 2.5x)",
|
|
2263
|
+
suggestion: "Add a comparison table, feature matrix, or data summary table. Tables are highly extractable by AI systems and increase citation likelihood."
|
|
2264
|
+
}];
|
|
2265
|
+
}
|
|
2266
|
+
return [];
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
var geoShortCitationBlocks = {
|
|
2270
|
+
name: "geo-short-citation-blocks",
|
|
2271
|
+
severity: "warning",
|
|
2272
|
+
category: "geo",
|
|
2273
|
+
fixStrategy: "Start each section with a 40-60 word paragraph that directly answers the heading",
|
|
2274
|
+
run: (item) => {
|
|
2275
|
+
if (item.contentType !== "blog") return [];
|
|
2276
|
+
const wordCount = countWords(item.body);
|
|
2277
|
+
if (wordCount < CITATION_BLOCK_MIN_WORDS) return [];
|
|
2278
|
+
const { totalSections, sectionsWithAdequateBlocks } = analyzeCitationBlocks(item.body);
|
|
2279
|
+
if (totalSections === 0) return [];
|
|
2280
|
+
const ratio = sectionsWithAdequateBlocks / totalSections;
|
|
2281
|
+
if (ratio < CITATION_BLOCK_SECTION_THRESHOLD) {
|
|
2282
|
+
return [{
|
|
2283
|
+
file: getDisplayPath(item),
|
|
2284
|
+
field: "body",
|
|
2285
|
+
rule: "geo-short-citation-blocks",
|
|
2286
|
+
severity: "warning",
|
|
2287
|
+
message: `Only ${sectionsWithAdequateBlocks}/${totalSections} sections have substantial lead paragraphs (${CITATION_BLOCK_WORD_THRESHOLD}+ words) \u2014 AI systems extract these as citation blocks`,
|
|
2288
|
+
suggestion: `Start each section with a ${CITATION_BLOCK_WORD_THRESHOLD}-60 word paragraph that directly answers the heading question. These 'citation blocks' are what AI systems extract and cite.`
|
|
2289
|
+
}];
|
|
2290
|
+
}
|
|
2291
|
+
return [];
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
function createGeoEntityRule(brandName, brandCity) {
|
|
2295
|
+
return {
|
|
2296
|
+
name: "geo-low-entity-density",
|
|
2297
|
+
severity: "warning",
|
|
2298
|
+
category: "geo",
|
|
2299
|
+
fixStrategy: "Mention brand name and location naturally in the content body",
|
|
2300
|
+
run: (item) => {
|
|
2301
|
+
if (item.contentType !== "blog") return [];
|
|
2302
|
+
const wordCount = countWords(item.body);
|
|
2303
|
+
if (wordCount < ENTITY_MIN_WORDS) return [];
|
|
2304
|
+
const results = [];
|
|
2305
|
+
const displayPath = getDisplayPath(item);
|
|
2306
|
+
if (brandName && countEntityMentions(item.body, brandName) === 0) {
|
|
2307
|
+
results.push({
|
|
2308
|
+
file: displayPath,
|
|
2309
|
+
field: "body",
|
|
2310
|
+
rule: "geo-low-entity-density",
|
|
2311
|
+
severity: "warning",
|
|
2312
|
+
message: `Brand name '${brandName}' not mentioned in content body \u2014 reduces entity recognition by AI systems`,
|
|
2313
|
+
suggestion: "Mention the brand name naturally in the content to strengthen entity signals for AI systems."
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
if (brandCity && countEntityMentions(item.body, brandCity) === 0) {
|
|
2317
|
+
results.push({
|
|
2318
|
+
file: displayPath,
|
|
2319
|
+
field: "body",
|
|
2320
|
+
rule: "geo-low-entity-density",
|
|
2321
|
+
severity: "warning",
|
|
2322
|
+
message: `Location '${brandCity}' not mentioned in content body \u2014 reduces local entity recognition`,
|
|
2323
|
+
suggestion: "Include a location reference to strengthen local entity recognition for AI search."
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
return results;
|
|
2327
|
+
}
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
var geoStaticRules = [
|
|
2331
|
+
geoNoQuestionHeadings,
|
|
2332
|
+
geoWeakLeadSentences,
|
|
2333
|
+
geoLowCitationDensity,
|
|
2334
|
+
geoMissingFaqSection,
|
|
2335
|
+
geoMissingTable,
|
|
2336
|
+
geoShortCitationBlocks
|
|
2337
|
+
];
|
|
2338
|
+
function createGeoRules(geo) {
|
|
2339
|
+
return [
|
|
2340
|
+
...geoStaticRules,
|
|
2341
|
+
createGeoEntityRule(geo.brandName, geo.brandCity)
|
|
2342
|
+
];
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/rules/category-rules.ts
|
|
2346
|
+
function findSuggestion(value, canonicalCategories) {
|
|
2347
|
+
const lower = value.toLowerCase();
|
|
2348
|
+
for (const canonical of canonicalCategories) {
|
|
2349
|
+
if (canonical.toLowerCase() === lower) {
|
|
2350
|
+
return canonical;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
return void 0;
|
|
2354
|
+
}
|
|
2355
|
+
function createCategoryRules(categories) {
|
|
2356
|
+
if (categories.length === 0) {
|
|
2357
|
+
return [];
|
|
2358
|
+
}
|
|
2359
|
+
const canonicalCategories = new Set(categories);
|
|
2360
|
+
const categoryInvalid = {
|
|
2361
|
+
name: "category-invalid",
|
|
2362
|
+
severity: "error",
|
|
2363
|
+
category: "content",
|
|
2364
|
+
fixStrategy: "Use a valid category from the configured list",
|
|
2365
|
+
run: (item) => {
|
|
2366
|
+
if (!item.categories || item.categories.length === 0) return [];
|
|
2367
|
+
const results = [];
|
|
2368
|
+
for (const cat of item.categories) {
|
|
2369
|
+
if (!canonicalCategories.has(cat)) {
|
|
2370
|
+
const suggested = findSuggestion(cat, canonicalCategories);
|
|
2371
|
+
results.push({
|
|
2372
|
+
file: getDisplayPath(item),
|
|
2373
|
+
field: "categories",
|
|
2374
|
+
rule: "category-invalid",
|
|
2375
|
+
severity: "error",
|
|
2376
|
+
message: `Invalid category "${cat}"`,
|
|
2377
|
+
suggestion: suggested ? `Did you mean "${suggested}"? Valid categories: ${[...canonicalCategories].join(", ")}` : `Valid categories: ${[...canonicalCategories].join(", ")}`
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
return results;
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
const missingCategories = {
|
|
2385
|
+
name: "missing-categories",
|
|
2386
|
+
severity: "warning",
|
|
2387
|
+
category: "content",
|
|
2388
|
+
fixStrategy: "Add at least one category from the configured list",
|
|
2389
|
+
run: (item) => {
|
|
2390
|
+
if (item.contentType !== "blog") return [];
|
|
2391
|
+
if (!item.categories || item.categories.length === 0) {
|
|
2392
|
+
return [{
|
|
2393
|
+
file: getDisplayPath(item),
|
|
2394
|
+
field: "categories",
|
|
2395
|
+
rule: "missing-categories",
|
|
2396
|
+
severity: "warning",
|
|
2397
|
+
message: "Blog post has no categories",
|
|
2398
|
+
suggestion: `Add at least one category: ${[...canonicalCategories].join(", ")}`
|
|
2399
|
+
}];
|
|
2400
|
+
}
|
|
2401
|
+
return [];
|
|
2402
|
+
}
|
|
2403
|
+
};
|
|
2404
|
+
return [categoryInvalid, missingCategories];
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// src/rules/canonical-rules.ts
|
|
2408
|
+
function createCanonicalRules(siteUrl) {
|
|
2409
|
+
const canonicalMissing = {
|
|
2410
|
+
name: "canonical-missing",
|
|
2411
|
+
severity: "warning",
|
|
2412
|
+
category: "seo",
|
|
2413
|
+
fixStrategy: "Ensure the content has a valid slug so a permalink can be generated",
|
|
2414
|
+
run: (item) => {
|
|
2415
|
+
if (item.noindex) return [];
|
|
2416
|
+
if (!item.permalink || item.permalink.trim().length === 0) {
|
|
2417
|
+
return [{
|
|
2418
|
+
file: getDisplayPath(item),
|
|
2419
|
+
field: "permalink",
|
|
2420
|
+
rule: "canonical-missing",
|
|
2421
|
+
severity: "warning",
|
|
2422
|
+
message: "Missing canonical URL (permalink)",
|
|
2423
|
+
suggestion: "Ensure the content has a valid slug so a permalink can be generated"
|
|
2424
|
+
}];
|
|
2425
|
+
}
|
|
2426
|
+
return [];
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
const canonicalMalformed = {
|
|
2430
|
+
name: "canonical-malformed",
|
|
2431
|
+
severity: "warning",
|
|
2432
|
+
category: "seo",
|
|
2433
|
+
fixStrategy: "Use a relative path (e.g., /blog/my-post) or absolute URL on the site domain",
|
|
2434
|
+
run: (item) => {
|
|
2435
|
+
if (!item.permalink || item.permalink.trim().length === 0) return [];
|
|
2436
|
+
const permalink = item.permalink.trim();
|
|
2437
|
+
if (permalink.startsWith("/")) return [];
|
|
2438
|
+
if (permalink.startsWith(siteUrl)) return [];
|
|
2439
|
+
return [{
|
|
2440
|
+
file: getDisplayPath(item),
|
|
2441
|
+
field: "permalink",
|
|
2442
|
+
rule: "canonical-malformed",
|
|
2443
|
+
severity: "warning",
|
|
2444
|
+
message: `Canonical URL has unexpected format: "${permalink}"`,
|
|
2445
|
+
suggestion: `Canonical should be a relative path (e.g., /blog/my-post) or absolute URL starting with ${siteUrl}`
|
|
2446
|
+
}];
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
return [canonicalMissing, canonicalMalformed];
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// src/rules/index.ts
|
|
2453
|
+
function buildRules(config, linkExtractor) {
|
|
2454
|
+
const rules = [
|
|
2455
|
+
...titleRules,
|
|
2456
|
+
...descriptionRules,
|
|
2457
|
+
...duplicateRules,
|
|
2458
|
+
...headingRules,
|
|
2459
|
+
...createLinkRules(linkExtractor),
|
|
2460
|
+
...createExternalLinkRules(linkExtractor),
|
|
2461
|
+
...imageRules,
|
|
2462
|
+
...contentRules,
|
|
2463
|
+
...ogRules,
|
|
2464
|
+
...performanceRules,
|
|
2465
|
+
...createOrphanRules(linkExtractor),
|
|
2466
|
+
...robotsRules,
|
|
2467
|
+
...slugRules,
|
|
2468
|
+
...i18nRules,
|
|
2469
|
+
...dateRules,
|
|
2470
|
+
...config.categories.length > 0 ? createCategoryRules(config.categories) : [],
|
|
2471
|
+
...schemaRules,
|
|
2472
|
+
...createGeoRules(config.geo),
|
|
2473
|
+
...keywordCoherenceRules,
|
|
2474
|
+
...createCanonicalRules(config.siteUrl)
|
|
2475
|
+
];
|
|
2476
|
+
return rules.map((rule) => applyRuleOverride(rule, config.rules));
|
|
2477
|
+
}
|
|
2478
|
+
function applyRuleOverride(rule, overrides) {
|
|
2479
|
+
const override = overrides[rule.name];
|
|
2480
|
+
if (!override) return rule;
|
|
2481
|
+
if (override === "off") return { ...rule, run: () => [] };
|
|
2482
|
+
return { ...rule, severity: override };
|
|
2483
|
+
}
|
|
2484
|
+
function runRule(rule, item, context) {
|
|
2485
|
+
try {
|
|
2486
|
+
return rule.run(item, context);
|
|
2487
|
+
} catch (error) {
|
|
2488
|
+
console.error(`Rule ${rule.name} failed on ${item.slug}:`, error);
|
|
2489
|
+
return [];
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
function runRulesOnItem(item, context, rules) {
|
|
2493
|
+
const results = [];
|
|
2494
|
+
for (const rule of rules) {
|
|
2495
|
+
const ruleResults = runRule(rule, item, context);
|
|
2496
|
+
results.push(...ruleResults);
|
|
2497
|
+
}
|
|
2498
|
+
return results;
|
|
2499
|
+
}
|
|
2500
|
+
function runAllRules(items, context, rules) {
|
|
2501
|
+
const results = [];
|
|
2502
|
+
for (const item of items) {
|
|
2503
|
+
const itemResults = runRulesOnItem(item, context, rules);
|
|
2504
|
+
results.push(...itemResults);
|
|
2505
|
+
}
|
|
2506
|
+
return results;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
// src/reporter.ts
|
|
2510
|
+
var COLORS = {
|
|
2511
|
+
reset: "\x1B[0m",
|
|
2512
|
+
red: "\x1B[31m",
|
|
2513
|
+
green: "\x1B[32m",
|
|
2514
|
+
yellow: "\x1B[33m",
|
|
2515
|
+
cyan: "\x1B[36m",
|
|
2516
|
+
dim: "\x1B[2m",
|
|
2517
|
+
bold: "\x1B[1m"
|
|
2518
|
+
};
|
|
2519
|
+
function groupByFile(results) {
|
|
2520
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2521
|
+
for (const result of results) {
|
|
2522
|
+
const existing = grouped.get(result.file) || [];
|
|
2523
|
+
existing.push(result);
|
|
2524
|
+
grouped.set(result.file, existing);
|
|
2525
|
+
}
|
|
2526
|
+
return grouped;
|
|
2527
|
+
}
|
|
2528
|
+
function formatResult(result) {
|
|
2529
|
+
const { red, yellow, dim, reset } = COLORS;
|
|
2530
|
+
const icon = result.severity === "error" ? `${red}\u2717${reset}` : `${yellow}\u26A0${reset}`;
|
|
2531
|
+
const ruleTag = `${dim}[${result.rule}]${reset}`;
|
|
2532
|
+
let line = ` ${icon} ${ruleTag} ${result.message}`;
|
|
2533
|
+
if (result.line) {
|
|
2534
|
+
line += ` ${dim}(line ${result.line})${reset}`;
|
|
2535
|
+
}
|
|
2536
|
+
if (result.suggestion) {
|
|
2537
|
+
line += `
|
|
2538
|
+
${dim}\u2192 ${result.suggestion}${reset}`;
|
|
2539
|
+
}
|
|
2540
|
+
return line;
|
|
2541
|
+
}
|
|
2542
|
+
function printResultsBySection(results, severity, label) {
|
|
2543
|
+
const filtered = results.filter((r) => r.severity === severity);
|
|
2544
|
+
if (filtered.length === 0) return;
|
|
2545
|
+
const { red, yellow, bold, reset } = COLORS;
|
|
2546
|
+
const color = severity === "error" ? red : yellow;
|
|
2547
|
+
const grouped = groupByFile(filtered);
|
|
2548
|
+
console.log(`
|
|
2549
|
+
${color}${bold}${label} (${filtered.length}):${reset}
|
|
2550
|
+
`);
|
|
2551
|
+
for (const [file, fileResults] of grouped) {
|
|
2552
|
+
console.log(` ${bold}${file}${reset}`);
|
|
2553
|
+
for (const result of fileResults) {
|
|
2554
|
+
console.log(formatResult(result));
|
|
2555
|
+
}
|
|
2556
|
+
console.log("");
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
function printSummary(summary) {
|
|
2560
|
+
const { red, yellow, green, bold, reset, dim } = COLORS;
|
|
2561
|
+
console.log(`${dim}${"\u2500".repeat(50)}${reset}`);
|
|
2562
|
+
console.log(`${bold}Summary:${reset}`);
|
|
2563
|
+
if (summary.errors > 0) {
|
|
2564
|
+
console.log(` ${red}\u2717 ${summary.errors} error(s)${reset} ${dim}(build blocked)${reset}`);
|
|
2565
|
+
}
|
|
2566
|
+
if (summary.warnings > 0) {
|
|
2567
|
+
console.log(` ${yellow}\u26A0 ${summary.warnings} warning(s)${reset}`);
|
|
2568
|
+
}
|
|
2569
|
+
if (summary.passed > 0) {
|
|
2570
|
+
console.log(` ${green}\u2713 ${summary.passed} file(s) passed${reset}`);
|
|
2571
|
+
}
|
|
2572
|
+
if (summary.excluded > 0) {
|
|
2573
|
+
console.log(` ${dim}\u2298 ${summary.excluded} file(s) excluded${reset}`);
|
|
2574
|
+
}
|
|
2575
|
+
console.log(`
|
|
2576
|
+
${dim}Total: ${summary.total} files checked${reset}
|
|
2577
|
+
`);
|
|
2578
|
+
}
|
|
2579
|
+
function formatResults(results, totalFiles, excludedFiles = 0) {
|
|
2580
|
+
const { bold, reset, cyan, green, dim } = COLORS;
|
|
2581
|
+
const errors = results.filter((r) => r.severity === "error").length;
|
|
2582
|
+
const warnings = results.filter((r) => r.severity === "warning").length;
|
|
2583
|
+
const filesWithIssues = new Set(results.map((r) => r.file)).size;
|
|
2584
|
+
const passed = totalFiles - filesWithIssues;
|
|
2585
|
+
const summary = { errors, warnings, passed, total: totalFiles, excluded: excludedFiles };
|
|
2586
|
+
console.log(`
|
|
2587
|
+
${cyan}${bold}GEO Lint Results${reset}`);
|
|
2588
|
+
console.log(`${cyan}${"\u2550".repeat(50)}${reset}`);
|
|
2589
|
+
if (results.length === 0) {
|
|
2590
|
+
console.log(`
|
|
2591
|
+
${green}${bold}\u2713 GEO Lint: All checks passed!${reset}
|
|
2592
|
+
`);
|
|
2593
|
+
console.log(` ${dim}${totalFiles} files checked${reset}
|
|
2594
|
+
`);
|
|
2595
|
+
return summary;
|
|
2596
|
+
}
|
|
2597
|
+
printResultsBySection(results, "error", "ERRORS");
|
|
2598
|
+
printResultsBySection(results, "warning", "WARNINGS");
|
|
2599
|
+
printSummary(summary);
|
|
2600
|
+
return summary;
|
|
2601
|
+
}
|
|
2602
|
+
function formatResultsJson(results) {
|
|
2603
|
+
return JSON.stringify(results, null, 2);
|
|
2604
|
+
}
|
|
2605
|
+
function printProgress(message) {
|
|
2606
|
+
const { dim, reset } = COLORS;
|
|
2607
|
+
console.log(`${dim}${message}${reset}`);
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// src/adapters/types.ts
|
|
2611
|
+
function createAdapter(fn) {
|
|
2612
|
+
return { loadItems: fn };
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// src/index.ts
|
|
2616
|
+
async function lint(options = {}) {
|
|
2617
|
+
const projectRoot = (0, import_node_path4.resolve)(options.projectRoot ?? process.cwd());
|
|
2618
|
+
const config = options.config ? mergeWithDefaults(options.config) : await loadConfig(projectRoot);
|
|
2619
|
+
const isPretty = (options.format ?? "pretty") === "pretty";
|
|
2620
|
+
if (isPretty) printProgress("Loading content...");
|
|
2621
|
+
const contentItems = options.adapter ? await options.adapter.loadItems(projectRoot) : loadContentItems(config.contentPaths, projectRoot);
|
|
2622
|
+
const excludeSlugs = new Set(config.excludeSlugs);
|
|
2623
|
+
const excludeCategories = new Set(config.excludeCategories);
|
|
2624
|
+
const isExcluded = (item) => {
|
|
2625
|
+
if (item.category && excludeCategories.has(item.category)) return true;
|
|
2626
|
+
return excludeSlugs.has(item.slug);
|
|
2627
|
+
};
|
|
2628
|
+
const excludedItems = contentItems.filter(isExcluded);
|
|
2629
|
+
const lintableItems = contentItems.filter((item) => !isExcluded(item));
|
|
2630
|
+
if (isPretty) {
|
|
2631
|
+
printProgress(`Loaded ${contentItems.length} content files (${excludedItems.length} excluded)`);
|
|
2632
|
+
printProgress("Building validation context...");
|
|
2633
|
+
}
|
|
2634
|
+
const linkExtractor = createLinkExtractor(config.siteUrl);
|
|
2635
|
+
const context = {
|
|
2636
|
+
allContent: contentItems,
|
|
2637
|
+
validSlugs: buildSlugRegistry(contentItems, config.staticRoutes, config.contentPaths),
|
|
2638
|
+
validImages: buildImageRegistry(config.imageDirectories)
|
|
2639
|
+
};
|
|
2640
|
+
if (isPretty) {
|
|
2641
|
+
printProgress(`Found ${context.validSlugs.size} valid URLs, ${context.validImages.size} images`);
|
|
2642
|
+
printProgress("Running validation rules...");
|
|
2643
|
+
}
|
|
2644
|
+
const rules = buildRules(config, linkExtractor);
|
|
2645
|
+
const results = runAllRules(lintableItems, context, rules);
|
|
2646
|
+
if (options.globalRules) {
|
|
2647
|
+
for (const globalRule of options.globalRules) {
|
|
2648
|
+
try {
|
|
2649
|
+
results.push(...globalRule.run());
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
if (isPretty) console.error(`Global rule ${globalRule.name} failed:`, error);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
if (options.format === "json") {
|
|
2656
|
+
console.log(formatResultsJson(results));
|
|
2657
|
+
} else {
|
|
2658
|
+
formatResults(results, lintableItems.length, excludedItems.length);
|
|
2659
|
+
}
|
|
2660
|
+
const errorCount = results.filter((r) => r.severity === "error").length;
|
|
2661
|
+
return errorCount > 0 ? 1 : 0;
|
|
2662
|
+
}
|
|
2663
|
+
async function lintQuiet(options = {}) {
|
|
2664
|
+
const projectRoot = (0, import_node_path4.resolve)(options.projectRoot ?? process.cwd());
|
|
2665
|
+
const config = options.config ? mergeWithDefaults(options.config) : await loadConfig(projectRoot);
|
|
2666
|
+
const contentItems = options.adapter ? await options.adapter.loadItems(projectRoot) : loadContentItems(config.contentPaths, projectRoot);
|
|
2667
|
+
const excludeSlugs = new Set(config.excludeSlugs);
|
|
2668
|
+
const excludeCategories = new Set(config.excludeCategories);
|
|
2669
|
+
const isExcluded = (item) => {
|
|
2670
|
+
if (item.category && excludeCategories.has(item.category)) return true;
|
|
2671
|
+
return excludeSlugs.has(item.slug);
|
|
2672
|
+
};
|
|
2673
|
+
const lintableItems = contentItems.filter((item) => !isExcluded(item));
|
|
2674
|
+
const linkExtractor = createLinkExtractor(config.siteUrl);
|
|
2675
|
+
const context = {
|
|
2676
|
+
allContent: contentItems,
|
|
2677
|
+
validSlugs: buildSlugRegistry(contentItems, config.staticRoutes, config.contentPaths),
|
|
2678
|
+
validImages: buildImageRegistry(config.imageDirectories)
|
|
2679
|
+
};
|
|
2680
|
+
const rules = buildRules(config, linkExtractor);
|
|
2681
|
+
return runAllRules(lintableItems, context, rules);
|
|
2682
|
+
}
|
|
2683
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2684
|
+
0 && (module.exports = {
|
|
2685
|
+
createAdapter,
|
|
2686
|
+
defineConfig,
|
|
2687
|
+
lint,
|
|
2688
|
+
lintQuiet,
|
|
2689
|
+
loadContentItems
|
|
2690
|
+
});
|
|
2691
|
+
//# sourceMappingURL=index.cjs.map
|