@writechoice/mint-cli 0.0.6 → 0.0.7

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
@@ -91,6 +91,20 @@ writechoice check links docs.example.com https://staging.example.com
91
91
 
92
92
  The validation base URL is only used for online checks. Local file validation remains unchanged for optimal performance.
93
93
 
94
+ **How the two-step validation works:**
95
+
96
+ For anchor links, the tool performs a smart validation:
97
+
98
+ 1. Navigates to your production docs (base URL) to find the actual heading the anchor points to
99
+ 2. Then navigates to your local dev server (validation URL) and clicks the same heading to see what anchor it generates
100
+ 3. Compares the two anchors to detect mismatches
101
+
102
+ This is useful because:
103
+
104
+ - Link text in MDX files may differ from actual heading text
105
+ - Handles pages with duplicate headings correctly by matching position
106
+ - Validates against your local development environment before deploying
107
+
94
108
  ### Common Options
95
109
 
96
110
  ```bash
@@ -165,14 +179,13 @@ The tool extracts internal links from MDX files in the following formats:
165
179
  1. **Local Validation**: First checks if the target MDX file exists locally
166
180
  - For normal links: Verifies the file exists in the repository
167
181
  - 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)
182
+ 2. **Online Validation**: If local check fails, performs a two-step validation process
183
+ - For normal links: Navigates to the validation base URL and verifies the page loads successfully
184
+ - For anchor links (two-step process):
185
+ 1. **Step 1 - Find the target heading**: Navigates to the base URL (production docs) with the anchor to identify which heading the anchor points to and its position (handles duplicate headings)
186
+ 2. **Step 2 - Get generated anchor**: Navigates to the validation base URL (e.g., localhost:3000), finds the same heading (by text and position), clicks it to trigger anchor generation, and extracts the generated anchor from the URL
187
+ 3. Compares the generated anchor with the expected anchor from the MDX file
188
+ 3. **Validation Base URL**: By default uses `http://localhost:3000` for online validation, or you can specify a custom URL (e.g., staging environment). This allows testing against a local development server or staging environment while validating links meant for production.
176
189
  4. **Auto-Fix**: When issues are found, can automatically update MDX files with the correct anchors
177
190
 
178
191
  ### Report Format
@@ -195,14 +208,6 @@ The tool generates a JSON report with the following structure:
195
208
  "failure": 8,
196
209
  "error": 2
197
210
  },
