@readme/cli 0.0.26 → 0.0.27
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 +2 -2
- package/package.json +3 -1
- package/src/cli.js +5 -0
- package/src/commands/components.js +288 -0
- package/src/commands/import.js +410 -34
- package/src/commands/lint.js +32 -35
- package/src/commands/pretty.js +314 -77
- package/src/prompts/index.js +86 -11
- package/src/utils/lint.js +33 -33
- package/src/utils/marketplace.js +208 -0
- package/src/utils/multi-spinner.js +71 -0
- package/src/utils/promise-pool.js +17 -0
- package/src/validators/numbering.js +64 -66
package/src/utils/lint.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import { pathToFileURL } from 'node:url'
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
4
|
|
|
5
5
|
const TARGET_PATTERNS = [
|
|
6
6
|
{ dir: 'custom_blocks', ext: ['.mdx', '.md'] },
|
|
@@ -8,28 +8,28 @@ const TARGET_PATTERNS = [
|
|
|
8
8
|
{ dir: 'reference', ext: '.md' },
|
|
9
9
|
{ dir: 'custom_pages', ext: '.md' },
|
|
10
10
|
{ dir: 'recipes', ext: '.md' },
|
|
11
|
-
]
|
|
11
|
+
]
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Collect all target files from the repo root.
|
|
15
15
|
*/
|
|
16
16
|
export function collectFiles(gitRoot) {
|
|
17
|
-
const files = []
|
|
17
|
+
const files = []
|
|
18
18
|
|
|
19
19
|
for (const { dir, ext } of TARGET_PATTERNS) {
|
|
20
|
-
const dirPath = path.join(gitRoot, dir)
|
|
21
|
-
if (!fs.existsSync(dirPath)) continue
|
|
20
|
+
const dirPath = path.join(gitRoot, dir)
|
|
21
|
+
if (!fs.existsSync(dirPath)) continue
|
|
22
22
|
|
|
23
|
-
const exts = Array.isArray(ext) ? ext : [ext]
|
|
24
|
-
const entries = fs.readdirSync(dirPath, { recursive: true })
|
|
23
|
+
const exts = Array.isArray(ext) ? ext : [ext]
|
|
24
|
+
const entries = fs.readdirSync(dirPath, { recursive: true })
|
|
25
25
|
for (const entry of entries) {
|
|
26
26
|
if (exts.some((e) => entry.endsWith(e))) {
|
|
27
|
-
files.push(path.join(dir, entry))
|
|
27
|
+
files.push(path.join(dir, entry))
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
return files.sort()
|
|
32
|
+
return files.sort()
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -38,18 +38,18 @@ export function collectFiles(gitRoot) {
|
|
|
38
38
|
* `validateAll()` for cross-file checks (like ordering).
|
|
39
39
|
*/
|
|
40
40
|
async function loadValidators() {
|
|
41
|
-
const validatorsDir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'validators')
|
|
42
|
-
const files = fs.readdirSync(validatorsDir).filter((f) => f.endsWith('.js'))
|
|
43
|
-
const validators = []
|
|
41
|
+
const validatorsDir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'validators')
|
|
42
|
+
const files = fs.readdirSync(validatorsDir).filter((f) => f.endsWith('.js'))
|
|
43
|
+
const validators = []
|
|
44
44
|
|
|
45
45
|
for (const file of files) {
|
|
46
|
-
const mod = await import(pathToFileURL(path.join(validatorsDir, file)).href)
|
|
46
|
+
const mod = await import(pathToFileURL(path.join(validatorsDir, file)).href)
|
|
47
47
|
if (mod.name && (mod.validate || mod.validateAll)) {
|
|
48
|
-
validators.push(mod)
|
|
48
|
+
validators.push(mod)
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
return validators
|
|
52
|
+
return validators
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
@@ -57,43 +57,43 @@ async function loadValidators() {
|
|
|
57
57
|
* Each result has { file, rule, message, severity? } where severity defaults to 'error'.
|
|
58
58
|
* Calls `onFile(relativePath)` before processing each file (for progress reporting).
|
|
59
59
|
*/
|
|
60
|
-
export async function runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix } = {}) {
|
|
61
|
-
const validators = await loadValidators()
|
|
62
|
-
const results = []
|
|
60
|
+
export async function runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix, nonInteractive } = {}) {
|
|
61
|
+
const validators = await loadValidators()
|
|
62
|
+
const results = []
|
|
63
63
|
|
|
64
64
|
// Per-file validators.
|
|
65
65
|
for (const relativePath of files) {
|
|
66
|
-
if (onFile) onFile(relativePath)
|
|
66
|
+
if (onFile) onFile(relativePath)
|
|
67
67
|
|
|
68
|
-
const filePath = path.join(gitRoot, relativePath)
|
|
69
|
-
const content = fs.readFileSync(filePath, 'utf-8')
|
|
68
|
+
const filePath = path.join(gitRoot, relativePath)
|
|
69
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
70
70
|
|
|
71
71
|
for (const validator of validators) {
|
|
72
|
-
if (!validator.validate) continue
|
|
73
|
-
const result = validator.validate({ filePath, content, relativePath, fix })
|
|
72
|
+
if (!validator.validate) continue
|
|
73
|
+
const result = validator.validate({ filePath, content, relativePath, fix })
|
|
74
74
|
if (result) {
|
|
75
75
|
if (Array.isArray(result)) {
|
|
76
|
-
results.push(...result)
|
|
76
|
+
results.push(...result)
|
|
77
77
|
} else {
|
|
78
|
-
results.push(result)
|
|
78
|
+
results.push(result)
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// Cross-file validators — stop the spinner first so interactive prompts work.
|
|
85
|
-
if (onBeforeCrossFile) onBeforeCrossFile()
|
|
85
|
+
if (onBeforeCrossFile) onBeforeCrossFile()
|
|
86
86
|
for (const validator of validators) {
|
|
87
|
-
if (!validator.validateAll) continue
|
|
88
|
-
const result = await validator.validateAll(files, gitRoot, { fix })
|
|
87
|
+
if (!validator.validateAll) continue
|
|
88
|
+
const result = await validator.validateAll(files, gitRoot, { fix, nonInteractive })
|
|
89
89
|
if (result) {
|
|
90
90
|
if (Array.isArray(result)) {
|
|
91
|
-
results.push(...result)
|
|
91
|
+
results.push(...result)
|
|
92
92
|
} else {
|
|
93
|
-
results.push(result)
|
|
93
|
+
results.push(result)
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
return results
|
|
98
|
+
return results
|
|
99
99
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talks to the readmeio/marketplace repo: lists the available MDX components,
|
|
3
|
+
* fetches a single component's files (the .mdx source + its readme.md), and
|
|
4
|
+
* caches everything on disk so we're not hitting GitHub on every command.
|
|
5
|
+
*
|
|
6
|
+
* Components are read at https://github.com/readmeio/marketplace/tree/main/components
|
|
7
|
+
* and copied into the user's local `components/` folder. The marketplace
|
|
8
|
+
* `.mdx` files are the canonical source — they hold the JSX export plus an
|
|
9
|
+
* example invocation at the bottom.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const REPO = 'readmeio/marketplace';
|
|
17
|
+
const BRANCH = 'main';
|
|
18
|
+
const COMPONENTS_PATH = 'components';
|
|
19
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
20
|
+
|
|
21
|
+
const CACHE_DIR = path.join(os.homedir(), '.cache', 'readme-cli', 'marketplace');
|
|
22
|
+
|
|
23
|
+
function ensureCacheDir() {
|
|
24
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cachePath(name) {
|
|
28
|
+
return path.join(CACHE_DIR, name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readCache(name, maxAgeMs = CACHE_TTL_MS) {
|
|
32
|
+
const file = cachePath(name);
|
|
33
|
+
try {
|
|
34
|
+
const stat = fs.statSync(file);
|
|
35
|
+
if (Date.now() - stat.mtimeMs > maxAgeMs) return null;
|
|
36
|
+
return fs.readFileSync(file, 'utf-8');
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeCache(name, contents) {
|
|
43
|
+
ensureCacheDir();
|
|
44
|
+
fs.writeFileSync(cachePath(name), contents, 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchText(url) {
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
headers: { 'User-Agent': '@readme/cli', Accept: 'application/vnd.github.v3+json' },
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
53
|
+
}
|
|
54
|
+
return res.text();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns the list of marketplace components as an array of:
|
|
59
|
+
* { name, mdxUrl, readmeUrl, htmlUrl }
|
|
60
|
+
* Hidden entries (e.g. `.ExampleComponent`) are filtered out.
|
|
61
|
+
*/
|
|
62
|
+
export async function listMarketplaceComponents({ force = false } = {}) {
|
|
63
|
+
const cacheKey = 'index.json';
|
|
64
|
+
if (!force) {
|
|
65
|
+
const cached = readCache(cacheKey);
|
|
66
|
+
if (cached) {
|
|
67
|
+
try { return JSON.parse(cached); } catch { /* fall through */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const url = `https://api.github.com/repos/${REPO}/contents/${COMPONENTS_PATH}?ref=${BRANCH}`;
|
|
72
|
+
const body = await fetchText(url);
|
|
73
|
+
const entries = JSON.parse(body);
|
|
74
|
+
|
|
75
|
+
const components = entries
|
|
76
|
+
.filter((e) => e.type === 'dir' && !e.name.startsWith('.'))
|
|
77
|
+
.map((e) => ({
|
|
78
|
+
name: e.name,
|
|
79
|
+
htmlUrl: `https://github.com/${REPO}/tree/${BRANCH}/${COMPONENTS_PATH}/${e.name}`,
|
|
80
|
+
mdxUrl: `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${COMPONENTS_PATH}/${e.name}/${e.name}.mdx`,
|
|
81
|
+
readmeUrl: `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${COMPONENTS_PATH}/${e.name}/readme.md`,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
writeCache(cacheKey, JSON.stringify(components, null, 2));
|
|
85
|
+
return components;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch the .mdx + readme.md for a single component. Returns:
|
|
90
|
+
* { name, mdx, readme, htmlUrl }
|
|
91
|
+
* `readme` may be null if the component has no readme.md.
|
|
92
|
+
*/
|
|
93
|
+
export async function fetchMarketplaceComponent(name, { force = false } = {}) {
|
|
94
|
+
const components = await listMarketplaceComponents({ force });
|
|
95
|
+
const match = components.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
96
|
+
if (!match) {
|
|
97
|
+
throw new Error(`Component "${name}" not found in marketplace.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const mdxCacheKey = `${match.name}.mdx`;
|
|
101
|
+
const readmeCacheKey = `${match.name}.readme.md`;
|
|
102
|
+
|
|
103
|
+
let mdx = !force && readCache(mdxCacheKey);
|
|
104
|
+
if (!mdx) {
|
|
105
|
+
mdx = await fetchText(match.mdxUrl);
|
|
106
|
+
writeCache(mdxCacheKey, mdx);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let readme = !force && readCache(readmeCacheKey);
|
|
110
|
+
if (!readme) {
|
|
111
|
+
try {
|
|
112
|
+
readme = await fetchText(match.readmeUrl);
|
|
113
|
+
writeCache(readmeCacheKey, readme);
|
|
114
|
+
} catch {
|
|
115
|
+
readme = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { name: match.name, mdx, readme, htmlUrl: match.htmlUrl };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pull the one-line summary out of a component's readme.md. We look for the
|
|
124
|
+
* first non-empty paragraph after the `## Overview` heading, falling back to
|
|
125
|
+
* the first non-heading line. Returns "" if nothing usable is found.
|
|
126
|
+
*/
|
|
127
|
+
export function summarizeReadme(readme) {
|
|
128
|
+
if (!readme) return '';
|
|
129
|
+
const lines = readme.split('\n');
|
|
130
|
+
let inOverview = false;
|
|
131
|
+
for (let i = 0; i < lines.length; i++) {
|
|
132
|
+
const line = lines[i].trim();
|
|
133
|
+
if (/^##\s+overview/i.test(line)) { inOverview = true; continue; }
|
|
134
|
+
if (inOverview && line && !line.startsWith('#') && !line.startsWith('![')) {
|
|
135
|
+
return line.replace(/[*_`]/g, '').slice(0, 140);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Fallback: first non-heading, non-image line.
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
const t = line.trim();
|
|
141
|
+
if (t && !t.startsWith('#') && !t.startsWith('![') && !t.startsWith('<')) {
|
|
142
|
+
return t.replace(/[*_`]/g, '').slice(0, 140);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pull the first example invocation block out of a component's mdx. Components
|
|
150
|
+
* conventionally end with a bare `<ComponentName ... />` line below the React
|
|
151
|
+
* export — return that. Returns the full mdx as a fallback.
|
|
152
|
+
*/
|
|
153
|
+
export function extractExample(mdx, name) {
|
|
154
|
+
const re = new RegExp(`^<${name}[\\s\\S]*?(?:/>|</${name}>)\\s*$`, 'm');
|
|
155
|
+
const match = mdx.match(re);
|
|
156
|
+
return match ? match[0] : '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Returns the absolute path to the local `components/` folder for a repo
|
|
161
|
+
* (relative to gitRoot). Does not create it.
|
|
162
|
+
*/
|
|
163
|
+
export function localComponentsDir(gitRoot) {
|
|
164
|
+
return path.join(gitRoot, 'components');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Read a previously-cached readme for a component without hitting the network.
|
|
169
|
+
* Returns null if there's nothing cached. Used so we can show short
|
|
170
|
+
* descriptions in prompts/lists without slowing down the command on a cold
|
|
171
|
+
* cache.
|
|
172
|
+
*/
|
|
173
|
+
export function readCachedReadme(name) {
|
|
174
|
+
return readCache(`${name}.readme.md`, Infinity);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Install a marketplace component into the user's local components/ folder.
|
|
179
|
+
* Used by both the `components add` subcommand and the auto-install path in
|
|
180
|
+
* `pretty`. Returns the destination path on success.
|
|
181
|
+
*/
|
|
182
|
+
export async function installComponent(gitRoot, name, { force = false } = {}) {
|
|
183
|
+
const comp = await fetchMarketplaceComponent(name, { force });
|
|
184
|
+
const dest = localComponentsDir(gitRoot);
|
|
185
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
186
|
+
const file = path.join(dest, `${comp.name}.mdx`);
|
|
187
|
+
fs.writeFileSync(file, comp.mdx, 'utf-8');
|
|
188
|
+
return { name: comp.name, file, mdx: comp.mdx };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* List components that have been copied into the user's local components/
|
|
193
|
+
* folder. Returns an array of { name, file, source } where `source` is the
|
|
194
|
+
* raw mdx contents.
|
|
195
|
+
*/
|
|
196
|
+
export function listLocalComponents(gitRoot) {
|
|
197
|
+
const dir = localComponentsDir(gitRoot);
|
|
198
|
+
if (!fs.existsSync(dir)) return [];
|
|
199
|
+
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
202
|
+
if (!entry.endsWith('.mdx')) continue;
|
|
203
|
+
const file = path.join(dir, entry);
|
|
204
|
+
const source = fs.readFileSync(file, 'utf-8');
|
|
205
|
+
out.push({ name: entry.replace(/\.mdx$/, ''), file, source });
|
|
206
|
+
}
|
|
207
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
208
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createLogUpdate } from 'log-update';
|
|
2
|
+
import cliSpinners from 'cli-spinners';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Multi-row spinner. Renders `rows` labeled lines, repainted in unison at the
|
|
7
|
+
* spinner's tick interval. Cleared rows render as blank lines (the live region
|
|
8
|
+
* keeps a fixed height). Falls back to no-op render + line-buffered `log()`
|
|
9
|
+
* in non-TTY environments.
|
|
10
|
+
*/
|
|
11
|
+
export class MultiSpinner {
|
|
12
|
+
constructor({ rows, color = 'blue', stream = process.stderr } = {}) {
|
|
13
|
+
this.rows = Math.max(0, rows);
|
|
14
|
+
this.color = color;
|
|
15
|
+
this.stream = stream;
|
|
16
|
+
this.isTTY = !!stream.isTTY;
|
|
17
|
+
this.lines = new Array(this.rows).fill('');
|
|
18
|
+
this.frame = 0;
|
|
19
|
+
this.spinner = cliSpinners.dots;
|
|
20
|
+
this.timer = null;
|
|
21
|
+
this.update = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
start() {
|
|
25
|
+
if (!this.isTTY) return;
|
|
26
|
+
this.update = createLogUpdate(this.stream);
|
|
27
|
+
this._render();
|
|
28
|
+
this.timer = setInterval(() => this._render(), this.spinner.interval);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setLine(rowIdx, text) {
|
|
32
|
+
if (!this.isTTY) return;
|
|
33
|
+
if (rowIdx < 0 || rowIdx >= this.rows) return;
|
|
34
|
+
this.lines[rowIdx] = text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clearLine(rowIdx) {
|
|
38
|
+
this.setLine(rowIdx, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
log(text) {
|
|
42
|
+
if (this.isTTY && this.update) {
|
|
43
|
+
this.update.persist(text);
|
|
44
|
+
} else {
|
|
45
|
+
this.stream.write(text + '\n');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop() {
|
|
50
|
+
if (this.timer) {
|
|
51
|
+
clearInterval(this.timer);
|
|
52
|
+
this.timer = null;
|
|
53
|
+
}
|
|
54
|
+
if (this.update) {
|
|
55
|
+
this.update.clear();
|
|
56
|
+
this.update.done();
|
|
57
|
+
this.update = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_render() {
|
|
62
|
+
if (!this.update) return;
|
|
63
|
+
const glyph = this.spinner.frames[this.frame % this.spinner.frames.length];
|
|
64
|
+
const colorize = chalk[this.color] ?? ((s) => s);
|
|
65
|
+
const out = this.lines
|
|
66
|
+
.map((text) => (text ? `${colorize(glyph)} ${text}` : ''))
|
|
67
|
+
.join('\n');
|
|
68
|
+
this.update(out);
|
|
69
|
+
this.frame++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `worker` over `items` with at most `concurrency` calls in flight.
|
|
3
|
+
* Each worker pulls the next item from a shared cursor; finishes when none left.
|
|
4
|
+
* `worker(item, index, workerIdx)` is responsible for catching its own errors —
|
|
5
|
+
* an uncaught rejection will propagate up and abort other in-flight workers.
|
|
6
|
+
*/
|
|
7
|
+
export async function runPool(items, concurrency, worker) {
|
|
8
|
+
let cursor = 0;
|
|
9
|
+
const next = () => (cursor < items.length ? cursor++ : -1);
|
|
10
|
+
const lanes = Math.max(1, Math.min(concurrency, items.length));
|
|
11
|
+
const workers = Array.from({ length: lanes }, (_, workerIdx) => (async () => {
|
|
12
|
+
for (let i = next(); i !== -1; i = next()) {
|
|
13
|
+
await worker(items[i], i, workerIdx);
|
|
14
|
+
}
|
|
15
|
+
})());
|
|
16
|
+
await Promise.all(workers);
|
|
17
|
+
}
|
|
@@ -1,68 +1,65 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import readline from 'node:readline'
|
|
4
|
-
import * as styles from '../utils/styles.js'
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import readline from 'node:readline'
|
|
4
|
+
import * as styles from '../utils/styles.js'
|
|
5
5
|
|
|
6
|
-
export const name = 'numbering'
|
|
6
|
+
export const name = 'numbering'
|
|
7
7
|
|
|
8
|
-
const SUFFIX_RE = /-(\d+)
|
|
8
|
+
const SUFFIX_RE = /-(\d+)$/
|
|
9
9
|
|
|
10
10
|
function prompt(question) {
|
|
11
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
11
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
12
12
|
return new Promise((resolve) => {
|
|
13
13
|
rl.question(question, (answer) => {
|
|
14
|
-
rl.close()
|
|
15
|
-
resolve(answer.trim().toLowerCase())
|
|
16
|
-
})
|
|
17
|
-
})
|
|
14
|
+
rl.close()
|
|
15
|
+
resolve(answer.trim().toLowerCase())
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function updateOrderYaml(fromPath, toPath) {
|
|
21
|
-
const dir = path.dirname(fromPath)
|
|
22
|
-
const orderFile = path.join(dir, '_order.yaml')
|
|
23
|
-
if (!fs.existsSync(orderFile)) return
|
|
21
|
+
const dir = path.dirname(fromPath)
|
|
22
|
+
const orderFile = path.join(dir, '_order.yaml')
|
|
23
|
+
if (!fs.existsSync(orderFile)) return
|
|
24
24
|
|
|
25
|
-
const oldSlug = path.basename(fromPath).replace(/\.(md|mdx)$/, '')
|
|
26
|
-
const newSlug = path.basename(toPath).replace(/\.(md|mdx)$/, '')
|
|
25
|
+
const oldSlug = path.basename(fromPath).replace(/\.(md|mdx)$/, '')
|
|
26
|
+
const newSlug = path.basename(toPath).replace(/\.(md|mdx)$/, '')
|
|
27
27
|
|
|
28
|
-
const content = fs.readFileSync(orderFile, 'utf-8')
|
|
29
|
-
const updated = content.replace(
|
|
30
|
-
new RegExp(`^(- )${oldSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'),
|
|
31
|
-
`$1${newSlug}`,
|
|
32
|
-
);
|
|
28
|
+
const content = fs.readFileSync(orderFile, 'utf-8')
|
|
29
|
+
const updated = content.replace(new RegExp(`^(- )${oldSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), `$1${newSlug}`)
|
|
33
30
|
|
|
34
31
|
if (updated !== content) {
|
|
35
|
-
fs.writeFileSync(orderFile, updated)
|
|
32
|
+
fs.writeFileSync(orderFile, updated)
|
|
36
33
|
}
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
export async function validateAll(files, gitRoot, { fix } = {}) {
|
|
40
|
-
const results = []
|
|
41
|
-
const renames = []
|
|
36
|
+
export async function validateAll(files, gitRoot, { fix, nonInteractive } = {}) {
|
|
37
|
+
const results = []
|
|
38
|
+
const renames = []
|
|
42
39
|
|
|
43
40
|
// Collect all slugs (filenames without ext) and directory names across the repo.
|
|
44
|
-
const allSlugs = new Set()
|
|
45
|
-
const allDirs = new Set()
|
|
41
|
+
const allSlugs = new Set()
|
|
42
|
+
const allDirs = new Set()
|
|
46
43
|
for (const relPath of files) {
|
|
47
|
-
allSlugs.add(path.basename(relPath).replace(/\.(md|mdx)$/, ''))
|
|
48
|
-
const parts = relPath.split('/')
|
|
44
|
+
allSlugs.add(path.basename(relPath).replace(/\.(md|mdx)$/, ''))
|
|
45
|
+
const parts = relPath.split('/')
|
|
49
46
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
50
|
-
allDirs.add(parts[i])
|
|
47
|
+
allDirs.add(parts[i])
|
|
51
48
|
}
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
// Check files: if slug ends with -N and the base slug doesn't exist anywhere, warn.
|
|
55
52
|
for (const relPath of files) {
|
|
56
|
-
const slug = path.basename(relPath).replace(/\.(md|mdx)$/, '')
|
|
57
|
-
const match = slug.match(SUFFIX_RE)
|
|
58
|
-
if (!match) continue
|
|
53
|
+
const slug = path.basename(relPath).replace(/\.(md|mdx)$/, '')
|
|
54
|
+
const match = slug.match(SUFFIX_RE)
|
|
55
|
+
if (!match) continue
|
|
59
56
|
|
|
60
|
-
const baseSlug = slug.slice(0, -match[0].length)
|
|
57
|
+
const baseSlug = slug.slice(0, -match[0].length)
|
|
61
58
|
if (!allSlugs.has(baseSlug)) {
|
|
62
|
-
const ext = path.extname(relPath)
|
|
63
|
-
const from = path.join(gitRoot, relPath)
|
|
64
|
-
const toRel = path.join(path.dirname(relPath), `${baseSlug}${ext}`)
|
|
65
|
-
const to = path.join(gitRoot, toRel)
|
|
59
|
+
const ext = path.extname(relPath)
|
|
60
|
+
const from = path.join(gitRoot, relPath)
|
|
61
|
+
const toRel = path.join(path.dirname(relPath), `${baseSlug}${ext}`)
|
|
62
|
+
const to = path.join(gitRoot, toRel)
|
|
66
63
|
|
|
67
64
|
results.push({
|
|
68
65
|
file: relPath,
|
|
@@ -70,29 +67,29 @@ export async function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
70
67
|
severity: 'warning',
|
|
71
68
|
fixable: true,
|
|
72
69
|
message: `Unnecessary suffix: "${slug}${ext}" should be renamed to "${baseSlug}${ext}"`,
|
|
73
|
-
})
|
|
74
|
-
renames.push({ from, to, label: `${relPath} → ${toRel}` })
|
|
70
|
+
})
|
|
71
|
+
renames.push({ from, to, label: `${relPath} → ${toRel}` })
|
|
75
72
|
}
|
|
76
73
|
}
|
|
77
74
|
|
|
78
75
|
// Check directories: if a dir name ends with -N and the base dir doesn't exist, warn.
|
|
79
|
-
const warnedDirs = new Set()
|
|
76
|
+
const warnedDirs = new Set()
|
|
80
77
|
for (const relPath of files) {
|
|
81
|
-
const parts = relPath.split('/')
|
|
78
|
+
const parts = relPath.split('/')
|
|
82
79
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
83
|
-
const dirName = parts[i]
|
|
84
|
-
if (warnedDirs.has(dirName)) continue
|
|
80
|
+
const dirName = parts[i]
|
|
81
|
+
if (warnedDirs.has(dirName)) continue
|
|
85
82
|
|
|
86
|
-
const match = dirName.match(SUFFIX_RE)
|
|
87
|
-
if (!match) continue
|
|
83
|
+
const match = dirName.match(SUFFIX_RE)
|
|
84
|
+
if (!match) continue
|
|
88
85
|
|
|
89
|
-
const baseName = dirName.slice(0, -match[0].length)
|
|
86
|
+
const baseName = dirName.slice(0, -match[0].length)
|
|
90
87
|
if (!allDirs.has(baseName)) {
|
|
91
|
-
warnedDirs.add(dirName)
|
|
92
|
-
const dirPath = parts.slice(0, i + 1).join('/')
|
|
93
|
-
const baseDirPath = [...parts.slice(0, i), baseName].join('/')
|
|
94
|
-
const from = path.join(gitRoot, dirPath)
|
|
95
|
-
const to = path.join(gitRoot, baseDirPath)
|
|
88
|
+
warnedDirs.add(dirName)
|
|
89
|
+
const dirPath = parts.slice(0, i + 1).join('/')
|
|
90
|
+
const baseDirPath = [...parts.slice(0, i), baseName].join('/')
|
|
91
|
+
const from = path.join(gitRoot, dirPath)
|
|
92
|
+
const to = path.join(gitRoot, baseDirPath)
|
|
96
93
|
|
|
97
94
|
results.push({
|
|
98
95
|
file: dirPath,
|
|
@@ -100,37 +97,38 @@ export async function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
100
97
|
severity: 'warning',
|
|
101
98
|
fixable: true,
|
|
102
99
|
message: `Unnecessary suffix: "${dirName}" folder should be renamed to "${baseName}"`,
|
|
103
|
-
})
|
|
104
|
-
renames.push({ from, to, label: `${dirPath}/ → ${baseDirPath}/` })
|
|
100
|
+
})
|
|
101
|
+
renames.push({ from, to, label: `${dirPath}/ → ${baseDirPath}/` })
|
|
105
102
|
}
|
|
106
103
|
}
|
|
107
104
|
}
|
|
108
105
|
|
|
109
106
|
// Interactive rename when --fix is passed.
|
|
110
107
|
if (fix && renames.length > 0) {
|
|
111
|
-
console.log()
|
|
112
|
-
console.log(` The following will be renamed:`)
|
|
108
|
+
console.log()
|
|
109
|
+
console.log(` The following will be renamed:`)
|
|
113
110
|
for (const r of renames) {
|
|
114
|
-
console.log(` ${styles.dim(r.label)}`)
|
|
111
|
+
console.log(` ${styles.dim(r.label)}`)
|
|
115
112
|
}
|
|
116
|
-
console.log()
|
|
117
|
-
console.log(` ${styles.warn('Note:')} Renaming changes slugs, which could break existing URLs.`)
|
|
118
|
-
console.log()
|
|
113
|
+
console.log()
|
|
114
|
+
console.log(` ${styles.warn('Note:')} Renaming changes slugs, which could break existing URLs.`)
|
|
115
|
+
console.log()
|
|
119
116
|
|
|
120
|
-
|
|
117
|
+
// assume yes if non-interactive, otherwise prompt for confirmation
|
|
118
|
+
const answer = nonInteractive ? 'yes' : await prompt(` Rename ${renames.length} ${renames.length === 1 ? 'path' : 'paths'}? (y/N) `)
|
|
121
119
|
|
|
122
120
|
if (answer === 'y' || answer === 'yes') {
|
|
123
121
|
// Sort longest path first so nested dirs get renamed before parents.
|
|
124
|
-
renames.sort((a, b) => b.from.length - a.from.length)
|
|
122
|
+
renames.sort((a, b) => b.from.length - a.from.length)
|
|
125
123
|
for (const r of renames) {
|
|
126
|
-
fs.renameSync(r.from, r.to)
|
|
127
|
-
updateOrderYaml(r.from, r.to)
|
|
124
|
+
fs.renameSync(r.from, r.to)
|
|
125
|
+
updateOrderYaml(r.from, r.to)
|
|
128
126
|
}
|
|
129
127
|
for (const r of results) {
|
|
130
|
-
r.message += ' (fixed)'
|
|
128
|
+
r.message += ' (fixed)'
|
|
131
129
|
}
|
|
132
130
|
}
|
|
133
131
|
}
|
|
134
132
|
|
|
135
|
-
return results.length > 0 ? results : null
|
|
133
|
+
return results.length > 0 ? results : null
|
|
136
134
|
}
|