validate-mdx-links 1.0.8 → 1.2.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 +21 -0
- package/README.md +1 -1
- package/dist/bin.js +9 -5
- package/dist/index.js +224 -12
- package/package.json +10 -8
- package/src/bin.ts +10 -5
- package/src/index.ts +286 -13
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# validate-mdx-links
|
|
2
|
+
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Auto-detects content-based frameworks (Fumadocs). Projects with `content/**/*.mdx` files get proper URL scanning without Next.js `app/` or `pages/` routes.
|
|
8
|
+
- `--content-dir` CLI flag to set the content directory for URL scanning.
|
|
9
|
+
- `--files` defaults to `${contentDir}/**/*.mdx` when `--content-dir` is set without `--files`.
|
|
10
|
+
- Framework detection reads `package.json` dependencies (`fumadocs-core`, `fumadocs-mdx`, `fumadocs-ui`, `next`) before falling back to directory heuristics.
|
|
11
|
+
- Extracts headings for `#id` fragment validation via `github-slugger` — matches Fumadocs/GitHub ID generation (duplicate tracking, non-ASCII preservation).
|
|
12
|
+
- TanStack Router route scanning — parses `src/routes/` using TanStack conventions (`_prefix` layout segments stripped, `$` splat routes become fallback regex patterns, `[.]` literal dots). Gated on `@tanstack/react-start`, `@tanstack/start`, or `@tanstack/react-router` in `package.json`.
|
|
13
|
+
- Resolves relative links (`./sibling-post`, `../other-dir/page`) in content-based projects via `pathToUrl`.
|
|
14
|
+
|
|
15
|
+
### API Changes
|
|
16
|
+
|
|
17
|
+
- `ValidateMdxLinksOptions` accepts a new optional `contentDir: string` field.
|
|
18
|
+
|
|
19
|
+
## 1.1.0
|
|
20
|
+
|
|
21
|
+
Initial public release. Next.js App Router and Nextra link validation with false-positive filtering for relative links.
|
package/README.md
CHANGED
package/dist/bin.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* validate-mdx-links --cwd <path> --files "content/**\/*.mdx" --verbose
|
|
11
|
+
* validate-mdx-links --files "content/docs/**\/*.mdx" --content-dir content
|
|
11
12
|
*/
|
|
12
13
|
import { parseArgs } from "node:util";
|
|
13
14
|
import { validateMdxLinks, printErrors } from "./index.js";
|
|
@@ -18,6 +19,7 @@ const args = (() => {
|
|
|
18
19
|
cwd: { type: "string", default: process.cwd() },
|
|
19
20
|
files: { type: "string" },
|
|
20
21
|
verbose: { type: "boolean", default: false, allowNegative: true },
|
|
22
|
+
"content-dir": { type: "string" },
|
|
21
23
|
help: { type: "boolean", default: false },
|
|
22
24
|
},
|
|
23
25
|
strict: true,
|
|
@@ -30,20 +32,22 @@ const args = (() => {
|
|
|
30
32
|
process.exit(1);
|
|
31
33
|
}
|
|
32
34
|
})();
|
|
33
|
-
const { values: { help, cwd, verbose, files }, } = args;
|
|
35
|
+
const { values: { help, cwd, verbose, files, "content-dir": contentDir }, } = args;
|
|
34
36
|
if (help) {
|
|
35
|
-
console.log('Usage: validate-mdx-links --cwd <path> --files "content/**/*.mdx" --verbose');
|
|
37
|
+
console.log('Usage: validate-mdx-links --cwd <path> --files "content/**/*.mdx" [--content-dir content] --verbose');
|
|
36
38
|
process.exit(0);
|
|
37
39
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
const resolvedFiles = files ?? (contentDir ? `${contentDir}/**/*.mdx` : undefined);
|
|
41
|
+
if (!resolvedFiles) {
|
|
42
|
+
console.error("No files passed. Please pass --files or --content-dir.");
|
|
40
43
|
process.exit(1);
|
|
41
44
|
}
|
|
42
45
|
try {
|
|
43
46
|
const errors = await validateMdxLinks({
|
|
44
47
|
cwd,
|
|
45
|
-
files,
|
|
48
|
+
files: resolvedFiles,
|
|
46
49
|
verbose,
|
|
50
|
+
contentDir,
|
|
47
51
|
});
|
|
48
52
|
if (errors.length > 0) {
|
|
49
53
|
printErrors(errors);
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,187 @@
|
|
|
1
|
-
import { globSync } from "node:fs";
|
|
1
|
+
import { existsSync, globSync, readFileSync } from "node:fs";
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
|
-
import { dirname, resolve, basename } from "node:path";
|
|
3
|
+
import { dirname, resolve, basename, relative } from "node:path";
|
|
4
|
+
import GithubSlugger from "github-slugger";
|
|
4
5
|
import { scanURLs, validateFiles, } from "next-validate-link";
|
|
5
6
|
export { printErrors } from "next-validate-link";
|
|
6
|
-
|
|
7
|
+
function readDeps(cwd) {
|
|
8
|
+
try {
|
|
9
|
+
const pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf-8"));
|
|
10
|
+
return new Set([
|
|
11
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
12
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return new Set();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function detectFramework(cwd, deps) {
|
|
20
|
+
const isFumadocs = deps.has("fumadocs-core") || deps.has("fumadocs-mdx") || deps.has("fumadocs-ui");
|
|
21
|
+
// If fumadocs is present, prefer content-based scanning
|
|
22
|
+
if (isFumadocs) {
|
|
23
|
+
// Find content dir: check common locations
|
|
24
|
+
for (const dir of ["content", "content/docs"]) {
|
|
25
|
+
if (existsSync(resolve(cwd, dir))) {
|
|
26
|
+
const mdxFiles = globSync(`${dir}/**/*.mdx`, { cwd });
|
|
27
|
+
if (mdxFiles.length > 0) {
|
|
28
|
+
return { type: "content", contentDir: dir };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Check for Next.js dirs (also validates against package.json if available)
|
|
34
|
+
const nextDirs = ["app", "src/app", "pages", "src/pages"];
|
|
35
|
+
for (const dir of nextDirs) {
|
|
36
|
+
if (existsSync(resolve(cwd, dir))) {
|
|
37
|
+
return { type: "nextjs" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Fallback: content dir with MDX files (no fumadocs dep but has content)
|
|
41
|
+
if (existsSync(resolve(cwd, "content"))) {
|
|
42
|
+
const mdxFiles = globSync("content/**/*.mdx", { cwd });
|
|
43
|
+
if (mdxFiles.length > 0) {
|
|
44
|
+
return { type: "content", contentDir: "content" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { type: "nextjs" };
|
|
48
|
+
}
|
|
49
|
+
const HEADING_REGEX = /^#{1,6}\s+(.+)$/gm;
|
|
50
|
+
function extractHashes(content) {
|
|
51
|
+
const slugger = new GithubSlugger();
|
|
52
|
+
const hashes = [];
|
|
53
|
+
let match;
|
|
54
|
+
while ((match = HEADING_REGEX.exec(content)) !== null) {
|
|
55
|
+
hashes.push(slugger.slug(match[1]));
|
|
56
|
+
}
|
|
57
|
+
return hashes;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Convert a TanStack Router file path to a URL path.
|
|
61
|
+
* - Strips `_prefix` layout segments (pathless routes)
|
|
62
|
+
* - `index.tsx` → parent path
|
|
63
|
+
* - `$.tsx` → null (handled as fallbackUrl)
|
|
64
|
+
* - `[.]` → literal dot
|
|
65
|
+
* - Strips extensions
|
|
66
|
+
* Returns null for splat routes (caller handles as fallbackUrl),
|
|
67
|
+
* or undefined for files that shouldn't produce routes (layouts, tests, api).
|
|
68
|
+
*/
|
|
69
|
+
function tanstackRouteToUrl(filePath, routesDir) {
|
|
70
|
+
const rel = relative(routesDir, filePath);
|
|
71
|
+
// Skip root layout, test files, api routes
|
|
72
|
+
if (rel.startsWith("__") ||
|
|
73
|
+
rel.includes(".test.") ||
|
|
74
|
+
rel.startsWith("api/") ||
|
|
75
|
+
rel.startsWith("api\\")) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
// Strip extension
|
|
79
|
+
const withoutExt = rel.replace(/\.(tsx?|jsx?)$/, "");
|
|
80
|
+
const segments = withoutExt.split(/[/\\]/);
|
|
81
|
+
const urlSegments = [];
|
|
82
|
+
let isSplat = false;
|
|
83
|
+
for (const seg of segments) {
|
|
84
|
+
// Layout files (e.g. _landing.tsx) — skip as a route themselves
|
|
85
|
+
// but as directories they wrap children
|
|
86
|
+
if (seg.startsWith("_")) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Splat route
|
|
90
|
+
if (seg === "$") {
|
|
91
|
+
isSplat = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Dynamic param like $slug — skip, produces fallback
|
|
95
|
+
if (seg.startsWith("$")) {
|
|
96
|
+
isSplat = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// index → parent
|
|
100
|
+
if (seg === "index") {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// [.] → literal dot
|
|
104
|
+
let processed = seg.replace(/\[\.\]/g, ".");
|
|
105
|
+
urlSegments.push(processed);
|
|
106
|
+
}
|
|
107
|
+
if (isSplat) {
|
|
108
|
+
return null; // caller creates fallbackUrl regex
|
|
109
|
+
}
|
|
110
|
+
return "/" + urlSegments.join("/") || "/";
|
|
111
|
+
}
|
|
112
|
+
function scanRoutes(routesDir, cwd, urls, fallbackUrls) {
|
|
113
|
+
const absRoutesDir = resolve(cwd, routesDir);
|
|
114
|
+
if (!existsSync(absRoutesDir))
|
|
115
|
+
return;
|
|
116
|
+
const routeFiles = globSync(`${routesDir}/**/*.{tsx,ts,jsx,js}`, { cwd });
|
|
117
|
+
for (const file of routeFiles) {
|
|
118
|
+
const result = tanstackRouteToUrl(file, routesDir);
|
|
119
|
+
if (result === undefined) {
|
|
120
|
+
// Skip (layout, test, api)
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (result === null) {
|
|
124
|
+
// Splat route — build a regex fallback from the non-splat prefix
|
|
125
|
+
const rel = relative(routesDir, file).replace(/\.(tsx?|jsx?)$/, "");
|
|
126
|
+
const segments = rel.split(/[/\\]/);
|
|
127
|
+
const prefixSegments = [];
|
|
128
|
+
for (const seg of segments) {
|
|
129
|
+
if (seg.startsWith("_"))
|
|
130
|
+
continue;
|
|
131
|
+
if (seg === "$" || seg.startsWith("$") || seg === "index")
|
|
132
|
+
break;
|
|
133
|
+
prefixSegments.push(seg.replace(/\[\.\]/g, "."));
|
|
134
|
+
}
|
|
135
|
+
const prefix = "/" + prefixSegments.join("/");
|
|
136
|
+
const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/.*)?$`);
|
|
137
|
+
fallbackUrls.push({ url: pattern, meta: {} });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!urls.has(result)) {
|
|
141
|
+
urls.set(result, {});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function contentPathToUrl(filePath, contentDir) {
|
|
146
|
+
let urlPath = "/" + relative(contentDir, filePath);
|
|
147
|
+
urlPath = urlPath.replace(/\.mdx$/, "");
|
|
148
|
+
if (urlPath.endsWith("/index")) {
|
|
149
|
+
urlPath = urlPath.slice(0, -"/index".length) || "/";
|
|
150
|
+
}
|
|
151
|
+
return urlPath;
|
|
152
|
+
}
|
|
153
|
+
function buildContentScanResult(contentDir, cwd, verbose, deps) {
|
|
154
|
+
const urls = new Map();
|
|
155
|
+
const fallbackUrls = [];
|
|
156
|
+
const mdxFiles = globSync(`${contentDir}/**/*.mdx`, { cwd });
|
|
157
|
+
for (const file of mdxFiles) {
|
|
158
|
+
const urlPath = contentPathToUrl(file, contentDir);
|
|
159
|
+
const content = readFileSync(resolve(cwd, file), "utf-8");
|
|
160
|
+
const hashes = extractHashes(content);
|
|
161
|
+
urls.set(urlPath, { hashes: hashes.length > 0 ? hashes : undefined });
|
|
162
|
+
}
|
|
163
|
+
// Scan TanStack Router routes if TanStack Start is a dependency
|
|
164
|
+
const isTanStack = deps?.has("@tanstack/react-start") ||
|
|
165
|
+
deps?.has("@tanstack/start") ||
|
|
166
|
+
deps?.has("@tanstack/react-router");
|
|
167
|
+
if (isTanStack) {
|
|
168
|
+
for (const routesDir of ["src/routes", "app/routes"]) {
|
|
169
|
+
scanRoutes(routesDir, cwd, urls, fallbackUrls);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (verbose) {
|
|
173
|
+
console.log("\n" +
|
|
174
|
+
"Scanned content routes:\n" +
|
|
175
|
+
[...urls.keys()].map((x) => `"${x}"`).join(", ") +
|
|
176
|
+
(fallbackUrls.length > 0
|
|
177
|
+
? "\nFallback patterns:\n" +
|
|
178
|
+
fallbackUrls.map((x) => `${x.url}`).join(", ")
|
|
179
|
+
: "") +
|
|
180
|
+
"\n");
|
|
181
|
+
}
|
|
182
|
+
return { urls, fallbackUrls };
|
|
183
|
+
}
|
|
184
|
+
export async function validateMdxLinks({ cwd = process.cwd(), files: filesGlob, verbose = false, contentDir, }) {
|
|
7
185
|
const originalCwd = process.cwd();
|
|
8
186
|
process.chdir(cwd);
|
|
9
187
|
const files = globSync(filesGlob);
|
|
@@ -14,16 +192,37 @@ export async function validateMdxLinks({ cwd = process.cwd(), files: filesGlob,
|
|
|
14
192
|
else if (verbose) {
|
|
15
193
|
console.log(`Found ${files.length} markdown files to validate.\n`);
|
|
16
194
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.map((x) => `"${x}"`)
|
|
23
|
-
.join(", ") +
|
|
24
|
-
"\n");
|
|
195
|
+
let scanned;
|
|
196
|
+
let effectiveContentDir = contentDir;
|
|
197
|
+
const deps = readDeps(cwd);
|
|
198
|
+
if (contentDir) {
|
|
199
|
+
scanned = buildContentScanResult(contentDir, cwd, verbose, deps);
|
|
25
200
|
}
|
|
26
|
-
|
|
201
|
+
else {
|
|
202
|
+
const framework = detectFramework(cwd, deps);
|
|
203
|
+
if (framework.type === "content") {
|
|
204
|
+
effectiveContentDir = framework.contentDir;
|
|
205
|
+
scanned = buildContentScanResult(framework.contentDir, cwd, verbose, deps);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
scanned = await scanURLs();
|
|
209
|
+
if (verbose) {
|
|
210
|
+
console.log("\n" +
|
|
211
|
+
"Scanned routes from the file system:\n" +
|
|
212
|
+
[
|
|
213
|
+
...scanned.urls.keys(),
|
|
214
|
+
...scanned.fallbackUrls.map((x) => x.url),
|
|
215
|
+
]
|
|
216
|
+
.map((x) => `"${x}"`)
|
|
217
|
+
.join(", ") +
|
|
218
|
+
"\n");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const pathToUrl = effectiveContentDir
|
|
223
|
+
? (filePath) => contentPathToUrl(filePath, effectiveContentDir)
|
|
224
|
+
: undefined;
|
|
225
|
+
const validations = await validateFiles(files, { scanned, pathToUrl });
|
|
27
226
|
const withoutFalsePositives = await Promise.all(validations.map(async ({ file, detected }) => {
|
|
28
227
|
const filteredDetected = [];
|
|
29
228
|
for (const error of detected) {
|
|
@@ -39,6 +238,19 @@ export async function validateMdxLinks({ cwd = process.cwd(), files: filesGlob,
|
|
|
39
238
|
continue;
|
|
40
239
|
}
|
|
41
240
|
}
|
|
241
|
+
// if the file is called index.mdx and is in content dir, it turns out
|
|
242
|
+
// both `./dirname/foo` and `./foo` links work and point to the same file and
|
|
243
|
+
if (basename(file) === "index.mdx") {
|
|
244
|
+
const dir = basename(dirname(file));
|
|
245
|
+
const isSameDir = dir === basename(dirname(link));
|
|
246
|
+
if (isSameDir) {
|
|
247
|
+
const path = resolve(dirname(file), "..", `${link}.mdx`);
|
|
248
|
+
if (await fileExists(path)) {
|
|
249
|
+
// file exists, the error is a false positive
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
42
254
|
// relative links can lose their .mdx extension
|
|
43
255
|
if (!link.endsWith(".mdx")) {
|
|
44
256
|
const dest = resolve(dirname(file), `${link}.mdx`);
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "validate-mdx-links",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"validate-mdx-links": "
|
|
8
|
+
"validate-mdx-links": "dist/bin.js"
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "https://github.com/hasparus/eclectic.git",
|
|
12
|
+
"url": "git+https://github.com/hasparus/eclectic.git",
|
|
13
13
|
"directory": "packages/validate-mdx-links"
|
|
14
14
|
},
|
|
15
15
|
"exports": {
|
|
@@ -20,14 +20,16 @@
|
|
|
20
20
|
"email": "hasparus@gmail.com",
|
|
21
21
|
"url": "https://haspar.us"
|
|
22
22
|
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"prepublishOnly": "tsc"
|
|
26
|
+
},
|
|
23
27
|
"dependencies": {
|
|
24
|
-
"
|
|
28
|
+
"github-slugger": "^2.0.0",
|
|
29
|
+
"next-validate-link": "1.4.0"
|
|
25
30
|
},
|
|
26
31
|
"devDependencies": {
|
|
27
32
|
"@types/node": "^22.10.7",
|
|
28
33
|
"typescript": "^5.7.3"
|
|
29
|
-
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"build": "tsc"
|
|
32
34
|
}
|
|
33
|
-
}
|
|
35
|
+
}
|
package/src/bin.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
11
11
|
* validate-mdx-links --cwd <path> --files "content/**\/*.mdx" --verbose
|
|
12
|
+
* validate-mdx-links --files "content/docs/**\/*.mdx" --content-dir content
|
|
12
13
|
*/
|
|
13
14
|
import { parseArgs } from "node:util";
|
|
14
15
|
import { validateMdxLinks, printErrors } from "./index.js";
|
|
@@ -20,6 +21,7 @@ const args = (() => {
|
|
|
20
21
|
cwd: { type: "string", default: process.cwd() },
|
|
21
22
|
files: { type: "string" },
|
|
22
23
|
verbose: { type: "boolean", default: false, allowNegative: true },
|
|
24
|
+
"content-dir": { type: "string" },
|
|
23
25
|
help: { type: "boolean", default: false },
|
|
24
26
|
},
|
|
25
27
|
strict: true,
|
|
@@ -33,26 +35,29 @@ const args = (() => {
|
|
|
33
35
|
})();
|
|
34
36
|
|
|
35
37
|
const {
|
|
36
|
-
values: { help, cwd, verbose, files },
|
|
38
|
+
values: { help, cwd, verbose, files, "content-dir": contentDir },
|
|
37
39
|
} = args;
|
|
38
40
|
|
|
39
41
|
if (help) {
|
|
40
42
|
console.log(
|
|
41
|
-
'Usage: validate-mdx-links --cwd <path> --files "content/**/*.mdx" --verbose'
|
|
43
|
+
'Usage: validate-mdx-links --cwd <path> --files "content/**/*.mdx" [--content-dir content] --verbose'
|
|
42
44
|
);
|
|
43
45
|
process.exit(0);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
const resolvedFiles = files ?? (contentDir ? `${contentDir}/**/*.mdx` : undefined);
|
|
49
|
+
|
|
50
|
+
if (!resolvedFiles) {
|
|
51
|
+
console.error("No files passed. Please pass --files or --content-dir.");
|
|
48
52
|
process.exit(1);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
try {
|
|
52
56
|
const errors = await validateMdxLinks({
|
|
53
57
|
cwd,
|
|
54
|
-
files,
|
|
58
|
+
files: resolvedFiles,
|
|
55
59
|
verbose,
|
|
60
|
+
contentDir,
|
|
56
61
|
});
|
|
57
62
|
|
|
58
63
|
if (errors.length > 0) {
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { globSync } from "node:fs";
|
|
1
|
+
import { existsSync, globSync, readFileSync } from "node:fs";
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
|
-
import { dirname, resolve, basename } from "node:path";
|
|
3
|
+
import { dirname, resolve, basename, relative } from "node:path";
|
|
4
|
+
import GithubSlugger from "github-slugger";
|
|
4
5
|
import {
|
|
5
6
|
scanURLs,
|
|
6
7
|
validateFiles,
|
|
7
8
|
type DetectedError,
|
|
9
|
+
type ScanResult,
|
|
8
10
|
} from "next-validate-link";
|
|
9
11
|
|
|
12
|
+
type UrlMeta = {
|
|
13
|
+
hashes?: string[];
|
|
14
|
+
queries?: Record<string, string>[];
|
|
15
|
+
};
|
|
16
|
+
|
|
10
17
|
export { printErrors } from "next-validate-link";
|
|
11
18
|
|
|
12
19
|
export type ValidationResult = {
|
|
@@ -18,12 +25,243 @@ export interface ValidateMdxLinksOptions {
|
|
|
18
25
|
cwd?: string;
|
|
19
26
|
files: string;
|
|
20
27
|
verbose?: boolean;
|
|
28
|
+
contentDir?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type FrameworkKind =
|
|
32
|
+
| { type: "nextjs" }
|
|
33
|
+
| { type: "content"; contentDir: string };
|
|
34
|
+
|
|
35
|
+
function readDeps(cwd: string): Set<string> {
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf-8"));
|
|
38
|
+
return new Set([
|
|
39
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
40
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
41
|
+
]);
|
|
42
|
+
} catch {
|
|
43
|
+
return new Set();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function detectFramework(cwd: string, deps: Set<string>): FrameworkKind {
|
|
48
|
+
const isFumadocs =
|
|
49
|
+
deps.has("fumadocs-core") || deps.has("fumadocs-mdx") || deps.has("fumadocs-ui");
|
|
50
|
+
|
|
51
|
+
// If fumadocs is present, prefer content-based scanning
|
|
52
|
+
if (isFumadocs) {
|
|
53
|
+
// Find content dir: check common locations
|
|
54
|
+
for (const dir of ["content", "content/docs"]) {
|
|
55
|
+
if (existsSync(resolve(cwd, dir))) {
|
|
56
|
+
const mdxFiles = globSync(`${dir}/**/*.mdx`, { cwd });
|
|
57
|
+
if (mdxFiles.length > 0) {
|
|
58
|
+
return { type: "content", contentDir: dir };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check for Next.js dirs (also validates against package.json if available)
|
|
65
|
+
const nextDirs = ["app", "src/app", "pages", "src/pages"];
|
|
66
|
+
for (const dir of nextDirs) {
|
|
67
|
+
if (existsSync(resolve(cwd, dir))) {
|
|
68
|
+
return { type: "nextjs" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fallback: content dir with MDX files (no fumadocs dep but has content)
|
|
73
|
+
if (existsSync(resolve(cwd, "content"))) {
|
|
74
|
+
const mdxFiles = globSync("content/**/*.mdx", { cwd });
|
|
75
|
+
if (mdxFiles.length > 0) {
|
|
76
|
+
return { type: "content", contentDir: "content" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { type: "nextjs" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const HEADING_REGEX = /^#{1,6}\s+(.+)$/gm;
|
|
84
|
+
|
|
85
|
+
function extractHashes(content: string): string[] {
|
|
86
|
+
const slugger = new GithubSlugger();
|
|
87
|
+
const hashes: string[] = [];
|
|
88
|
+
let match;
|
|
89
|
+
while ((match = HEADING_REGEX.exec(content)) !== null) {
|
|
90
|
+
hashes.push(slugger.slug(match[1]!));
|
|
91
|
+
}
|
|
92
|
+
return hashes;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convert a TanStack Router file path to a URL path.
|
|
97
|
+
* - Strips `_prefix` layout segments (pathless routes)
|
|
98
|
+
* - `index.tsx` → parent path
|
|
99
|
+
* - `$.tsx` → null (handled as fallbackUrl)
|
|
100
|
+
* - `[.]` → literal dot
|
|
101
|
+
* - Strips extensions
|
|
102
|
+
* Returns null for splat routes (caller handles as fallbackUrl),
|
|
103
|
+
* or undefined for files that shouldn't produce routes (layouts, tests, api).
|
|
104
|
+
*/
|
|
105
|
+
function tanstackRouteToUrl(
|
|
106
|
+
filePath: string,
|
|
107
|
+
routesDir: string
|
|
108
|
+
): string | null | undefined {
|
|
109
|
+
const rel = relative(routesDir, filePath);
|
|
110
|
+
|
|
111
|
+
// Skip root layout, test files, api routes
|
|
112
|
+
if (
|
|
113
|
+
rel.startsWith("__") ||
|
|
114
|
+
rel.includes(".test.") ||
|
|
115
|
+
rel.startsWith("api/") ||
|
|
116
|
+
rel.startsWith("api\\")
|
|
117
|
+
) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Strip extension
|
|
122
|
+
const withoutExt = rel.replace(/\.(tsx?|jsx?)$/, "");
|
|
123
|
+
|
|
124
|
+
const segments = withoutExt.split(/[/\\]/);
|
|
125
|
+
const urlSegments: string[] = [];
|
|
126
|
+
let isSplat = false;
|
|
127
|
+
|
|
128
|
+
for (const seg of segments) {
|
|
129
|
+
// Layout files (e.g. _landing.tsx) — skip as a route themselves
|
|
130
|
+
// but as directories they wrap children
|
|
131
|
+
if (seg.startsWith("_")) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Splat route
|
|
136
|
+
if (seg === "$") {
|
|
137
|
+
isSplat = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Dynamic param like $slug — skip, produces fallback
|
|
142
|
+
if (seg.startsWith("$")) {
|
|
143
|
+
isSplat = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// index → parent
|
|
148
|
+
if (seg === "index") {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// [.] → literal dot
|
|
153
|
+
let processed = seg.replace(/\[\.\]/g, ".");
|
|
154
|
+
|
|
155
|
+
urlSegments.push(processed);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (isSplat) {
|
|
159
|
+
return null; // caller creates fallbackUrl regex
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return "/" + urlSegments.join("/") || "/";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function scanRoutes(
|
|
166
|
+
routesDir: string,
|
|
167
|
+
cwd: string,
|
|
168
|
+
urls: Map<string, UrlMeta>,
|
|
169
|
+
fallbackUrls: { url: RegExp; meta: UrlMeta }[]
|
|
170
|
+
): void {
|
|
171
|
+
const absRoutesDir = resolve(cwd, routesDir);
|
|
172
|
+
if (!existsSync(absRoutesDir)) return;
|
|
173
|
+
|
|
174
|
+
const routeFiles = globSync(`${routesDir}/**/*.{tsx,ts,jsx,js}`, { cwd });
|
|
175
|
+
|
|
176
|
+
for (const file of routeFiles) {
|
|
177
|
+
const result = tanstackRouteToUrl(file, routesDir);
|
|
178
|
+
|
|
179
|
+
if (result === undefined) {
|
|
180
|
+
// Skip (layout, test, api)
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (result === null) {
|
|
185
|
+
// Splat route — build a regex fallback from the non-splat prefix
|
|
186
|
+
const rel = relative(routesDir, file).replace(/\.(tsx?|jsx?)$/, "");
|
|
187
|
+
const segments = rel.split(/[/\\]/);
|
|
188
|
+
const prefixSegments: string[] = [];
|
|
189
|
+
for (const seg of segments) {
|
|
190
|
+
if (seg.startsWith("_")) continue;
|
|
191
|
+
if (seg === "$" || seg.startsWith("$") || seg === "index") break;
|
|
192
|
+
prefixSegments.push(seg.replace(/\[\.\]/g, "."));
|
|
193
|
+
}
|
|
194
|
+
const prefix = "/" + prefixSegments.join("/");
|
|
195
|
+
const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/.*)?$`);
|
|
196
|
+
fallbackUrls.push({ url: pattern, meta: {} });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!urls.has(result)) {
|
|
201
|
+
urls.set(result, {});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function contentPathToUrl(filePath: string, contentDir: string): string {
|
|
207
|
+
let urlPath = "/" + relative(contentDir, filePath);
|
|
208
|
+
urlPath = urlPath.replace(/\.mdx$/, "");
|
|
209
|
+
if (urlPath.endsWith("/index")) {
|
|
210
|
+
urlPath = urlPath.slice(0, -"/index".length) || "/";
|
|
211
|
+
}
|
|
212
|
+
return urlPath;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildContentScanResult(
|
|
216
|
+
contentDir: string,
|
|
217
|
+
cwd: string,
|
|
218
|
+
verbose?: boolean,
|
|
219
|
+
deps?: Set<string>
|
|
220
|
+
): ScanResult {
|
|
221
|
+
const urls = new Map<string, UrlMeta>();
|
|
222
|
+
const fallbackUrls: { url: RegExp; meta: UrlMeta }[] = [];
|
|
223
|
+
const mdxFiles = globSync(`${contentDir}/**/*.mdx`, { cwd });
|
|
224
|
+
|
|
225
|
+
for (const file of mdxFiles) {
|
|
226
|
+
const urlPath = contentPathToUrl(file, contentDir);
|
|
227
|
+
const content = readFileSync(resolve(cwd, file), "utf-8");
|
|
228
|
+
const hashes = extractHashes(content);
|
|
229
|
+
|
|
230
|
+
urls.set(urlPath, { hashes: hashes.length > 0 ? hashes : undefined });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Scan TanStack Router routes if TanStack Start is a dependency
|
|
234
|
+
const isTanStack =
|
|
235
|
+
deps?.has("@tanstack/react-start") ||
|
|
236
|
+
deps?.has("@tanstack/start") ||
|
|
237
|
+
deps?.has("@tanstack/react-router");
|
|
238
|
+
if (isTanStack) {
|
|
239
|
+
for (const routesDir of ["src/routes", "app/routes"]) {
|
|
240
|
+
scanRoutes(routesDir, cwd, urls, fallbackUrls);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (verbose) {
|
|
245
|
+
console.log(
|
|
246
|
+
"\n" +
|
|
247
|
+
"Scanned content routes:\n" +
|
|
248
|
+
[...urls.keys()].map((x) => `"${x}"`).join(", ") +
|
|
249
|
+
(fallbackUrls.length > 0
|
|
250
|
+
? "\nFallback patterns:\n" +
|
|
251
|
+
fallbackUrls.map((x) => `${x.url}`).join(", ")
|
|
252
|
+
: "") +
|
|
253
|
+
"\n"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { urls, fallbackUrls };
|
|
21
258
|
}
|
|
22
259
|
|
|
23
260
|
export async function validateMdxLinks({
|
|
24
261
|
cwd = process.cwd(),
|
|
25
262
|
files: filesGlob,
|
|
26
263
|
verbose = false,
|
|
264
|
+
contentDir,
|
|
27
265
|
}: ValidateMdxLinksOptions): Promise<ValidationResult[]> {
|
|
28
266
|
const originalCwd = process.cwd();
|
|
29
267
|
process.chdir(cwd);
|
|
@@ -36,20 +274,40 @@ export async function validateMdxLinks({
|
|
|
36
274
|
console.log(`Found ${files.length} markdown files to validate.\n`);
|
|
37
275
|
}
|
|
38
276
|
|
|
39
|
-
|
|
277
|
+
let scanned: ScanResult;
|
|
278
|
+
let effectiveContentDir: string | undefined = contentDir;
|
|
279
|
+
const deps = readDeps(cwd);
|
|
40
280
|
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
281
|
+
if (contentDir) {
|
|
282
|
+
scanned = buildContentScanResult(contentDir, cwd, verbose, deps);
|
|
283
|
+
} else {
|
|
284
|
+
const framework = detectFramework(cwd, deps);
|
|
285
|
+
if (framework.type === "content") {
|
|
286
|
+
effectiveContentDir = framework.contentDir;
|
|
287
|
+
scanned = buildContentScanResult(framework.contentDir, cwd, verbose, deps);
|
|
288
|
+
} else {
|
|
289
|
+
scanned = await scanURLs();
|
|
290
|
+
if (verbose) {
|
|
291
|
+
console.log(
|
|
292
|
+
"\n" +
|
|
293
|
+
"Scanned routes from the file system:\n" +
|
|
294
|
+
[
|
|
295
|
+
...scanned.urls.keys(),
|
|
296
|
+
...scanned.fallbackUrls.map((x) => x.url),
|
|
297
|
+
]
|
|
298
|
+
.map((x) => `"${x}"`)
|
|
299
|
+
.join(", ") +
|
|
300
|
+
"\n"
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
50
304
|
}
|
|
51
305
|
|
|
52
|
-
const
|
|
306
|
+
const pathToUrl = effectiveContentDir
|
|
307
|
+
? (filePath: string) => contentPathToUrl(filePath, effectiveContentDir!)
|
|
308
|
+
: undefined;
|
|
309
|
+
|
|
310
|
+
const validations = await validateFiles(files, { scanned, pathToUrl });
|
|
53
311
|
|
|
54
312
|
const withoutFalsePositives = await Promise.all(
|
|
55
313
|
validations.map(async ({ file, detected }) => {
|
|
@@ -72,6 +330,21 @@ export async function validateMdxLinks({
|
|
|
72
330
|
}
|
|
73
331
|
}
|
|
74
332
|
|
|
333
|
+
// if the file is called index.mdx and is in content dir, it turns out
|
|
334
|
+
// both `./dirname/foo` and `./foo` links work and point to the same file and
|
|
335
|
+
if (basename(file) === "index.mdx") {
|
|
336
|
+
const dir = basename(dirname(file));
|
|
337
|
+
const isSameDir = dir === basename(dirname(link));
|
|
338
|
+
|
|
339
|
+
if (isSameDir) {
|
|
340
|
+
const path = resolve(dirname(file), "..", `${link}.mdx`);
|
|
341
|
+
if (await fileExists(path)) {
|
|
342
|
+
// file exists, the error is a false positive
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
75
348
|
// relative links can lose their .mdx extension
|
|
76
349
|
if (!link.endsWith(".mdx")) {
|
|
77
350
|
const dest = resolve(dirname(file), `${link}.mdx`);
|