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();