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