198
- "summary_by_file": {
199
- "docs/getting-started.mdx": {
200
- "total": 10,
201
- "success": 9,
202
- "failure": 1,
203
- "error": 0
204
- }
205
- },
206
211
  "results_by_file": {
207
212
  "docs/getting-started.mdx": [
208
213
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writechoice/mint-cli",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "CLI tool for Mintlify documentation validation and utilities",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -26,6 +26,8 @@ const __filename = fileURLToPath(import.meta.url);
26
26
  const __dirname = dirname(__filename);
27
27
 
28
28
  // Configuration
29
+
30
+ const DEFAULT_SPACE = " ";
29
31
  const DEFAULT_BASE_URL = "https://docs.nebius.com";
30
32
  const EXCLUDED_DIRS = ["snippets"];
31
33
  const MDX_DIRS = ["."];
@@ -44,12 +46,14 @@ const LINK_PATTERNS = {
44
46
 
45
47
  // Data Structures
46
48
  class LinkLocation {
47
- constructor(filePath, lineNumber, linkText, rawHref, linkType) {
49
+ constructor(filePath, lineNumber, linkText, rawHref, linkType, sourceUrl, targetUrl) {
48
50
  this.filePath = filePath;
49
51
  this.lineNumber = lineNumber;
50
52
  this.linkText = linkText;
51
53
  this.rawHref = rawHref;
52
54
  this.linkType = linkType;
55
+ this.sourceUrl = sourceUrl;
56
+ this.targetUrl = targetUrl;
53
57
  }
54
58
  }
55
59
 
@@ -66,6 +70,7 @@ class Link {
66
70
  class ValidationResult {
67
71
  constructor(
68
72
  source,
73
+ sourceUrl,
69
74
  targetUrl,
70
75
  basePath,
71
76
  anchor,
@@ -73,11 +78,12 @@ class ValidationResult {
73
78
  status,
74
79
  actualUrl = null,
75
80
  actualHeading = null,
76
- actualHeadingKebab = null,
81
+ actualHeadingAnchor = null,
77
82
  errorMessage = null,
78
83
  validationTimeMs = 0,
79
84
  ) {
80
85
  this.source = source;
86
+ this.sourceUrl = sourceUrl;
81
87
  this.targetUrl = targetUrl;
82
88
  this.basePath = basePath;
83
89
  this.anchor = anchor;
@@ -85,7 +91,7 @@ class ValidationResult {
85
91
  this.status = status;
86
92
  this.actualUrl = actualUrl;
87
93
  this.actualHeading = actualHeading;
88
- this.actualHeadingKebab = actualHeadingKebab;
94
+ this.actualHeadingAnchor = actualHeadingAnchor;
89
95
  this.errorMessage = errorMessage;
90
96
  this.validationTimeMs = validationTimeMs;
91
97
  }
@@ -199,7 +205,7 @@ function extractMdxHeadings(filePath) {
199
205
  }
200
206
  }
201
207
 
202
- function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
208
+ function extractLinksFromFile(filePath, baseUrl, validationBaseUrl, repoRoot, verbose = false) {
203
209
  if (verbose) {
204
210
  console.log(` Extracting links from ${relative(repoRoot, filePath)}`);
205
211
  }
@@ -215,6 +221,12 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
215
221
  const { cleanedContent } = removeCodeBlocksAndFrontmatter(content);
216
222
  const links = [];
217
223
 
224
+ // Calculate source URLs from file path
225
+ const relativeFilePath = relative(repoRoot, filePath);
226
+ const urlPath = relativeFilePath.replace(/\.mdx$/, "").replace(/\/index$/, "");
227
+ const fileSourceUrl = normalizeUrl(`${baseUrl}/${urlPath}`);
228
+ const fileTargetUrl = normalizeUrl(`${validationBaseUrl}/${urlPath}`);
229
+
218
230
  // Collect all image positions to skip them
219
231
  const imagePositions = new Set();
220
232
 
@@ -253,6 +265,8 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
253
265
  linkText.trim(),
254
266
  href,
255
267
  "markdown",
268
+ fileSourceUrl,
269
+ fileTargetUrl,
256
270
  );
257
271
 
258
272
  const [basePath, anchor = ""] = targetUrl.split("#");
@@ -278,6 +292,8 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
278
292
  linkText.trim(),
279
293
  href,
280
294
  "html",
295
+ fileSourceUrl,
296
+ fileTargetUrl,
281
297
  );
282
298
 
283
299
  const [basePath, anchor = ""] = targetUrl.split("#");
@@ -303,6 +319,8 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
303
319
  linkText.trim(),
304
320
  href,
305
321
  "jsx",
322
+ fileSourceUrl,
323
+ fileTargetUrl,
306
324
  );
307
325
 
308
326
  const [basePath, anchor = ""] = targetUrl.split("#");
@@ -328,6 +346,8 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
328
346
  linkText.trim(),
329
347
  href,
330
348
  "jsx",
349
+ fileSourceUrl,
350
+ fileTargetUrl,
331
351
  );
332
352
 
333
353
  const [basePath, anchor = ""] = targetUrl.split("#");
@@ -382,7 +402,7 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
382
402
 
383
403
  try {
384
404
  if (verbose) {
385
- console.log(`${progress} Validating anchor: ${link.anchor}`);
405
+ console.log(`${progress} -> Validating anchor: ${link.anchor}`);
386
406
  }
387
407
 
388
408
  // OPTIMIZATION: Check if anchor exists in local MDX file first (local validation)
@@ -394,11 +414,14 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
394
414
  if (mdxHeadingsKebab.includes(link.anchor)) {
395
415
  const heading = mdxHeadings.find((h) => toKebabCase(h) === link.anchor);
396
416
  if (verbose) {
397
- console.log(`${progress} ✓ Anchor validated locally in MDX file`);
417
+ console.log(`${DEFAULT_SPACE}✓ Anchor validated locally in MDX file`);
398
418
  }
419
+ // Construct validation URL
420
+ const validationTargetUrl = link.targetUrl.replace(baseUrl, validationBaseUrl);
399
421
  return new ValidationResult(
400
422
  link.source,
401
- link.targetUrl,
423
+ link.targetUrl, // sourceUrl (production)
424
+ validationTargetUrl, // targetUrl (validation)
402
425
  link.basePath,
403
426
  link.anchor,
404
427
  link.expectedSlug,
@@ -410,50 +433,53 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
410
433
  Date.now() - startTime,
411
434
  );
412
435
  } else if (verbose) {
413
- console.log(`${progress} Anchor not found in local MDX, checking online...`);
436
+ console.log(`${DEFAULT_SPACE}Anchor not found in local MDX, checking online...`);
414
437
  }
415
438
  }
416
439
 
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);
420
-
440
+ // ONLINE VALIDATION: Two-step process
441
+ // Step 1: Navigate to the base URL (production docs) to find the actual heading
421
442
  if (verbose) {
422
- console.log(`${progress} Navigating to: ${validationUrl}`);
443
+ console.log(`${DEFAULT_SPACE}Step 1: Navigating to base URL to find heading: ${link.targetUrl}`);
423
444
  }
424
445
 
425
- // Navigate to validation page
426
- await page.goto(validationUrl, { waitUntil: "networkidle", timeout: DEFAULT_TIMEOUT });
427
-
428
- // Extract headings from the MDX file to find the matching heading text
429
- let targetHeadingText = null;
446
+ await page.goto(link.targetUrl, { waitUntil: "networkidle", timeout: DEFAULT_TIMEOUT });
430
447
 
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
- }
448
+ // Try to find the heading by the anchor ID
449
+ let targetHeading = await page.$(`#${link.anchor}`);
450
+ if (!targetHeading) {
451
+ targetHeading = await page.$(`[id="${link.anchor}"]`);
443
452
  }
444
453
 
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;
454
+ // If we still can't find it, try scrolling to the anchor via hash navigation
455
+ if (!targetHeading) {
448
456
  if (verbose) {
449
- console.log(`${progress} Using link text as heading: "${targetHeadingText}"`);
457
+ console.log(`${DEFAULT_SPACE}Heading not found by ID, checking if page scrolled to anchor...`);
458
+ }
459
+
460
+ // Get all headings and see if any are in the viewport (likely scrolled to)
461
+ const headings = await page.$$("h1, h2, h3, h4, h5, h6");
462
+ for (const heading of headings) {
463
+ const isInViewport = await heading.isVisible();
464
+ const boundingBox = await heading.boundingBox();
465
+
466
+ // Check if heading is near the top of the viewport (likely the anchor target)
467
+ if (isInViewport && boundingBox && boundingBox.y < 300) {
468
+ const headingId = await heading.getAttribute("id");
469
+ if (headingId === link.anchor) {
470
+ targetHeading = heading;
471
+ break;
472
+ }
473
+ }
450
474
  }
451
475
  }
452
476
 
453
- if (!targetHeadingText) {
477
+ if (!targetHeading) {
478
+ const validationTargetUrl = link.basePath.replace(baseUrl, validationBaseUrl);
454
479
  return new ValidationResult(
455
480
  link.source,
456
- link.targetUrl,
481
+ link.targetUrl, // sourceUrl (production)
482
+ validationTargetUrl, // targetUrl (validation)
457
483
  link.basePath,
458
484
  link.anchor,
459
485
  link.expectedSlug,
@@ -461,128 +487,163 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
461
487
  null,
462
488
  null,
463
489
  null,
464
- `Could not determine heading text to search for anchor #${link.anchor}`,
490
+ `Anchor #${link.anchor} not found on base URL page`,
465
491
  Date.now() - startTime,
466
492
  );
467
493
  }
468
494
 
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;
495
+ // Get the actual heading text from the base URL
496
+ const actualHeadingText = await targetHeading.innerText();
497
+ const actualHeadingTextClean = cleanHeadingText(actualHeadingText);
473
498
 
474
- // Try to find the heading by text matching
475
- for (const heading of headings) {
476
- const headingText = await heading.innerText();
499
+ if (verbose) {
500
+ console.log(`${DEFAULT_SPACE}Found heading on base URL: "${actualHeadingTextClean}"`);
501
+ }
502
+
503
+ // Get all headings on the page to determine the index of this heading (for duplicates)
504
+ const allHeadings = await page.$$("h1, h2, h3, h4, h5, h6");
505
+ let targetHeadingIndex = -1;
506
+ const headingsWithSameText = [];
507
+
508
+ for (let i = 0; i < allHeadings.length; i++) {
509
+ const headingText = await allHeadings[i].innerText();
477
510
  const headingTextClean = cleanHeadingText(headingText);
478
511
 
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}"`);
512
+ if (headingTextClean.toLowerCase() === actualHeadingTextClean.toLowerCase()) {
513
+ headingsWithSameText.push(i);
514
+
515
+ // Check if this is our target heading
516
+ const isSameElement = await page.evaluate(({ h1, h2 }) => h1 === h2, { h1: targetHeading, h2: allHeadings[i] });
517
+
518
+ if (isSameElement) {
519
+ targetHeadingIndex = headingsWithSameText.length - 1; // Index within headings with same text
485
520
  }
486
- break;
487
521
  }
522
+ }
488
523
 
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
- }
524
+ if (verbose) {
525
+ console.log(
526
+ `${DEFAULT_SPACE}Heading occurrence: ${targetHeadingIndex + 1} of ${headingsWithSameText.length} with text "${actualHeadingTextClean}"`,
527
+ );
528
+ }
529
+
530
+ // Step 2: Navigate to the validation URL (localhost) to get the generated anchor
531
+ const validationUrl = link.basePath.replace(baseUrl, validationBaseUrl);
532
+
533
+ if (verbose) {
534
+ console.log(`${DEFAULT_SPACE}Step 2: Navigating to validation URL: ${validationUrl}`);
535
+ }
536
+
537
+ await page.goto(validationUrl, { waitUntil: "networkidle", timeout: DEFAULT_TIMEOUT });
538
+
539
+ // Find the same heading on the validation page (by text and index)
540
+ const validationHeadings = await page.$$("h1, h2, h3, h4, h5, h6");
541
+ const matchingHeadings = [];
542
+
543
+ for (const heading of validationHeadings) {
544
+ const headingText = await heading.innerText();
545
+ const headingTextClean = cleanHeadingText(headingText);
546
+
547
+ if (headingTextClean.toLowerCase() === actualHeadingTextClean.toLowerCase()) {
548
+ matchingHeadings.push(heading);
501
549
  }
502
550
  }
503
551
 
504
- if (!matchedHeading) {
552
+ if (matchingHeadings.length === 0) {
553
+ const validationTargetUrl = validationUrl;
505
554
  return new ValidationResult(
506
555
  link.source,
507
- link.targetUrl,
556
+ link.targetUrl, // sourceUrl (production)
557
+ validationTargetUrl, // targetUrl (validation)
508
558
  link.basePath,
509
559
  link.anchor,
510
560
  link.expectedSlug,
511
561
  "failure",
512
562
  null,
563
+ actualHeadingTextClean,
513
564
  null,
514
- null,
515
- `Heading "${targetHeadingText}" not found on page`,
565
+ `Heading "${actualHeadingTextClean}" found on base URL but not on validation URL`,
516
566
  Date.now() - startTime,
517
567
  );
518
568
  }
519
569
 
520
- // Click the heading to trigger anchor generation
570
+ // Use the same index to handle duplicate headings
571
+ const targetValidationHeading = matchingHeadings[Math.min(targetHeadingIndex, matchingHeadings.length - 1)];
572
+
521
573
  if (verbose) {
522
- console.log(`${progress} Clicking heading to get generated anchor...`);
574
+ console.log(
575
+ `${DEFAULT_SPACE}Found matching heading on validation page (${targetHeadingIndex + 1} of ${matchingHeadings.length})`,
576
+ );
577
+ console.log(`${DEFAULT_SPACE}Clicking heading to get generated anchor...`);
523
578
  }
524
579
 
525
- // Try to click on the heading element or a link inside it
526
- let clickTarget = matchedHeading;
527
- const linkInHeading = await matchedHeading.$("a");
580
+ // Click the heading to get the generated anchor
581
+ let clickTarget = targetValidationHeading;
582
+ const linkInHeading = await targetValidationHeading.$("a");
528
583
  if (linkInHeading) {
529
584
  clickTarget = linkInHeading;
530
585
  }
531
586
 
532
587
  await clickTarget.click();
533
588
 
534
- // Wait a bit for any JavaScript to update the URL
589
+ // Wait for URL update
535
590
  await page.waitForTimeout(500);
536
591
 
537
- // Get the current URL to extract the anchor
592
+ // Extract the generated anchor
538
593
  const currentUrl = page.url();
539
594
  let generatedAnchor = null;
540
595
 
541
596
  if (currentUrl.includes("#")) {
542
597
  generatedAnchor = currentUrl.split("#")[1];
543
598
  if (verbose) {
544
- console.log(`${progress} Generated anchor from URL: #${generatedAnchor}`);
599
+ console.log(`${DEFAULT_SPACE}Generated anchor from URL: #${generatedAnchor}`);
545
600
  }
546
601
  }
547
602
 
548
- // If no anchor in URL, try to get it from the href attribute of the link
603
+ // If no anchor in URL, try to get it from the href attribute
549
604
  if (!generatedAnchor && linkInHeading) {
550
605
  const href = await linkInHeading.getAttribute("href");
551
606
  if (href && href.includes("#")) {
552
607
  generatedAnchor = href.split("#")[1];
553
608
  if (verbose) {
554
- console.log(`${progress} Generated anchor from href: #${generatedAnchor}`);
609
+ console.log(`${DEFAULT_SPACE}Generated anchor from href: #${generatedAnchor}`);
555
610
  }
556
611
  }
557
612
  }
558
613
 
559
614
  if (!generatedAnchor) {
615
+ const validationTargetUrl = validationUrl;
560
616
  return new ValidationResult(
561
617
  link.source,
562
- link.targetUrl,
618
+ link.targetUrl, // sourceUrl (production)
619
+ validationTargetUrl, // targetUrl (validation)
563
620
  link.basePath,
564
621
  link.anchor,
565
622
  link.expectedSlug,
566
623
  "failure",
567
624
  null,
568
- matchedHeadingText,
625
+ actualHeadingTextClean,
569
626
  null,
570
- `Could not extract generated anchor after clicking heading "${matchedHeadingText}"`,
627
+ `Could not extract generated anchor after clicking heading "${actualHeadingTextClean}"`,
571
628
  Date.now() - startTime,
572
629
  );
573
630
  }
574
631
 
575
632
  // Compare the generated anchor with the expected anchor
633
+ // Construct the full validation URL with the generated anchor
634
+ const validationTargetUrl = generatedAnchor ? `${validationUrl}#${generatedAnchor}` : validationUrl;
635
+
576
636
  if (generatedAnchor === link.anchor) {
577
637
  return new ValidationResult(
578
638
  link.source,
579
- link.targetUrl,
639
+ link.targetUrl, // sourceUrl (production)
640
+ validationTargetUrl, // targetUrl (validation with generated anchor)
580
641
  link.basePath,
581
642
  link.anchor,
582
643
  link.expectedSlug,
583
644
  "success",
584
645
  link.basePath,
585
- matchedHeadingText,
646
+ actualHeadingTextClean,
586
647
  generatedAnchor,
587
648
  null,
588
649
  Date.now() - startTime,
@@ -590,22 +651,25 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
590
651
  } else {
591
652
  return new ValidationResult(
592
653
  link.source,
593
- link.targetUrl,
654
+ link.targetUrl, // sourceUrl (production)
655
+ validationTargetUrl, // targetUrl (validation with generated anchor)
594
656
  link.basePath,
595
657
  link.anchor,
596
658
  link.expectedSlug,
597
659
  "failure",
598
660
  null,
599
- matchedHeadingText,
661
+ actualHeadingTextClean,
600
662
  generatedAnchor,
601
- `Expected anchor "#${link.anchor}" but page generates "#${generatedAnchor}" for heading "${matchedHeadingText}"`,
663
+ `Expected anchor "#${link.anchor}" but page generates "#${generatedAnchor}" for heading "${actualHeadingTextClean}"`,
602
664
  Date.now() - startTime,
603
665
  );
604
666
  }
605
667
  } catch (error) {
668
+ const validationTargetUrl = link.targetUrl.replace(baseUrl, validationBaseUrl);
606
669
  return new ValidationResult(
607
670
  link.source,
608
- link.targetUrl,
671
+ link.targetUrl, // sourceUrl (production)
672
+ validationTargetUrl, // targetUrl (validation)
609
673
  link.basePath,
610
674
  link.anchor,
611
675
  link.expectedSlug,
@@ -624,18 +688,20 @@ async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRo
624
688
 
625
689
  try {
626
690
  if (verbose) {
627
- console.log(`${progress} Validating link: ${link.targetUrl}`);
691
+ console.log(`${progress} -> Validating link: ${link.targetUrl}`);
628
692
  }
629
693
 
630
694
  // OPTIMIZATION: Check if target MDX file exists locally first
631
695
  const mdxFilePath = urlToFilePath(link.targetUrl, baseUrl, repoRoot);
632
696
  if (mdxFilePath && existsSync(mdxFilePath)) {
633
697
  if (verbose) {
634
- console.log(` ✓ Link validated locally (file exists)`);
698
+ console.log(`${DEFAULT_SPACE}✓ Link validated locally (file exists)`);
635
699
  }
700
+ const validationTargetUrl = link.targetUrl.replace(baseUrl, validationBaseUrl);
636
701
  return new ValidationResult(
637
702
  link.source,
638
- link.targetUrl,
703
+ link.targetUrl, // sourceUrl (production)
704
+ validationTargetUrl, // targetUrl (validation)
639
705
  link.basePath,
640
706
  link.anchor,
641
707
  link.expectedSlug,
@@ -647,14 +713,14 @@ async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRo
647
713
  Date.now() - startTime,
648
714
  );
649
715
  } else if (verbose) {
650
- console.log(` File not found locally, checking online...`);
716
+ console.log(`${DEFAULT_SPACE}File not found locally, checking online...`);
651
717
  }
652
718
 
653
719
  // Convert the target URL to use the validation base URL
654
720
  const validationUrl = link.targetUrl.replace(baseUrl, validationBaseUrl);
655
721
 
656
722
  if (verbose) {
657
- console.log(`${progress} Navigating to: ${validationUrl}`);
723
+ console.log(`${DEFAULT_SPACE}Navigating to: ${validationUrl}`);
658
724
  }
659
725
 
660
726
  // Navigate to the validation URL
@@ -663,7 +729,8 @@ async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRo
663
729
  if (!response) {
664
730
  return new ValidationResult(
665
731
  link.source,
666
- link.targetUrl,
732
+ link.targetUrl, // sourceUrl (production)
733
+ validationUrl, // targetUrl (validation)
667
734
  link.basePath,
668
735
  link.anchor,
669
736
  link.expectedSlug,
@@ -681,7 +748,8 @@ async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRo
681
748
  if (response.status() >= 400) {
682
749
  return new ValidationResult(
683
750
  link.source,
684
- link.targetUrl,
751
+ link.targetUrl, // sourceUrl (production)
752
+ validationUrl, // targetUrl (validation)
685
753
  link.basePath,
686
754
  link.anchor,
687
755
  link.expectedSlug,
@@ -696,7 +764,8 @@ async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRo
696
764
 
697
765
  return new ValidationResult(
698
766
  link.source,
699
- link.targetUrl,
767
+ link.targetUrl, // sourceUrl (production)
768
+ validationUrl, // targetUrl (validation)
700
769
  link.basePath,
701
770
  link.anchor,
702
771
  link.expectedSlug,
@@ -708,9 +777,11 @@ async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRo
708
777
  Date.now() - startTime,
709
778
  );
710
779
  } catch (error) {
780
+ const validationTargetUrl = link.targetUrl.replace(baseUrl, validationBaseUrl);
711
781
  return new ValidationResult(
712
782
  link.source,
713
- link.targetUrl,
783
+ link.targetUrl, // sourceUrl (production)
784
+ validationTargetUrl, // targetUrl (validation)
714
785
  link.basePath,
715
786
  link.anchor,
716
787
  link.expectedSlug,
@@ -922,7 +993,7 @@ function fixLinks(results, repoRoot, verbose = false) {
922
993
  const failuresByFile = {};
923
994
 
924
995
  for (const result of results) {
925
- if (result.status !== "failure" || !result.actualHeadingKebab || !result.anchor) {
996
+ if (result.status !== "failure" || !result.actualHeadingAnchor || !result.anchor) {
926
997
  continue;
927
998
  }
928
999
 
@@ -969,7 +1040,7 @@ function fixLinks(results, repoRoot, verbose = false) {
969
1040
  const linkType = failure.source.linkType;
970
1041
 
971
1042
  const pathPart = oldHref.includes("#") ? oldHref.split("#")[0] : oldHref;
972
- const newHref = pathPart ? `${pathPart}#${failure.actualHeadingKebab}` : `#${failure.actualHeadingKebab}`;
1043
+ const newHref = pathPart ? `${pathPart}#${failure.actualHeadingAnchor}` : `#${failure.actualHeadingAnchor}`;
973
1044
 
974
1045
  if (oldHref === newHref) {
975
1046
  if (verbose) {
@@ -1073,7 +1144,6 @@ function generateReport(results, config, outputPath) {
1073
1144
  failure,
1074
1145
  error,
1075
1146
  },
1076
- summary_by_file: summaryByFile,
1077
1147
  results_by_file: resultsByFile,
1078
1148
  };
1079
1149
 
@@ -1138,13 +1208,27 @@ export async function validateLinks(baseUrl, options) {
1138
1208
  console.log(`Found ${mdxFiles.length} MDX files\n`);
1139
1209
  }
1140
1210
 
1211
+ // Normalize validation base URL
1212
+ let normalizedValidationBaseUrl = options.validationBaseUrl || "http://localhost:3000";
1213
+ if (!normalizedValidationBaseUrl.startsWith("http://") && !normalizedValidationBaseUrl.startsWith("https://")) {
1214
+ normalizedValidationBaseUrl = "https://" + normalizedValidationBaseUrl;
1215
+ }
1216
+ // Remove trailing slash
1217
+ normalizedValidationBaseUrl = normalizeUrl(normalizedValidationBaseUrl);
1218
+
1141
1219
  if (options.verbose && !options.quiet) {
1142
1220
  console.log("Extracting links...");
1143
1221
  }
1144
1222
 
1145
1223
  const allLinks = [];
1146
1224
  for (const mdxFile of mdxFiles) {
1147
- const links = extractLinksFromFile(mdxFile, normalizedBaseUrl, repoRoot, options.verbose && !options.quiet);
1225
+ const links = extractLinksFromFile(
1226
+ mdxFile,
1227
+ normalizedBaseUrl,
1228
+ normalizedValidationBaseUrl,
1229
+ repoRoot,
1230
+ options.verbose && !options.quiet,
1231
+ );
1148
1232
  allLinks.push(...links);
1149
1233
  }
1150
1234
 
@@ -1177,14 +1261,6 @@ export async function validateLinks(baseUrl, options) {
1177
1261
  console.log("\nValidating links...");
1178
1262
  }
1179
1263
 
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
1264
  if (!options.quiet) {
1189
1265
  console.log(`\nUsing validation base URL: ${normalizedValidationBaseUrl}`);
1190
1266
  }
@@ -8,6 +8,11 @@ import { URL } from 'url';
8
8
  * "Create resources\nCreate resources" -> "Create resources"
9
9
  */
10
10
  export function cleanHeadingText(text) {
11
+ // Remove zero-width characters and other invisible Unicode characters
12
+ // This includes: zero-width space, zero-width non-joiner, zero-width joiner,
13
+ // left-to-right mark, right-to-left mark, etc.
14
+ text = text.replace(/[\u200B-\u200D\u200E-\u200F\uFEFF]/g, '');
15
+
11
16
  // Split by newlines and get unique parts while preserving order
12
17
  const lines = text
13
18
  .split('\n')