master
1import { config as scriptConfig } from './config';
2import { changeFrontmatter, findAvailableFileName, findMonorepoRoot, slugify } from './utils';
3import type { AvailableFileNameInfo } from './utils';
4import { L, type Locales } from '@astral-halo/i18n';
5import { Command, OptionValues } from '@commander-js/extra-typings';
6import { ExitPromptError } from '@inquirer/core';
7import { input, select } from '@inquirer/prompts';
8import fs from 'node:fs/promises';
9import path from 'node:path';
10import { osLocaleSync } from 'os-locale';
11
12// Define CLI options interface
13interface CLIOptions extends OptionValues {
14 title?: string;
15 category?: string;
16 tags?: string;
17 root?: string;
18}
19
20const program = new Command<[], CLIOptions>();
21
22// Determine current locale
23const availableLocalesForScript: Locales[] = ['en', 'zh-CN', 'zh-TW'];
24let detectedSystemLocale: string = 'en';
25try {
26 detectedSystemLocale = osLocaleSync().replace('_', '-');
27} catch {
28 console.warn('Failed to detect system locale, defaulting to en.');
29}
30const currentLocale: Locales = availableLocalesForScript.includes(
31 detectedSystemLocale as Locales
32)
33 ? (detectedSystemLocale as Locales)
34 : 'en';
35
36program
37 .name('new-article')
38 .description('Create a new draft article')
39 .option('-t, --title <titleString>', 'Article title')
40 .option('-c, --category <categoryString>', 'Article category (optional)')
41 .option('-T, --tags <tagsString>', 'Article tags, comma-separated (optional)')
42 .option('--root <path>', 'Specify the root directory of the project')
43 .action(async (options: CLIOptions) => {
44 const { title: cliTitle, category: cliCategory, tags: cliTags, root: cliRootDir } = options;
45
46 let title = cliTitle;
47 let category = cliCategory;
48 let tags = cliTags;
49
50 try {
51 // Interactive mode
52 if (!title) {
53 title = await input({ message: L[currentLocale].cli.new.prompt.title() });
54 }
55 if (category === undefined && title) {
56 category = await input({ message: L[currentLocale].cli.new.prompt.category() });
57 }
58 if (tags === undefined && title) {
59 tags = await input({ message: L[currentLocale].cli.new.prompt.tags() });
60 }
61 } catch (error) {
62 if (error instanceof ExitPromptError) {
63 console.log(L[currentLocale].cli.info.cancelled_by_user());
64 process.exit(0);
65 }
66 throw error;
67 }
68
69 if (!title || title.trim() === '') {
70 console.error(L[currentLocale].cli.new.error.no_title());
71 process.exit(1);
72 }
73
74 let slug = slugify(title, true); // Used for frontmatter
75 const baseSlugForFile = slugify(title); // Used for filename generation, might be different from frontmatter slug
76 const fileExtension = '.md';
77 const currentFileName = `${baseSlugForFile}${fileExtension}`;
78
79 let projectRootDir: string;
80 try {
81 if (cliRootDir) {
82 projectRootDir = path.resolve(cliRootDir);
83 await fs.access(path.join(projectRootDir, 'astro.config.mjs'));
84 } else {
85 projectRootDir = await findMonorepoRoot();
86 }
87 } catch (error) {
88 console.error(
89 L[currentLocale].cli.error.failed_to_find_root({ message: (error as Error).message })
90 );
91 console.error(L[currentLocale].cli.info.provide_root_dir_guidance({ option: '--root' }));
92 process.exit(1);
93 }
94
95 let targetDir = path.resolve(projectRootDir, scriptConfig.draftsDir);
96 if (scriptConfig.draftStructure === 'category' && category && category.trim() !== '') {
97 const categorySlug = slugify(category);
98 targetDir = path.join(targetDir, categorySlug);
99 }
100
101 try {
102 await fs.mkdir(targetDir, { recursive: true });
103 const filePath = path.join(targetDir, currentFileName);
104 let finalFilePath = filePath;
105
106 let fileExists = false;
107 try {
108 await fs.access(filePath);
109 fileExists = true;
110 } catch {
111 // File does not exist, can proceed
112 }
113
114 if (fileExists) {
115 const action = await select({
116 message: L[currentLocale].cli.prompt.file_exists({ filePath }),
117 choices: [
118 {
119 name: L[currentLocale].cli.action.rename(),
120 value: 'rename',
121 },
122 {
123 name: L[currentLocale].cli.action.overwrite(),
124 value: 'overwrite',
125 },
126 {
127 name: L[currentLocale].cli.new.action.exit(),
128 value: 'exit',
129 },
130 ],
131 });
132
133 if (action === 'exit') {
134 console.log(L[currentLocale].cli.info.cancelled_by_user());
135 process.exit(0);
136 } else if (action === 'rename') {
137 const suggestedNameInfo: AvailableFileNameInfo = await findAvailableFileName(
138 targetDir,
139 baseSlugForFile,
140 fileExtension
141 );
142 let userConfirmedNewName = false;
143 while (!userConfirmedNewName) {
144 try {
145 const newNameInput = await input({
146 message: L[currentLocale].cli.prompt.enter_new_name(),
147 default: suggestedNameInfo.fileName,
148 });
149
150 if (!newNameInput || newNameInput.trim() === '') {
151 console.error(L[currentLocale].cli.error.empty_filename());
152 continue;
153 }
154
155 let potentialNewFileName = newNameInput.trim();
156 if (!potentialNewFileName.endsWith(fileExtension)) {
157 potentialNewFileName += fileExtension;
158 }
159
160 const potentialNewFilePath = path.join(targetDir, potentialNewFileName);
161
162 if (potentialNewFilePath === filePath) {
163 console.error(
164 L[currentLocale].cli.error.rename_to_original_conflict({
165 fileName: potentialNewFileName,
166 })
167 );
168 continue;
169 }
170
171 try {
172 await fs.access(potentialNewFilePath);
173 console.error(
174 L[currentLocale].cli.error.file_exists({
175 filePath: potentialNewFileName,
176 })
177 );
178 } catch {
179 finalFilePath = potentialNewFilePath;
180 slug = slug + `${suggestedNameInfo.counter}`;
181 userConfirmedNewName = true;
182 }
183 } catch (error) {
184 if (error instanceof ExitPromptError) {
185 console.log(L[currentLocale].cli.info.cancelled_by_user());
186 process.exit(0);
187 }
188 throw error; // Re-throw other errors
189 }
190 }
191 } else if (action === 'overwrite') {
192 // finalFilePath, finalFileName, and finalNameWithoutExt remain as initially calculated
193 console.log(L[currentLocale].cli.new.info.overwrite({ filePath }));
194 }
195 }
196
197 const scaffoldPath = './scaffolds/draft.md';
198 let content = await fs.readFile(scaffoldPath, 'utf-8');
199
200 const frontmatterChanges: Record<string, unknown> = {
201 title: title.replaceAll(/"/g, '\\"'),
202 slug: slug,
203 };
204
205 if (category && category.trim() !== '') {
206 frontmatterChanges.category = category.trim();
207 } else {
208 frontmatterChanges.category = '';
209 }
210
211 if (tags && tags.trim() !== '') {
212 const tagsArray = tags
213 .split(',')
214 .map((tag) => tag.trim())
215 .filter((tag) => tag);
216 if (tagsArray.length > 0) {
217 frontmatterChanges.tags = tagsArray;
218 } else {
219 frontmatterChanges.tags = [];
220 }
221 } else {
222 frontmatterChanges.tags = [];
223 }
224
225 content = changeFrontmatter(content, frontmatterChanges);
226
227 await fs.writeFile(finalFilePath, content);
228 console.log(L[currentLocale].cli.new.info.success_created({ filePath: finalFilePath }));
229 } catch (error) {
230 console.error(
231 L[currentLocale].cli.error.create_file({ message: (error as Error).message })
232 );
233 process.exit(1);
234 }
235 });
236
237async function main() {
238 try {
239 await program.parseAsync(process.argv);
240 } catch (error) {
241 if (!(error instanceof ExitPromptError)) {
242 console.error(
243 L[currentLocale].cli.error.unexpected({
244 message: (error as Error).message || String(error),
245 }),
246 error
247 );
248 }
249 process.exit(1);
250 }
251}
252
253process.on('SIGINT', async () => {
254 console.log(L[currentLocale].cli.info.cancelled_by_user());
255 process.exit(0);
256});
257
258main();