@writechoice/mint-cli 0.0.19 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -177,6 +177,7 @@ fix
177
177
  .description("Wrap standalone images in <Frame> components in MDX files")
178
178
  .option("-f, --file <path>", "Fix a single MDX file directly")
179
179
  .option("-d, --dir <path>", "Fix MDX files in a specific directory")
180
+ .option("--download [url]", "Download missing local images; uses source from config or provide a URL")
180
181
  .option("--dry-run", "Preview changes without writing files")
181
182
  .option("--quiet", "Suppress terminal output")
182
183
  .action(async (options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writechoice/mint-cli",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "CLI tool for Mintlify documentation validation and utilities",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  "wc": "bin/cli.js"
10
10
  },
11
11
  "scripts": {
12
- "test": "echo \"Error: no test specified\" && exit 1",
12
+ "test": "node --test 'test/**/*.test.js'",
13
13
  "dev": "node bin/cli.js",
14
14
  "release": "bash publish.sh",
15
15
  "postinstall": "echo \"\\n⚠️ Don't forget to install Playwright browsers:\\n npx playwright install chromium\\n\""
@@ -75,7 +75,7 @@ function splitLines(content) {
75
75
  * Processes the token list for a single code block's info string.
76
76
  * Returns { newTokens, changes }.
77
77
  */
78
- function processInfoTokens(tokens, lineCount, lineNum, options) {
78
+ export function processInfoTokens(tokens, lineCount, lineNum, options) {
79
79
  const changes = [];
80
80
  let newTokens = [...tokens];
81
81
 
@@ -120,7 +120,7 @@ function processInfoTokens(tokens, lineCount, lineNum, options) {
120
120
  * Scans MDX content for fenced code blocks and applies flag fixes.
121
121
  * Returns { newContent, changes }.
122
122
  */
123
- function processContent(content, options) {
123
+ export function processContent(content, options) {
124
124
  const lines = splitLines(content);
125
125
  const result = [];
126
126
  const changes = [];
@@ -27,7 +27,7 @@ const H1_RE = /^\s*#\s+(.*?)\s*$/;
27
27
  /**
28
28
  * Returns the frontmatter title value, or null if not found.
29
29
  */
30
- function extractFrontmatterTitle(content) {
30
+ export function extractFrontmatterTitle(content) {
31
31
  const fmMatch = FRONTMATTER_RE.exec(content);
32
32
  if (!fmMatch) return null;
33
33
  const fmText = fmMatch[0];
@@ -39,7 +39,7 @@ function extractFrontmatterTitle(content) {
39
39
  * Removes the duplicate H1 from `content` if present.
40
40
  * Returns { newContent, changed }.
41
41
  */
42
- function removeDuplicateH1(content, fmTitle) {
42
+ export function removeDuplicateH1(content, fmTitle) {
43
43
  const fmMatch = FRONTMATTER_RE.exec(content);
44
44
  if (!fmMatch) return { newContent: content, changed: false };
45
45
 
@@ -11,8 +11,8 @@
11
11
  * - HTML tables
12
12
  */
13
13
 
14
- import { existsSync, readdirSync, statSync, readFileSync, writeFileSync } from "fs";
15
- import { join, relative, resolve } from "path";
14
+ import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from "fs";
15
+ import { dirname, join, relative, resolve } from "path";
16
16
  import chalk from "chalk";
17
17
 
18
18
  const EXCLUDED_DIRS = ["node_modules", ".git"];
@@ -72,7 +72,7 @@ function detokenize(text, tag, stash) {
72
72
  * Processes MDX content and wraps standalone images in <Frame> components.
73
73
  * Returns { newContent, count }.
74
74
  */
75
- function processContent(content) {
75
+ export function processContent(content) {
76
76
  let text = content;
77
77
 
78
78
  // 1. Protect existing <Frame> blocks
@@ -113,6 +113,90 @@ function processContent(content) {
113
113
  return { newContent: text, count };
114
114
  }
115
115
 
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+ // Image src extraction (for --download)
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+
120
+ // Extract all image src values from MDX content (markdown + HTML img)
121
+ export function extractImageSrcs(content) {
122
+ const srcs = [];
123
+
124
+ // Markdown images: ![alt](src) — capture the URL part
125
+ const mdRe = /!\[[^\]]*\]\(([^)\s"']+)/g;
126
+ let m;
127
+ while ((m = mdRe.exec(content)) !== null) {
128
+ srcs.push(m[1]);
129
+ }
130
+
131
+ // HTML img tags: <img src="..." /> or <img src='...' />
132
+ const htmlRe = /<img\b[^>]*\bsrc=["']([^"']+)["']/gi;
133
+ while ((m = htmlRe.exec(content)) !== null) {
134
+ srcs.push(m[1]);
135
+ }
136
+
137
+ return srcs;
138
+ }
139
+
140
+ /**
141
+ * Downloads missing local images from the source URL.
142
+ * Only attempts images with local absolute paths (starting with /).
143
+ * Returns { downloaded, failed } arrays and optionally writes image_download.json.
144
+ */
145
+ async function downloadMissingImages(files, repoRoot, downloadUrl, options) {
146
+ const base = downloadUrl.replace(/\/$/, "");
147
+
148
+ // Collect unique local srcs across all files
149
+ const srcSet = new Set();
150
+ for (const filePath of files) {
151
+ const content = readFileSync(filePath, "utf-8");
152
+ for (const src of extractImageSrcs(content)) {
153
+ // Only handle root-relative paths like /images/foo.png
154
+ if (src.startsWith("/") && !src.startsWith("//")) {
155
+ srcSet.add(src);
156
+ }
157
+ }
158
+ }
159
+
160
+ if (srcSet.size === 0) {
161
+ return { downloaded: [], failed: [] };
162
+ }
163
+
164
+ const downloaded = [];
165
+ const failed = [];
166
+
167
+ for (const src of srcSet) {
168
+ const localPath = join(repoRoot, src);
169
+
170
+ if (existsSync(localPath)) continue; // already present
171
+
172
+ const url = base + src;
173
+
174
+ try {
175
+ const response = await fetch(url);
176
+ if (!response.ok) {
177
+ failed.push({ src, url, reason: `HTTP ${response.status}` });
178
+ continue;
179
+ }
180
+
181
+ if (!options.dryRun) {
182
+ mkdirSync(dirname(localPath), { recursive: true });
183
+ const buffer = await response.arrayBuffer();
184
+ writeFileSync(localPath, Buffer.from(buffer));
185
+ }
186
+
187
+ downloaded.push({ src, url });
188
+
189
+ if (options.verbose) {
190
+ console.log(` ${chalk.green("↓")} ${src}`);
191
+ }
192
+ } catch (err) {
193
+ failed.push({ src, url, reason: err.message });
194
+ }
195
+ }
196
+
197
+ return { downloaded, failed };
198
+ }
199
+
116
200
  // ─────────────────────────────────────────────────────────────────────────────
117
201
  // File discovery
118
202
  // ─────────────────────────────────────────────────────────────────────────────
@@ -200,7 +284,7 @@ export async function fixImages(options) {
200
284
  }
201
285
  }
202
286
 
203
- // Summary
287
+ // Summary — wrapping
204
288
  if (!options.quiet) {
205
289
  const fileCount = Object.keys(results).length;
206
290
 
@@ -217,4 +301,57 @@ export async function fixImages(options) {
217
301
  console.log(chalk.yellow("⚠️ No unwrapped images found."));
218
302
  }
219
303
  }
304
+
305
+ // ── Download pass ──────────────────────────────────────────────────────────
306
+ if (!options.download) return;
307
+
308
+ if (!options.downloadUrl) {
309
+ console.error(chalk.red(
310
+ '\n✗ --download requires a source URL.\n' +
311
+ ' Pass it after the flag: wc fix images --download https://docs.example.com\n' +
312
+ ' Or set "source" in config.json'
313
+ ));
314
+ process.exit(1);
315
+ }
316
+
317
+ if (!options.quiet) {
318
+ console.log(chalk.bold("\n⬇️ Downloading missing images\n"));
319
+ if (options.dryRun) {
320
+ console.log(chalk.yellow("Dry run — images will not be saved\n"));
321
+ }
322
+ }
323
+
324
+ const { downloaded, failed } = await downloadMissingImages(files, repoRoot, options.downloadUrl, options);
325
+
326
+ if (!options.quiet) {
327
+ if (downloaded.length > 0) {
328
+ const verb = options.dryRun ? "Would download" : "Downloaded";
329
+ console.log(chalk.green(`\n✓ ${verb} ${downloaded.length} image(s)`));
330
+ if (!options.verbose) {
331
+ for (const { src } of downloaded) {
332
+ console.log(` ${src}`);
333
+ }
334
+ }
335
+ }
336
+
337
+ if (failed.length > 0) {
338
+ console.log(chalk.red(`\n✗ Failed to download ${failed.length} image(s)`));
339
+ for (const { src, reason } of failed) {
340
+ console.log(` ${chalk.cyan(src)}: ${reason}`);
341
+ }
342
+ }
343
+
344
+ if (downloaded.length === 0 && failed.length === 0) {
345
+ console.log(chalk.yellow("⚠️ No missing local images found."));
346
+ }
347
+ }
348
+
349
+ // Write report when there are failures
350
+ if (failed.length > 0 && !options.dryRun) {
351
+ const reportPath = join(repoRoot, "image_download.json");
352
+ writeFileSync(reportPath, JSON.stringify({ downloaded, failed }, null, 2), "utf-8");
353
+ if (!options.quiet) {
354
+ console.log(`\nReport written to ${chalk.cyan("image_download.json")}`);
355
+ }
356
+ }
220
357
  }
@@ -74,7 +74,7 @@ function findFrontmatterEnd(content) {
74
74
  * Ensures the InlineImage import is present in the file.
75
75
  * Inserts after frontmatter (if any) with an empty line below.
76
76
  */
77
- function ensureImport(content) {
77
+ export function ensureImport(content) {
78
78
  if (content.includes(IMPORT_LINE)) return content;
79
79
 
80
80
  const fmEnd = findFrontmatterEnd(content);
@@ -158,7 +158,7 @@ function processLine(line) {
158
158
  * Processes MDX content: replaces inline images and injects the import.
159
159
  * Returns { newContent, count }.
160
160
  */
161
- function processContent(content) {
161
+ export function processContent(content) {
162
162
  let text = content;
163
163
 
164
164
  // 1. Protect multi-line regions
@@ -89,7 +89,7 @@ function getFilesFromReport(reportPath, repoRoot) {
89
89
  * Splits file content into protected (code) and unprotected (text) segments.
90
90
  * Returns an array of { text, protected } objects.
91
91
  */
92
- function segmentContent(content) {
92
+ export function segmentContent(content) {
93
93
  const segments = [];
94
94
  let pos = 0;
95
95
  const len = content.length;
@@ -154,7 +154,7 @@ function segmentContent(content) {
154
154
  * Fixes void HTML tags in a text segment (not inside inline code).
155
155
  * Returns { text, count }.
156
156
  */
157
- function fixVoidTags(text) {
157
+ export function fixVoidTags(text) {
158
158
  let count = 0;
159
159
 
160
160
  // Process the text but protect inline code spans
@@ -205,7 +205,7 @@ function replaceVoidTags(text) {
205
205
  * Fixes stray < and > in a text segment (not inside inline code or tags).
206
206
  * Returns { text, count }.
207
207
  */
208
- function fixStrayAngleBrackets(text) {
208
+ export function fixStrayAngleBrackets(text) {
209
209
  let count = 0;
210
210
 
211
211
  // Process the text but protect inline code spans and valid tags
@@ -56,7 +56,7 @@ function parseHtmlAttributes(attrStr) {
56
56
  * Looks at property, name, and itemprop attributes.
57
57
  * Returns { "og:title": "...", ... }
58
58
  */
59
- function extractMetaTags(html, tags) {
59
+ export function extractMetaTags(html, tags) {
60
60
  const results = {};
61
61
  const metaRe = /<meta\s+([^>]+?)(?:\s*\/?>)/gi;
62
62
  let m;
@@ -120,7 +120,7 @@ async function runConcurrent(tasks, concurrency) {
120
120
  // URL construction
121
121
  // ─────────────────────────────────────────────────────────────────────────────
122
122
 
123
- function fileToUrl(filePath, scanDir, baseUrl) {
123
+ export function fileToUrl(filePath, scanDir, baseUrl) {
124
124
  const rel = relative(scanDir, filePath)
125
125
  .replace(/\.mdx$/, "")
126
126
  .replace(/\\/g, "/");
@@ -141,7 +141,7 @@ function escapeRe(str) {
141
141
  * Formats a string value for YAML output.
142
142
  * Always produces a quoted scalar to avoid YAML interpretation issues.
143
143
  */
144
- function yamlValue(str) {
144
+ export function yamlValue(str) {
145
145
  if (!str.includes('"')) return `"${str}"`;
146
146
  if (!str.includes("'")) return `'${str}'`;
147
147
  // Both quotes present — escape double quotes
@@ -153,7 +153,7 @@ function yamlValue(str) {
153
153
  * Updates existing frontmatter keys, appends missing ones.
154
154
  * Returns { newContent, updated: string[], added: string[], skipped: boolean }
155
155
  */
156
- function applyMetaToContent(content, metaData) {
156
+ export function applyMetaToContent(content, metaData) {
157
157
  const fmMatch = FRONTMATTER_RE.exec(content);
158
158
  if (!fmMatch) {
159
159
  return { newContent: content, updated: [], added: [], skipped: true };
@@ -171,11 +171,20 @@ export function mergeInlineImagesConfig(options, config) {
171
171
  export function mergeImagesConfig(options, config) {
172
172
  const imgConfig = config?.images || {};
173
173
 
174
+ // download: true if --download was passed without a URL, string if URL provided
175
+ const downloadFlag = options.download !== undefined ? options.download : (imgConfig.download ?? false);
176
+ let downloadUrl = null;
177
+ if (downloadFlag) {
178
+ downloadUrl = typeof downloadFlag === "string" ? downloadFlag : (config?.source || null);
179
+ }
180
+
174
181
  return {
175
182
  file: options.file || imgConfig.file || null,
176
183
  dir: options.dir || imgConfig.dir || null,
177
184
  dryRun: options.dryRun !== undefined ? options.dryRun : (imgConfig["dry-run"] ?? false),
178
185
  quiet: options.quiet !== undefined ? options.quiet : (imgConfig.quiet ?? false),
186
+ download: !!downloadFlag,
187
+ downloadUrl,
179
188
  };
180
189
  }
181
190