@writechoice/mint-cli 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -16,7 +16,9 @@ const program = new Command();
16
16
 
17
17
  program
18
18
  .name("writechoice")
19
- .description("CLI tool for Mintlify documentation validation and utilities")
19
+ .description(
20
+ "@writechoice/mint-cli@" + packageJson.version + "\n\nCLI tool for Mintlify documentation validation and utilities",
21
+ )
20
22
  .version(packageJson.version, "-v, --version", "Output the current version");
21
23
 
22
24
  // Validate command
@@ -109,6 +111,30 @@ fix
109
111
  await fixParse(options);
110
112
  });
111
113
 
114
+ // Fix codeblocks subcommand
115
+ fix
116
+ .command("codeblocks")
117
+ .description("Fix code block flags (expandable, lines, wrap) in MDX files")
118
+ .option("-f, --file <path>", "Fix a single MDX file directly")
119
+ .option("-d, --dir <path>", "Fix MDX files in a specific directory")
120
+ .option("-t, --threshold <number>", "Line count threshold for expandable (default: 15)")
121
+ .option("--no-expandable", "Skip expandable threshold processing")
122
+ .option("--lines", "Add 'lines' to all code blocks that lack it")
123
+ .option("--remove-lines", "Remove 'lines' from all code blocks that have it")
124
+ .option("--wrap", "Add 'wrap' to all code blocks that lack it")
125
+ .option("--remove-wrap", "Remove 'wrap' from all code blocks that have it")
126
+ .option("--dry-run", "Preview changes without writing files")
127
+ .option("--quiet", "Suppress terminal output")
128
+ .action(async (options) => {
129
+ const { loadConfig, mergeCodeblocksConfig } = await import("../src/utils/config.js");
130
+ const { fixCodeblocks } = await import("../src/commands/fix/codeblocks.js");
131
+
132
+ const config = loadConfig();
133
+ const mergedOptions = mergeCodeblocksConfig(options, config);
134
+ mergedOptions.verbose = !mergedOptions.quiet;
135
+ await fixCodeblocks(mergedOptions);
136
+ });
137
+
112
138
  // Config command
113
139
  program
114
140
  .command("config")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writechoice/mint-cli",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "CLI tool for Mintlify documentation validation and utilities",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Code Block Fix Tool
