@writechoice/mint-cli 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,7 +47,7 @@ Check the installed version:
47
47
  ```bash
48
48
  writechoice --version
49
49
  # or
50
- writechoice -V
50
+ writechoice -v
51
51
  ```
52
52
 
53
53
  ### Update to Latest Version
@@ -74,6 +74,23 @@ You can also omit the `https://` prefix:
74
74
  writechoice check links docs.example.com
75
75
  ```
76
76
 
77
+ **Using a Validation Base URL**
78
+
79
+ When validating anchor links online, the tool can use a different base URL (e.g., a local development server or staging environment) to click on headings and extract the generated anchors:
80
+
81
+ ```bash
82
+ # Use localhost:3000 for validation (default)
83
+ writechoice check links docs.example.com
84
+
85
+ # Use a custom validation URL
86
+ writechoice check links docs.example.com http://localhost:3000
87
+
88
+ # Use a staging environment
89
+ writechoice check links docs.example.com https://staging.example.com
90
+ ```
91
+
92
+ The validation base URL is only used for online checks. Local file validation remains unchanged for optimal performance.
93
+
77
94
  ### Common Options
78
95
 
79
96
  ```bash
@@ -83,16 +100,10 @@ writechoice check links docs.example.com -f path/to/file.mdx
83
100
  # Validate links in a specific directory
84
101
  writechoice check links docs.example.com -d path/to/docs
85
102
 
86
- # Use short aliases for common flags
87
- writechoice check links docs.example.com -v -o my_report.json
88
-
89
103
  # Dry run (extract links without validating)
90
104
  writechoice check links docs.example.com --dry-run
91
105
 
92
- # Verbose output
93
- writechoice check links docs.example.com -v
94
-
95
- # Quiet mode (only generate report)
106
+ # Quiet mode (suppress terminal output, only generate report)
96
107
  writechoice check links docs.example.com --quiet
97
108
 
98
109
  # Custom output path for report
@@ -116,20 +127,22 @@ writechoice check links docs.example.com --fix-from-report custom_report.json
116
127
 
117
128
  ### Complete Options
118
129
 
