@writechoice/mint-cli 0.0.16 → 0.0.17

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
@@ -153,6 +153,24 @@ fix
153
153
  await fixInlineImages(mergedOptions);
154
154
  });
155
155
 
156
+ // Fix h1 subcommand
157
+ fix
158
+ .command("h1")
159
+ .description("Remove duplicate H1 headings that match the frontmatter title in MDX files")
160
+ .option("-f, --file <path>", "Fix a single MDX file directly")
161
+ .option("-d, --dir <path>", "Fix MDX files in a specific directory")
162
+ .option("--dry-run", "Preview changes without writing files")
163
+ .option("--quiet", "Suppress terminal output")
164
+ .action(async (options) => {
165
+ const { loadConfig, mergeH1Config } = await import("../src/utils/config.js");
166
+ const { fixH1 } = await import("../src/commands/fix/h1.js");
167
+
168
+ const config = loadConfig();
169
+ const mergedOptions = mergeH1Config(options, config);
170
+ mergedOptions.verbose = !mergedOptions.quiet;
171
+ await fixH1(mergedOptions);
172
+ });
173
+
156
174
  // Fix images subcommand
157
175
  fix
158
176
  .command("images")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writechoice/mint-cli",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "CLI tool for Mintlify documentation validation and utilities",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Fix H1 Tool