3
+ *
4
+ * Fixes code block flags in MDX documentation files:
5
+ * - expandable: adds when line count > threshold, removes when < threshold
6
+ * - lines: adds to all code blocks (--lines) or removes from all (--remove-lines)
7
+ * - wrap: adds to all code blocks (--wrap) or removes from all (--remove-wrap)
8
+ */
9
+
10
+ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync } from "fs";
11
+ import { join, relative, resolve } from "path";
12
+ import chalk from "chalk";
13
+
14
+ const DEFAULT_THRESHOLD = 15;
15
+ const EXCLUDED_DIRS = ["node_modules", ".git"];
16
+
17
+ /**
18
+ * Finds MDX files to process
19
+ */
20
+ function findMdxFiles(repoRoot, directory = null, file = null) {
21
+ if (file) {
22
+ const fullPath = resolve(repoRoot, file);
23
+ return existsSync(fullPath) ? [fullPath] : [];
24
+ }
25
+
26
+ const searchDirs = directory ? [resolve(repoRoot, directory)] : [repoRoot];
27
+ const mdxFiles = [];
28
+
29
+ function walkDirectory(dir) {
30
+ const dirName = dir.split("/").pop();
31
+ if (EXCLUDED_DIRS.includes(dirName)) return;
32
+
33
+ try {
34
+ const entries = readdirSync(dir);
35
+ for (const entry of entries) {
36
+ const fullPath = join(dir, entry);
37
+ const stat = statSync(fullPath);
38
+ if (stat.isDirectory()) {
39
+ walkDirectory(fullPath);
40
+ } else if (stat.isFile() && entry.endsWith(".mdx")) {
41
+ mdxFiles.push(fullPath);
42
+ }
43
+ }
44
+ } catch (error) {
45
+ console.error(`Error reading directory ${dir}: ${error.message}`);
46
+ }
47
+ }
48
+
49
+ for (const dir of searchDirs) {
50
+ if (existsSync(dir)) walkDirectory(dir);
51
+ }
52
+
53
+ return mdxFiles.sort();
54
+ }
55
+
56
+ /**
57
+ * Splits content into lines preserving original line endings.
58
+ */
59
+ function splitLines(content) {
60
+ const lines = [];
61
+ let pos = 0;
62
+ while (pos < content.length) {
63
+ const nlPos = content.indexOf("\n", pos);
64
+ if (nlPos === -1) {
65
+ lines.push(content.slice(pos));
66
+ break;
67
+ }
68
+ lines.push(content.slice(pos, nlPos + 1));
69
+ pos = nlPos + 1;
70
+ }
71
+ return lines;
72
+ }
73
+
74
+ /**
75
+ * Processes the token list for a single code block's info string.
76
+ * Returns { newTokens, changes }.
77
+ */
78
+ function processInfoTokens(tokens, lineCount, lineNum, options) {
79
+ const changes = [];
80
+ let newTokens = [...tokens];
81
+
82
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
83
+
84
+ // expandable: threshold-based (unless disabled via --no-expandable)
85
+ if (options.expandable !== false) {
86
+ const hasExpandable = newTokens.includes("expandable");
87
+ if (hasExpandable && lineCount < threshold) {
88
+ newTokens = newTokens.filter((t) => t !== "expandable");
89
+ changes.push(`line ${lineNum}: removed 'expandable' (${lineCount} lines < ${threshold})`);
90
+ } else if (!hasExpandable && lineCount > threshold) {
91
+ newTokens.push("expandable");
92
+ changes.push(`line ${lineNum}: added 'expandable' (${lineCount} lines > ${threshold})`);
93
+ }
94
+ }
95
+
96
+ // lines: add or remove
97
+ const hasLines = newTokens.includes("lines");
98
+ if (options.lines && !hasLines) {
99
+ newTokens.push("lines");
100
+ changes.push(`line ${lineNum}: added 'lines'`);
101
+ } else if (options.removeLines && hasLines) {
102
+ newTokens = newTokens.filter((t) => t !== "lines");
103
+ changes.push(`line ${lineNum}: removed 'lines'`);
104
+ }
105
+
106
+ // wrap: add or remove
107
+ const hasWrap = newTokens.includes("wrap");
108
+ if (options.wrap && !hasWrap) {
109
+ newTokens.push("wrap");
110
+ changes.push(`line ${lineNum}: added 'wrap'`);
111
+ } else if (options.removeWrap && hasWrap) {
112
+ newTokens = newTokens.filter((t) => t !== "wrap");
113
+ changes.push(`line ${lineNum}: removed 'wrap'`);
114
+ }
115
+
116
+ return { newTokens, changes };
117
+ }
118
+
119
+ /**
120
+ * Scans MDX content for fenced code blocks and applies flag fixes.
121
+ * Returns { newContent, changes }.
122
+ */
123
+ function processContent(content, options) {
124
+ const lines = splitLines(content);
125
+ const result = [];
126
+ const changes = [];
127
+ let i = 0;
128
+
129
+ while (i < lines.length) {
130
+ const line = lines[i];
131
+ // Strip trailing newline for matching, preserving it for output
132
+ const stripped = line.replace(/\r?\n$/, "");
133
+
134
+ // Detect a fenced code block opener: optional indent + 3+ backticks + info string
135
+ const openMatch = stripped.match(/^([ \t]*)(`{3,})(.*)$/);
136
+
137
+ if (openMatch) {
138
+ const indent = openMatch[1];
139
+ const fence = openMatch[2];
140
+ const info = openMatch[3];
141
+ const fenceLen = fence.length;
142
+
143
+ // Find the matching closing fence
144
+ let j = i + 1;
145
+ while (j < lines.length) {
146
+ const closeStripped = lines[j].replace(/\r?\n$/, "");
147
+ const closeMatch = closeStripped.match(/^[ \t]*(`{3,})[ \t]*$/);
148
+ if (closeMatch && closeMatch[1].length >= fenceLen) {
149
+ break;
150
+ }
151
+ j++;
152
+ }
153
+
154
+ const bodyLines = lines.slice(i + 1, j);
155
+ const lineCount = bodyLines.length;
156
+
157
+ // Parse info string tokens
158
+ const infoStripped = info.trim();
159
+ const tokens = infoStripped ? infoStripped.split(/\s+/) : [];
160
+
161
+ const { newTokens, changes: blockChanges } = processInfoTokens(tokens, lineCount, i + 1, options);
162
+ changes.push(...blockChanges);
163
+
164
+ // Reconstruct the opening fence line with original line ending
165
+ const lineEnding = line.slice(stripped.length);
166
+ const newInfo = newTokens.join(" ");
167
+ result.push(`${indent}${fence}${newInfo}${lineEnding}`);
168
+ result.push(...bodyLines);
169
+
170
+ if (j < lines.length) {
171
+ result.push(lines[j]); // closing fence
172
+ i = j + 1;
173
+ } else {
174
+ // Unterminated block — leave as-is
175
+ i = j;
176
+ }
177
+ } else {
178
+ result.push(line);
179
+ i++;
180
+ }
181
+ }
182
+
183
+ return { newContent: result.join(""), changes };
184
+ }
185
+
186
+ /**
187
+ * Main exported function for the fix codeblocks command.
188
+ */
189
+ export async function fixCodeblocks(options) {
190
+ const repoRoot = process.cwd();
191
+
192
+ if (!options.quiet) {
193
+ console.log(chalk.bold("\nšŸ”§ Code Block Fixer\n"));
194
+ }
195
+
196
+ const files = findMdxFiles(repoRoot, options.dir, options.file);
197
+
198
+ if (files.length === 0) {
199
+ console.error(chalk.red("āœ— No MDX files found."));
200
+ process.exit(1);
201
+ }
202
+
203
+ if (!options.quiet) {
204
+ console.log(`Found ${files.length} MDX file(s) to process\n`);
205
+ if (options.dryRun) {
206
+ console.log(chalk.yellow("Dry run — no files will be written\n"));
207
+ }
208
+ }
209
+
210
+ const results = {};
211
+ let totalChanges = 0;
212
+
213
+ for (const filePath of files) {
214
+ const content = readFileSync(filePath, "utf-8");
215
+ const { newContent, changes } = processContent(content, options);
216
+
217
+ if (changes.length > 0) {
218
+ const relPath = relative(repoRoot, filePath);
219
+ results[relPath] = changes;
220
+ totalChanges += changes.length;
221
+
222
+ if (options.verbose) {
223
+ console.log(`${chalk.cyan(relPath)}: ${changes.length} change(s)`);
224
+ for (const change of changes) {
225
+ console.log(` ${change}`);
226
+ }
227
+ }
228
+
229
+ if (!options.dryRun) {
230
+ writeFileSync(filePath, newContent, "utf-8");
231
+ }
232
+ }
233
+ }
234
+
235
+ // Summary
236
+ if (!options.quiet) {
237
+ const fileCount = Object.keys(results).length;
238
+
239
+ if (fileCount > 0) {
240
+ const verb = options.dryRun ? "Would make" : "Made";
241
+ console.log(chalk.green(`\nāœ“ ${verb} ${totalChanges} change(s) in ${fileCount} file(s)`));
242
+
243
+ if (!options.verbose) {
244
+ for (const [filePath, changes] of Object.entries(results)) {
245
+ console.log(` ${chalk.cyan(filePath)}: ${changes.length} change(s)`);
246
+ }
247
+ }
248
+ } else {
249
+ console.log(chalk.yellow("āš ļø No changes needed."));
250
+ }
251
+ }
252
+ }
@@ -97,6 +97,50 @@ export function mergeParseConfig(options, config) {
97
97
  };
