@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 +21 -16
- package/package.json +1 -1
- package/src/commands/validate/links.js +183 -107
- package/src/utils/helpers.js +5 -0
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,
|
|
169
|
-
- For normal links:
|
|
170
|
-
- For anchor links:
|
|
171
|
-
-
|
|
172
|
-
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
@@ -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
|
-
|
|
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.
|
|
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}
|
|
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(`${
|
|
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(`${
|
|
436
|
+
console.log(`${DEFAULT_SPACE}Anchor not found in local MDX, checking online...`);
|
|
414
437
|
}
|
|
415
438
|
}
|
|
416
439
|
|
|
417
|
-
// ONLINE VALIDATION:
|
|
418
|
-
//
|
|
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(`${
|
|
443
|
+
console.log(`${DEFAULT_SPACE}Step 1: Navigating to base URL to find heading: ${link.targetUrl}`);
|
|
423
444
|
}
|
|
424
445
|
|
|
425
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
446
|
-
if (!
|
|
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(`${
|
|
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 (!
|
|
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
|
-
`
|
|
490
|
+
`Anchor #${link.anchor} not found on base URL page`,
|
|
465
491
|
Date.now() - startTime,
|
|
466
492
|
);
|
|
467
493
|
}
|
|
468
494
|
|
|
469
|
-
//
|
|
470
|
-
const
|
|
471
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
526
|
-
let clickTarget =
|
|
527
|
-
const linkInHeading = await
|
|
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
|
|
589
|
+
// Wait for URL update
|
|
535
590
|
await page.waitForTimeout(500);
|
|
536
591
|
|
|
537
|
-
//
|
|
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(`${
|
|
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
|
|
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(`${
|
|
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
|
-
|
|
625
|
+
actualHeadingTextClean,
|
|
569
626
|
null,
|
|
570
|
-
`Could not extract generated anchor after clicking heading "${
|
|
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
|
-
|
|
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
|
-
|
|
661
|
+
actualHeadingTextClean,
|
|
600
662
|
generatedAnchor,
|
|
601
|
-
`Expected anchor "#${link.anchor}" but page generates "#${generatedAnchor}" for heading "${
|
|
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}
|
|
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(
|
|
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(
|
|
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(`${
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
}
|
package/src/utils/helpers.js
CHANGED
|
@@ -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')
|