master
  1import { config as scriptConfig } from './config';
  2import { findAvailableFileName, findMonorepoRoot, slugify } from './utils';
  3import type { AvailableFileNameInfo } from './utils';
  4import { L, type Locales } from '@astral-halo/i18n';
  5import { ExitPromptError } from '@inquirer/core';
  6import { input, select } from '@inquirer/prompts';
  7import dayjs from 'dayjs';
  8import fs from 'node:fs/promises';
  9import path from 'node:path';
 10import { osLocaleSync } from 'os-locale';
 11
 12const availableLocalesForScript: Locales[] = ['en', 'zh-CN', 'zh-TW'];
 13let detectedSystemLocale: string = 'en';
 14try {
 15  detectedSystemLocale = osLocaleSync().replace('_', '-');
 16} catch {
 17  console.warn('Failed to detect system locale, defaulting to en.');
 18}
 19const currentLocale: Locales = availableLocalesForScript.includes(
 20  detectedSystemLocale as Locales
 21)
 22  ? (detectedSystemLocale as Locales)
 23  : 'en';
 24
 25async function listDrafts(draftsDirPath: string): Promise<string[]> {
 26  try {
 27    const dirents = await fs.readdir(draftsDirPath, { withFileTypes: true });
 28    const files = await Promise.all(
 29      dirents.map(async (dirent) => {
 30        const res = path.resolve(draftsDirPath, dirent.name);
 31        if (dirent.isDirectory()) {
 32          const subFiles = await listDrafts(res);
 33          return subFiles.map((sf) => path.join(dirent.name, sf));
 34        }
 35        return dirent.isFile() && dirent.name.endsWith('.md') ? dirent.name : null;
 36      })
 37    );
 38    return files.flat().filter((file) => file !== null) as string[];
 39  } catch (error) {
 40    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
 41      return [];
 42    }
 43    throw error;
 44  }
 45}
 46
 47async function updateFrontmatterWithPublishedDate(filePath: string): Promise<void> {
 48  let content = await fs.readFile(filePath, 'utf-8');
 49  const publishedDate = dayjs().format('YYYY-MM-DDTHH:mm:ssZ');
 50
 51  const frontmatterRegex = /^---([\s\S]*?^---\s*)/m;
 52  const match = content.match(frontmatterRegex);
 53
 54  if (match) {
 55    let fmString = match[1];
 56    const publishedLineRegex = /^published:.*$/m;
 57
 58    if (publishedLineRegex.test(fmString)) {
 59      fmString = fmString.replace(publishedLineRegex, `published: ${publishedDate}`);
 60    } else {
 61      fmString = fmString.replace(/^(---)$/m, `published: ${publishedDate}\n---`);
 62    }
 63    content = content.replace(frontmatterRegex, '---' + fmString);
 64  } else {
 65    throw new Error('Frontmatter not found in the file.');
 66  }
 67  await fs.writeFile(filePath, content, 'utf-8');
 68}
 69
 70async function run() {
 71  const root = await findMonorepoRoot(process.cwd());
 72  const draftsDir = path.resolve(root, scriptConfig.draftsDir);
 73  let postsDir = path.resolve(root, scriptConfig.postsDir);
 74
 75  let selectedDraftRelativePath: string | undefined;
 76
 77  try {
 78    const draftFiles = await listDrafts(draftsDir);
 79
 80    if (draftFiles.length === 0) {
 81      console.log(L[currentLocale].cli.pub.info.no_drafts_found());
 82      return;
 83    }
 84
 85    selectedDraftRelativePath = await select({
 86      message: L[currentLocale].cli.pub.prompt.select(),
 87      choices: draftFiles.map((file) => ({ name: file, value: file })),
 88    });
 89
 90    let fileName = path.basename(selectedDraftRelativePath);
 91    const finalSourcePath = path.join(draftsDir, selectedDraftRelativePath);
 92    const fileExtension = path.extname(fileName);
 93    const baseNameWithoutExt = path.basename(fileName, fileExtension);
 94
 95    let finalDestinationPath = path.join(postsDir, fileName);
 96
 97    if (scriptConfig.postStructure === 'category') {
 98      try {
 99        const draftContent = await fs.readFile(finalSourcePath, 'utf-8');
100        // Basic frontmatter parsing to find category
101        const fmRegex = /^---([\s\S]*?)^---/m;
102        const fmMatch = draftContent.match(fmRegex);
103        let category: string | undefined;
104        if (fmMatch && fmMatch[1]) {
105          const fmLines = fmMatch[1].split('\n');
106          const categoryLine = fmLines.find((line) => line.trim().startsWith('category:'));
107          if (categoryLine) {
108            // Extract category value, remove potential quotes, and trim whitespace
109            category = categoryLine
110              .substring(categoryLine.indexOf(':') + 1)
111              .trim()
112              .replace(/^['"]|['"]$/g, '');
113          }
114        }
115
116        if (slugify(category || '') !== '') {
117          postsDir = path.join(postsDir, slugify(category || ''));
118          finalDestinationPath = path.join(postsDir, fileName);
119        }
120      } catch (e) {
121        console.warn(
122          L[currentLocale].cli.error.generic({
123            message: `Failed to read or parse category from draft: ${(e as Error).message}`,
124          })
125        );
126        // Proceed with flat structure if category parsing fails
127      }
128    }
129
130    await fs.mkdir(postsDir, { recursive: true });
131
132    let proceedWithPublish = false;
133    try {
134      await fs.access(finalDestinationPath); // Check if destination file exists
135
136      const action = await select({
137        message: L[currentLocale].cli.prompt.file_exists({
138          filePath: finalDestinationPath,
139        }),
140        choices: [
141          {
142            name: L[currentLocale].cli.action.rename(), // Reusing from 'new' script i18n
143            value: 'rename',
144          },
145          {
146            name: L[currentLocale].cli.action.overwrite(), // Reusing from 'new' script i18n
147            value: 'overwrite',
148          },
149          {
150            name: L[currentLocale].cli.pub.action.exit(), // Reusing from 'new' script i18n
151            value: 'exit',
152          },
153        ],
154      });
155
156      if (action === 'exit') {
157        console.log(L[currentLocale].cli.pub.info.publish_cancelled());
158        return;
159      } else if (action === 'overwrite') {
160        proceedWithPublish = true;
161      } else if (action === 'rename') {
162        const suggestedNameInfo: AvailableFileNameInfo = await findAvailableFileName(
163          postsDir, // Target directory for posts
164          baseNameWithoutExt,
165          fileExtension
166        );
167
168        let userConfirmedNewName = false;
169        while (!userConfirmedNewName) {
170          try {
171            const newNameInput = await input({
172              message: L[currentLocale].cli.prompt.enter_new_name(), // Reusing
173              default: suggestedNameInfo.fileName,
174            });
175
176            if (!newNameInput || newNameInput.trim() === '') {
177              console.error(L[currentLocale].cli.error.empty_filename()); // Reusing
178              continue;
179            }
180
181            let potentialNewFileName = newNameInput.trim();
182            if (path.extname(potentialNewFileName) !== fileExtension) {
183              potentialNewFileName =
184                path.basename(potentialNewFileName, path.extname(potentialNewFileName)) +
185                fileExtension;
186            }
187
188            const potentialNewDestPath = path.join(postsDir, potentialNewFileName);
189
190            if (potentialNewDestPath === finalDestinationPath) {
191              console.error(
192                L[currentLocale].cli.error.rename_to_original_conflict({
193                  // Reusing
194                  fileName: potentialNewFileName,
195                })
196              );
197              continue;
198            }
199
200            try {
201              await fs.access(potentialNewDestPath);
202              console.error(
203                L[currentLocale].cli.error.file_exists({
204                  // Reusing
205                  filePath: potentialNewDestPath,
206                })
207              );
208            } catch {
209              // File does not exist, this name is good
210              fileName = potentialNewFileName; // Update fileName for the destination
211              finalDestinationPath = potentialNewDestPath;
212              proceedWithPublish = true;
213              userConfirmedNewName = true;
214            }
215          } catch (error) {
216            if (error instanceof ExitPromptError) {
217              console.log(L[currentLocale].cli.info.cancelled_by_user());
218              process.exit(0);
219            }
220            throw error;
221          }
222        }
223      }
224    } catch {
225      // File does not exist at destination, can proceed directly
226      proceedWithPublish = true;
227    }
228
229    if (!proceedWithPublish) {
230      console.log(L[currentLocale].cli.pub.info.publish_cancelled());
231      return;
232    }
233
234    await fs.rename(finalSourcePath, finalDestinationPath);
235    await updateFrontmatterWithPublishedDate(finalDestinationPath);
236
237    console.log(
238      L[currentLocale].cli.pub.info.success_article_published({
239        source: selectedDraftRelativePath,
240        destination: finalDestinationPath,
241      })
242    );
243
244    const sourceDir = path.dirname(finalSourcePath);
245    if (sourceDir !== draftsDir) {
246      try {
247        const filesInSourceDir = await fs.readdir(sourceDir);
248        if (filesInSourceDir.length === 0) {
249          await fs.rmdir(sourceDir);
250          console.log(
251            L[currentLocale].cli.pub.info.info_empty_dir_removed({ dirPath: sourceDir })
252          );
253        }
254      } catch (err) {
255        // Ignore if directory removal fails (e.g. not empty, permissions)
256        console.warn(`Could not remove directory ${sourceDir}:`, err);
257      }
258    }
259  } catch (error) {
260    if (error instanceof ExitPromptError) {
261      console.log(L[currentLocale].cli.info.cancelled_by_user());
262      process.exit(0);
263    } else {
264      console.error(
265        L[currentLocale].cli.pub.error.publish_article({ message: (error as Error).message })
266      );
267      process.exit(1);
268    }
269  }
270}
271
272async function main() {
273  try {
274    await run();
275  } catch (error) {
276    if (!(error instanceof ExitPromptError)) {
277      console.error(
278        L[currentLocale].cli.error.unexpected({
279          message: (error as Error).message || String(error),
280        }),
281        error
282      );
283    }
284    process.exit(1);
285  }
286}
287
288process.on('SIGINT', async () => {
289  console.log(L[currentLocale].cli.info.cancelled_by_user());
290  process.exit(0);
291});
292
293main();