@writechoice/mint-cli 0.0.11 → 0.0.13

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/bin/cli.js CHANGED
@@ -16,7 +16,9 @@ const program = new Command();
16
16
 
17
17
  program
18
18
  .name("writechoice")
19
- .description("CLI tool for Mintlify documentation validation and utilities")
19
+ .description(
20
+ "@writechoice/mint-cli@" + packageJson.version + "\n\nCLI tool for Mintlify documentation validation and utilities",
21
+ )
20
22
  .version(packageJson.version, "-v, --version", "Output the current version");
21
23
 
22
24
  // Validate command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writechoice/mint-cli",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "CLI tool for Mintlify documentation validation and utilities",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
- const validationTargetUrl = link.basePath.replace(baseUrl, validationBaseUrl);
480
- return new ValidationResult(
481
- link.source,
482
- link.targetUrl, // sourceUrl (production)
483
- validationTargetUrl, // targetUrl (validation)
484
- link.basePath,
485
- link.anchor,
486
- link.expectedSlug,
487
- "failure",
488
- null,
489
- null,
490
- null,
491
- `Anchor #${link.anchor} not found on base URL page`,
492
- Date.now() - startTime,
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
- return new ValidationResult(
556
- link.source,
557
- link.targetUrl, // sourceUrl (production)
558
- validationTargetUrl, // targetUrl (validation)
559
- link.basePath,
560
- link.anchor,
561
- link.expectedSlug,
562
- "failure",
563
- null,
564
- actualHeadingTextClean,
565
- null,
566
- `Heading "${actualHeadingTextClean}" found on base URL but not on validation URL`,
567
- Date.now() - startTime,
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
  }