@writechoice/mint-cli 0.0.17 ā 0.0.19
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 +51 -253
- package/bin/cli.js +19 -0
- package/package.json +1 -1
- package/src/commands/metadata.js +357 -0
- package/src/utils/config.js +24 -0
package/README.md
CHANGED
|
@@ -2,307 +2,105 @@
|
|
|
2
2
|
|
|
3
3
|
CLI tool for Mintlify documentation validation and utilities.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
### Installation
|
|
5
|
+
## Installation
|
|
8
6
|
|
|
9
7
|
```bash
|
|
10
8
|
npm install -g @writechoice/mint-cli
|
|
11
9
|
npx playwright install chromium
|
|
12
10
|
```
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
## Quick Start
|
|
15
13
|
|
|
16
14
|
```bash
|
|
17
|
-
#
|
|
18
|
-
writechoice
|
|
15
|
+
# Generate a config.json with your docs URL
|
|
16
|
+
writechoice config
|
|
19
17
|
|
|
20
|
-
#
|
|
21
|
-
|
|
18
|
+
# Edit config.json and set your source URL
|
|
19
|
+
# "source": "https://docs.example.com"
|
|
22
20
|
|
|
23
|
-
#
|
|
21
|
+
# Then run any command without arguments
|
|
24
22
|
writechoice check parse
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
writechoice check links https://docs.example.com
|
|
28
|
-
|
|
29
|
-
# Validate with local development server
|
|
30
|
-
writechoice check links docs.example.com http://localhost:3000
|
|
31
|
-
|
|
32
|
-
# Fix broken anchor links
|
|
33
|
-
writechoice fix links
|
|
34
|
-
|
|
35
|
-
# Fix MDX parsing errors (void tags, stray angle brackets)
|
|
36
|
-
writechoice fix parse
|
|
37
|
-
|
|
38
|
-
# Generate config.json template
|
|
39
|
-
writechoice config
|
|
23
|
+
writechoice check links
|
|
24
|
+
writechoice metadata
|
|
40
25
|
```
|
|
41
26
|
|
|
42
27
|
## Commands
|
|
43
28
|
|
|
44
|
-
###
|
|
29
|
+
### Validate
|
|
45
30
|
|
|
46
|
-
|
|
31
|
+
| Command | Description |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `writechoice check parse` | Validate MDX files for parsing errors |
|
|
34
|
+
| `writechoice check links [baseUrl]` | Validate internal links and anchors using Playwright |
|
|
47
35
|
|
|
48
|
-
|
|
49
|
-
writechoice config # Create config.json
|
|
50
|
-
writechoice config --force # Overwrite existing config.json
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
**Output:** Creates `config.json` in the current directory with placeholder values.
|
|
54
|
-
|
|
55
|
-
### `check parse`
|
|
36
|
+
### Fix
|
|
56
37
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
writechoice
|
|
61
|
-
writechoice
|
|
62
|
-
writechoice
|
|
63
|
-
|
|
38
|
+
| Command | Description |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `writechoice fix parse` | Fix void tags and stray angle brackets |
|
|
41
|
+
| `writechoice fix links` | Fix broken anchor links from a validation report |
|
|
42
|
+
| `writechoice fix codeblocks` | Add/remove `expandable`, `lines`, `wrap` on code blocks |
|
|
43
|
+
| `writechoice fix images` | Wrap standalone images in `<Frame>` |
|
|
44
|
+
| `writechoice fix inlineimages` | Convert inline images to `<InlineImage>` |
|
|
45
|
+
| `writechoice fix h1` | Remove H1 headings that duplicate the frontmatter title |
|
|
64
46
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
### `check links`
|
|
68
|
-
|
|
69
|
-
Validates internal links and anchors using browser automation.
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
writechoice check links <baseUrl> [validationBaseUrl]
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
**Common options:**
|
|
76
|
-
- `-f, --file <path>` - Validate single file
|
|
77
|
-
- `-d, --dir <path>` - Validate specific directory
|
|
78
|
-
- `-o, --output <path>` - Base name for reports (default: `links_report`)
|
|
79
|
-
- `-c, --concurrency <number>` - Concurrent browser tabs (default: 25)
|
|
80
|
-
- `--quiet` - Suppress output
|
|
81
|
-
- `--dry-run` - Extract links without validating
|
|
82
|
-
|
|
83
|
-
**Output:** Both `links_report.json` and `links_report.md`
|
|
84
|
-
|
|
85
|
-
### `fix links`
|
|
86
|
-
|
|
87
|
-
Automatically fixes broken anchor links based on validation reports.
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
writechoice fix links # Use default report
|
|
91
|
-
writechoice fix links -r custom_report.json # Use custom report
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
**Common options:**
|
|
95
|
-
- `-r, --report <path>` - Path to JSON report (default: `links_report.json`)
|
|
96
|
-
- `--quiet` - Suppress output
|
|
97
|
-
|
|
98
|
-
**Note:** Requires JSON report from `check links` command.
|
|
99
|
-
|
|
100
|
-
### `fix parse`
|
|
101
|
-
|
|
102
|
-
Automatically fixes common MDX parsing errors: void HTML tags not self-closed and stray angle brackets in text.
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
writechoice fix parse # Fix from check parse report
|
|
106
|
-
writechoice fix parse -f file.mdx # Fix a single file directly
|
|
107
|
-
writechoice fix parse -d docs # Fix files in a directory
|
|
108
|
-
writechoice fix parse -r custom_report.json # Use custom report
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
**Common options:**
|
|
112
|
-
- `-r, --report <path>` - Path to JSON report (default: `mdx_errors_report.json`)
|
|
113
|
-
- `-f, --file <path>` - Fix a single MDX file directly
|
|
114
|
-
- `-d, --dir <path>` - Fix MDX files in a directory
|
|
115
|
-
- `--quiet` - Suppress output
|
|
116
|
-
|
|
117
|
-
**What it fixes:**
|
|
118
|
-
- Void tags: `<br>` ā `<br />`, `<img src="x">` ā `<img src="x" />`
|
|
119
|
-
- Stray brackets: `x < 10` ā `x < 10`, `y > 5` ā `y > 5`
|
|
120
|
-
|
|
121
|
-
Content inside code blocks and inline code is never modified.
|
|
122
|
-
|
|
123
|
-
### `update`
|
|
124
|
-
|
|
125
|
-
Update CLI to latest version.
|
|
126
|
-
|
|
127
|
-
```bash
|
|
128
|
-
writechoice update
|
|
129
|
-
```
|
|
47
|
+
### Other
|
|
130
48
|
|
|
131
|
-
|
|
49
|
+
| Command | Description |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `writechoice metadata` | Fetch meta tags from live pages and write to frontmatter |
|
|
52
|
+
| `writechoice config` | Generate a `config.json` template |
|
|
53
|
+
| `writechoice update` | Update to the latest version |
|
|
132
54
|
|
|
133
|
-
|
|
134
|
-
- **Link Validation** - Test links against live websites with Playwright
|
|
135
|
-
- **Two-Step Anchor Validation** - Compare production vs development anchors
|
|
136
|
-
- **Auto-Fix Links** - Automatically correct broken anchor links
|
|
137
|
-
- **Auto-Fix Parsing** - Automatically fix void tags and stray angle brackets
|
|
138
|
-
- **Dual Report Formats** - Generates both JSON (for automation) and Markdown (for humans)
|
|
139
|
-
- **Configuration File** - Optional config.json for default settings
|
|
140
|
-
- **CI/CD Ready** - Exit codes for pipeline integration
|
|
55
|
+
All commands support `--file <path>`, `--dir <path>`, `--dry-run`, and `--quiet` where applicable.
|
|
141
56
|
|
|
142
|
-
##
|
|
57
|
+
## Configuration
|
|
143
58
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
1. **Finds the target** (Production): Navigates to production docs to identify which heading the anchor points to
|
|
147
|
-
2. **Gets the anchor** (Validation): Navigates to your dev server (localhost:3000), clicks the heading, and extracts the generated anchor
|
|
148
|
-
3. **Compares**: Checks if anchors match
|
|
149
|
-
|
|
150
|
-
This ensures your local development environment matches production behavior.
|
|
151
|
-
|
|
152
|
-
## Configuration File
|
|
153
|
-
|
|
154
|
-
Create an optional `config.json` in your project root to set default values:
|
|
59
|
+
Create a `config.json` in your project root to set defaults and avoid repeating arguments:
|
|
155
60
|
|
|
156
61
|
```json
|
|
157
62
|
{
|
|
158
63
|
"source": "https://docs.example.com",
|
|
159
|
-
"target": "http://localhost:3000"
|
|
160
|
-
"links": {
|
|
161
|
-
"concurrency": 25,
|
|
162
|
-
"quiet": false
|
|
163
|
-
},
|
|
164
|
-
"parse": {
|
|
165
|
-
"quiet": false
|
|
166
|
-
}
|
|
64
|
+
"target": "http://localhost:3000"
|
|
167
65
|
}
|
|
168
66
|
```
|
|
169
67
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
```bash
|
|
173
|
-
# Uses source and target from config.json
|
|
174
|
-
writechoice check links
|
|
175
|
-
|
|
176
|
-
# CLI args override config.json values
|
|
177
|
-
writechoice check links https://staging.example.com
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
See [config.example.json](config.example.json) for all available options.
|
|
181
|
-
|
|
182
|
-
## Examples
|
|
183
|
-
|
|
184
|
-
```bash
|
|
185
|
-
# Validate all MDX files for parsing errors
|
|
186
|
-
writechoice check parse
|
|
187
|
-
|
|
188
|
-
# Validate all links (uses localhost:3000 by default)
|
|
189
|
-
writechoice check links https://docs.example.com
|
|
190
|
-
|
|
191
|
-
# Validate with staging environment
|
|
192
|
-
writechoice check links docs.example.com https://staging.example.com
|
|
193
|
-
|
|
194
|
-
# Validate specific directory only
|
|
195
|
-
writechoice check links docs.example.com -d api
|
|
196
|
-
|
|
197
|
-
# Run quietly (only generate reports)
|
|
198
|
-
writechoice check links docs.example.com --quiet
|
|
199
|
-
|
|
200
|
-
# Fix broken anchor links
|
|
201
|
-
writechoice fix links
|
|
202
|
-
|
|
203
|
-
# Fix from custom report
|
|
204
|
-
writechoice fix links -r custom_report.json
|
|
205
|
-
|
|
206
|
-
# Fix MDX parsing errors
|
|
207
|
-
writechoice fix parse
|
|
208
|
-
|
|
209
|
-
# Fix a single file directly
|
|
210
|
-
writechoice fix parse -f docs/getting-started.mdx
|
|
68
|
+
Run `writechoice config` to generate a full template with all options.
|
|
211
69
|
|
|
212
|
-
|
|
213
|
-
writechoice check links docs.example.com
|
|
214
|
-
writechoice fix links
|
|
215
|
-
writechoice check links docs.example.com
|
|
216
|
-
|
|
217
|
-
# Full parse workflow: validate -> fix -> re-validate
|
|
218
|
-
writechoice check parse
|
|
219
|
-
writechoice fix parse
|
|
220
|
-
writechoice check parse
|
|
221
|
-
```
|
|
70
|
+
See [docs/config-file.md](docs/config-file.md) for the complete reference.
|
|
222
71
|
|
|
223
72
|
## Documentation
|
|
224
73
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
- **Commands**
|
|
228
|
-
- [config](docs/commands/config.md) - Generate config.json template
|
|
229
|
-
- [check links](docs/commands/check-links.md) - Link validation
|
|
230
|
-
- [check parse](docs/commands/check-parse.md) - MDX parsing validation
|
|
231
|
-
- [fix links](docs/commands/fix-links.md) - Auto-fix broken links
|
|
232
|
-
- [fix parse](docs/commands/fix-parse.md) - Auto-fix MDX parsing errors
|
|
233
|
-
- [update](docs/commands/update.md) - Update command
|
|
234
|
-
- **Guides**
|
|
235
|
-
- [Configuration File](docs/config-file.md) - Using config.json
|
|
236
|
-
- [Configuration](docs/configuration.md) - Advanced configuration
|
|
237
|
-
- [Publishing](docs/publishing.md) - How to publish new versions
|
|
238
|
-
|
|
239
|
-
## Development
|
|
240
|
-
|
|
241
|
-
### Local Setup
|
|
242
|
-
|
|
243
|
-
```bash
|
|
244
|
-
git clone <repository-url>
|
|
245
|
-
cd writechoice-mint-cli
|
|
246
|
-
npm install
|
|
247
|
-
npx playwright install chromium
|
|
248
|
-
chmod +x bin/cli.js
|
|
249
|
-
npm link
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
### Project Structure
|
|
74
|
+
Full command documentation is in [docs/commands/](docs/commands/):
|
|
253
75
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
ā ā āāā fix/
|
|
264
|
-
ā ā āāā links.js # Link fixing
|
|
265
|
-
ā ā āāā parse.js # Parse error fixing
|
|
266
|
-
ā āāā utils/
|
|
267
|
-
ā āāā helpers.js # Utility functions
|
|
268
|
-
ā āāā reports.js # Report generation
|
|
269
|
-
āāā docs/ # Detailed documentation
|
|
270
|
-
āāā package.json
|
|
271
|
-
āāā README.md
|
|
272
|
-
```
|
|
76
|
+
- [check-links](docs/commands/check-links.md)
|
|
77
|
+
- [check-parse](docs/commands/check-parse.md)
|
|
78
|
+
- [fix-links](docs/commands/fix-links.md)
|
|
79
|
+
- [fix-parse](docs/commands/fix-parse.md)
|
|
80
|
+
- [fix-codeblocks](docs/commands/fix-codeblocks.md)
|
|
81
|
+
- [fix-images](docs/commands/fix-images.md)
|
|
82
|
+
- [fix-inlineimages](docs/commands/fix-inlineimages.md)
|
|
83
|
+
- [fix-h1](docs/commands/fix-h1.md)
|
|
84
|
+
- [metadata](docs/commands/metadata.md)
|
|
273
85
|
|
|
274
86
|
## Troubleshooting
|
|
275
87
|
|
|
276
|
-
|
|
277
|
-
|
|
88
|
+
**Playwright not installed:**
|
|
278
89
|
```bash
|
|
279
90
|
npx playwright install chromium
|
|
280
91
|
```
|
|
281
92
|
|
|
282
|
-
|
|
283
|
-
|
|
93
|
+
**Too many concurrent requests:**
|
|
284
94
|
```bash
|
|
285
|
-
writechoice check links
|
|
95
|
+
writechoice check links -c 10
|
|
286
96
|
```
|
|
287
97
|
|
|
288
|
-
|
|
289
|
-
|
|
98
|
+
**Permission error on install:**
|
|
290
99
|
```bash
|
|
291
100
|
sudo npm install -g @writechoice/mint-cli
|
|
101
|
+
# or use nvm: https://github.com/nvm-sh/nvm
|
|
292
102
|
```
|
|
293
103
|
|
|
294
|
-
Or use a node version manager like [nvm](https://github.com/nvm-sh/nvm).
|
|
295
|
-
|
|
296
104
|
## License
|
|
297
105
|
|
|
298
|
-
MIT
|
|
299
|
-
|
|
300
|
-
## Contributing
|
|
301
|
-
|
|
302
|
-
Contributions are welcome! Please open an issue or submit a pull request.
|
|
303
|
-
|
|
304
|
-
## Links
|
|
305
|
-
|
|
306
|
-
- [npm package](https://www.npmjs.com/package/@writechoice/mint-cli)
|
|
307
|
-
- [GitHub repository](https://github.com/writechoice/mint-cli)
|
|
308
|
-
- [Issue tracker](https://github.com/writechoice/mint-cli/issues)
|
|
106
|
+
MIT ā [GitHub](https://github.com/writechoice/mint-cli) Ā· [npm](https://www.npmjs.com/package/@writechoice/mint-cli) Ā· [Issues](https://github.com/writechoice/mint-cli/issues)
|
package/bin/cli.js
CHANGED
|
@@ -189,6 +189,25 @@ fix
|
|
|
189
189
|
await fixImages(mergedOptions);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
// Metadata command
|
|
193
|
+
program
|
|
194
|
+
.command("metadata [baseUrl]")
|
|
195
|
+
.description("Fetch meta tags from live pages and write them into MDX frontmatter")
|
|
196
|
+
.option("-f, --file <path>", "Process a single MDX file")
|
|
197
|
+
.option("-d, --dir <path>", "Process MDX files in a specific directory")
|
|
198
|
+
.option("-c, --concurrency <number>", "Number of parallel HTTP requests", "15")
|
|
199
|
+
.option("--dry-run", "Preview changes without writing files")
|
|
200
|
+
.option("--quiet", "Suppress terminal output")
|
|
201
|
+
.action(async (baseUrl, options) => {
|
|
202
|
+
const { loadConfig, mergeMetadataConfig } = await import("../src/utils/config.js");
|
|
203
|
+
const { runMetadata } = await import("../src/commands/metadata.js");
|
|
204
|
+
|
|
205
|
+
const config = loadConfig();
|
|
206
|
+
const mergedOptions = mergeMetadataConfig({ ...options, baseUrl }, config);
|
|
207
|
+
mergedOptions.verbose = !mergedOptions.quiet;
|
|
208
|
+
await runMetadata(mergedOptions);
|
|
209
|
+
});
|
|
210
|
+
|
|
192
211
|
// Config command
|
|
193
212
|
program
|
|
194
213
|
.command("config")
|
package/package.json
CHANGED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Command
|
|
3
|
+
*
|
|
4
|
+
* Fetches meta tags from live documentation pages and writes them into
|
|
5
|
+
* the frontmatter of the corresponding MDX source files.
|
|
6
|
+
*
|
|
7
|
+
* URL mapping:
|
|
8
|
+
* baseUrl + "/" + relative-path-from-scan-dir (without .mdx)
|
|
9
|
+
*
|
|
10
|
+
* Example:
|
|
11
|
+
* baseUrl = https://docs.example.com
|
|
12
|
+
* file = docs/api/reference.mdx
|
|
13
|
+
* scan dir = docs/
|
|
14
|
+
* ā URL = https://docs.example.com/api/reference
|
|
15
|
+
*
|
|
16
|
+
* Existing frontmatter keys are updated (overwritten).
|
|
17
|
+
* Missing keys are appended at the end of the frontmatter block.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync } from "fs";
|
|
21
|
+
import { join, relative, resolve } from "path";
|
|
22
|
+
import chalk from "chalk";
|
|
23
|
+
|
|
24
|
+
const EXCLUDED_DIRS = ["node_modules", ".git"];
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_META_TAGS = [
|
|
27
|
+
"og:title",
|
|
28
|
+
"og:description",
|
|
29
|
+
"og:image",
|
|
30
|
+
"og:url",
|
|
31
|
+
"twitter:title",
|
|
32
|
+
"twitter:description",
|
|
33
|
+
"twitter:image",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
37
|
+
// HTML helpers
|
|
38
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parses an HTML attribute string into a keyāvalue object.
|
|
42
|
+
* Handles both double and single-quoted values.
|
|
43
|
+
*/
|
|
44
|
+
function parseHtmlAttributes(attrStr) {
|
|
45
|
+
const attrs = {};
|
|
46
|
+
const re = /(\w[\w-]*)=(?:"([^"]*)"|'([^']*)')/g;
|
|
47
|
+
let m;
|
|
48
|
+
while ((m = re.exec(attrStr)) !== null) {
|
|
49
|
+
attrs[m[1]] = m[2] !== undefined ? m[2] : m[3];
|
|
50
|
+
}
|
|
51
|
+
return attrs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extracts the requested meta tag values from an HTML string.
|
|
56
|
+
* Looks at property, name, and itemprop attributes.
|
|
57
|
+
* Returns { "og:title": "...", ... }
|
|
58
|
+
*/
|
|
59
|
+
function extractMetaTags(html, tags) {
|
|
60
|
+
const results = {};
|
|
61
|
+
const metaRe = /<meta\s+([^>]+?)(?:\s*\/?>)/gi;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = metaRe.exec(html)) !== null) {
|
|
64
|
+
const attrs = parseHtmlAttributes(m[1]);
|
|
65
|
+
const tagName = attrs.property || attrs.name || attrs.itemprop;
|
|
66
|
+
if (tagName && tags.includes(tagName) && attrs.content && attrs.content.trim()) {
|
|
67
|
+
results[tagName] = attrs.content.trim();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fetches a URL and returns the extracted meta tags.
|
|
75
|
+
*/
|
|
76
|
+
async function fetchMetaTags(url, tags) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url, {
|
|
79
|
+
headers: {
|
|
80
|
+
"User-Agent":
|
|
81
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
82
|
+
},
|
|
83
|
+
signal: AbortSignal.timeout(30_000),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
return { error: `HTTP ${res.status}`, tags: {} };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const html = await res.text();
|
|
91
|
+
return { error: null, tags: extractMetaTags(html, tags) };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { error: err.message, tags: {} };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
98
|
+
// Concurrency
|
|
99
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Runs an array of async task factories with a maximum concurrency.
|
|
103
|
+
*/
|
|
104
|
+
async function runConcurrent(tasks, concurrency) {
|
|
105
|
+
const results = new Array(tasks.length);
|
|
106
|
+
const queue = tasks.map((task, idx) => ({ task, idx }));
|
|
107
|
+
|
|
108
|
+
async function worker() {
|
|
109
|
+
while (queue.length > 0) {
|
|
110
|
+
const { task, idx } = queue.shift();
|
|
111
|
+
results[idx] = await task();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
|
|
116
|
+
return results;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
120
|
+
// URL construction
|
|
121
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
122
|
+
|
|
123
|
+
function fileToUrl(filePath, scanDir, baseUrl) {
|
|
124
|
+
const rel = relative(scanDir, filePath)
|
|
125
|
+
.replace(/\.mdx$/, "")
|
|
126
|
+
.replace(/\\/g, "/");
|
|
127
|
+
return baseUrl.replace(/\/$/, "") + "/" + rel;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
131
|
+
// Frontmatter helpers
|
|
132
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
133
|
+
|
|
134
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
135
|
+
|
|
136
|
+
function escapeRe(str) {
|
|
137
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Formats a string value for YAML output.
|
|
142
|
+
* Always produces a quoted scalar to avoid YAML interpretation issues.
|
|
143
|
+
*/
|
|
144
|
+
function yamlValue(str) {
|
|
145
|
+
if (!str.includes('"')) return `"${str}"`;
|
|
146
|
+
if (!str.includes("'")) return `'${str}'`;
|
|
147
|
+
// Both quotes present ā escape double quotes
|
|
148
|
+
return `"${str.replace(/"/g, '\\"')}"`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Applies meta data to the MDX file content.
|
|
153
|
+
* Updates existing frontmatter keys, appends missing ones.
|
|
154
|
+
* Returns { newContent, updated: string[], added: string[], skipped: boolean }
|
|
155
|
+
*/
|
|
156
|
+
function applyMetaToContent(content, metaData) {
|
|
157
|
+
const fmMatch = FRONTMATTER_RE.exec(content);
|
|
158
|
+
if (!fmMatch) {
|
|
159
|
+
return { newContent: content, updated: [], added: [], skipped: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let fmText = fmMatch[1];
|
|
163
|
+
const fmEnd = fmMatch[0].length;
|
|
164
|
+
const body = content.slice(fmEnd);
|
|
165
|
+
|
|
166
|
+
const updated = [];
|
|
167
|
+
const added = [];
|
|
168
|
+
|
|
169
|
+
for (const [key, value] of Object.entries(metaData)) {
|
|
170
|
+
// Keys containing colons must be quoted in YAML
|
|
171
|
+
const yamlKey = key.includes(":") ? `"${key}"` : key;
|
|
172
|
+
const newLine = `${yamlKey}: ${yamlValue(value)}`;
|
|
173
|
+
|
|
174
|
+
// Match existing key in any of its quoting variants
|
|
175
|
+
const keyEsc = escapeRe(key);
|
|
176
|
+
const existingRe = new RegExp(
|
|
177
|
+
`^(?:${keyEsc}|"${keyEsc}"|'${keyEsc}')\\s*:.*$`,
|
|
178
|
+
"m"
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (existingRe.test(fmText)) {
|
|
182
|
+
fmText = fmText.replace(existingRe, newLine);
|
|
183
|
+
updated.push(key);
|
|
184
|
+
} else {
|
|
185
|
+
fmText += `\n${newLine}`;
|
|
186
|
+
added.push(key);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const newContent = `---\n${fmText}\n---\n${body}`;
|
|
191
|
+
return { newContent, updated, added, skipped: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
195
|
+
// File discovery
|
|
196
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
197
|
+
|
|
198
|
+
function findMdxFiles(repoRoot, directory = null, file = null) {
|
|
199
|
+
if (file) {
|
|
200
|
+
const fullPath = resolve(repoRoot, file);
|
|
201
|
+
return existsSync(fullPath) ? [fullPath] : [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const searchDirs = directory ? [resolve(repoRoot, directory)] : [repoRoot];
|
|
205
|
+
const mdxFiles = [];
|
|
206
|
+
|
|
207
|
+
function walkDirectory(dir) {
|
|
208
|
+
const dirName = dir.split("/").pop();
|
|
209
|
+
if (EXCLUDED_DIRS.includes(dirName)) return;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const entries = readdirSync(dir);
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
const fullPath = join(dir, entry);
|
|
215
|
+
const stat = statSync(fullPath);
|
|
216
|
+
if (stat.isDirectory()) {
|
|
217
|
+
walkDirectory(fullPath);
|
|
218
|
+
} else if (stat.isFile() && entry.endsWith(".mdx")) {
|
|
219
|
+
mdxFiles.push(fullPath);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error(`Error reading directory ${dir}: ${error.message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const dir of searchDirs) {
|
|
228
|
+
if (existsSync(dir)) walkDirectory(dir);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return mdxFiles.sort();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
235
|
+
// Main export
|
|
236
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
237
|
+
|
|
238
|
+
export async function runMetadata(options) {
|
|
239
|
+
const repoRoot = process.cwd();
|
|
240
|
+
|
|
241
|
+
if (!options.quiet) {
|
|
242
|
+
console.log(chalk.bold("\nš·ļø Metadata Fetcher\n"));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!options.baseUrl) {
|
|
246
|
+
console.error(
|
|
247
|
+
chalk.red("ā No base URL provided. Pass --base-url or set 'source' in config.json.")
|
|
248
|
+
);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const tags = options.tags || DEFAULT_META_TAGS;
|
|
253
|
+
const concurrency = options.concurrency || 15;
|
|
254
|
+
const scanDir = options.dir ? resolve(repoRoot, options.dir) : repoRoot;
|
|
255
|
+
|
|
256
|
+
const files = findMdxFiles(repoRoot, options.dir, options.file);
|
|
257
|
+
|
|
258
|
+
if (files.length === 0) {
|
|
259
|
+
console.error(chalk.red("ā No MDX files found."));
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!options.quiet) {
|
|
264
|
+
console.log(`Base URL : ${options.baseUrl}`);
|
|
265
|
+
console.log(`Tags : ${tags.join(", ")}`);
|
|
266
|
+
console.log(`Files : ${files.length} MDX file(s)`);
|
|
267
|
+
console.log(`Concurrency: ${concurrency}\n`);
|
|
268
|
+
if (options.dryRun) {
|
|
269
|
+
console.log(chalk.yellow("Dry run ā no files will be written\n"));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let processed = 0;
|
|
274
|
+
let skipped = 0;
|
|
275
|
+
let errors = 0;
|
|
276
|
+
const changed = [];
|
|
277
|
+
|
|
278
|
+
const tasks = files.map((filePath) => async () => {
|
|
279
|
+
const url = fileToUrl(filePath, scanDir, options.baseUrl);
|
|
280
|
+
const relPath = relative(repoRoot, filePath);
|
|
281
|
+
|
|
282
|
+
const { error, tags: metaData } = await fetchMetaTags(url, tags);
|
|
283
|
+
|
|
284
|
+
processed++;
|
|
285
|
+
|
|
286
|
+
if (error) {
|
|
287
|
+
if (!options.quiet) {
|
|
288
|
+
console.log(`${chalk.red("ā")} ${chalk.cyan(relPath)} ā ${error}`);
|
|
289
|
+
}
|
|
290
|
+
errors++;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (Object.keys(metaData).length === 0) {
|
|
295
|
+
if (options.verbose) {
|
|
296
|
+
console.log(`${chalk.gray("ā")} ${chalk.cyan(relPath)} ā no meta tags found`);
|
|
297
|
+
}
|
|
298
|
+
skipped++;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const content = readFileSync(filePath, "utf-8");
|
|
303
|
+
const { newContent, updated, added, skipped: noFm } = applyMetaToContent(content, metaData);
|
|
304
|
+
|
|
305
|
+
if (noFm) {
|
|
306
|
+
if (options.verbose) {
|
|
307
|
+
console.log(`${chalk.gray("ā")} ${chalk.cyan(relPath)} ā no frontmatter, skipped`);
|
|
308
|
+
}
|
|
309
|
+
skipped++;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const totalChanges = updated.length + added.length;
|
|
314
|
+
if (totalChanges > 0) {
|
|
315
|
+
changed.push({ relPath, updated, added });
|
|
316
|
+
if (options.verbose) {
|
|
317
|
+
const parts = [];
|
|
318
|
+
if (updated.length) parts.push(`updated: ${updated.join(", ")}`);
|
|
319
|
+
if (added.length) parts.push(`added: ${added.join(", ")}`);
|
|
320
|
+
console.log(`${chalk.green("ā")} ${chalk.cyan(relPath)} ā ${parts.join(" | ")}`);
|
|
321
|
+
}
|
|
322
|
+
if (!options.dryRun) {
|
|
323
|
+
writeFileSync(filePath, newContent, "utf-8");
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
if (options.verbose) {
|
|
327
|
+
console.log(`${chalk.gray("ā")} ${chalk.cyan(relPath)} ā already up to date`);
|
|
328
|
+
}
|
|
329
|
+
skipped++;
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await runConcurrent(tasks, concurrency);
|
|
334
|
+
|
|
335
|
+
// Summary
|
|
336
|
+
if (!options.quiet) {
|
|
337
|
+
if (changed.length > 0) {
|
|
338
|
+
const verb = options.dryRun ? "Would update" : "Updated";
|
|
339
|
+
console.log(chalk.green(`\nā ${verb} ${changed.length} file(s)`));
|
|
340
|
+
|
|
341
|
+
if (!options.verbose) {
|
|
342
|
+
for (const { relPath, updated, added } of changed) {
|
|
343
|
+
const parts = [];
|
|
344
|
+
if (updated.length) parts.push(`updated: ${updated.length}`);
|
|
345
|
+
if (added.length) parts.push(`added: ${added.length}`);
|
|
346
|
+
console.log(` ${chalk.cyan(relPath)} ā ${parts.join(" | ")} tag(s)`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
console.log(chalk.yellow("ā ļø No files needed updating."));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (errors > 0) {
|
|
354
|
+
console.log(chalk.yellow(`\nā ļø ${errors} file(s) had fetch errors.`));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
package/src/utils/config.js
CHANGED
|
@@ -198,6 +198,30 @@ export function mergeH1Config(options, config) {
|
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Merges config file with CLI options for the metadata command
|
|
203
|
+
* CLI options take precedence over config file
|
|
204
|
+
*
|
|
205
|
+
* @param {Object} options - CLI options
|
|
206
|
+
* @param {Object|null} config - Loaded config object
|
|
207
|
+
* @returns {Object} Merged options
|
|
208
|
+
*/
|
|
209
|
+
export function mergeMetadataConfig(options, config) {
|
|
210
|
+
const metaConfig = config?.metadata || {};
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
baseUrl: options.baseUrl || config?.source || null,
|
|
214
|
+
file: options.file || metaConfig.file || null,
|
|
215
|
+
dir: options.dir || metaConfig.dir || null,
|
|
216
|
+
concurrency: options.concurrency != null
|
|
217
|
+
? parseInt(options.concurrency, 10)
|
|
218
|
+
: (metaConfig.concurrency ?? 15),
|
|
219
|
+
tags: metaConfig.tags || null, // null means use defaults
|
|
220
|
+
dryRun: options.dryRun !== undefined ? options.dryRun : (metaConfig["dry-run"] ?? false),
|
|
221
|
+
quiet: options.quiet !== undefined ? options.quiet : (metaConfig.quiet ?? false),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
201
225
|
/**
|
|
202
226
|
* Validates that required fields are present
|
|
203
227
|
* @param {string|undefined} baseUrl - Base URL
|