@writechoice/mint-cli 0.0.4 → 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 +57 -29
- package/bin/cli.js +7 -4
- package/package.json +1 -1
- package/src/commands/validate/links.js +325 -253
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Check the installed version:
|
|
|
47
47
|
```bash
|
|
48
48
|
writechoice --version
|
|
49
49
|
# or
|
|
50
|
-
writechoice -
|
|
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
|
|
@@ -83,16 +100,10 @@ writechoice check links docs.example.com -f path/to/file.mdx
|
|
|
83
100
|
# Validate links in a specific directory
|
|
84
101
|
writechoice check links docs.example.com -d path/to/docs
|
|
85
102
|
|
|
86
|
-
# Use short aliases for common flags
|
|
87
|
-
writechoice check links docs.example.com -v -o my_report.json
|
|
88
|
-
|
|
89
103
|
# Dry run (extract links without validating)
|
|
90
104
|
writechoice check links docs.example.com --dry-run
|
|
91
105
|
|
|
92
|
-
#
|
|
93
|
-
writechoice check links docs.example.com -v
|
|
94
|
-
|
|
95
|
-
# Quiet mode (only generate report)
|
|
106
|
+
# Quiet mode (suppress terminal output, only generate report)
|
|
96
107
|
writechoice check links docs.example.com --quiet
|
|
97
108
|
|
|
98
109
|
# Custom output path for report
|
|
@@ -116,20 +127,22 @@ writechoice check links docs.example.com --fix-from-report custom_report.json
|
|
|
116
127
|
|
|
117
128
|
### Complete Options
|
|
118
129
|
|
|
119
|
-
| Option | Alias | Description
|
|
120
|
-
| -------------------------- | ----- |
|
|
121
|
-
| `<baseUrl>` | - | Base URL for the documentation site (required, with or without https://)
|
|
122
|
-
|
|
|
123
|
-
| `--
|
|
124
|
-
| `--
|
|
125
|
-
| `--
|
|
126
|
-
| `--
|
|
127
|
-
| `--quiet` | - | Suppress
|
|
128
|
-
| `--concurrency <number>` | `-c` | Number of concurrent browser tabs
|
|
129
|
-
| `--headless` | - | Run browser in headless mode
|
|
130
|
-
| `--no-headless` | - | Show browser window (for debugging)
|
|
131
|
-
| `--fix` | - | Automatically fix anchor links in MDX files
|
|
132
|
-
| `--fix-from-report [path]` | - | Fix anchor links from report file (optional path)
|
|
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` |
|
|
144
|
+
|
|
145
|
+
**Note:** Detailed progress output is shown by default. Use `--quiet` to suppress terminal output.
|
|
133
146
|
|
|
134
147
|
## How It Works
|
|
135
148
|
|
|
@@ -143,15 +156,24 @@ The tool extracts internal links from MDX files in the following formats:
|
|
|
143
156
|
4. **JSX Button components**: `<Button href="/path/to/page#anchor">Button Text</Button>`
|
|
144
157
|
|
|
145
158
|
**Images are automatically ignored:**
|
|
159
|
+
|
|
146
160
|
- Markdown images: ``
|
|
147
161
|
- HTML images: `<img src="./image.png" />`
|
|
148
162
|
|
|
149
163
|
### Validation Process
|
|
150
164
|
|
|
151
165
|
1. **Local Validation**: First checks if the target MDX file exists locally
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
155
177
|
|
|
156
178
|
### Report Format
|
|
157
179
|
|
|
@@ -273,16 +295,22 @@ The default concurrency is set to 25 concurrent browser tabs. Adjust this based
|
|
|
273
295
|
|
|
274
296
|
## Examples
|
|
275
297
|
|
|
276
|
-
### Validate all links with
|
|
298
|
+
### Validate all links (with progress output)
|
|
277
299
|
|
|
278
300
|
```bash
|
|
279
|
-
writechoice check links docs.example.com
|
|
301
|
+
writechoice check links docs.example.com
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Validate quietly (suppress terminal output)
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
writechoice check links docs.example.com --quiet
|
|
280
308
|
```
|
|
281
309
|
|
|
282
310
|
### Validate and fix issues in one command
|
|
283
311
|
|
|
284
312
|
```bash
|
|
285
|
-
writechoice check links docs.example.com --fix
|
|
313
|
+
writechoice check links docs.example.com --fix
|
|
286
314
|
```
|
|
287
315
|
|
|
288
316
|
### Two-step fix workflow
|
|
@@ -301,7 +329,7 @@ writechoice check links docs.example.com --fix-from-report
|
|
|
301
329
|
### Validate specific directory
|
|
302
330
|
|
|
303
331
|
```bash
|
|
304
|
-
writechoice check links docs.example.com -d docs/api
|
|
332
|
+
writechoice check links docs.example.com -d docs/api
|
|
305
333
|
```
|
|
306
334
|
|
|
307
335
|
## Troubleshooting
|
package/bin/cli.js
CHANGED
|
@@ -24,21 +24,24 @@ 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")
|
|
31
31
|
.option("-o, --output <path>", "Output path for JSON report", "links_report.json")
|
|
32
32
|
.option("--dry-run", "Extract and show links without validating")
|
|
33
|
-
.option("
|
|
34
|
-
.option("--quiet", "Suppress stdout output (only generate report)")
|
|
33
|
+
.option("--quiet", "Suppress terminal output (only generate report)")
|
|
35
34
|
.option("-c, --concurrency <number>", "Number of concurrent browser tabs", "25")
|
|
36
35
|
.option("--headless", "Run browser in headless mode (default)", true)
|
|
37
36
|
.option("--no-headless", "Show browser window (for debugging)")
|
|
38
37
|
.option("--fix", "Automatically fix anchor links in MDX files")
|
|
39
38
|
.option("--fix-from-report [path]", "Fix anchor links from report file (default: links_report.json)")
|
|
40
|
-
.action(async (baseUrl, options) => {
|
|
39
|
+
.action(async (baseUrl, validationBaseUrl, options) => {
|
|
41
40
|
const { validateLinks } = await import("../src/commands/validate/links.js");
|
|
41
|
+
// Verbose is now default (true unless --quiet is specified)
|
|
42
|
+
options.verbose = !options.quiet;
|
|
43
|
+
// Set validation base URL to localhost:3000 if not provided
|
|
44
|
+
options.validationBaseUrl = validationBaseUrl || "http://localhost:3000";
|
|
42
45
|
await validateLinks(baseUrl, options);
|
|
43
46
|
});
|
|
44
47
|
|
package/package.json
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* JavaScript-rendered Mintlify pages.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from
|
|
10
|
-
import { join, relative, resolve, dirname } from
|
|
11
|
-
import { fileURLToPath } from
|
|
12
|
-
import { chromium } from
|
|
13
|
-
import chalk from
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
|
|
10
|
+
import { join, relative, resolve, dirname } from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { chromium } from "playwright";
|
|
13
|
+
import chalk from "chalk";
|
|
14
14
|
import {
|
|
15
15
|
cleanHeadingText,
|
|
16
16
|
toKebabCase,
|
|
@@ -20,15 +20,15 @@ import {
|
|
|
20
20
|
findLineNumber,
|
|
21
21
|
removeCodeBlocksAndFrontmatter,
|
|
22
22
|
resolvePath as resolvePathUtil,
|
|
23
|
-
} from
|
|
23
|
+
} from "../../utils/helpers.js";
|
|
24
24
|
|
|
25
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
26
|
const __dirname = dirname(__filename);
|
|
27
27
|
|
|
28
28
|
// Configuration
|
|
29
|
-
const DEFAULT_BASE_URL =
|
|
30
|
-
const EXCLUDED_DIRS = [
|
|
31
|
-
const MDX_DIRS = [
|
|
29
|
+
const DEFAULT_BASE_URL = "https://docs.nebius.com";
|
|
30
|
+
const EXCLUDED_DIRS = ["snippets"];
|
|
31
|
+
const MDX_DIRS = ["."];
|
|
32
32
|
const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
|
33
33
|
const DEFAULT_CONCURRENCY = 25;
|
|
34
34
|
|
|
@@ -75,7 +75,7 @@ class ValidationResult {
|
|
|
75
75
|
actualHeading = null,
|
|
76
76
|
actualHeadingKebab = null,
|
|
77
77
|
errorMessage = null,
|
|
78
|
-
validationTimeMs = 0
|
|
78
|
+
validationTimeMs = 0,
|
|
79
79
|
) {
|
|
80
80
|
this.source = source;
|
|
81
81
|
this.targetUrl = targetUrl;
|
|
@@ -106,10 +106,10 @@ function urlToFilePath(url, baseUrl, repoRoot) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
path = path.replace(/^\/+/,
|
|
109
|
+
path = path.replace(/^\/+/, "");
|
|
110
110
|
|
|
111
|
-
if (!path || path ===
|
|
112
|
-
const indexPath = join(repoRoot,
|
|
111
|
+
if (!path || path === "/") {
|
|
112
|
+
const indexPath = join(repoRoot, "index.mdx");
|
|
113
113
|
return existsSync(indexPath) ? indexPath : null;
|
|
114
114
|
}
|
|
115
115
|
|
|
@@ -118,7 +118,7 @@ function urlToFilePath(url, baseUrl, repoRoot) {
|
|
|
118
118
|
return mdxPath;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
const indexPath = join(repoRoot, path,
|
|
121
|
+
const indexPath = join(repoRoot, path, "index.mdx");
|
|
122
122
|
if (existsSync(indexPath)) {
|
|
123
123
|
return indexPath;
|
|
124
124
|
}
|
|
@@ -132,44 +132,44 @@ function resolvePath(mdxFilePath, href, baseUrl, repoRoot) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
let path, anchor;
|
|
135
|
-
if (href.includes(
|
|
136
|
-
[path, anchor] = href.split(
|
|
135
|
+
if (href.includes("#")) {
|
|
136
|
+
[path, anchor] = href.split("#", 2);
|
|
137
137
|
} else {
|
|
138
138
|
path = href;
|
|
139
|
-
anchor =
|
|
139
|
+
anchor = "";
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
if (!path && anchor) {
|
|
143
143
|
const relPath = relative(repoRoot, mdxFilePath);
|
|
144
|
-
const urlPath = relPath.replace(/\.mdx$/,
|
|
144
|
+
const urlPath = relPath.replace(/\.mdx$/, "");
|
|
145
145
|
const fullUrl = normalizeUrl(`${baseUrl}/${urlPath}`);
|
|
146
146
|
return `${fullUrl}#${anchor}`;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
let fullUrl;
|
|
150
150
|
|
|
151
|
-
if (path.startsWith(
|
|
151
|
+
if (path.startsWith("/")) {
|
|
152
152
|
fullUrl = normalizeUrl(baseUrl + path);
|
|
153
153
|
} else {
|
|
154
154
|
const mdxDir = dirname(mdxFilePath);
|
|
155
155
|
|
|
156
|
-
if (path.startsWith(
|
|
156
|
+
if (path.startsWith("./")) {
|
|
157
157
|
path = path.slice(2);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
const resolved = resolve(mdxDir, path);
|
|
161
161
|
|
|
162
162
|
const relToRoot = relative(repoRoot, resolved);
|
|
163
|
-
if (relToRoot.startsWith(
|
|
163
|
+
if (relToRoot.startsWith("..")) {
|
|
164
164
|
return null;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
const urlPath = relToRoot.replace(/\.mdx$/,
|
|
167
|
+
const urlPath = relToRoot.replace(/\.mdx$/, "");
|
|
168
168
|
fullUrl = normalizeUrl(`${baseUrl}/${urlPath}`);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
if (anchor) {
|
|
172
|
-
fullUrl +=
|
|
172
|
+
fullUrl += "#" + anchor;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
return fullUrl;
|
|
@@ -179,7 +179,7 @@ function resolvePath(mdxFilePath, href, baseUrl, repoRoot) {
|
|
|
179
179
|
|
|
180
180
|
function extractMdxHeadings(filePath) {
|
|
181
181
|
try {
|
|
182
|
-
const content = readFileSync(filePath,
|
|
182
|
+
const content = readFileSync(filePath, "utf-8");
|
|
183
183
|
const { cleanedContent } = removeCodeBlocksAndFrontmatter(content);
|
|
184
184
|
|
|
185
185
|
const headingPattern = /^#{1,6}\s+(.+)$/gm;
|
|
@@ -189,7 +189,7 @@ function extractMdxHeadings(filePath) {
|
|
|
189
189
|
while ((match = headingPattern.exec(cleanedContent)) !== null) {
|
|
190
190
|
let headingText = match[1].trim();
|
|
191
191
|
// Remove any trailing {#custom-id} syntax if present
|
|
192
|
-
headingText = headingText.replace(/\s*\{#[^}]+\}\s*$/,
|
|
192
|
+
headingText = headingText.replace(/\s*\{#[^}]+\}\s*$/, "");
|
|
193
193
|
headings.push(headingText);
|
|
194
194
|
}
|
|
195
195
|
|
|
@@ -206,7 +206,7 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
|
|
|
206
206
|
|
|
207
207
|
let content;
|
|
208
208
|
try {
|
|
209
|
-
content = readFileSync(filePath,
|
|
209
|
+
content = readFileSync(filePath, "utf-8");
|
|
210
210
|
} catch (error) {
|
|
211
211
|
console.error(`Error reading ${filePath}: ${error.message}`);
|
|
212
212
|
return [];
|
|
@@ -234,8 +234,8 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
|
|
|
234
234
|
const markdownMatches = [...cleanedContent.matchAll(LINK_PATTERNS.markdown)];
|
|
235
235
|
for (const match of markdownMatches) {
|
|
236
236
|
// Check if this is actually an image by looking at the character before '['
|
|
237
|
-
const charBefore = match.index > 0 ? cleanedContent[match.index - 1] :
|
|
238
|
-
if (charBefore ===
|
|
237
|
+
const charBefore = match.index > 0 ? cleanedContent[match.index - 1] : "";
|
|
238
|
+
if (charBefore === "!") {
|
|
239
239
|
// This is a markdown image , skip it
|
|
240
240
|
continue;
|
|
241
241
|
}
|
|
@@ -252,21 +252,13 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
|
|
|
252
252
|
findLineNumber(content, match.index),
|
|
253
253
|
linkText.trim(),
|
|
254
254
|
href,
|
|
255
|
-
|
|
255
|
+
"markdown",
|
|
256
256
|
);
|
|
257
257
|
|
|
258
|
-
const [basePath, anchor =
|
|
258
|
+
const [basePath, anchor = ""] = targetUrl.split("#");
|
|
259
259
|
const expectedSlug = new URL(targetUrl).pathname;
|
|
260
260
|
|
|
261
|
-
links.push(
|
|
262
|
-
new Link(
|
|
263
|
-
location,
|
|
264
|
-
targetUrl,
|
|
265
|
-
basePath,
|
|
266
|
-
anchor || null,
|
|
267
|
-
expectedSlug
|
|
268
|
-
)
|
|
269
|
-
);
|
|
261
|
+
links.push(new Link(location, targetUrl, basePath, anchor || null, expectedSlug));
|
|
270
262
|
}
|
|
271
263
|
}
|
|
272
264
|
|
|
@@ -285,21 +277,13 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
|
|
|
285
277
|
findLineNumber(content, match.index),
|
|
286
278
|
linkText.trim(),
|
|
287
279
|
href,
|
|
288
|
-
|
|
280
|
+
"html",
|
|
289
281
|
);
|
|
290
282
|
|
|
291
|
-
const [basePath, anchor =
|
|
283
|
+
const [basePath, anchor = ""] = targetUrl.split("#");
|
|
292
284
|
const expectedSlug = new URL(targetUrl).pathname;
|
|
293
285
|
|
|
294
|
-
links.push(
|
|
295
|
-
new Link(
|
|
296
|
-
location,
|
|
297
|
-
targetUrl,
|
|
298
|
-
basePath,
|
|
299
|
-
anchor || null,
|
|
300
|
-
expectedSlug
|
|
301
|
-
)
|
|
302
|
-
);
|
|
286
|
+
links.push(new Link(location, targetUrl, basePath, anchor || null, expectedSlug));
|
|
303
287
|
}
|
|
304
288
|
}
|
|
305
289
|
|
|
@@ -318,21 +302,13 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
|
|
|
318
302
|
findLineNumber(content, match.index),
|
|
319
303
|
linkText.trim(),
|
|
320
304
|
href,
|
|
321
|
-
|
|
305
|
+
"jsx",
|
|
322
306
|
);
|
|
323
307
|
|
|
324
|
-
const [basePath, anchor =
|
|
308
|
+
const [basePath, anchor = ""] = targetUrl.split("#");
|
|
325
309
|
const expectedSlug = new URL(targetUrl).pathname;
|
|
326
310
|
|
|
327
|
-
links.push(
|
|
328
|
-
new Link(
|
|
329
|
-
location,
|
|
330
|
-
targetUrl,
|
|
331
|
-
basePath,
|
|
332
|
-
anchor || null,
|
|
333
|
-
expectedSlug
|
|
334
|
-
)
|
|
335
|
-
);
|
|
311
|
+
links.push(new Link(location, targetUrl, basePath, anchor || null, expectedSlug));
|
|
336
312
|
}
|
|
337
313
|
}
|
|
338
314
|
|
|
@@ -351,21 +327,13 @@ function extractLinksFromFile(filePath, baseUrl, repoRoot, verbose = false) {
|
|
|
351
327
|
findLineNumber(content, match.index),
|
|
352
328
|
linkText.trim(),
|
|
353
329
|
href,
|
|
354
|
-
|
|
330
|
+
"jsx",
|
|
355
331
|
);
|
|
356
332
|
|
|
357
|
-
const [basePath, anchor =
|
|
333
|
+
const [basePath, anchor = ""] = targetUrl.split("#");
|
|
358
334
|
const expectedSlug = new URL(targetUrl).pathname;
|
|
359
335
|
|
|
360
|
-
links.push(
|
|
361
|
-
new Link(
|
|
362
|
-
location,
|
|
363
|
-
targetUrl,
|
|
364
|
-
basePath,
|
|
365
|
-
anchor || null,
|
|
366
|
-
expectedSlug
|
|
367
|
-
)
|
|
368
|
-
);
|
|
336
|
+
links.push(new Link(location, targetUrl, basePath, anchor || null, expectedSlug));
|
|
369
337
|
}
|
|
370
338
|
}
|
|
371
339
|
|
|
@@ -378,9 +346,7 @@ function findMdxFiles(repoRoot, directory = null, file = null) {
|
|
|
378
346
|
return existsSync(fullPath) ? [fullPath] : [];
|
|
379
347
|
}
|
|
380
348
|
|
|
381
|
-
const searchDirs = directory
|
|
382
|
-
? [resolve(repoRoot, directory)]
|
|
383
|
-
: MDX_DIRS.map(d => join(repoRoot, d));
|
|
349
|
+
const searchDirs = directory ? [resolve(repoRoot, directory)] : MDX_DIRS.map((d) => join(repoRoot, d));
|
|
384
350
|
|
|
385
351
|
const files = [];
|
|
386
352
|
|
|
@@ -393,10 +359,10 @@ function findMdxFiles(repoRoot, directory = null, file = null) {
|
|
|
393
359
|
const stat = statSync(fullPath);
|
|
394
360
|
|
|
395
361
|
if (stat.isDirectory()) {
|
|
396
|
-
if (!EXCLUDED_DIRS.some(excluded => fullPath.includes(excluded))) {
|
|
362
|
+
if (!EXCLUDED_DIRS.some((excluded) => fullPath.includes(excluded))) {
|
|
397
363
|
walkDir(fullPath);
|
|
398
364
|
}
|
|
399
|
-
} else if (entry.endsWith(
|
|
365
|
+
} else if (entry.endsWith(".mdx")) {
|
|
400
366
|
files.push(fullPath);
|
|
401
367
|
}
|
|
402
368
|
}
|
|
@@ -411,7 +377,7 @@ function findMdxFiles(repoRoot, directory = null, file = null) {
|
|
|
411
377
|
|
|
412
378
|
// Playwright Validation Functions
|
|
413
379
|
|
|
414
|
-
async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, progress =
|
|
380
|
+
async function validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
|
|
415
381
|
const startTime = Date.now();
|
|
416
382
|
|
|
417
383
|
try {
|
|
@@ -419,14 +385,14 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
|
|
|
419
385
|
console.log(`${progress} Validating anchor: ${link.anchor}`);
|
|
420
386
|
}
|
|
421
387
|
|
|
422
|
-
// OPTIMIZATION: Check if anchor exists in local MDX file first
|
|
388
|
+
// OPTIMIZATION: Check if anchor exists in local MDX file first (local validation)
|
|
423
389
|
const mdxFilePath = urlToFilePath(link.basePath, baseUrl, repoRoot);
|
|
424
390
|
if (mdxFilePath && existsSync(mdxFilePath)) {
|
|
425
391
|
const mdxHeadings = extractMdxHeadings(mdxFilePath);
|
|
426
|
-
const mdxHeadingsKebab = mdxHeadings.map(h => toKebabCase(h));
|
|
392
|
+
const mdxHeadingsKebab = mdxHeadings.map((h) => toKebabCase(h));
|
|
427
393
|
|
|
428
394
|
if (mdxHeadingsKebab.includes(link.anchor)) {
|
|
429
|
-
const heading = mdxHeadings.find(h => toKebabCase(h) === link.anchor);
|
|
395
|
+
const heading = mdxHeadings.find((h) => toKebabCase(h) === link.anchor);
|
|
430
396
|
if (verbose) {
|
|
431
397
|
console.log(`${progress} ✓ Anchor validated locally in MDX file`);
|
|
432
398
|
}
|
|
@@ -436,117 +402,206 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
|
|
|
436
402
|
link.basePath,
|
|
437
403
|
link.anchor,
|
|
438
404
|
link.expectedSlug,
|
|
439
|
-
|
|
405
|
+
"success",
|
|
440
406
|
link.basePath,
|
|
441
407
|
heading,
|
|
442
408
|
link.anchor,
|
|
443
409
|
null,
|
|
444
|
-
Date.now() - startTime
|
|
410
|
+
Date.now() - startTime,
|
|
445
411
|
);
|
|
446
412
|
} else if (verbose) {
|
|
447
413
|
console.log(`${progress} Anchor not found in local MDX, checking online...`);
|
|
448
414
|
}
|
|
449
415
|
}
|
|
450
416
|
|
|
451
|
-
//
|
|
452
|
-
|
|
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);
|
|
453
420
|
|
|
454
|
-
|
|
455
|
-
|
|
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 });
|
|
456
427
|
|
|
457
|
-
|
|
458
|
-
|
|
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
|
+
}
|
|
459
443
|
}
|
|
460
444
|
|
|
461
|
-
|
|
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
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!targetHeadingText) {
|
|
462
454
|
return new ValidationResult(
|
|
463
455
|
link.source,
|
|
464
456
|
link.targetUrl,
|
|
465
457
|
link.basePath,
|
|
466
458
|
link.anchor,
|
|
467
459
|
link.expectedSlug,
|
|
468
|
-
|
|
460
|
+
"failure",
|
|
469
461
|
null,
|
|
470
462
|
null,
|
|
471
463
|
null,
|
|
472
|
-
`
|
|
473
|
-
Date.now() - startTime
|
|
464
|
+
`Could not determine heading text to search for anchor #${link.anchor}`,
|
|
465
|
+
Date.now() - startTime,
|
|
474
466
|
);
|
|
475
467
|
}
|
|
476
468
|
|
|
477
|
-
//
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
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;
|
|
481
473
|
|
|
482
|
-
//
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
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);
|
|
486
478
|
|
|
487
|
-
|
|
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
|
+
}
|
|
488
488
|
|
|
489
|
-
|
|
490
|
-
if (
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
null,
|
|
502
|
-
Date.now() - startTime
|
|
503
|
-
);
|
|
504
|
-
} else {
|
|
505
|
-
return new ValidationResult(
|
|
506
|
-
link.source,
|
|
507
|
-
link.targetUrl,
|
|
508
|
-
link.basePath,
|
|
509
|
-
link.anchor,
|
|
510
|
-
link.expectedSlug,
|
|
511
|
-
'failure',
|
|
512
|
-
null,
|
|
513
|
-
actualTextClean,
|
|
514
|
-
actualKebab,
|
|
515
|
-
`Anchor "#${link.anchor}" matches page heading "${actualTextClean}" but this heading is not found in the MDX file`,
|
|
516
|
-
Date.now() - startTime
|
|
517
|
-
);
|
|
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
|
+
}
|
|
518
501
|
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
)
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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}`);
|
|
548
545
|
}
|
|
549
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
|
+
}
|
|
550
605
|
} catch (error) {
|
|
551
606
|
return new ValidationResult(
|
|
552
607
|
link.source,
|
|
@@ -554,17 +609,17 @@ async function validateAnchor(page, link, baseUrl, repoRoot, verbose = false, pr
|
|
|
554
609
|
link.basePath,
|
|
555
610
|
link.anchor,
|
|
556
611
|
link.expectedSlug,
|
|
557
|
-
|
|
612
|
+
"error",
|
|
558
613
|
null,
|
|
559
614
|
null,
|
|
560
615
|
null,
|
|
561
616
|
`Error validating anchor: ${error.message}`,
|
|
562
|
-
Date.now() - startTime
|
|
617
|
+
Date.now() - startTime,
|
|
563
618
|
);
|
|
564
619
|
}
|
|
565
620
|
}
|
|
566
621
|
|
|
567
|
-
async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false, progress =
|
|
622
|
+
async function validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
|
|
568
623
|
const startTime = Date.now();
|
|
569
624
|
|
|
570
625
|
try {
|
|
@@ -576,7 +631,7 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
|
|
|
576
631
|
const mdxFilePath = urlToFilePath(link.targetUrl, baseUrl, repoRoot);
|
|
577
632
|
if (mdxFilePath && existsSync(mdxFilePath)) {
|
|
578
633
|
if (verbose) {
|
|
579
|
-
console.log(
|
|
634
|
+
console.log(` ✓ Link validated locally (file exists)`);
|
|
580
635
|
}
|
|
581
636
|
return new ValidationResult(
|
|
582
637
|
link.source,
|
|
@@ -584,19 +639,26 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
|
|
|
584
639
|
link.basePath,
|
|
585
640
|
link.anchor,
|
|
586
641
|
link.expectedSlug,
|
|
587
|
-
|
|
642
|
+
"success",
|
|
588
643
|
link.targetUrl,
|
|
589
644
|
null,
|
|
590
645
|
null,
|
|
591
646
|
null,
|
|
592
|
-
Date.now() - startTime
|
|
647
|
+
Date.now() - startTime,
|
|
593
648
|
);
|
|
594
649
|
} else if (verbose) {
|
|
595
|
-
console.log(
|
|
650
|
+
console.log(` File not found locally, checking online...`);
|
|
651
|
+
}
|
|
652
|
+
|
|
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}`);
|
|
596
658
|
}
|
|
597
659
|
|
|
598
|
-
// Navigate to the
|
|
599
|
-
const response = await page.goto(
|
|
660
|
+
// Navigate to the validation URL
|
|
661
|
+
const response = await page.goto(validationUrl, { waitUntil: "networkidle", timeout: DEFAULT_TIMEOUT });
|
|
600
662
|
|
|
601
663
|
if (!response) {
|
|
602
664
|
return new ValidationResult(
|
|
@@ -605,12 +667,12 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
|
|
|
605
667
|
link.basePath,
|
|
606
668
|
link.anchor,
|
|
607
669
|
link.expectedSlug,
|
|
608
|
-
|
|
670
|
+
"error",
|
|
609
671
|
null,
|
|
610
672
|
null,
|
|
611
673
|
null,
|
|
612
|
-
|
|
613
|
-
Date.now() - startTime
|
|
674
|
+
"No response received",
|
|
675
|
+
Date.now() - startTime,
|
|
614
676
|
);
|
|
615
677
|
}
|
|
616
678
|
|
|
@@ -623,12 +685,12 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
|
|
|
623
685
|
link.basePath,
|
|
624
686
|
link.anchor,
|
|
625
687
|
link.expectedSlug,
|
|
626
|
-
|
|
688
|
+
"failure",
|
|
627
689
|
actualUrl,
|
|
628
690
|
null,
|
|
629
691
|
null,
|
|
630
692
|
`HTTP ${response.status()}: ${response.statusText()}`,
|
|
631
|
-
Date.now() - startTime
|
|
693
|
+
Date.now() - startTime,
|
|
632
694
|
);
|
|
633
695
|
}
|
|
634
696
|
|
|
@@ -638,12 +700,12 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
|
|
|
638
700
|
link.basePath,
|
|
639
701
|
link.anchor,
|
|
640
702
|
link.expectedSlug,
|
|
641
|
-
|
|
703
|
+
"success",
|
|
642
704
|
actualUrl,
|
|
643
705
|
null,
|
|
644
706
|
null,
|
|
645
707
|
null,
|
|
646
|
-
Date.now() - startTime
|
|
708
|
+
Date.now() - startTime,
|
|
647
709
|
);
|
|
648
710
|
} catch (error) {
|
|
649
711
|
return new ValidationResult(
|
|
@@ -652,39 +714,41 @@ async function validateNormalLink(page, link, baseUrl, repoRoot, verbose = false
|
|
|
652
714
|
link.basePath,
|
|
653
715
|
link.anchor,
|
|
654
716
|
link.expectedSlug,
|
|
655
|
-
|
|
717
|
+
"error",
|
|
656
718
|
null,
|
|
657
719
|
null,
|
|
658
720
|
null,
|
|
659
721
|
`Error validating link: ${error.message}`,
|
|
660
|
-
Date.now() - startTime
|
|
722
|
+
Date.now() - startTime,
|
|
661
723
|
);
|
|
662
724
|
}
|
|
663
725
|
}
|
|
664
726
|
|
|
665
|
-
async function validateLink(page, link, baseUrl, repoRoot, verbose = false, progress =
|
|
727
|
+
async function validateLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose = false, progress = "") {
|
|
666
728
|
if (link.anchor) {
|
|
667
|
-
return await validateAnchor(page, link, baseUrl, repoRoot, verbose, progress);
|
|
729
|
+
return await validateAnchor(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
|
|
668
730
|
} else {
|
|
669
|
-
return await validateNormalLink(page, link, baseUrl, repoRoot, verbose, progress);
|
|
731
|
+
return await validateNormalLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
|
|
670
732
|
}
|
|
671
733
|
}
|
|
672
734
|
|
|
673
|
-
async function validateLinksAsync(links, baseUrl, repoRoot, concurrency, headless, verbose) {
|
|
735
|
+
async function validateLinksAsync(links, baseUrl, validationBaseUrl, repoRoot, concurrency, headless, verbose) {
|
|
674
736
|
const results = [];
|
|
675
737
|
|
|
676
738
|
let browser;
|
|
677
739
|
try {
|
|
678
740
|
browser = await chromium.launch({ headless });
|
|
679
741
|
} catch (error) {
|
|
680
|
-
if (
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
console.error(chalk.
|
|
686
|
-
console.error(
|
|
687
|
-
console.error(chalk.cyan(
|
|
742
|
+
if (
|
|
743
|
+
error.message.includes("Executable doesn't exist") ||
|
|
744
|
+
error.message.includes("Browser was not installed") ||
|
|
745
|
+
error.message.includes("browserType.launch")
|
|
746
|
+
) {
|
|
747
|
+
console.error(chalk.red("\n✗ Playwright browsers are not installed!"));
|
|
748
|
+
console.error(chalk.yellow("\nTo install Playwright browsers, run:"));
|
|
749
|
+
console.error(chalk.cyan(" npx playwright install chromium\n"));
|
|
750
|
+
console.error("Or install all browsers with:");
|
|
751
|
+
console.error(chalk.cyan(" npx playwright install\n"));
|
|
688
752
|
process.exit(1);
|
|
689
753
|
}
|
|
690
754
|
throw error;
|
|
@@ -696,13 +760,13 @@ async function validateLinksAsync(links, baseUrl, repoRoot, concurrency, headles
|
|
|
696
760
|
async function validateWithSemaphore(link) {
|
|
697
761
|
counter++;
|
|
698
762
|
const current = counter;
|
|
699
|
-
const progress = verbose ? `[${current}/${links.length}] ` :
|
|
763
|
+
const progress = verbose ? `[${current}/${links.length}] ` : "";
|
|
700
764
|
|
|
701
765
|
const context = await browser.newContext();
|
|
702
766
|
const page = await context.newPage();
|
|
703
767
|
|
|
704
768
|
try {
|
|
705
|
-
const result = await validateLink(page, link, baseUrl, repoRoot, verbose, progress);
|
|
769
|
+
const result = await validateLink(page, link, baseUrl, validationBaseUrl, repoRoot, verbose, progress);
|
|
706
770
|
return result;
|
|
707
771
|
} finally {
|
|
708
772
|
await context.close();
|
|
@@ -716,7 +780,7 @@ async function validateLinksAsync(links, baseUrl, repoRoot, concurrency, headles
|
|
|
716
780
|
// Process links with concurrency control
|
|
717
781
|
for (let i = 0; i < links.length; i += concurrency) {
|
|
718
782
|
const batch = links.slice(i, i + concurrency);
|
|
719
|
-
const batchResults = await Promise.all(batch.map(link => validateWithSemaphore(link)));
|
|
783
|
+
const batchResults = await Promise.all(batch.map((link) => validateWithSemaphore(link)));
|
|
720
784
|
results.push(...batchResults);
|
|
721
785
|
}
|
|
722
786
|
|
|
@@ -735,7 +799,7 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
|
|
|
735
799
|
|
|
736
800
|
let reportData;
|
|
737
801
|
try {
|
|
738
|
-
reportData = JSON.parse(readFileSync(reportPath,
|
|
802
|
+
reportData = JSON.parse(readFileSync(reportPath, "utf-8"));
|
|
739
803
|
} catch (error) {
|
|
740
804
|
console.error(`Error reading report file: ${error.message}`);
|
|
741
805
|
return {};
|
|
@@ -745,7 +809,7 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
|
|
|
745
809
|
|
|
746
810
|
if (Object.keys(resultsByFile).length === 0) {
|
|
747
811
|
if (verbose) {
|
|
748
|
-
console.log(
|
|
812
|
+
console.log("No failures found in report.");
|
|
749
813
|
}
|
|
750
814
|
return {};
|
|
751
815
|
}
|
|
@@ -762,15 +826,13 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
|
|
|
762
826
|
continue;
|
|
763
827
|
}
|
|
764
828
|
|
|
765
|
-
const fixableFailures = failures.filter(
|
|
766
|
-
f => f.status === 'failure' && f.actual_heading_kebab && f.anchor
|
|
767
|
-
);
|
|
829
|
+
const fixableFailures = failures.filter((f) => f.status === "failure" && f.actual_heading_kebab && f.anchor);
|
|
768
830
|
|
|
769
831
|
if (fixableFailures.length === 0) continue;
|
|
770
832
|
|
|
771
833
|
try {
|
|
772
|
-
const content = readFileSync(fullPath,
|
|
773
|
-
let lines = content.split(
|
|
834
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
835
|
+
let lines = content.split("\n");
|
|
774
836
|
let modified = false;
|
|
775
837
|
let fixesCount = 0;
|
|
776
838
|
|
|
@@ -791,7 +853,7 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
|
|
|
791
853
|
const newAnchor = failure.actual_heading_kebab;
|
|
792
854
|
const linkType = failure.source.link_type;
|
|
793
855
|
|
|
794
|
-
const pathPart = oldHref.includes(
|
|
856
|
+
const pathPart = oldHref.includes("#") ? oldHref.split("#")[0] : oldHref;
|
|
795
857
|
const newHref = pathPart ? `${pathPart}#${newAnchor}` : `#${newAnchor}`;
|
|
796
858
|
|
|
797
859
|
if (oldHref === newHref) {
|
|
@@ -803,14 +865,14 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
|
|
|
803
865
|
|
|
804
866
|
let replaced = false;
|
|
805
867
|
|
|
806
|
-
if (linkType ===
|
|
868
|
+
if (linkType === "markdown") {
|
|
807
869
|
const oldPattern = `(${oldHref})`;
|
|
808
870
|
const newPattern = `(${newHref})`;
|
|
809
871
|
if (line.includes(oldPattern)) {
|
|
810
872
|
line = line.replace(oldPattern, newPattern);
|
|
811
873
|
replaced = true;
|
|
812
874
|
}
|
|
813
|
-
} else if (linkType ===
|
|
875
|
+
} else if (linkType === "html" || linkType === "jsx") {
|
|
814
876
|
for (const quote of ['"', "'"]) {
|
|
815
877
|
const oldPattern = `href=${quote}${oldHref}${quote}`;
|
|
816
878
|
const newPattern = `href=${quote}${newHref}${quote}`;
|
|
@@ -838,8 +900,8 @@ function fixLinksFromReport(reportPath, repoRoot, verbose = false) {
|
|
|
838
900
|
}
|
|
839
901
|
|
|
840
902
|
if (modified) {
|
|
841
|
-
const newContent = lines.join(
|
|
842
|
-
writeFileSync(fullPath, newContent,
|
|
903
|
+
const newContent = lines.join("\n");
|
|
904
|
+
writeFileSync(fullPath, newContent, "utf-8");
|
|
843
905
|
fixesApplied[filePath] = fixesCount;
|
|
844
906
|
|
|
845
907
|
if (verbose) {
|
|
@@ -860,7 +922,7 @@ function fixLinks(results, repoRoot, verbose = false) {
|
|
|
860
922
|
const failuresByFile = {};
|
|
861
923
|
|
|
862
924
|
for (const result of results) {
|
|
863
|
-
if (result.status !==
|
|
925
|
+
if (result.status !== "failure" || !result.actualHeadingKebab || !result.anchor) {
|
|
864
926
|
continue;
|
|
865
927
|
}
|
|
866
928
|
|
|
@@ -885,8 +947,8 @@ function fixLinks(results, repoRoot, verbose = false) {
|
|
|
885
947
|
}
|
|
886
948
|
|
|
887
949
|
try {
|
|
888
|
-
const content = readFileSync(fullPath,
|
|
889
|
-
let lines = content.split(
|
|
950
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
951
|
+
let lines = content.split("\n");
|
|
890
952
|
let modified = false;
|
|
891
953
|
let fixesCount = 0;
|
|
892
954
|
|
|
@@ -906,7 +968,7 @@ function fixLinks(results, repoRoot, verbose = false) {
|
|
|
906
968
|
const oldHref = failure.source.rawHref;
|
|
907
969
|
const linkType = failure.source.linkType;
|
|
908
970
|
|
|
909
|
-
const pathPart = oldHref.includes(
|
|
971
|
+
const pathPart = oldHref.includes("#") ? oldHref.split("#")[0] : oldHref;
|
|
910
972
|
const newHref = pathPart ? `${pathPart}#${failure.actualHeadingKebab}` : `#${failure.actualHeadingKebab}`;
|
|
911
973
|
|
|
912
974
|
if (oldHref === newHref) {
|
|
@@ -918,14 +980,14 @@ function fixLinks(results, repoRoot, verbose = false) {
|
|
|
918
980
|
|
|
919
981
|
let replaced = false;
|
|
920
982
|
|
|
921
|
-
if (linkType ===
|
|
983
|
+
if (linkType === "markdown") {
|
|
922
984
|
const oldPattern = `(${oldHref})`;
|
|
923
985
|
const newPattern = `(${newHref})`;
|
|
924
986
|
if (line.includes(oldPattern)) {
|
|
925
987
|
line = line.replace(oldPattern, newPattern);
|
|
926
988
|
replaced = true;
|
|
927
989
|
}
|
|
928
|
-
} else if (linkType ===
|
|
990
|
+
} else if (linkType === "html" || linkType === "jsx") {
|
|
929
991
|
for (const quote of ['"', "'"]) {
|
|
930
992
|
const oldPattern = `href=${quote}${oldHref}${quote}`;
|
|
931
993
|
const newPattern = `href=${quote}${newHref}${quote}`;
|
|
@@ -953,8 +1015,8 @@ function fixLinks(results, repoRoot, verbose = false) {
|
|
|
953
1015
|
}
|
|
954
1016
|
|
|
955
1017
|
if (modified) {
|
|
956
|
-
const newContent = lines.join(
|
|
957
|
-
writeFileSync(fullPath, newContent,
|
|
1018
|
+
const newContent = lines.join("\n");
|
|
1019
|
+
writeFileSync(fullPath, newContent, "utf-8");
|
|
958
1020
|
fixesApplied[filePath] = fixesCount;
|
|
959
1021
|
|
|
960
1022
|
if (verbose) {
|
|
@@ -975,9 +1037,9 @@ function fixLinks(results, repoRoot, verbose = false) {
|
|
|
975
1037
|
|
|
976
1038
|
function generateReport(results, config, outputPath) {
|
|
977
1039
|
const total = results.length;
|
|
978
|
-
const success = results.filter(r => r.status ===
|
|
979
|
-
const failure = results.filter(r => r.status ===
|
|
980
|
-
const error = results.filter(r => r.status ===
|
|
1040
|
+
const success = results.filter((r) => r.status === "success").length;
|
|
1041
|
+
const failure = results.filter((r) => r.status === "failure").length;
|
|
1042
|
+
const error = results.filter((r) => r.status === "error").length;
|
|
981
1043
|
|
|
982
1044
|
const summaryByFile = {};
|
|
983
1045
|
for (const result of results) {
|
|
@@ -992,7 +1054,7 @@ function generateReport(results, config, outputPath) {
|
|
|
992
1054
|
|
|
993
1055
|
const resultsByFile = {};
|
|
994
1056
|
for (const result of results) {
|
|
995
|
-
if (result.status ===
|
|
1057
|
+
if (result.status === "success") continue;
|
|
996
1058
|
|
|
997
1059
|
const filePath = result.source.filePath;
|
|
998
1060
|
if (!resultsByFile[filePath]) {
|
|
@@ -1015,7 +1077,7 @@ function generateReport(results, config, outputPath) {
|
|
|
1015
1077
|
results_by_file: resultsByFile,
|
|
1016
1078
|
};
|
|
1017
1079
|
|
|
1018
|
-
writeFileSync(outputPath, JSON.stringify(report, null, 2),
|
|
1080
|
+
writeFileSync(outputPath, JSON.stringify(report, null, 2), "utf-8");
|
|
1019
1081
|
|
|
1020
1082
|
return report;
|
|
1021
1083
|
}
|
|
@@ -1028,19 +1090,14 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1028
1090
|
// Handle --fix-from-report mode
|
|
1029
1091
|
if (options.fixFromReport !== undefined) {
|
|
1030
1092
|
// If flag is passed with a path, use that path; otherwise use default
|
|
1031
|
-
const reportPath =
|
|
1032
|
-
? options.fixFromReport
|
|
1033
|
-
: 'links_report.json';
|
|
1093
|
+
const reportPath =
|
|
1094
|
+
typeof options.fixFromReport === "string" && options.fixFromReport ? options.fixFromReport : "links_report.json";
|
|
1034
1095
|
|
|
1035
1096
|
if (!options.quiet) {
|
|
1036
1097
|
console.log(`Applying fixes from report: ${reportPath}`);
|
|
1037
1098
|
}
|
|
1038
1099
|
|
|
1039
|
-
const fixesApplied = fixLinksFromReport(
|
|
1040
|
-
reportPath,
|
|
1041
|
-
repoRoot,
|
|
1042
|
-
options.verbose && !options.quiet
|
|
1043
|
-
);
|
|
1100
|
+
const fixesApplied = fixLinksFromReport(reportPath, repoRoot, options.verbose && !options.quiet);
|
|
1044
1101
|
|
|
1045
1102
|
if (!options.quiet) {
|
|
1046
1103
|
if (Object.keys(fixesApplied).length > 0) {
|
|
@@ -1049,9 +1106,9 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1049
1106
|
for (const [filePath, count] of Object.entries(fixesApplied)) {
|
|
1050
1107
|
console.log(` ${filePath}: ${count} fix(es)`);
|
|
1051
1108
|
}
|
|
1052
|
-
console.log(
|
|
1109
|
+
console.log("\nRun validation again to verify the fixes.");
|
|
1053
1110
|
} else {
|
|
1054
|
-
console.log(
|
|
1111
|
+
console.log("\nNo fixable issues found in report.");
|
|
1055
1112
|
}
|
|
1056
1113
|
}
|
|
1057
1114
|
|
|
@@ -1060,20 +1117,20 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1060
1117
|
|
|
1061
1118
|
// Normalize base URL - add https:// if not present
|
|
1062
1119
|
let normalizedBaseUrl = baseUrl;
|
|
1063
|
-
if (!normalizedBaseUrl.startsWith(
|
|
1064
|
-
normalizedBaseUrl =
|
|
1120
|
+
if (!normalizedBaseUrl.startsWith("http://") && !normalizedBaseUrl.startsWith("https://")) {
|
|
1121
|
+
normalizedBaseUrl = "https://" + normalizedBaseUrl;
|
|
1065
1122
|
}
|
|
1066
1123
|
// Remove trailing slash
|
|
1067
|
-
normalizedBaseUrl = normalizedBaseUrl.replace(/\/+$/,
|
|
1124
|
+
normalizedBaseUrl = normalizedBaseUrl.replace(/\/+$/, "");
|
|
1068
1125
|
|
|
1069
1126
|
if (options.verbose && !options.quiet) {
|
|
1070
|
-
console.log(
|
|
1127
|
+
console.log("Finding MDX files...");
|
|
1071
1128
|
}
|
|
1072
1129
|
|
|
1073
1130
|
const mdxFiles = findMdxFiles(repoRoot, options.dir, options.file);
|
|
1074
1131
|
|
|
1075
1132
|
if (mdxFiles.length === 0) {
|
|
1076
|
-
console.error(
|
|
1133
|
+
console.error("No MDX files found.");
|
|
1077
1134
|
process.exit(1);
|
|
1078
1135
|
}
|
|
1079
1136
|
|
|
@@ -1082,7 +1139,7 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1082
1139
|
}
|
|
1083
1140
|
|
|
1084
1141
|
if (options.verbose && !options.quiet) {
|
|
1085
|
-
console.log(
|
|
1142
|
+
console.log("Extracting links...");
|
|
1086
1143
|
}
|
|
1087
1144
|
|
|
1088
1145
|
const allLinks = [];
|
|
@@ -1092,7 +1149,7 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1092
1149
|
}
|
|
1093
1150
|
|
|
1094
1151
|
if (allLinks.length === 0) {
|
|
1095
|
-
console.log(
|
|
1152
|
+
console.log("No internal links found.");
|
|
1096
1153
|
return;
|
|
1097
1154
|
}
|
|
1098
1155
|
|
|
@@ -1101,7 +1158,7 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1101
1158
|
}
|
|
1102
1159
|
|
|
1103
1160
|
if (options.dryRun) {
|
|
1104
|
-
console.log(
|
|
1161
|
+
console.log("\nExtracted links:");
|
|
1105
1162
|
allLinks.forEach((link, i) => {
|
|
1106
1163
|
console.log(`\n${i + 1}. ${link.source.filePath}:${link.source.lineNumber}`);
|
|
1107
1164
|
console.log(` Text: ${link.source.linkText}`);
|
|
@@ -1117,23 +1174,36 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1117
1174
|
const startTime = Date.now();
|
|
1118
1175
|
|
|
1119
1176
|
if (!options.quiet) {
|
|
1120
|
-
console.log(
|
|
1177
|
+
console.log("\nValidating links...");
|
|
1178
|
+
}
|
|
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}`);
|
|
1121
1190
|
}
|
|
1122
1191
|
|
|
1123
1192
|
const results = await validateLinksAsync(
|
|
1124
1193
|
allLinks,
|
|
1125
1194
|
normalizedBaseUrl,
|
|
1195
|
+
normalizedValidationBaseUrl,
|
|
1126
1196
|
repoRoot,
|
|
1127
1197
|
parseInt(options.concurrency) || DEFAULT_CONCURRENCY,
|
|
1128
1198
|
options.headless !== false,
|
|
1129
|
-
options.verbose && !options.quiet
|
|
1199
|
+
options.verbose && !options.quiet,
|
|
1130
1200
|
);
|
|
1131
1201
|
|
|
1132
1202
|
const executionTime = (Date.now() - startTime) / 1000;
|
|
1133
1203
|
|
|
1134
1204
|
if (options.fix) {
|
|
1135
1205
|
if (!options.quiet) {
|
|
1136
|
-
console.log(
|
|
1206
|
+
console.log("\nApplying fixes...");
|
|
1137
1207
|
}
|
|
1138
1208
|
|
|
1139
1209
|
const fixesApplied = fixLinks(results, repoRoot, options.verbose && !options.quiet);
|
|
@@ -1145,9 +1215,9 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1145
1215
|
for (const [filePath, count] of Object.entries(fixesApplied)) {
|
|
1146
1216
|
console.log(` ${filePath}: ${count} fix(es)`);
|
|
1147
1217
|
}
|
|
1148
|
-
console.log(
|
|
1218
|
+
console.log("\nRun validation again to verify the fixes.");
|
|
1149
1219
|
} else {
|
|
1150
|
-
console.log(
|
|
1220
|
+
console.log("\nNo fixable issues found.");
|
|
1151
1221
|
}
|
|
1152
1222
|
}
|
|
1153
1223
|
}
|
|
@@ -1160,23 +1230,23 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1160
1230
|
execution_time_seconds: Math.round(executionTime * 100) / 100,
|
|
1161
1231
|
};
|
|
1162
1232
|
|
|
1163
|
-
const report = generateReport(results, config, options.output ||
|
|
1233
|
+
const report = generateReport(results, config, options.output || "links_report.json");
|
|
1164
1234
|
|
|
1165
1235
|
if (!options.quiet) {
|
|
1166
|
-
console.log(`\n${
|
|
1167
|
-
console.log(
|
|
1168
|
-
console.log(
|
|
1236
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
1237
|
+
console.log("VALIDATION SUMMARY");
|
|
1238
|
+
console.log("=".repeat(60));
|
|
1169
1239
|
console.log(`Total links: ${report.summary.total_links}`);
|
|
1170
|
-
console.log(`Success: ${chalk.green(report.summary.success +
|
|
1171
|
-
console.log(`Failure: ${chalk.red(report.summary.failure +
|
|
1172
|
-
console.log(`Error: ${chalk.yellow(report.summary.error +
|
|
1240
|
+
console.log(`Success: ${chalk.green(report.summary.success + " ✓")}`);
|
|
1241
|
+
console.log(`Failure: ${chalk.red(report.summary.failure + " ✗")}`);
|
|
1242
|
+
console.log(`Error: ${chalk.yellow(report.summary.error + " ⚠")}`);
|
|
1173
1243
|
console.log(`Execution time: ${executionTime.toFixed(2)}s`);
|
|
1174
|
-
console.log(`\nReport saved to: ${options.output ||
|
|
1244
|
+
console.log(`\nReport saved to: ${options.output || "links_report.json"}`);
|
|
1175
1245
|
|
|
1176
1246
|
if (report.summary.failure > 0 || report.summary.error > 0) {
|
|
1177
|
-
console.log(`\n${
|
|
1178
|
-
console.log(
|
|
1179
|
-
console.log(
|
|
1247
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
1248
|
+
console.log("ISSUES FOUND");
|
|
1249
|
+
console.log("=".repeat(60));
|
|
1180
1250
|
let shown = 0;
|
|
1181
1251
|
|
|
1182
1252
|
for (const [filePath, fileResults] of Object.entries(report.results_by_file)) {
|
|
@@ -1196,7 +1266,9 @@ export async function validateLinks(baseUrl, options) {
|
|
|
1196
1266
|
|
|
1197
1267
|
if (shown < report.summary.failure + report.summary.error) {
|
|
1198
1268
|
const remaining = report.summary.failure + report.summary.error - shown;
|
|
1199
|
-
console.log(
|
|
1269
|
+
console.log(
|
|
1270
|
+
`\n... and ${remaining} more issues. See ${options.output || "links_report.json"} for full details.`,
|
|
1271
|
+
);
|
|
1200
1272
|
}
|
|
1201
1273
|
}
|
|
1202
1274
|
}
|