@writechoice/mint-cli 0.0.11 → 0.0.12
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/package.json +1 -1
- package/src/commands/validate/links.js +139 -31
package/package.json
CHANGED
|
@@ -398,6 +398,76 @@ function findMdxFiles(repoRoot, directory = null, file = null) {
|
|
|
398
398
|
|
|
399
399
|
// Playwright Validation Functions
|
|
400
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Find a heading on the page by matching its text with the link text.
|
|
403
|
+
* Considers exact matches, partial matches, and phrase variations.
|
|
404
|
+
*
|
|
405
|
+
* @param {Page} page - Playwright page object
|
|
406
|
+
* @param {string} linkText - The text from the link (e.g., "Document Picker Security Settings")
|
|
407
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
408
|
+
* @returns {Promise<{heading: ElementHandle|null, text: string|null, index: number}>}
|
|
409
|
+
*/
|
|
410
|
+
async function findHeadingByText(page, linkText, verbose = false) {
|
|
411
|
+
const allHeadings = await page.$$("h1, h2, h3, h4, h5, h6");
|
|
412
|
+
|
|
413
|
+
// Normalize the link text for comparison
|
|
414
|
+
const normalizedLinkText = linkText.toLowerCase().trim();
|
|
415
|
+
|
|
416
|
+
// Store potential matches with their scores
|
|
417
|
+
const matches = [];
|
|
418
|
+
|
|
419
|
+
for (let i = 0; i < allHeadings.length; i++) {
|
|
420
|
+
const heading = allHeadings[i];
|
|
421
|
+
const headingText = await heading.innerText();
|
|
422
|
+
const headingTextClean = cleanHeadingText(headingText);
|
|
423
|
+
const normalizedHeadingText = headingTextClean.toLowerCase().trim();
|
|
424
|
+
|
|
425
|
+
// Exact match (highest priority)
|
|
426
|
+
if (normalizedHeadingText === normalizedLinkText) {
|
|
427
|
+
matches.push({ heading, text: headingTextClean, index: i, score: 100 });
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Check if link text is contained in heading (complete phrase match)
|
|
432
|
+
if (normalizedHeadingText.includes(normalizedLinkText)) {
|
|
433
|
+
matches.push({ heading, text: headingTextClean, index: i, score: 80 });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Check if heading is contained in link text (link text might be more specific)
|
|
438
|
+
if (normalizedLinkText.includes(normalizedHeadingText)) {
|
|
439
|
+
matches.push({ heading, text: headingTextClean, index: i, score: 70 });
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Check word-by-word match (all words from link text appear in heading)
|
|
444
|
+
const linkWords = normalizedLinkText.split(/\s+/);
|
|
445
|
+
const headingWords = normalizedHeadingText.split(/\s+/);
|
|
446
|
+
const matchingWords = linkWords.filter(word => headingWords.includes(word));
|
|
447
|
+
|
|
448
|
+
if (matchingWords.length === linkWords.length && linkWords.length >= 2) {
|
|
449
|
+
// All words match
|
|
450
|
+
matches.push({ heading, text: headingTextClean, index: i, score: 60 });
|
|
451
|
+
} else if (matchingWords.length >= Math.ceil(linkWords.length * 0.7) && linkWords.length >= 3) {
|
|
452
|
+
// At least 70% of words match (for longer phrases)
|
|
453
|
+
matches.push({ heading, text: headingTextClean, index: i, score: 50 });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Sort by score (highest first)
|
|
458
|
+
matches.sort((a, b) => b.score - a.score);
|
|
459
|
+
|
|
460
|
+
if (matches.length > 0) {
|
|
461
|
+
const bestMatch = matches[0];
|
|
462
|
+
if (verbose) {
|
|
463
|
+
console.log(`${DEFAULT_SPACE}Found heading by text match (score: ${bestMatch.score}): "${bestMatch.text}"`);
|
|
464
|
+
}
|
|
465
|
+
return { heading: bestMatch.heading, text: bestMatch.text, index: bestMatch.index };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { heading: null, text: null, index: -1 };
|
|
469
|
+
}
|
|
470
|
+
|
|
401
471
|
async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
|
|
402
472
|
const startTime = Date.now();
|
|
403
473
|
|
|
@@ -475,22 +545,39 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
|
|
|
475
545
|
}
|
|
476
546
|
}
|
|
477
547
|
|
|
548
|
+
// If anchor not found by ID, try to find heading by text
|
|
478
549
|
if (!targetHeading) {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
550
|
+
if (verbose) {
|
|
551
|
+
console.log(`${DEFAULT_SPACE}Anchor #${link.anchor} not found by ID, searching by text...`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Try to find the heading by matching the link text
|
|
555
|
+
const textMatch = await findHeadingByText(page, link.source.linkText, verbose);
|
|
556
|
+
|
|
557
|
+
if (!textMatch.heading) {
|
|
558
|
+
const validationTargetUrl = link.basePath.replace(baseUrl, validationBaseUrl);
|
|
559
|
+
return new ValidationResult(
|
|
560
|
+
link.source,
|
|
561
|
+
link.targetUrl, // sourceUrl (production)
|
|
562
|
+
validationTargetUrl, // targetUrl (validation)
|
|
563
|
+
link.basePath,
|
|
564
|
+
link.anchor,
|
|
565
|
+
link.expectedSlug,
|
|
566
|
+
"failure",
|
|
567
|
+
null,
|
|
568
|
+
null,
|
|
569
|
+
null,
|
|
570
|
+
`Anchor #${link.anchor} not found on base URL page. Also could not find a heading matching "${link.source.linkText}"`,
|
|
571
|
+
Date.now() - startTime,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Found a heading by text! Use it as the target
|
|
576
|
+
targetHeading = textMatch.heading;
|
|
577
|
+
|
|
578
|
+
if (verbose) {
|
|
579
|
+
console.log(`${DEFAULT_SPACE}✓ Using heading found by text match: "${textMatch.text}"`);
|
|
580
|
+
}
|
|
494
581
|
}
|
|
495
582
|
|
|
496
583
|
// Get the actual heading text from the base URL
|
|
@@ -552,20 +639,37 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
|
|
|
552
639
|
|
|
553
640
|
if (matchingHeadings.length === 0) {
|
|
554
641
|
const validationTargetUrl = validationUrl;
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
642
|
+
|
|
643
|
+
// Try to find heading by text on validation page as well
|
|
644
|
+
if (verbose) {
|
|
645
|
+
console.log(`${DEFAULT_SPACE}Heading not found by text on validation page, trying broader search...`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const validationTextMatch = await findHeadingByText(page, link.source.linkText, verbose);
|
|
649
|
+
|
|
650
|
+
if (validationTextMatch.heading) {
|
|
651
|
+
if (verbose) {
|
|
652
|
+
console.log(`${DEFAULT_SPACE}Found alternative heading on validation page: "${validationTextMatch.text}"`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Use this heading instead
|
|
656
|
+
matchingHeadings.push(validationTextMatch.heading);
|
|
657
|
+
} else {
|
|
658
|
+
return new ValidationResult(
|
|
659
|
+
link.source,
|
|
660
|
+
link.targetUrl, // sourceUrl (production)
|
|
661
|
+
validationTargetUrl, // targetUrl (validation)
|
|
662
|
+
link.basePath,
|
|
663
|
+
link.anchor,
|
|
664
|
+
link.expectedSlug,
|
|
665
|
+
"failure",
|
|
666
|
+
null,
|
|
667
|
+
actualHeadingTextClean,
|
|
668
|
+
null,
|
|
669
|
+
`Heading "${actualHeadingTextClean}" found on base URL but not on validation URL`,
|
|
670
|
+
Date.now() - startTime,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
569
673
|
}
|
|
570
674
|
|
|
571
675
|
// Use the same index to handle duplicate headings
|
|
@@ -614,6 +718,10 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
|
|
|
614
718
|
|
|
615
719
|
if (!generatedAnchor) {
|
|
616
720
|
const validationTargetUrl = validationUrl;
|
|
721
|
+
|
|
722
|
+
// Suggest a kebab-case anchor based on the heading text
|
|
723
|
+
const suggestedAnchor = toKebabCase(actualHeadingTextClean);
|
|
724
|
+
|
|
617
725
|
return new ValidationResult(
|
|
618
726
|
link.source,
|
|
619
727
|
link.targetUrl, // sourceUrl (production)
|
|
@@ -625,7 +733,7 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
|
|
|
625
733
|
null,
|
|
626
734
|
actualHeadingTextClean,
|
|
627
735
|
null,
|
|
628
|
-
`Could not extract generated anchor after clicking heading "${actualHeadingTextClean}"`,
|
|
736
|
+
`Could not extract generated anchor after clicking heading "${actualHeadingTextClean}". Suggested anchor based on heading: #${suggestedAnchor}`,
|
|
629
737
|
Date.now() - startTime,
|
|
630
738
|
);
|
|
631
739
|
}
|
|
@@ -661,7 +769,7 @@ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot,
|
|
|
661
769
|
null,
|
|
662
770
|
actualHeadingTextClean,
|
|
663
771
|
generatedAnchor,
|
|
664
|
-
`Expected anchor "#${link.anchor}" but page generates "#${generatedAnchor}" for heading "${actualHeadingTextClean}"`,
|
|
772
|
+
`Expected anchor "#${link.anchor}" but page generates "#${generatedAnchor}" for heading "${actualHeadingTextClean}". Suggestion: Update link to use #${generatedAnchor}`,
|
|
665
773
|
Date.now() - startTime,
|
|
666
774
|
);
|
|
667
775
|
}
|