@writechoice/mint-cli 0.0.5 → 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
@@ -110,19 +127,20 @@ writechoice check links docs.example.com --fix-from-report custom_report.json
110
127
 
111
128
  ### Complete Options
112
129
 
113
- | Option | Alias | Description | Default |
114
- | -------------------------- | ----- | ------------------------------------------------------------------------ | ------------------- |
115
- | `<baseUrl>` | - | Base URL for the documentation site (required, with or without https://) | - |
116
- | `--file <path>` | `-f` | Validate links in a single MDX file | - |
117
- | `--dir <path>` | `-d` | Validate links in a specific directory | - |
118
- | `--output <path>` | `-o` | Output path for JSON report | `links_report.json` |
119
- | `--dry-run` | - | Extract and show links without validating | `false` |
120
- | `--quiet` | - | Suppress terminal output (only generate report) | `false` |
121
- | `--concurrency <number>` | `-c` | Number of concurrent browser tabs | `25` |
122
- | `--headless` | - | Run browser in headless mode | `true` |
123
- | `--no-headless` | - | Show browser window (for debugging) | - |
124
- | `--fix` | - | Automatically fix anchor links in MDX files | `false` |
125
- | `--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` |
126
144
 
127
145
  **Note:** Detailed progress output is shown by default. Use `--quiet` to suppress terminal output.
128
146
 
@@ -138,15 +156,24 @@ The tool extracts internal links from MDX files in the following formats:
138
156
  4. **JSX Button components**: `<Button href="/path/to/page#anchor">Button Text</Button>`
139
157
 
140
158
  **Images are automatically ignored:**
159
+
141
160
  - Markdown images: `![Alt Text](./image.png)`
142
161
  - HTML images: `<img src="./image.png" />`
143
162
 
144
163
  ### Validation Process
145
164
 
146
165
  1. **Local Validation**: First checks if the target MDX file exists locally
147
- 2. **Online Validation**: If local check fails, uses Playwright to navigate to the live URL
148
- 3. **Anchor Validation**: For anchor links, verifies the heading exists and matches the anchor format
149
- 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
150
177
 
151
178
  ### Report Format
152
179
 
package/bin/cli.js CHANGED
@@ -24,7 +24,7 @@ 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")
@@ -36,10 +36,12 @@ check
36
36
  .option("--no-headless", "Show browser window (for debugging)")
37
37
  .option("--fix", "Automatically fix anchor links in MDX files")
38
38
  .option("--fix-from-report [path]", "Fix anchor links from report file (default: links_report.json)")
39
- .action(async (baseUrl, options) => {
39
+ .action(async (baseUrl, validationBaseUrl, options) => {
40
40
  const { validateLinks } = await import("../src/commands/validate/links.js");
41
41
  // Verbose is now default (true unless --quiet is specified)
42
42
  options.verbose = !options.quiet;
43
+ // Set validation base URL to localhost:3000 if not provided
44
+ options.validationBaseUrl = validationBaseUrl || "http://localhost:3000";
43
45
  await validateLinks(baseUrl, options);
44
46
  });
45
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writechoice/mint-cli",
3
- "version": "0.0.5",
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",
@@ -377,7 +377,7 @@ function findMdxFiles(repoRoot, directory = null, file = null) {
377
377
 
378
378
  // Playwright Validation Functions
379
379
 
380
- async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, progress = "") {
380
+ async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
381
381
  const startTime = Date.now();
382
382
 
383
383
  try {
@@ -385,7 +385,7 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
385
385
  console.log(`${progress} Validating anchor: ${link.anchor}`);
386
386
  }
387
387
 
388
- // OPTIMIZATION: Check if anchor exists in local MDX file first
388
+ // OPTIMIZATION: Check if anchor exists in local MDX file first (local validation)
389
389
  const mdxFilePath = urlToFilePath(link.basePath, baseUrl, repoRoot);
390
390
  if (mdxFilePath && existsSync(mdxFilePath)) {
391
391
  const mdxHeadings = extractMdxHeadings(mdxFilePath);
@@ -394,7 +394,7 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
394
394
  if (mdxHeadingsKebab.includes(link.anchor)) {
395
395
  const heading = mdxHeadings.find((h) => toKebabCase(h) === link.anchor);
396
396
  if (verbose) {
397
- console.log(` ✓ Anchor validated locally in MDX file`);
397
+ console.log(`${progress} ✓ Anchor validated locally in MDX file`);
398
398
  }
399
399
  return new ValidationResult(
400
400
  link.source,
@@ -410,21 +410,47 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
410
410
  Date.now() - startTime,
411
411
  );
412
412
  } else if (verbose) {
413
- console.log(` Anchor not found in local MDX, checking online...`);
413
+ console.log(`${progress} Anchor not found in local MDX, checking online...`);
414
414
  }
415
415
  }
416
416
 
417
- // Navigate to base page
418
- 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);
419
420
 
420
- // Try to find heading by anchor
421
- 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 });
427
+
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
+ }
443
+ }
422
444
 
423
- if (!heading) {
424
- heading = await page.$(`[id="${link.anchor}"]`);
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
+ }
425
451
  }
426
452
 