119
- | Option | Alias | Description | Default |
120
- | -------------------------- | ----- | ------------------------------------------------------------------------ | ------------------- |
121
- | `<baseUrl>` | - | Base URL for the documentation site (required, with or without https://) | - |
122
- | `--file <path>` | `-f` | Validate links in a single MDX file | - |
123
- | `--dir <path>` | `-d` | Validate links in a specific directory | - |
124
- | `--output <path>` | `-o` | Output path for JSON report | `links_report.json` |
125
- | `--dry-run` | - | Extract and show links without validating | `false` |
126
- | `--verbose` | `-v` | Print detailed progress information | `false` |
127
- | `--quiet` | - | Suppress stdout output (only generate report) | `false` |
128
- | `--concurrency <number>` | `-c` | Number of concurrent browser tabs | `25` |
129
- | `--headless` | - | Run browser in headless mode | `true` |
130
- | `--no-headless` | - | Show browser window (for debugging) | - |
131
- | `--fix` | - | Automatically fix anchor links in MDX files | `false` |
132
- | `--fix-from-report [path]` | - | Fix anchor links from report file (optional path) | `links_report.json` |
130
+ | Option | Alias | Description | Default |
131
+ | -------------------------- | ----- | ------------------------------------------------------------------------- | ----------------------- |
132
+ | `<baseUrl>` | - | Base URL for the documentation site (required, with or without https://) | - |
133
+ | `[validationBaseUrl]` | - | Base URL for online validation (optional, clicks headings to get anchors) | `http://localhost:3000` |
134
+ | `--file <path>` | `-f` | Validate links in a single MDX file | - |
135
+ | `--dir <path>` | `-d` | Validate links in a specific directory | - |
136
+ | `--output <path>` | `-o` | Output path for JSON report | `links_report.json` |
137
+ | `--dry-run` | - | Extract and show links without validating | `false` |
138
+ | `--quiet` | - | Suppress terminal output (only generate report) | `false` |
139
+ | `--concurrency <number>` | `-c` | Number of concurrent browser tabs | `25` |
140
+ | `--headless` | - | Run browser in headless mode | `true` |
141
+ | `--no-headless` | - | Show browser window (for debugging) | - |
142
+ | `--fix` | - | Automatically fix anchor links in MDX files | `false` |
143
+ | `--fix-from-report [path]` | - | Fix anchor links from report file (optional path) | `links_report.json` |
144
+
145
+ **Note:** Detailed progress output is shown by default. Use `--quiet` to suppress terminal output.
133
146
 
134
147
  ## How It Works
135
148
 
@@ -143,15 +156,24 @@ The tool extracts internal links from MDX files in the following formats:
143
156
  4. **JSX Button components**: `<Button href="/path/to/page#anchor">Button Text</Button>`
144
157
 
145
158
  **Images are automatically ignored:**
159
+
146
160
  - Markdown images: `![Alt Text](./image.png)`
147
161
  - HTML images: `<img src="./image.png" />`
148
162
 
149
163
  ### Validation Process
150
164
 
151
165
  1. **Local Validation**: First checks if the target MDX file exists locally
152
- 2. **Online Validation**: If local check fails, uses Playwright to navigate to the live URL
153
- 3. **Anchor Validation**: For anchor links, verifies the heading exists and matches the anchor format
154
- 4. **Kebab-case Checking**: Ensures anchors follow the correct kebab-case format
166
+ - For normal links: Verifies the file exists in the repository
167
+ - For anchor links: Checks if the heading exists in the MDX file with matching kebab-case format
168
+ 2. **Online Validation**: If local check fails, uses Playwright to navigate to the validation base URL
169
+ - For normal links: Verifies the page loads successfully
170
+ - For anchor links:
171
+ - Finds the heading on the page by text matching
172
+ - Clicks on the heading to trigger anchor generation
173
+ - Extracts the generated anchor from the URL or href attribute
174
+ - Compares the generated anchor with the expected anchor
175
+ 3. **Validation Base URL**: By default uses `http://localhost:3000` for online validation, or you can specify a custom URL (e.g., staging environment)
176
+ 4. **Auto-Fix**: When issues are found, can automatically update MDX files with the correct anchors
155
177
 
156
178
  ### Report Format
157
179
 
@@ -273,16 +295,22 @@ The default concurrency is set to 25 concurrent browser tabs. Adjust this based
273
295
 
274
296
  ## Examples
275
297
 
276
- ### Validate all links with verbose output
298
+ ### Validate all links (with progress output)
277
299
 
278
300
  ```bash
279
- writechoice check links docs.example.com -v
301
+ writechoice check links docs.example.com
302
+ ```
303
+
304
+ ### Validate quietly (suppress terminal output)
305
+
306
+ ```bash
307
+ writechoice check links docs.example.com --quiet
280
308
  ```
281
309
 
282
310
  ### Validate and fix issues in one command
283
311
 
284
312
  ```bash
285
- writechoice check links docs.example.com --fix -v
313
+ writechoice check links docs.example.com --fix
286
314
  ```
287
315
 
288
316
  ### Two-step fix workflow
@@ -301,7 +329,7 @@ writechoice check links docs.example.com --fix-from-report
301
329
  ### Validate specific directory
302
330
 
303
331
  ```bash
304
- writechoice check links docs.example.com -d docs/api -v
332
+ writechoice check links docs.example.com -d docs/api
305
333
  ```
306
334
 
307
335
  ## Troubleshooting
package/bin/cli.js CHANGED
@@ -24,21 +24,24 @@ const check = program.command("check").description("Validation commands for docu
24
24
 
25
25
  // Validate links subcommand
26
26
  check
27
- .command("links <baseUrl>")
27
+ .command("links <baseUrl> [validationBaseUrl]")
28
28
  .description("Validate internal links and anchors in MDX documentation files")
29
29
  .option("-f, --file <path>", "Validate links in a single MDX file")
30
30
  .option("-d, --dir <path>", "Validate links in a specific directory")
31
31
  .option("-o, --output <path>", "Output path for JSON report", "links_report.json")
32
32
  .option("--dry-run", "Extract and show links without validating")
33
- .option("-v, --verbose", "Print detailed progress information")
34
- .option("--quiet", "Suppress stdout output (only generate report)")
33
+ .option("--quiet", "Suppress terminal output (only generate report)")
35
34
  .option("-c, --concurrency <number>", "Number of concurrent browser tabs", "25")
36
35
  .option("--headless", "Run browser in headless mode (default)", true)
37
36
  .option("--no-headless", "Show browser window (for debugging)")
38
37
  .option("--fix", "Automatically fix anchor links in MDX files")
39
38
  .option("--fix-from-report [path]", "Fix anchor links from report file (default: links_report.json)")
40
- .action(async (baseUrl, options) => {
39
+ .action(async (baseUrl, validationBaseUrl, options) => {
41
40
  const { validateLinks } = await import("../src/commands/validate/links.js");
41
+ // Verbose is now default (true unless --quiet is specified)
42
+ options.verbose = !options.quiet;
43
+ // Set validation base URL to localhost:3000 if not provided
44
+ options.validationBaseUrl = validationBaseUrl || "http://localhost:3000";
42
45
  await validateLinks(baseUrl, options);
43
46
  });
44
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writechoice/mint-cli",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "CLI tool for Mintlify documentation validation and utilities",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -6,11 +6,11 @@
6
6
  * JavaScript-rendered Mintlify pages.
7
7
  */
8
8
 
9
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
10
- import { join, relative, resolve, dirname } from 'path';
11
- import { fileURLToPath } from 'url';
12
- import { chromium } from 'playwright';
13
- import chalk from 'chalk';
9
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
10
+ import { join, relative, resolve, dirname } from "path";
11
+ import { fileURLToPath } from "url";
12
+ import { chromium } from "playwright";
13
+ import chalk from "chalk";
14
14
  import {
15
15
  cleanHeadingText,
16
16
  toKebabCase,
@@ -20,15 +20,15 @@ import {
20
20
  findLineNumber,
21
21
  removeCodeBlocksAndFrontmatter,
22
22
  resolvePath as resolvePathUtil,
23
- } from '../../utils/helpers.js';
23
+ } from "../../utils/helpers.js";
24
24
 
25
25
  const __filename = fileURLToPath(import.meta.url);
26
26
  const __dirname = dirname(__filename);
27
27
 
28
28
  // Configuration
29
- const DEFAULT_BASE_URL = 'https://docs.nebius.com';
30
- const EXCLUDED_DIRS = ['snippets'];
31
- const MDX_DIRS = ['.'];
29
+ const DEFAULT_BASE_URL = "https://docs.nebius.com";
30
+ const EXCLUDED_DIRS = ["snippets"];
31
+ const MDX_DIRS = ["."];
32
32
  const DEFAULT_TIMEOUT = 30000; // 30 seconds
33
33
  const DEFAULT_CONCURRENCY = 25;
34
34
 
@@ -75,7 +75,7 @@ class ValidationResult {
75
75
  actualHeading = null,
76
76
  actualHeadingKebab = null,
77
77
  errorMessage = null,
78
- validationTimeMs = 0
78
+ validationTimeMs = 0,
79
79
  ) {
80
80
  this.source = source;
81
81
  this.targetUrl = targetUrl;
@@ -106,10 +106,10 @@ function urlToFilePath(url, baseUrl, repoRoot) {
106
106
  }
107
107
  }
108
108
 
109
- path = path.replace(/^\/+/, '');
109
+ path = path.replace(/^\/+/, "");
110
110
 
111
- if (!path || path === '/') {
112
- const indexPath = join(repoRoot, 'index.mdx');
111
+ if (!path || path === "/") {
112
+ const indexPath = join(repoRoot, "index.mdx");
113
113
  return existsSync(indexPath) ? indexPath : null;
114
114
  }
115
115
 
@@ -118,7 +118,7 @@ function urlToFilePath(url, baseUrl, repoRoot) {
118
118
  return mdxPath;
119
119
  }
120
120
 
121
- const indexPath = join(repoRoot, path, 'index.mdx');
121
+ const indexPath = join(repoRoot, path, "index.mdx");
122
122
  if (existsSync(indexPath)) {
123
123
  return indexPath;
124
124
  }
@@ -132,44 +132,44 @@ function resolvePath(mdxFilePath, href, baseUrl, repoRoot) {
132
132
  }
133
133
 
134
134
  let path, anchor;
135
- if (href.includes('#')) {
136
- [path, anchor] = href.split('#', 2);
135
+ if (href.includes("#")) {
136
+ [path, anchor] = href.split("#", 2);
137
137
  } else {
138
138
  path = href;
139
- anchor = '';
139
+ anchor = "";
140
140
  }
141
141
 
142
142
  if (!path && anchor) {
143
143
  const relPath = relative(repoRoot, mdxFilePath);
144
- const urlPath = relPath.replace(/\.mdx$/, '');
144
+ const urlPath = relPath.replace(/\.mdx$/, "");
145
145
  const fullUrl = normalizeUrl(`${baseUrl}/${urlPath}`);
146
146
  return `${fullUrl}#${anchor}`;
147
147
  }
148
148
 
149
149
  let fullUrl;
150
150
 
151
- if (path.startsWith('/')) {
151
+ if (path.startsWith("/")) {
152
152
  fullUrl = normalizeUrl(baseUrl + path);
153
153
  } else {
154
154
  const mdxDir = dirname(mdxFilePath);
155
155
 
156
- if (path.startsWith('./')) {
156
+ if (path.startsWith("./")) {
157
157
  path = path.slice(2);
158
158
  }
159
159
 
160
160
  const resolved = resolve(mdxDir, path);
161
161
 
162
162
  const relToRoot = relative(repoRoot, resolved);
163
- if (relToRoot.startsWith('..')) {
163
+ if (relToRoot.startsWith("..")) {
164
164
  return null;
165
165
  }
166
166
 
167
- const urlPath = relToRoot.replace(/\.mdx$/, '');
167
+ const urlPath = relToRoot.replace(/\.mdx$/, "");
168
168
  fullUrl = normalizeUrl(`${baseUrl}/${urlPath}`);
169
169
  }
170
170
 
171
171
  if (anchor) {
172
- fullUrl += '#' + anchor;
172
+ fullUrl += "#" + anchor;
173
173
  }
174
174
 
175
175
  return fullUrl;
@@ -179,7 +179,7 @@ function resolvePath(mdxFilePath, href, baseUrl, repoRoot) {
179
179
 
180
180
  function extractMdxHeadings(filePath) {
181
181
  try {
182
- const content = readFileSync(filePath, 'utf-8');
182
+ const content = readFileSync(filePath, "utf-8");
183
183
  const { cleanedContent } = removeCodeBlocksAndFrontmatter(content);
184
184
 
185
185
  const headingPattern = /^#{1,6}\s+(.+)$/gm;
@@ -189,7 +189,7 @@ function extractMdxHeadings(filePath) {
189
189
  while ((match = headingPattern.exec(cleanedContent)) !== null) {
190
190
  let headingText = match[1].trim();
191
191
  // Remove any trailing {#custom-id} syntax if present
192
- headingText = headingText.replace(/\s*\{#[^}]+\}\s*$/, '');
192
+ headingText = headingText.replace(/\s*\{#[^}]+\}\s*$/, "");
193
193
  headings.push(headingText);
194
194
  }
195
195
 
@@ -206,7 +206,7 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
206
206
 
207
207
  let content;
208
208
  try {
209
- content = readFileSync(filePath, 'utf-8');
209
+ content = readFileSync(filePath, "utf-8");
210
210
  } catch (error) {
211
211
  console.error(`Error reading ${filePath}: ${error.message}`);
212
212
  return [];
@@ -234,8 +234,8 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
234
234
  const markdownMatches = [...cleanedContent.matchAll(LINK_PATTERNS.markdown)];
235
235
  for (const match of markdownMatches) {
236
236
  // Check if this is actually an image by looking at the character before '['
237
- const charBefore = match.index > 0 ? cleanedContent[match.index - 1] : '';
238
- if (charBefore === '!') {
237
+ const charBefore = match.index > 0 ? cleanedContent[match.index - 1] : "";
238
+ if (charBefore === "!") {
239
239
  // This is a markdown image ![alt](url), skip it
240
240
  continue;
241
241
  }
@@ -252,21 +252,13 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
252
252
  findLineNumber(content, match.index),
253
253
  linkText.trim(),
254
254
  href,
255
- 'markdown'
255
+ "markdown",
256
256
  );
257
257
 
258
- const [basePath, anchor = ''] = targetUrl.split('#');
258
+ const [basePath, anchor = ""] = targetUrl.split("#");
259
259
  const expectedSlug = new URL(targetUrl).pathname;
260
260
 
261
- links.push(
262
- new Link(
263
- location,
264
- targetUrl,
265
- basePath,
266
- anchor || null,
267
- expectedSlug
268
- )
269
- );
261
+ links.push(new Link(location, targetUrl, basePath, anchor || null, expectedSlug));
270
262
  }
271
263
  }
272
264
 
@@ -285,21 +277,13 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
285
277
  findLineNumber(content, match.index),
286
278
  linkText.trim(),
287
279
  href,
288
- 'html'
280
+ "html",
289
281
  );
290
282
 
291
- const [basePath, anchor = ''] = targetUrl.split('#');
283
+ const [basePath, anchor = ""] = targetUrl.split("#");
292
284
  const expectedSlug = new URL(targetUrl).pathname;
293
285
 
294
- links.push(
295
- new Link(
296
- location,
297
- targetUrl,
298
- basePath,
299
- anchor || null,
300
- expectedSlug
301
- )
302
- );
286
+ links.push(new Link(location, targetUrl, basePath, anchor || null, expectedSlug));
303
287
  }
304
288
  }
305
289
 
@@ -318,21 +302,13 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
318
302
  findLineNumber(content, match.index),
319
303
  linkText.trim(),
320
304
  href,
321
- 'jsx'
305
+ "jsx",
322
306
  );
323
307
 
324
- const [basePath, anchor = ''] = targetUrl.split('#');
308
+ const [basePath, anchor = ""] = targetUrl.split("#");
325
309
  const expectedSlug = new URL(targetUrl).pathname;
326
310
 
327
- links.push(
328
- new Link(
329
- location,
330
- targetUrl,
331
- basePath,
332
- anchor || null,
333
- expectedSlug
334
- )
335
- );
311
+ links.push(new Link(location, targetUrl, basePath, anchor || null, expectedSlug));
336
312
  }
337
313
  }
338
314
 
@@ -351,21 +327,13 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
351
327
  findLineNumber(content, match.index),
352
328
  linkText.trim(),
353
329
  href,
354
- 'jsx'
330
+ "jsx",
355
331
  );
356
332
 
357
- const [basePath, anchor = ''] = targetUrl.split('#');
333
+ const [basePath, anchor = ""] = targetUrl.split("#");
358
334
  const expectedSlug = new URL(targetUrl).pathname;
359
335
 
360
- links.push(
361
- new Link(
362
- location,
363
- targetUrl,
364
- basePath,
365
- anchor || null,
366
- expectedSlug
367
- )
368
- );
336
+ links.push(new Link(location, targetUrl, basePath, anchor || null, expectedSlug));
369
337
  }
370
338
  }
371
339
 
@@ -378,9 +346,7 @@ function findMdxFiles(repoRoot, directory = null, file = null) {
378
346
  return existsSync(fullPath) ? [fullPath] : [];
379
347
  }
380
348
 
381
- const searchDirs = directory
382
- ? [resolve(repoRoot, directory)]
383
- : MDX_DIRS.map(d => join(repoRoot, d));
349
+ const searchDirs = directory ? [resolve(repoRoot, directory)] : MDX_DIRS.map((d) => join(repoRoot, d));
384
350
 
385
351
  const files = [];
386
352
 
@@ -393,10 +359,10 @@ function findMdxFiles(repoRoot, directory = null, file = null) {
393
359
  const stat = statSync(fullPath);
394
360
 
395
361
  if (stat.isDirectory()) {
396
- if (!EXCLUDED_DIRS.some(excluded => fullPath.includes(excluded))) {
362
+ if (!EXCLUDED_DIRS.some((excluded) => fullPath.includes(excluded))) {
397
363
  walkDir(fullPath);
398
364
  }
399
- } else if (entry.endsWith('.mdx')) {
365
+ } else if (entry.endsWith(".mdx")) {
400
366
  files.push(fullPath);
401
367
  }
402
368
  }
@@ -411,7 +377,7 @@ function findMdxFiles(repoRoot, directory = null, file = null) {
411
377
 
412
378
  // Playwright Validation Functions
413
379
 
414
- async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, progress = '') {
380
+ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
415
381
  const startTime = Date.now();
416
382
 
417
383
  try {
@@ -419,14 +385,14 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
419
385
  console.log(`${progress} Validating anchor: ${link.anchor}`);
420
386
  }
421
387
 
422
- // OPTIMIZATION: Check if anchor exists in local MDX file first
388
+ // OPTIMIZATION: Check if anchor exists in local MDX file first (local validation)
423
389
  const mdxFilePath = urlToFilePath(link.basePath, baseUrl, repoRoot);
424
390
  if (mdxFilePath && existsSync(mdxFilePath)) {
425
391
  const mdxHeadings = extractMdxHeadings(mdxFilePath);
426
- const mdxHeadingsKebab = mdxHeadings.map(h => toKebabCase(h));
392
+ const mdxHeadingsKebab = mdxHeadings.map((h) => toKebabCase(h));
427
393
 
428
394
  if (mdxHeadingsKebab.includes(link.anchor)) {
429
- const heading = mdxHeadings.find(h => toKebabCase(h) === link.anchor);
395
+ const heading = mdxHeadings.find((h) => toKebabCase(h) === link.anchor);
430
396
  if (verbose) {
431
397
  console.log(`${progress} ✓ Anchor validated locally in MDX file`);
432
398
  }
@@ -436,117 +402,206 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
436
402
  link.basePath,
437
403
  link.anchor,
438
404
  link.expectedSlug,
439
- 'success',
405
+ "success",
440
406
  link.basePath,
441
407
  heading,
442
408
  link.anchor,
443
409
  null,
444
- Date.now() - startTime
410
+ Date.now() - startTime,
445
411
  );
446
412
  } else if (verbose) {
447
413
  console.log(`${progress} Anchor not found in local MDX, checking online...`);
448
414
  }
449
415
  }
450
416
 
451
- // Navigate to base page
452
- await page.goto(link.basePath, { waitUntil: 'networkidle', timeout: DEFAULT_TIMEOUT });
417
+ // ONLINE VALIDATION: Use validation base URL to navigate and click on headings
418
+ // Convert the base path to use the validation base URL
419
+ const validationUrl = link.basePath.replace(baseUrl, validationBaseUrl);
453
420
 
454
- // Try to find heading by anchor
455
- let heading = await page.$(`#${link.anchor}`);
421
+ if (verbose) {
422
+ console.log(`${progress} Navigating to: ${validationUrl}`);
423
+ }
424
+
425
+ // Navigate to validation page
426
+ await page.goto(validationUrl, { waitUntil: "networkidle", timeout: DEFAULT_TIMEOUT });
456
427
 
457
- if (!heading) {
458
- heading = await page.$(`[id="${link.anchor}"]`);
428
+ // Extract headings from the MDX file to find the matching heading text
429
+ let targetHeadingText = null;
430
+
431
+ if (mdxFilePath && existsSync(mdxFilePath)) {
432
+ const mdxHeadings = extractMdxHeadings(mdxFilePath);
433
+ const mdxHeadingsKebab = mdxHeadings.map((h) => toKebabCase(h));
434
+
435
+ // Find the heading that matches our anchor
436
+ const headingIndex = mdxHeadingsKebab.indexOf(link.anchor);
437
+ if (headingIndex !== -1) {
438
+ targetHeadingText = mdxHeadings[headingIndex];
439
+ if (verbose) {
440
+ console.log(`${progress} Looking for heading: "${targetHeadingText}"`);
441
+ }
442
+ }
459
443
  }
460
444
 
461
- if (!heading) {
445
+ // If we couldn't find the heading text from MDX, try to find it by the link text
446
+ if (!targetHeadingText && link.source.linkText) {
447
+ targetHeadingText = link.source.linkText;
448
+ if (verbose) {
449
+ console.log(`${progress} Using link text as heading: "${targetHeadingText}"`);
450
+ }
451
+ }
452
+
453
+ if (!targetHeadingText) {
462
454
  return new ValidationResult(
463
455
  link.source,
464
456
  link.targetUrl,
465
457
  link.basePath,
466
458
  link.anchor,
467
459
  link.expectedSlug,
468
- 'failure',
460
+ "failure",
469
461
  null,
470
462
  null,
471
463
  null,
472
- `Anchor #${link.anchor} not found on page`,
473
- Date.now() - startTime
464
+ `Could not determine heading text to search for anchor #${link.anchor}`,
465
+ Date.now() - startTime,
474
466
  );
475
467
  }
476
468
 
477
- // Get heading text and clean it
478
- const actualText = await heading.innerText();
479
- const actualTextClean = cleanHeadingText(actualText);
480
- const actualKebab = toKebabCase(actualTextClean);
469
+ // Find all headings on the page
470
+ const headings = await page.$$("h1, h2, h3, h4, h5, h6");
471
+ let matchedHeading = null;
472
+ let matchedHeadingText = null;
481
473
 
482
- // Extract headings from the TARGET MDX file to verify
483
- const mdxFilePath2 = urlToFilePath(link.basePath, baseUrl, repoRoot);
484
- const mdxHeadings = mdxFilePath2 ? extractMdxHeadings(mdxFilePath2) : [];
485
- const mdxHeadingsKebab = mdxHeadings.map(h => toKebabCase(h));
474
+ // Try to find the heading by text matching
475
+ for (const heading of headings) {
476
+ const headingText = await heading.innerText();
477
+ const headingTextClean = cleanHeadingText(headingText);
486
478
 
487
- const matchesMdx = mdxHeadingsKebab.includes(actualKebab);
479
+ // Try exact match first
480
+ if (headingTextClean.toLowerCase() === targetHeadingText.toLowerCase()) {
481
+ matchedHeading = heading;
482
+ matchedHeadingText = headingTextClean;
483
+ if (verbose) {
484
+ console.log(`${progress} Found exact match: "${headingTextClean}"`);
485
+ }
486
+ break;
487
+ }
488
488
 
489
- if (actualKebab === link.anchor) {
490
- if (matchesMdx) {
491
- return new ValidationResult(
492
- link.source,
493
- link.targetUrl,
494
- link.basePath,
495
- link.anchor,
496
- link.expectedSlug,
497
- 'success',
498
- link.basePath,
499
- actualTextClean,
500
- actualKebab,
501
- null,
502
- Date.now() - startTime
503
- );
504
- } else {
505
- return new ValidationResult(
506
- link.source,
507
- link.targetUrl,
508
- link.basePath,
509
- link.anchor,
510
- link.expectedSlug,
511
- 'failure',
512
- null,
513
- actualTextClean,
514
- actualKebab,
515
- `Anchor "#${link.anchor}" matches page heading "${actualTextClean}" but this heading is not found in the MDX file`,
516
- Date.now() - startTime
517
- );
489
+ // Try partial match
490
+ if (
491
+ headingTextClean.toLowerCase().includes(targetHeadingText.toLowerCase()) ||
492
+ targetHeadingText.toLowerCase().includes(headingTextClean.toLowerCase())
493
+ ) {
494
+ if (!matchedHeading) {
495
+ matchedHeading = heading;
496
+ matchedHeadingText = headingTextClean;
497
+ if (verbose) {
498
+ console.log(`${progress} Found partial match: "${headingTextClean}"`);
499
+ }
500
+ }
518
501
  }
519
- } else {
520
- if (matchesMdx) {
521
- return new ValidationResult(
522
- link.source,
523
- link.targetUrl,
524
- link.basePath,
525
- link.anchor,
526
- link.expectedSlug,
527
- 'failure',
528
- null,
529
- actualTextClean,
530
- actualKebab,
531
- `Expected anchor "#${link.anchor}" but page heading "${actualTextClean}" should use "#${actualKebab}"`,
532
- Date.now() - startTime
533
- );
534
- } else {
535
- return new ValidationResult(
536
- link.source,
537
- link.targetUrl,
538
- link.basePath,
539
- link.anchor,
540
- link.expectedSlug,
541
- 'failure',
542
- null,
543
- actualTextClean,
544
- actualKebab,
545
- `Expected anchor "#${link.anchor}" but found heading "${actualTextClean}" (#${actualKebab}) which is not in the MDX file`,
546
- Date.now() - startTime
547
- );
502
+ }
503
+
504
+ if (!matchedHeading) {
505
+ return new ValidationResult(
506
+ link.source,
507
+ link.targetUrl,
508
+ link.basePath,
509
+ link.anchor,
510
+ link.expectedSlug,
511
+ "failure",
512
+ null,
513
+ null,
514
+ null,
515
+ `Heading "${targetHeadingText}" not found on page`,
516
+ Date.now() - startTime,
517
+ );
518
+ }
519
+
520
+ // Click the heading to trigger anchor generation
521
+ if (verbose) {
522
+ console.log(`${progress} Clicking heading to get generated anchor...`);
523
+ }
524
+
525
+ // Try to click on the heading element or a link inside it
526
+ let clickTarget = matchedHeading;
527
+ const linkInHeading = await matchedHeading.$("a");
528
+ if (linkInHeading) {
529
+ clickTarget = linkInHeading;
530
+ }
531
+
532
+ await clickTarget.click();
533
+
534
+ // Wait a bit for any JavaScript to update the URL
535
+ await page.waitForTimeout(500);
536
+
537
+ // Get the current URL to extract the anchor
538
+ const currentUrl = page.url();
539
+ let generatedAnchor = null;
540
+
541
+ if (currentUrl.includes("#")) {
542
+ generatedAnchor = currentUrl.split("#")[1];
543
+ if (verbose) {
544
+ console.log(`${progress} Generated anchor from URL: #${generatedAnchor}`);
548
545
  }
549
546
  }
547
+
548
+ // If no anchor in URL, try to get it from the href attribute of the link
549
+ if (!generatedAnchor && linkInHeading) {
550
+ const href = await linkInHeading.getAttribute("href");
551
+ if (href && href.includes("#")) {
552
+ generatedAnchor = href.split("#")[1];
553
+ if (verbose) {
554
+ console.log(`${progress} Generated anchor from href: #${generatedAnchor}`);
555
+ }
556
+ }
557
+ }
558
+
559
+ if (!generatedAnchor) {
560
+ return new ValidationResult(
561
+ link.source,
562
+ link.targetUrl,
563
+ link.basePath,
564
+ link.anchor,
565
+ link.expectedSlug,
566
+ "failure",
567
+ null,
568
+ matchedHeadingText,
569
+ null,
570
+ `Could not extract generated anchor after clicking heading "${matchedHeadingText}"`,
571
+ Date.now() - startTime,
572
+ );
573
+ }
574
+
575
+ // Compare the generated anchor with the expected anchor
576
+ if (generatedAnchor === link.anchor) {
577
+ return new ValidationResult(
578
+ link.source,
579
+ link.targetUrl,
580
+ link.basePath,
581
+ link.anchor,
582
+ link.expectedSlug,
583
+ "success",
584
+ link.basePath,
585
+ matchedHeadingText,
586
+ generatedAnchor,
587
+ null,
588
+ Date.now() - startTime,
589
+ );
590
+ } else {
591
+ return new ValidationResult(
592
+ link.source,
593
+ link.targetUrl,
594
+ link.basePath,
595
+ link.anchor,
596
+ link.expectedSlug,
597
+ "failure",
598
+ null,
599
+ matchedHeadingText,
600
+ generatedAnchor,
601
+ `Expected anchor "#${link.anchor}" but page generates "#${generatedAnchor}" for heading "${matchedHeadingText}"`,
602
+ Date.now() - startTime,
603
+ );
604
+ }
550
605
  } catch (error) {
551
606
  return new ValidationResult(
552
607
  link.source,
@@ -554,17 +609,17 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
554
609
  link.basePath,
555
610
  link.anchor,
556
611
  link.expectedSlug,
557
- 'error',
612
+ "error",
558
613
  null,
559
614
  null,
560
615
  null,
561
616
  `Error validating anchor: ${error.message}`,
562
- Date.now() - startTime
617
+ Date.now() - startTime,
563
618
  );
564
619
  }
565
620
  }
566
621
 
567
- async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false, progress = '') {
622
+ async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
568
623
  const startTime = Date.now();
569
624
 
570
625
  try {
@@ -576,7 +631,7 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
576
631
  const mdxFilePath = urlToFilePath(link.targetUrl, baseUrl, repoRoot);
577
632
  if (mdxFilePath && existsSync(mdxFilePath)) {
578
633
  if (verbose) {
579
- console.log(`${progress} ✓ Link validated locally (file exists)`);
634
+ console.log(` ✓ Link validated locally (file exists)`);
580
635
  }
581
636
  return new ValidationResult(
582
637
  link.source,
@@ -584,19 +639,26 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
584
639
  link.basePath,
585
640
  link.anchor,
586
641
  link.expectedSlug,
587
- 'success',
642
+ "success",
588
643
  link.targetUrl,
589
644
  null,
590
645
  null,
591
646
  null,
592
- Date.now() - startTime
647
+ Date.now() - startTime,
593
648
  );
594
649
  } else if (verbose) {
595
- console.log(`${progress} File not found locally, checking online...`);
650
+ console.log(` File not found locally, checking online...`);
651
+ }
652
+
653
+ // Convert the target URL to use the validation base URL
654
+ const validationUrl = link.targetUrl.replace(baseUrl, validationBaseUrl);
655
+
656
+ if (verbose) {
657
+ console.log(`${progress} Navigating to: ${validationUrl}`);
596
658
  }
597
659
 
598
- // Navigate to the target URL
599
- const response = await page.goto(link.targetUrl, { waitUntil: 'networkidle', timeout: DEFAULT_TIMEOUT });
660
+ // Navigate to the validation URL
661
+ const response = await page.goto(validationUrl, { waitUntil: "networkidle", timeout: DEFAULT_TIMEOUT });
600
662
 
601
663
  if (!response) {
602
664
  return new ValidationResult(
@@ -605,12 +667,12 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
605
667
  link.basePath,
606
668
  link.anchor,
607
669
  link.expectedSlug,
608
- 'error',
670
+ "error",
609
671
  null,
610
672
  null,
611
673
  null,
612
- 'No response received',
613
- Date.now() - startTime
674
+ "No response received",
675
+ Date.now() - startTime,
614
676
  );
615
677
  }
616
678
 
@@ -623,12 +685,12 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
623
685
  link.basePath,
624
686
  link.anchor,
625
687
  link.expectedSlug,
626
- 'failure',
688
+ "failure",
627
689
  actualUrl,
628
690
  null,
629
691
  null,
630
692
  `HTTP ${response.status()}: ${response.statusText()}`,
631
- Date.now() - startTime
693
+ Date.now() - startTime,
632
694
  );
633
695
  }
634
696
 
@@ -638,12 +700,12 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
638
700
  link.basePath,
639
701
  link.anchor,
640
702
  link.expectedSlug,
641
- 'success',
703
+ "success",
642
704
  actualUrl,
643
705
  null,
644
706
  null,
645
707
  null,
646
- Date.now() - startTime
708
+ Date.now() - startTime,
647
709
  );
648
710
  } catch (error) {
649
711
  return new ValidationResult(
@@ -652,39 +714,41 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
652
714
  link.basePath,
653
715
  link.anchor,
654
716
  link.expectedSlug,
655
- 'error',
717
+ "error",
656
718
  null,
657
719
  null,
658
720
  null,
659
721
  `Error validating link: ${error.message}`,
660
- Date.now() - startTime
722
+ Date.now() - startTime,
661
723
  );
662
724
  }
663
725
  }
664
726
 
665
- async function validateLink(page, link, baseUrl, repoRoot, verbose = false, progress = '') {
727
+ async function validateLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
666
728
  if (link.anchor) {
667
- return await validateAnchor(page, link, baseUrl, repoRoot, verbose, progress);
729
+ return await validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
668
730
  } else {
669
- return await validateNormalLink(page, link, baseUrl, repoRoot, verbose, progress);
731
+ return await validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
670
732
  }
671
733
  }
672
734
 
673
- async function validateLinksAsync(links, baseUrl, repoRoot, concurrency, headless, verbose) {
735
+ async function validateLinksAsync(links, baseUrl, validationBaseUrl, repoRoot, concurrency, headless, verbose) {
674
736
  const results = [];
675
737
 
676
738
  let browser;
677
739
  try {
678
740
  browser = await chromium.launch({ headless });
679
741
  } catch (error) {
680
- if (error.message.includes('Executable doesn\'t exist') ||
681
- error.message.includes('Browser was not installed') ||
682
- error.message.includes('browserType.launch')) {
683
- console.error(chalk.red('\n✗ Playwright browsers are not installed!'));
684
- console.error(chalk.yellow('\nTo install Playwright browsers, run:'));
685
- console.error(chalk.cyan(' npx playwright install chromium\n'));
686
- console.error('Or install all browsers with:');
687
- console.error(chalk.cyan(' npx playwright install\n'));
742
+ if (
743
+ error.message.includes("Executable doesn't exist") ||
744
+ error.message.includes("Browser was not installed") ||
745
+ error.message.includes("browserType.launch")
746
+ ) {
747
+ console.error(chalk.red("\n✗ Playwright browsers are not installed!"));
748
+ console.error(chalk.yellow("\nTo install Playwright browsers, run:"));
749
+ console.error(chalk.cyan(" npx playwright install chromium\n"));
750
+ console.error("Or install all browsers with:");
751
+ console.error(chalk.cyan(" npx playwright install\n"));
688
752
  process.exit(1);
689
753
  }
690
754
  throw error;
@@ -696,13 +760,13 @@ async function validateLinksAsync(links, baseUrl, repoRoot, concurrency, headles
696
760
  async function validateWithSemaphore(link) {
697
761
  counter++;
698
762
  const current = counter;
699
- const progress = verbose ? `[${current}/${links.length}] ` : '';
763
+ const progress = verbose ? `[${current}/${links.length}] ` : "";
700
764
 
701
765
  const context = await browser.newContext();
702
766
  const page = await context.newPage();
703
767
 
704
768
  try {
705
- const result = await validateLink(page, link, baseUrl, repoRoot, verbose, progress);
769
+ const result = await validateLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
706
770
  return result;
707
771
  } finally {
708
772
  await context.close();
@@ -716,7 +780,7 @@ async function validateLinksAsync(links, baseUrl, repoRoot, concurrency, headles
716
780
  // Process links with concurrency control
717
781
  for (let i = 0; i < links.length; i += concurrency) {
718
782
  const batch = links.slice(i, i + concurrency);
719
- const batchResults = await Promise.all(batch.map(link => validateWithSemaphore(link)));
783
+ const batchResults = await Promise.all(batch.map((link) => validateWithSemaphore(link)));
720
784
  results.push(...batchResults);
721
785
  }
722
786
 
@@ -735,7 +799,7 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
735
799
 
736
800
  let reportData;
737
801
  try {
738
- reportData = JSON.parse(readFileSync(reportPath, 'utf-8'));
802
+ reportData = JSON.parse(readFileSync(reportPath, "utf-8"));
739
803
  } catch (error) {
740
804
  console.error(`Error reading report file: ${error.message}`);
741
805
  return {};
@@ -745,7 +809,7 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
745
809
 
746
810
  if (Object.keys(resultsByFile).length === 0) {
747
811
  if (verbose) {
748
- console.log('No failures found in report.');
812
+ console.log("No failures found in report.");
749
813
  }
750
814
  return {};
751
815
  }
@@ -762,15 +826,13 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
762
826
  continue;
763
827
  }
764
828
 
765
- const fixableFailures = failures.filter(
766
- f => f.status === 'failure' && f.actual_heading_kebab && f.anchor
767
- );
829
+ const fixableFailures = failures.filter((f) => f.status === "failure" && f.actual_heading_kebab && f.anchor);
768
830
 
769
831
  if (fixableFailures.length === 0) continue;
770
832
 
771
833
  try {
772
- const content = readFileSync(fullPath, 'utf-8');
773
- let lines = content.split('\n');
834
+ const content = readFileSync(fullPath, "utf-8");
835
+ let lines = content.split("\n");
774
836
  let modified = false;
775
837
  let fixesCount = 0;
776
838
 
@@ -791,7 +853,7 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
791
853
  const newAnchor = failure.actual_heading_kebab;
792
854
  const linkType = failure.source.link_type;
793
855
 
794
- const pathPart = oldHref.includes('#') ? oldHref.split('#')[0] : oldHref;
856
+ const pathPart = oldHref.includes("#") ? oldHref.split("#")[0] : oldHref;
795
857
  const newHref = pathPart ? `${pathPart}#${newAnchor}` : `#${newAnchor}`;
796
858
 
797
859
  if (oldHref === newHref) {
@@ -803,14 +865,14 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
803
865
 
804
866
  let replaced = false;
805
867
 
806
- if (linkType === 'markdown') {
868
+ if (linkType === "markdown") {
807
869
  const oldPattern = `(${oldHref})`;
808
870
  const newPattern = `(${newHref})`;
809
871
  if (line.includes(oldPattern)) {
810
872
  line = line.replace(oldPattern, newPattern);
811
873
  replaced = true;
812
874
  }
813
- } else if (linkType === 'html' || linkType === 'jsx') {
875
+ } else if (linkType === "html" || linkType === "jsx") {
814
876
  for (const quote of ['"', "'"]) {
815
877
  const oldPattern = `href=${quote}${oldHref}${quote}`;
816
878
  const newPattern = `href=${quote}${newHref}${quote}`;
@@ -838,8 +900,8 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
838
900
  }
839
901
 
840
902
  if (modified) {
841
- const newContent = lines.join('\n');
842
- writeFileSync(fullPath, newContent, 'utf-8');
903
+ const newContent = lines.join("\n");
904
+ writeFileSync(fullPath, newContent, "utf-8");
843
905
  fixesApplied[filePath] = fixesCount;
844
906
 
845
907
  if (verbose) {
@@ -860,7 +922,7 @@ function fixLinks(results, repoRoot, verbose = false) {
860
922
  const failuresByFile = {};
861
923
 
862
924
  for (const result of results) {
863
- if (result.status !== 'failure' || !result.actualHeadingKebab || !result.anchor) {
925
+ if (result.status !== "failure" || !result.actualHeadingKebab || !result.anchor) {
864
926
  continue;
865
927
  }
866
928
 
@@ -885,8 +947,8 @@ function fixLinks(results, repoRoot, verbose = false) {
885
947
  }
886
948
 
887
949
  try {
888
- const content = readFileSync(fullPath, 'utf-8');
889
- let lines = content.split('\n');
950
+ const content = readFileSync(fullPath, "utf-8");
951
+ let lines = content.split("\n");
890
952
  let modified = false;
891
953
  let fixesCount = 0;
892
954
 
@@ -906,7 +968,7 @@ function fixLinks(results, repoRoot, verbose = false) {
906
968
  const oldHref = failure.source.rawHref;
907
969
  const linkType = failure.source.linkType;
908
970
 
909
- const pathPart = oldHref.includes('#') ? oldHref.split('#')[0] : oldHref;
971
+ const pathPart = oldHref.includes("#") ? oldHref.split("#")[0] : oldHref;
910
972
  const newHref = pathPart ? `${pathPart}#${failure.actualHeadingKebab}` : `#${failure.actualHeadingKebab}`;
911
973
 
912
974
  if (oldHref === newHref) {
@@ -918,14 +980,14 @@ function fixLinks(results, repoRoot, verbose = false) {
918
980
 
919
981
  let replaced = false;
920
982
 
921
- if (linkType === 'markdown') {
983
+ if (linkType === "markdown") {
922
984
  const oldPattern = `(${oldHref})`;
923
985
  const newPattern = `(${newHref})`;
924
986
  if (line.includes(oldPattern)) {
925
987
  line = line.replace(oldPattern, newPattern);
926
988
  replaced = true;
927
989
  }
928
- } else if (linkType === 'html' || linkType === 'jsx') {
990
+ } else if (linkType === "html" || linkType === "jsx") {
929
991
  for (const quote of ['"', "'"]) {
930
992
  const oldPattern = `href=${quote}${oldHref}${quote}`;
931
993
  const newPattern = `href=${quote}${newHref}${quote}`;
@@ -953,8 +1015,8 @@ function fixLinks(results, repoRoot, verbose = false) {
953
1015
  }
954
1016
 
955
1017
  if (modified) {
956
- const newContent = lines.join('\n');
957
- writeFileSync(fullPath, newContent, 'utf-8');
1018
+ const newContent = lines.join("\n");
1019
+ writeFileSync(fullPath, newContent, "utf-8");
958
1020
  fixesApplied[filePath] = fixesCount;
959
1021
 
960
1022
  if (verbose) {
@@ -975,9 +1037,9 @@ function fixLinks(results, repoRoot, verbose = false) {
975
1037
 
976
1038
  function generateReport(results, config, outputPath) {
977
1039
  const total = results.length;
978
- const success = results.filter(r => r.status === 'success').length;
979
- const failure = results.filter(r => r.status === 'failure').length;
980
- const error = results.filter(r => r.status === 'error').length;
1040
+ const success = results.filter((r) => r.status === "success").length;
1041
+ const failure = results.filter((r) => r.status === "failure").length;
1042
+ const error = results.filter((r) => r.status === "error").length;
981
1043
 
982
1044
  const summaryByFile = {};
983
1045
  for (const result of results) {
@@ -992,7 +1054,7 @@ function generateReport(results, config, outputPath) {
992
1054
 
993
1055
  const resultsByFile = {};
994
1056
  for (const result of results) {
995
- if (result.status === 'success') continue;
1057
+ if (result.status === "success") continue;
996
1058
 
997
1059
  const filePath = result.source.filePath;
998
1060
  if (!resultsByFile[filePath]) {
@@ -1015,7 +1077,7 @@ function generateReport(results, config, outputPath) {
1015
1077
  results_by_file: resultsByFile,
1016
1078
  };
1017
1079
 
1018
- writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8');
1080
+ writeFileSync(outputPath, JSON.stringify(report, null, 2), "utf-8");
1019
1081
 
1020
1082
  return report;
1021
1083
  }
@@ -1028,19 +1090,14 @@ export async function validateLinks(baseUrl, options) {
1028
1090
  // Handle --fix-from-report mode
1029
1091
  if (options.fixFromReport !== undefined) {
1030
1092
  // If flag is passed with a path, use that path; otherwise use default
1031
- const reportPath = typeof options.fixFromReport === 'string' && options.fixFromReport
1032
- ? options.fixFromReport
1033
- : 'links_report.json';
1093
+ const reportPath =
1094
+ typeof options.fixFromReport === "string" && options.fixFromReport ? options.fixFromReport : "links_report.json";
1034
1095
 
1035
1096
  if (!options.quiet) {
1036
1097
  console.log(`Applying fixes from report: ${reportPath}`);
1037
1098
  }
1038
1099
 
1039
- const fixesApplied = fixLinksFromReport(
1040
- reportPath,
1041
- repoRoot,
1042
- options.verbose && !options.quiet
1043
- );
1100
+ const fixesApplied = fixLinksFromReport(reportPath, repoRoot, options.verbose && !options.quiet);
1044
1101
 
1045
1102
  if (!options.quiet) {
1046
1103
  if (Object.keys(fixesApplied).length > 0) {
@@ -1049,9 +1106,9 @@ export async function validateLinks(baseUrl, options) {
1049
1106
  for (const [filePath, count] of Object.entries(fixesApplied)) {
1050
1107
  console.log(` ${filePath}: ${count} fix(es)`);
1051
1108
  }
1052
- console.log('\nRun validation again to verify the fixes.');
1109
+ console.log("\nRun validation again to verify the fixes.");
1053
1110
  } else {
1054
- console.log('\nNo fixable issues found in report.');
1111
+ console.log("\nNo fixable issues found in report.");
1055
1112
  }
1056
1113
  }
1057
1114
 
@@ -1060,20 +1117,20 @@ export async function validateLinks(baseUrl, options) {
1060
1117
 
1061
1118
  // Normalize base URL - add https:// if not present
1062
1119
  let normalizedBaseUrl = baseUrl;
1063
- if (!normalizedBaseUrl.startsWith('http://') && !normalizedBaseUrl.startsWith('https://')) {
1064
- normalizedBaseUrl = 'https://' + normalizedBaseUrl;
1120
+ if (!normalizedBaseUrl.startsWith("http://") && !normalizedBaseUrl.startsWith("https://")) {
1121
+ normalizedBaseUrl = "https://" + normalizedBaseUrl;
1065
1122
  }
1066
1123
  // Remove trailing slash
1067
- normalizedBaseUrl = normalizedBaseUrl.replace(/\/+$/, '');
1124
+ normalizedBaseUrl = normalizedBaseUrl.replace(/\/+$/, "");
1068
1125
 
1069
1126
  if (options.verbose && !options.quiet) {
1070
- console.log('Finding MDX files...');
1127
+ console.log("Finding MDX files...");
1071
1128
  }
1072
1129
 
1073
1130
  const mdxFiles = findMdxFiles(repoRoot, options.dir, options.file);
1074
1131
 
1075
1132
  if (mdxFiles.length === 0) {
1076
- console.error('No MDX files found.');
1133
+ console.error("No MDX files found.");
1077
1134
  process.exit(1);
1078
1135
  }
1079
1136
 
@@ -1082,7 +1139,7 @@ export async function validateLinks(baseUrl, options) {
1082
1139
  }
1083
1140
 
1084
1141
  if (options.verbose && !options.quiet) {
1085
- console.log('Extracting links...');
1142
+ console.log("Extracting links...");
1086
1143
  }
1087
1144
 
1088
1145
  const allLinks = [];
@@ -1092,7 +1149,7 @@ export async function validateLinks(baseUrl, options) {
1092
1149
  }
1093
1150
 
1094
1151
  if (allLinks.length === 0) {
1095
- console.log('No internal links found.');
1152
+ console.log("No internal links found.");
1096
1153
  return;
1097
1154
  }
1098
1155
 
@@ -1101,7 +1158,7 @@ export async function validateLinks(baseUrl, options) {
1101
1158
  }
1102
1159
 
1103
1160
  if (options.dryRun) {
1104
- console.log('\nExtracted links:');
1161
+ console.log("\nExtracted links:");
1105
1162
  allLinks.forEach((link, i) => {
1106
1163
  console.log(`\n${i + 1}. ${link.source.filePath}:${link.source.lineNumber}`);
1107
1164
  console.log(` Text: ${link.source.linkText}`);
@@ -1117,23 +1174,36 @@ export async function validateLinks(baseUrl, options) {
1117
1174
  const startTime = Date.now();
1118
1175
 
1119
1176
  if (!options.quiet) {
1120
- console.log('\nValidating links...');
1177
+ console.log("\nValidating links...");
1178
+ }
1179
+
1180
+ // Normalize validation base URL
1181
+ let normalizedValidationBaseUrl = options.validationBaseUrl || "http://localhost:3000";
1182
+ if (!normalizedValidationBaseUrl.startsWith("http://") && !normalizedValidationBaseUrl.startsWith("https://")) {
1183
+ normalizedValidationBaseUrl = "https://" + normalizedValidationBaseUrl;
1184
+ }
1185
+ // Remove trailing slash
1186
+ normalizedValidationBaseUrl = normalizedValidationBaseUrl.replace(/\/+$/, "");
1187
+
1188
+ if (!options.quiet) {
1189
+ console.log(`\nUsing validation base URL: ${normalizedValidationBaseUrl}`);
1121
1190
  }
1122
1191
 
1123
1192
  const results = await validateLinksAsync(
1124
1193
  allLinks,
1125
1194
  normalizedBaseUrl,
1195
+ normalizedValidationBaseUrl,
1126
1196
  repoRoot,
1127
1197
  parseInt(options.concurrency) || DEFAULT_CONCURRENCY,
1128
1198
  options.headless !== false,
1129
- options.verbose && !options.quiet
1199
+ options.verbose && !options.quiet,
1130
1200
  );
1131
1201
 
1132
1202
  const executionTime = (Date.now() - startTime) / 1000;
1133
1203
 
1134
1204
  if (options.fix) {
1135
1205
  if (!options.quiet) {
1136
- console.log('\nApplying fixes...');
1206
+ console.log("\nApplying fixes...");
1137
1207
  }
1138
1208
 
1139
1209
  const fixesApplied = fixLinks(results, repoRoot, options.verbose && !options.quiet);
@@ -1145,9 +1215,9 @@ export async function validateLinks(baseUrl, options) {
1145
1215
  for (const [filePath, count] of Object.entries(fixesApplied)) {
1146
1216
  console.log(` ${filePath}: ${count} fix(es)`);
1147
1217
  }
1148
- console.log('\nRun validation again to verify the fixes.');
1218
+ console.log("\nRun validation again to verify the fixes.");
1149
1219
  } else {
1150
- console.log('\nNo fixable issues found.');
1220
+ console.log("\nNo fixable issues found.");
1151
1221
  }
1152
1222
  }
1153
1223
  }
@@ -1160,23 +1230,23 @@ export async function validateLinks(baseUrl, options) {
1160
1230
  execution_time_seconds: Math.round(executionTime * 100) / 100,
1161
1231
  };
1162
1232
 
1163
- const report = generateReport(results, config, options.output || 'links_report.json');
1233
+ const report = generateReport(results, config, options.output || "links_report.json");
1164
1234
 
1165
1235
  if (!options.quiet) {
1166
- console.log(`\n${'='.repeat(60)}`);
1167
- console.log('VALIDATION SUMMARY');
1168
- console.log('='.repeat(60));
1236
+ console.log(`\n${"=".repeat(60)}`);
1237
+ console.log("VALIDATION SUMMARY");
1238
+ console.log("=".repeat(60));
1169
1239
  console.log(`Total links: ${report.summary.total_links}`);
1170
- console.log(`Success: ${chalk.green(report.summary.success + '')}`);
1171
- console.log(`Failure: ${chalk.red(report.summary.failure + '')}`);
1172
- console.log(`Error: ${chalk.yellow(report.summary.error + '')}`);
1240
+ console.log(`Success: ${chalk.green(report.summary.success + "")}`);
1241
+ console.log(`Failure: ${chalk.red(report.summary.failure + "")}`);
1242
+ console.log(`Error: ${chalk.yellow(report.summary.error + "")}`);
1173
1243
  console.log(`Execution time: ${executionTime.toFixed(2)}s`);
1174
- console.log(`\nReport saved to: ${options.output || 'links_report.json'}`);
1244
+ console.log(`\nReport saved to: ${options.output || "links_report.json"}`);
1175
1245
 
1176
1246
  if (report.summary.failure > 0 || report.summary.error > 0) {
1177
- console.log(`\n${'='.repeat(60)}`);
1178
- console.log('ISSUES FOUND');
1179
- console.log('='.repeat(60));
1247
+ console.log(`\n${"=".repeat(60)}`);
1248
+ console.log("ISSUES FOUND");
1249
+ console.log("=".repeat(60));
1180
1250
  let shown = 0;
1181
1251
 
1182
1252
  for (const [filePath, fileResults] of Object.entries(report.results_by_file)) {
@@ -1196,7 +1266,9 @@ export async function validateLinks(baseUrl, options) {
1196
1266
 
1197
1267
  if (shown < report.summary.failure + report.summary.error) {
1198
1268
  const remaining = report.summary.failure + report.summary.error - shown;
1199
- console.log(`\n... and ${remaining} more issues. See ${options.output || 'links_report.json'} for full details.`);
1269
+ console.log(
1270
+ `\n... and ${remaining} more issues. See ${options.output || "links_report.json"} for full details.`,
1271
+ );
1200
1272
  }
1201
1273
  }
1202
1274
  }