3
+ *
4
+ * Removes duplicate H1 headings that match the frontmatter title field.
5
+ *
6
+ * If the first non-empty line after frontmatter is an H1 exactly equal
7
+ * to the frontmatter `title`, that line (and the immediately following
8
+ * blank line, if any) is removed.
9
+ *
10
+ * This mirrors the behaviour of remove_double_titles.py.
11
+ */
12
+
13
+ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync } from "fs";
14
+ import { join, relative, resolve } from "path";
15
+ import chalk from "chalk";
16
+
17
+ const EXCLUDED_DIRS = ["node_modules", ".git"];
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Frontmatter helpers
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ const FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
24
+ const TITLE_LINE_RE = /^\s*title\s*:\s*["']?(.*?)["']?\s*$/im;
25
+ const H1_RE = /^\s*#\s+(.*?)\s*$/;
26
+
27
+ /**
28
+ * Returns the frontmatter title value, or null if not found.
29
+ */
30
+ function extractFrontmatterTitle(content) {
31
+ const fmMatch = FRONTMATTER_RE.exec(content);
32
+ if (!fmMatch) return null;
33
+ const fmText = fmMatch[0];
34
+ const titleMatch = TITLE_LINE_RE.exec(fmText);
35
+ return titleMatch ? titleMatch[1].trim() : null;
36
+ }
37
+
38
+ /**
39
+ * Removes the duplicate H1 from `content` if present.
40
+ * Returns { newContent, changed }.
41
+ */
42
+ function removeDuplicateH1(content, fmTitle) {
43
+ const fmMatch = FRONTMATTER_RE.exec(content);
44
+ if (!fmMatch) return { newContent: content, changed: false };
45
+
46
+ const fmEnd = fmMatch[0].length;
47
+ const afterFm = content.slice(fmEnd);
48
+
49
+ const lines = afterFm.split("\n");
50
+
51
+ // Find the first non-empty, non-import line after frontmatter
52
+ let targetIdx = null;
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const trimmed = lines[i].trim();
55
+ if (trimmed === "") continue;
56
+ if (/^import\s/.test(trimmed)) continue; // skip import statements
57
+
58
+ const h1Match = H1_RE.exec(lines[i]);
59
+ if (h1Match && h1Match[1].trim() === fmTitle) {
60
+ targetIdx = i;
61
+ }
62
+ // Either it matched or it didn't — stop after first content line
63
+ break;
64
+ }
65
+
66
+ if (targetIdx === null) return { newContent: content, changed: false };
67
+
68
+ // Remove the H1 line
69
+ const newLines = [...lines.slice(0, targetIdx), ...lines.slice(targetIdx + 1)];
70
+
71
+ // Remove the immediately following blank line (now at targetIdx)
72
+ if (newLines[targetIdx] !== undefined && newLines[targetIdx].trim() === "") {
73
+ newLines.splice(targetIdx, 1);
74
+ }
75
+
76
+ const newContent = content.slice(0, fmEnd) + newLines.join("\n");
77
+ return { newContent, changed: true };
78
+ }
79
+
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+ // File discovery
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+
84
+ function findMdxFiles(repoRoot, directory = null, file = null) {
85
+ if (file) {
86
+ const fullPath = resolve(repoRoot, file);
87
+ return existsSync(fullPath) ? [fullPath] : [];
88
+ }
89
+
90
+ const searchDirs = directory ? [resolve(repoRoot, directory)] : [repoRoot];
91
+ const mdxFiles = [];
92
+
93
+ function walkDirectory(dir) {
94
+ const dirName = dir.split("/").pop();
95
+ if (EXCLUDED_DIRS.includes(dirName)) return;
96
+
97
+ try {
98
+ const entries = readdirSync(dir);
99
+ for (const entry of entries) {
100
+ const fullPath = join(dir, entry);
101
+ const stat = statSync(fullPath);
102
+ if (stat.isDirectory()) {
103
+ walkDirectory(fullPath);
104
+ } else if (stat.isFile() && entry.endsWith(".mdx")) {
105
+ mdxFiles.push(fullPath);
106
+ }
107
+ }
108
+ } catch (error) {
109
+ console.error(`Error reading directory ${dir}: ${error.message}`);
110
+ }
111
+ }
112
+
113
+ for (const dir of searchDirs) {
114
+ if (existsSync(dir)) walkDirectory(dir);
115
+ }
116
+
117
+ return mdxFiles.sort();
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // Main export
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+
124
+ export async function fixH1(options) {
125
+ const repoRoot = process.cwd();
126
+
127
+ if (!options.quiet) {
128
+ console.log(chalk.bold("\n# H1 Duplicate Title Fixer\n"));
129
+ }
130
+
131
+ const files = findMdxFiles(repoRoot, options.dir, options.file);
132
+
133
+ if (files.length === 0) {
134
+ console.error(chalk.red("✗ No MDX files found."));
135
+ process.exit(1);
136
+ }
137
+
138
+ if (!options.quiet) {
139
+ console.log(`Found ${files.length} MDX file(s) to process\n`);
140
+ if (options.dryRun) {
141
+ console.log(chalk.yellow("Dry run — no files will be written\n"));
142
+ }
143
+ }
144
+
145
+ const changed = [];
146
+
147
+ for (const filePath of files) {
148
+ const content = readFileSync(filePath, "utf-8");
149
+ const fmTitle = extractFrontmatterTitle(content);
150
+ if (!fmTitle) continue;
151
+
152
+ const { newContent, changed: didChange } = removeDuplicateH1(content, fmTitle);
153
+
154
+ if (didChange) {
155
+ const relPath = relative(repoRoot, filePath);
156
+ changed.push(relPath);
157
+
158
+ if (options.verbose) {
159
+ console.log(`${chalk.cyan(relPath)}: removed duplicate H1`);
160
+ }
161
+
162
+ if (!options.dryRun) {
163
+ writeFileSync(filePath, newContent, "utf-8");
164
+ }
165
+ }
166
+ }
167
+
168
+ if (!options.quiet) {
169
+ if (changed.length > 0) {
170
+ const verb = options.dryRun ? "Would remove" : "Removed";
171
+ console.log(chalk.green(`\n✓ ${verb} duplicate H1 in ${changed.length} file(s)`));
172
+
173
+ if (!options.verbose) {
174
+ for (const relPath of changed) {
175
+ console.log(` ${chalk.cyan(relPath)}`);
176
+ }
177
+ }
178
+ } else {
179
+ console.log(chalk.yellow("⚠️ No duplicate H1 headings found."));
180
+ }
181
+ }
182
+ }
@@ -179,6 +179,25 @@ export function mergeImagesConfig(options, config) {
179
179
  };
180
180
  }
181
181
 
182
+ /**
183
+ * Merges config file with CLI options for the h1 command
184
+ * CLI options take precedence over config file
185
+ *
186
+ * @param {Object} options - CLI options
187
+ * @param {Object|null} config - Loaded config object
188
+ * @returns {Object} Merged options
189
+ */
190
+ export function mergeH1Config(options, config) {
191
+ const h1Config = config?.h1 || {};
192
+
193
+ return {
194
+ file: options.file || h1Config.file || null,
195
+ dir: options.dir || h1Config.dir || null,
196
+ dryRun: options.dryRun !== undefined ? options.dryRun : (h1Config["dry-run"] ?? false),
197
+ quiet: options.quiet !== undefined ? options.quiet : (h1Config.quiet ?? false),
198
+ };
199
+ }
200
+
182
201
  /**
183
202
  * Validates that required fields are present
184
203
  * @param {string|undefined} baseUrl - Base URL