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 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
@@ -6,7 +6,7 @@ Handles relative links, with and without `.mdx` extension, and treats `page.mdx`
6
6
  ## Install
7
7
 
8
8
  ```bash
9
- pnpm add -D validate-mdx-links
9
+ bun add -D validate-mdx-links
10
10
  ```
11
11
 
12
12
  ## CLI
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
- if (!files) {
39
- console.error("No files passed. Please pass the --files option.");
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
- export async function validateMdxLinks({ cwd = process.cwd(), files: filesGlob, verbose = false, }) {
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
- const scanned = await scanURLs();
18
- if (verbose) {
19
- console.log("\n" +
20
- "Scanned routes from the file system:\n" +
21
- [...scanned.urls.keys(), ...scanned.fallbackUrls.map((x) => x.url)]
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
- const validations = await validateFiles(files, { scanned });
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.8",
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": "./dist/bin.js"
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
- "next-validate-link": "^1.4.1"
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
- if (!files) {
47
- console.error("No files passed. Please pass the --files option.");
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
- const scanned = await scanURLs();
277
+ let scanned: ScanResult;
278
+ let effectiveContentDir: string | undefined = contentDir;
279
+ const deps = readDeps(cwd);
40
280
 
41
- if (verbose) {
42
- console.log(
43
- "\n" +
44
- "Scanned routes from the file system:\n" +
45
- [...scanned.urls.keys(), ...scanned.fallbackUrls.map((x) => x.url)]
46
- .map((x) => `"${x}"`)
47
- .join(", ") +
48
- "\n"
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 validations = await validateFiles(files, { scanned });
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`);