98
98
  }
99
99
 
100
+ /**
101
+ * Merges config file with CLI options for the codeblocks command
102
+ * CLI options take precedence over config file
103
+ *
104
+ * @param {Object} options - CLI options
105
+ * @param {Object|null} config - Loaded config object
106
+ * @returns {Object} Merged options
107
+ */
108
+ export function mergeCodeblocksConfig(options, config) {
109
+ const cbConfig = config?.codeblocks || {};
110
+
111
+ // Resolve lines/wrap from config: "add" | true → add, "remove" | false → remove
112
+ const configLines = cbConfig.lines;
113
+ const configWrap = cbConfig.wrap;
114
+
115
+ const addLinesFromConfig = configLines === "add" || configLines === true;
116
+ const removeLinesFromConfig = configLines === "remove" || configLines === false;
117
+ const addWrapFromConfig = configWrap === "add" || configWrap === true;
118
+ const removeWrapFromConfig = configWrap === "remove" || configWrap === false;
119
+
120
+ const threshold = options.threshold != null
121
+ ? parseInt(options.threshold, 10)
122
+ : (cbConfig.threshold ?? 15);
123
+
124
+ // expandable: Commander sets options.expandable=false when --no-expandable is passed
125
+ const expandable = options.expandable !== false
126
+ ? (cbConfig.expandable !== false)
127
+ : false;
128
+
129
+ return {
130
+ file: options.file || cbConfig.file || null,
131
+ dir: options.dir || cbConfig.dir || null,
132
+ dryRun: options.dryRun !== undefined ? options.dryRun : (cbConfig["dry-run"] ?? false),
133
+ quiet: options.quiet !== undefined ? options.quiet : (cbConfig.quiet ?? false),
134
+ threshold,
135
+ expandable,
136
+ // CLI flags take precedence over config
137
+ lines: options.lines || addLinesFromConfig,
138
+ removeLines: options.removeLines || removeLinesFromConfig,
139
+ wrap: options.wrap || addWrapFromConfig,
140
+ removeWrap: options.removeWrap || removeWrapFromConfig,
141
+ };
142
+ }
143
+
100
144
  /**
101
145
  * Validates that required fields are present
102
146
  * @param {string|undefined} baseUrl - Base URL