427
- if (!heading) {
453
+ if (!targetHeadingText) {
428
454
  return new ValidationResult(
429
455
  link.source,
430
456
  link.targetUrl,
@@ -435,84 +461,147 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
435
461
  null,
436
462
  null,
437
463
  null,
438
- `Anchor #${link.anchor} not found on page`,
464
+ `Could not determine heading text to search for anchor #${link.anchor}`,
439
465
  Date.now() - startTime,
440
466
  );
441
467
  }
442
468
 
443
- // Get heading text and clean it
444
- const actualText = await heading.innerText();
445
- const actualTextClean = cleanHeadingText(actualText);
446
- 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;
447
473
 
448
- // Extract headings from the TARGET MDX file to verify
449
- const mdxFilePath2 = urlToFilePath(link.basePath, baseUrl, repoRoot);
450
- const mdxHeadings = mdxFilePath2 ? extractMdxHeadings(mdxFilePath2) : [];
451
- 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);
452
478
 
453
- 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
+ }
454
488
 
455
- if (actualKebab === link.anchor) {
456
- if (matchesMdx) {
457
- return new ValidationResult(
458
- link.source,
459
- link.targetUrl,
460
- link.basePath,
461
- link.anchor,
462
- link.expectedSlug,
463
- "success",
464
- link.basePath,
465
- actualTextClean,
466
- actualKebab,
467
- null,
468
- Date.now() - startTime,
469
- );
470
- } else {
471
- return new ValidationResult(
472
- link.source,
473
- link.targetUrl,
474
- link.basePath,
475
- link.anchor,
476
- link.expectedSlug,
477
- "failure",
478
- null,
479
- actualTextClean,
480
- actualKebab,
481
- `Anchor "#${link.anchor}" matches page heading "${actualTextClean}" but this heading is not found in the MDX file`,
482
- Date.now() - startTime,
483
- );
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
+ }
484
501
  }
485
- } else {
486
- if (matchesMdx) {
487
- return new ValidationResult(
488
- link.source,
489
- link.targetUrl,
490
- link.basePath,
491
- link.anchor,
492
- link.expectedSlug,
493
- "failure",
494
- null,
495
- actualTextClean,
496
- actualKebab,
497
- `Expected anchor "#${link.anchor}" but page heading "${actualTextClean}" should use "#${actualKebab}"`,
498
- Date.now() - startTime,
499
- );
500
- } else {
501
- return new ValidationResult(
502
- link.source,
503
- link.targetUrl,
504
- link.basePath,
505
- link.anchor,
506
- link.expectedSlug,
507
- "failure",
508
- null,
509
- actualTextClean,
510
- actualKebab,
511
- `Expected anchor "#${link.anchor}" but found heading "${actualTextClean}" (#${actualKebab}) which is not in the MDX file`,
512
- Date.now() - startTime,
513
- );
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}`);
514
545
  }
515
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
+ }
516
605
  } catch (error) {
517
606
  return new ValidationResult(
518
607
  link.source,
@@ -530,7 +619,7 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
530
619
  }
531
620
  }
532
621
 
533
- async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false, progress = "") {
622
+ async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
534
623
  const startTime = Date.now();
535
624
 
536
625
  try {
@@ -561,8 +650,15 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
561
650
  console.log(` File not found locally, checking online...`);
562
651
  }
563
652
 
564
- // Navigate to the target URL
565
- const response = await page.goto(link.targetUrl, { waitUntil: "networkidle", timeout: DEFAULT_TIMEOUT });
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}`);
658
+ }
659
+
660
+ // Navigate to the validation URL
661
+ const response = await page.goto(validationUrl, { waitUntil: "networkidle", timeout: DEFAULT_TIMEOUT });
566
662
 
567
663
  if (!response) {
568
664
  return new ValidationResult(
@@ -628,15 +724,15 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
628
724
  }
629
725
  }
630
726
 
631
- async function validateLink(page, link, baseUrl, repoRoot, verbose = false, progress = "") {
727
+ async function validateLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
632
728
  if (link.anchor) {
633
- return await validateAnchor(page, link, baseUrl, repoRoot, verbose, progress);
729
+ return await validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
634
730
  } else {
635
- return await validateNormalLink(page, link, baseUrl, repoRoot, verbose, progress);
731
+ return await validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
636
732
  }
637
733
  }
638
734
 
639
- async function validateLinksAsync(links, baseUrl, repoRoot, concurrency, headless, verbose) {
735
+ async function validateLinksAsync(links, baseUrl, validationBaseUrl, repoRoot, concurrency, headless, verbose) {
640
736
  const results = [];
641
737
 
642
738
  let browser;
@@ -670,7 +766,7 @@ async function validateLinksAsync(links, baseUrl, repoRoot, concurrency, headles
670
766
  const page = await context.newPage();
671
767
 
672
768
  try {
673
- const result = await validateLink(page, link, baseUrl, repoRoot, verbose, progress);
769
+ const result = await validateLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
674
770
  return result;
675
771
  } finally {
676
772
  await context.close();
@@ -1081,9 +1177,22 @@ export async function validateLinks(baseUrl, options) {
1081
1177
  console.log("\nValidating links...");
1082
1178
  }
1083
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}`);
1190
+ }
1191
+
1084
1192
  const results = await validateLinksAsync(
1085
1193
  allLinks,
1086
1194
  normalizedBaseUrl,
1195
+ normalizedValidationBaseUrl,
1087
1196
  repoRoot,
1088
1197
  parseInt(options.concurrency) || DEFAULT_CONCURRENCY,
1089
1198
  options.headless !== false,