Commit 443ada1

HPCesia <me@hpcesia.com>
2025-05-19 15:55:41
feat: new cli tool
- Use Inquirer.js to enhance interactive cli; - Use typesafe-i18n to improve localiazation.
1 parent bd24b5c
i18n/en/cli/index.ts
@@ -0,0 +1,60 @@
+import type { BaseTranslation } from '../../i18n-types.js';
+
+const en_cli = {
+  new: {
+    prompt: {
+      title: 'Enter the title of the new article:',
+      category: 'Enter the category for the article (optional):',
+      tags: 'Enter tags for the article, comma-separated (optional):',
+    },
+    action: {
+      exit: 'Exit without creating draft',
+    },
+    info: {
+      success_created: 'Successfully created article: {filePath:string}',
+      overwrite: 'Overwriting existing file: {filePath:string}',
+    },
+    error: {
+      no_title: 'Article title cannot be empty.',
+    },
+  },
+  pub: {
+    prompt: {
+      select: 'Select the draft to publish:',
+    },
+    action: {
+      exit: 'Exit without publishing',
+    },
+    info: {
+      no_drafts_found: 'No drafts found to publish.',
+      success_article_published:
+        "Successfully published '{source:string}' to '{destination:string}'.",
+      info_empty_dir_removed: 'Cleaned up empty source directory: {dirPath:string}',
+      publish_cancelled: 'Publish operation cancelled.',
+    },
+    error: {
+      publish_article: 'Error publishing article: {message:string}',
+    },
+  },
+  prompt: {
+    file_exists: 'File {filePath:string} already exists. Choose an action:',
+    enter_new_name: 'Enter a new name for the file (without extension):',
+  },
+  action: {
+    rename: 'Rename the file',
+    overwrite: 'Overwrite the existing file',
+  },
+  info: {
+    cancelled_by_user: 'Operation cancelled by user.',
+  },
+  error: {
+    unexpected: 'An unexpected error occurred: {message:string}',
+    generic: 'An error occurred: {message:string}',
+    file_exists: 'File already exists: {filePath:string}',
+    create_file: 'Error creating file: {message:string}',
+    empty_filename: 'File name cannot be empty.',
+    rename_to_original_conflict: 'Cannot rename to original file name: {fileName:string}',
+  },
+} satisfies BaseTranslation;
+
+export default en_cli;
i18n/en/index.ts
@@ -0,0 +1,7 @@
+import type { BaseTranslation } from '../i18n-types.js';
+
+const en = {
+  // Add non-namespaced English translations here if needed
+} satisfies BaseTranslation;
+
+export default en;
i18n/zh-CN/cli/index.ts
@@ -0,0 +1,59 @@
+import type { NamespaceCliTranslation } from '../../i18n-types.js';
+
+const zh_CN_cli = {
+  new: {
+    prompt: {
+      title: '请输入新文章的标题:',
+      category: '请输入文章分类(可选):',
+      tags: '请输入文章标签,以逗号分隔(可选):',
+    },
+    action: {
+      exit: '退出,不创建草稿',
+    },
+    info: {
+      success_created: '成功创建文章:{filePath}',
+      overwrite: '正在覆盖现有文件:{filePath}',
+    },
+    error: {
+      no_title: '文章标题不能为空。',
+    },
+  },
+  pub: {
+    prompt: {
+      select: '请选择要发布的草稿:',
+    },
+    action: {
+      exit: '退出,不发布',
+    },
+    info: {
+      no_drafts_found: '未找到可发布的草稿。',
+      success_article_published: "成功将 '{source}' 发布到 '{destination}'。",
+      info_empty_dir_removed: '已清理空的源目录:{dirPath}',
+      publish_cancelled: '发布操作已取消。',
+    },
+    error: {
+      publish_article: '发布文章时出错:{message}',
+    },
+  },
+  prompt: {
+    file_exists: '文件 {filePath} 已存在。请选择操作:',
+    enter_new_name: '请输入新文件名(不带扩展名):',
+  },
+  action: {
+    rename: '重命名文件',
+    overwrite: '覆盖现有文件',
+  },
+  info: {
+    cancelled_by_user: '用户已取消操作。',
+  },
+  error: {
+    unexpected: '发生意外错误:{message}',
+    generic: '发生错误:{message}',
+    file_exists: '文件已存在:{filePath}',
+    create_file: '创建文件时出错:{message}',
+    empty_filename: '文件名不能为空。',
+    rename_to_original_conflict: '新文件名与原文件名冲突:{fileName}',
+  },
+} satisfies NamespaceCliTranslation;
+
+export default zh_CN_cli;
i18n/zh-CN/index.ts
@@ -0,0 +1,7 @@
+import type { Translation } from '../i18n-types.js';
+
+const zh_CN = {
+  // Add non-namespaced Chinese translations here if needed
+} satisfies Translation;
+
+export default zh_CN;
i18n/formatters.ts
@@ -0,0 +1,11 @@
+import type { FormattersInitializer } from 'typesafe-i18n'
+import type { Locales, Formatters } from './i18n-types.js'
+
+export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
+
+	const formatters: Formatters = {
+		// add your formatter functions here
+	}
+
+	return formatters
+}
i18n/i18n-node.ts
@@ -0,0 +1,13 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { i18n } from './i18n-util.js'
+import { loadAllLocales } from './i18n-util.sync.js'
+import type { LocaleTranslationFunctions } from 'typesafe-i18n'
+import type { Locales, Translations, TranslationFunctions } from './i18n-types.js'
+
+loadAllLocales()
+
+export const L: LocaleTranslationFunctions<Locales, Translations, TranslationFunctions> = i18n()
+
+export default L
i18n/i18n-types.ts
@@ -0,0 +1,308 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n'
+
+export type BaseTranslation = BaseTranslationType & DisallowNamespaces
+export type BaseLocale = 'en'
+
+export type Locales =
+	| 'en'
+	| 'zh-CN'
+
+export type Translation = RootTranslation & DisallowNamespaces
+
+export type Translations = RootTranslation &
+{
+	cli: NamespaceCliTranslation
+}
+
+type RootTranslation = {}
+
+export type NamespaceCliTranslation = {
+	'new': {
+		prompt: {
+			/**
+			 * E​n​t​e​r​ ​t​h​e​ ​t​i​t​l​e​ ​o​f​ ​t​h​e​ ​n​e​w​ ​a​r​t​i​c​l​e​:
+			 */
+			title: string
+			/**
+			 * E​n​t​e​r​ ​t​h​e​ ​c​a​t​e​g​o​r​y​ ​f​o​r​ ​t​h​e​ ​a​r​t​i​c​l​e​ ​(​o​p​t​i​o​n​a​l​)​:
+			 */
+			category: string
+			/**
+			 * E​n​t​e​r​ ​t​a​g​s​ ​f​o​r​ ​t​h​e​ ​a​r​t​i​c​l​e​,​ ​c​o​m​m​a​-​s​e​p​a​r​a​t​e​d​ ​(​o​p​t​i​o​n​a​l​)​:
+			 */
+			tags: string
+		}
+		action: {
+			/**
+			 * E​x​i​t​ ​w​i​t​h​o​u​t​ ​c​r​e​a​t​i​n​g​ ​d​r​a​f​t
+			 */
+			exit: string
+		}
+		info: {
+			/**
+			 * S​u​c​c​e​s​s​f​u​l​l​y​ ​c​r​e​a​t​e​d​ ​a​r​t​i​c​l​e​:​ ​{​f​i​l​e​P​a​t​h​}
+			 * @param {string} filePath
+			 */
+			success_created: RequiredParams<'filePath'>
+			/**
+			 * O​v​e​r​w​r​i​t​i​n​g​ ​e​x​i​s​t​i​n​g​ ​f​i​l​e​:​ ​{​f​i​l​e​P​a​t​h​}
+			 * @param {string} filePath
+			 */
+			overwrite: RequiredParams<'filePath'>
+		}
+		error: {
+			/**
+			 * A​r​t​i​c​l​e​ ​t​i​t​l​e​ ​c​a​n​n​o​t​ ​b​e​ ​e​m​p​t​y​.
+			 */
+			no_title: string
+		}
+	}
+	pub: {
+		prompt: {
+			/**
+			 * S​e​l​e​c​t​ ​t​h​e​ ​d​r​a​f​t​ ​t​o​ ​p​u​b​l​i​s​h​:
+			 */
+			select: string
+		}
+		action: {
+			/**
+			 * E​x​i​t​ ​w​i​t​h​o​u​t​ ​p​u​b​l​i​s​h​i​n​g
+			 */
+			exit: string
+		}
+		info: {
+			/**
+			 * N​o​ ​d​r​a​f​t​s​ ​f​o​u​n​d​ ​t​o​ ​p​u​b​l​i​s​h​.
+			 */
+			no_drafts_found: string
+			/**
+			 * S​u​c​c​e​s​s​f​u​l​l​y​ ​p​u​b​l​i​s​h​e​d​ ​'​{​s​o​u​r​c​e​}​'​ ​t​o​ ​'​{​d​e​s​t​i​n​a​t​i​o​n​}​'​.
+			 * @param {string} destination
+			 * @param {string} source
+			 */
+			success_article_published: RequiredParams<'destination' | 'source'>
+			/**
+			 * C​l​e​a​n​e​d​ ​u​p​ ​e​m​p​t​y​ ​s​o​u​r​c​e​ ​d​i​r​e​c​t​o​r​y​:​ ​{​d​i​r​P​a​t​h​}
+			 * @param {string} dirPath
+			 */
+			info_empty_dir_removed: RequiredParams<'dirPath'>
+			/**
+			 * P​u​b​l​i​s​h​ ​o​p​e​r​a​t​i​o​n​ ​c​a​n​c​e​l​l​e​d​.
+			 */
+			publish_cancelled: string
+		}
+		error: {
+			/**
+			 * E​r​r​o​r​ ​p​u​b​l​i​s​h​i​n​g​ ​a​r​t​i​c​l​e​:​ ​{​m​e​s​s​a​g​e​}
+			 * @param {string} message
+			 */
+			publish_article: RequiredParams<'message'>
+		}
+	}
+	prompt: {
+		/**
+		 * F​i​l​e​ ​{​f​i​l​e​P​a​t​h​}​ ​a​l​r​e​a​d​y​ ​e​x​i​s​t​s​.​ ​C​h​o​o​s​e​ ​a​n​ ​a​c​t​i​o​n​:
+		 * @param {string} filePath
+		 */
+		file_exists: RequiredParams<'filePath'>
+		/**
+		 * E​n​t​e​r​ ​a​ ​n​e​w​ ​n​a​m​e​ ​f​o​r​ ​t​h​e​ ​f​i​l​e​ ​(​w​i​t​h​o​u​t​ ​e​x​t​e​n​s​i​o​n​)​:
+		 */
+		enter_new_name: string
+	}
+	action: {
+		/**
+		 * R​e​n​a​m​e​ ​t​h​e​ ​f​i​l​e
+		 */
+		rename: string
+		/**
+		 * O​v​e​r​w​r​i​t​e​ ​t​h​e​ ​e​x​i​s​t​i​n​g​ ​f​i​l​e
+		 */
+		overwrite: string
+	}
+	info: {
+		/**
+		 * O​p​e​r​a​t​i​o​n​ ​c​a​n​c​e​l​l​e​d​ ​b​y​ ​u​s​e​r​.
+		 */
+		cancelled_by_user: string
+	}
+	error: {
+		/**
+		 * A​n​ ​u​n​e​x​p​e​c​t​e​d​ ​e​r​r​o​r​ ​o​c​c​u​r​r​e​d​:​ ​{​m​e​s​s​a​g​e​}
+		 * @param {string} message
+		 */
+		unexpected: RequiredParams<'message'>
+		/**
+		 * A​n​ ​e​r​r​o​r​ ​o​c​c​u​r​r​e​d​:​ ​{​m​e​s​s​a​g​e​}
+		 * @param {string} message
+		 */
+		generic: RequiredParams<'message'>
+		/**
+		 * F​i​l​e​ ​a​l​r​e​a​d​y​ ​e​x​i​s​t​s​:​ ​{​f​i​l​e​P​a​t​h​}
+		 * @param {string} filePath
+		 */
+		file_exists: RequiredParams<'filePath'>
+		/**
+		 * E​r​r​o​r​ ​c​r​e​a​t​i​n​g​ ​f​i​l​e​:​ ​{​m​e​s​s​a​g​e​}
+		 * @param {string} message
+		 */
+		create_file: RequiredParams<'message'>
+		/**
+		 * F​i​l​e​ ​n​a​m​e​ ​c​a​n​n​o​t​ ​b​e​ ​e​m​p​t​y​.
+		 */
+		empty_filename: string
+		/**
+		 * C​a​n​n​o​t​ ​r​e​n​a​m​e​ ​t​o​ ​o​r​i​g​i​n​a​l​ ​f​i​l​e​ ​n​a​m​e​:​ ​{​f​i​l​e​N​a​m​e​}
+		 * @param {string} fileName
+		 */
+		rename_to_original_conflict: RequiredParams<'fileName'>
+	}
+}
+
+export type Namespaces =
+	| 'cli'
+
+type DisallowNamespaces = {
+	/**
+	 * reserved for 'cli'-namespace\
+	 * you need to use the `./cli/index.ts` file instead
+	 */
+	cli?: "[typesafe-i18n] reserved for 'cli'-namespace. You need to use the `./cli/index.ts` file instead."
+}
+
+export type TranslationFunctions = {
+	cli: {
+		'new': {
+			prompt: {
+				/**
+				 * Enter the title of the new article:
+				 */
+				title: () => LocalizedString
+				/**
+				 * Enter the category for the article (optional):
+				 */
+				category: () => LocalizedString
+				/**
+				 * Enter tags for the article, comma-separated (optional):
+				 */
+				tags: () => LocalizedString
+			}
+			action: {
+				/**
+				 * Exit without creating draft
+				 */
+				exit: () => LocalizedString
+			}
+			info: {
+				/**
+				 * Successfully created article: {filePath}
+				 */
+				success_created: (arg: { filePath: string }) => LocalizedString
+				/**
+				 * Overwriting existing file: {filePath}
+				 */
+				overwrite: (arg: { filePath: string }) => LocalizedString
+			}
+			error: {
+				/**
+				 * Article title cannot be empty.
+				 */
+				no_title: () => LocalizedString
+			}
+		}
+		pub: {
+			prompt: {
+				/**
+				 * Select the draft to publish:
+				 */
+				select: () => LocalizedString
+			}
+			action: {
+				/**
+				 * Exit without publishing
+				 */
+				exit: () => LocalizedString
+			}
+			info: {
+				/**
+				 * No drafts found to publish.
+				 */
+				no_drafts_found: () => LocalizedString
+				/**
+				 * Successfully published '{source}' to '{destination}'.
+				 */
+				success_article_published: (arg: { destination: string, source: string }) => LocalizedString
+				/**
+				 * Cleaned up empty source directory: {dirPath}
+				 */
+				info_empty_dir_removed: (arg: { dirPath: string }) => LocalizedString
+				/**
+				 * Publish operation cancelled.
+				 */
+				publish_cancelled: () => LocalizedString
+			}
+			error: {
+				/**
+				 * Error publishing article: {message}
+				 */
+				publish_article: (arg: { message: string }) => LocalizedString
+			}
+		}
+		prompt: {
+			/**
+			 * File {filePath} already exists. Choose an action:
+			 */
+			file_exists: (arg: { filePath: string }) => LocalizedString
+			/**
+			 * Enter a new name for the file (without extension):
+			 */
+			enter_new_name: () => LocalizedString
+		}
+		action: {
+			/**
+			 * Rename the file
+			 */
+			rename: () => LocalizedString
+			/**
+			 * Overwrite the existing file
+			 */
+			overwrite: () => LocalizedString
+		}
+		info: {
+			/**
+			 * Operation cancelled by user.
+			 */
+			cancelled_by_user: () => LocalizedString
+		}
+		error: {
+			/**
+			 * An unexpected error occurred: {message}
+			 */
+			unexpected: (arg: { message: string }) => LocalizedString
+			/**
+			 * An error occurred: {message}
+			 */
+			generic: (arg: { message: string }) => LocalizedString
+			/**
+			 * File already exists: {filePath}
+			 */
+			file_exists: (arg: { filePath: string }) => LocalizedString
+			/**
+			 * Error creating file: {message}
+			 */
+			create_file: (arg: { message: string }) => LocalizedString
+			/**
+			 * File name cannot be empty.
+			 */
+			empty_filename: () => LocalizedString
+			/**
+			 * Cannot rename to original file name: {fileName}
+			 */
+			rename_to_original_conflict: (arg: { fileName: string }) => LocalizedString
+		}
+	}
+}
+
+export type Formatters = {}
i18n/i18n-util.async.ts
@@ -0,0 +1,42 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initFormatters } from './formatters.js'
+import type { Locales, Namespaces, Translations } from './i18n-types.js'
+import { loadedFormatters, loadedLocales, locales } from './i18n-util.js'
+
+const localeTranslationLoaders = {
+	en: () => import('./en/index.js'),
+	'zh-CN': () => import('./zh-CN/index.js'),
+}
+
+const localeNamespaceLoaders = {
+	en: {
+		cli: () => import('./en/cli/index.js')
+	},
+	'zh-CN': {
+		cli: () => import('./zh-CN/cli/index.js')
+	}
+}
+
+const updateDictionary = (locale: Locales, dictionary: Partial<Translations>): Translations =>
+	loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }
+
+export const importLocaleAsync = async (locale: Locales): Promise<Translations> =>
+	(await localeTranslationLoaders[locale]()).default as unknown as Translations
+
+export const loadLocaleAsync = async (locale: Locales): Promise<void> => {
+	updateDictionary(locale, await importLocaleAsync(locale))
+	loadFormatters(locale)
+}
+
+export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync))
+
+export const loadFormatters = (locale: Locales): void =>
+	void (loadedFormatters[locale] = initFormatters(locale))
+
+export const importNamespaceAsync = async<Namespace extends Namespaces>(locale: Locales, namespace: Namespace) =>
+	(await localeNamespaceLoaders[locale][namespace]()).default as unknown as Translations[Namespace]
+
+export const loadNamespaceAsync = async <Namespace extends Namespaces>(locale: Locales, namespace: Namespace): Promise<void> =>
+	void updateDictionary(locale, { [namespace]: await importNamespaceAsync(locale, namespace )})
i18n/i18n-util.sync.ts
@@ -0,0 +1,35 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initFormatters } from './formatters.js'
+import type { Locales, Translations } from './i18n-types.js'
+import { loadedFormatters, loadedLocales, locales } from './i18n-util.js'
+
+import en from './en/index.js'
+import zh_CN from './zh-CN/index.js'
+
+import en_cli from './en/cli/index.js'
+import zh_CN_cli from './zh-CN/cli/index.js'
+
+const localeTranslations = {
+	en: {
+		...en,
+		cli: en_cli
+	},
+	'zh-CN': {
+		...zh_CN,
+		cli: zh_CN_cli
+	},
+}
+
+export const loadLocale = (locale: Locales): void => {
+	if (loadedLocales[locale]) return
+
+	loadedLocales[locale] = localeTranslations[locale] as unknown as Translations
+	loadFormatters(locale)
+}
+
+export const loadAllLocales = (): void => locales.forEach(loadLocale)
+
+export const loadFormatters = (locale: Locales): void =>
+	void (loadedFormatters[locale] = initFormatters(locale))
i18n/i18n-util.ts
@@ -0,0 +1,44 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n'
+import type { LocaleDetector } from 'typesafe-i18n/detectors'
+import type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n'
+import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors'
+import { initExtendDictionary } from 'typesafe-i18n/utils'
+import type { Formatters, Locales, Namespaces, Translations, TranslationFunctions } from './i18n-types.js'
+
+export const baseLocale: Locales = 'en'
+
+export const locales: Locales[] = [
+	'en',
+	'zh-CN'
+]
+
+export const namespaces: Namespaces[] = [
+	'cli'
+]
+
+export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales)
+
+export const isNamespace = (namespace: string): namespace is Namespaces => namespaces.includes(namespace as Namespaces)
+
+export const loadedLocales: Record<Locales, Translations> = {} as Record<Locales, Translations>
+
+export const loadedFormatters: Record<Locales, Formatters> = {} as Record<Locales, Formatters>
+
+export const extendDictionary = initExtendDictionary<Translations>()
+
+export const i18nString = (locale: Locales): TranslateByString => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale])
+
+export const i18nObject = (locale: Locales): TranslationFunctions =>
+	initI18nObject<Locales, Translations, TranslationFunctions, Formatters>(
+		locale,
+		loadedLocales[locale],
+		loadedFormatters[locale]
+	)
+
+export const i18n = (): LocaleTranslationFunctions<Locales, Translations, TranslationFunctions> =>
+	initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
+
+export const detectLocale = (...detectors: LocaleDetector[]): Locales => detectLocaleFn<Locales>(baseLocale, locales, ...detectors)
scaffolds/draft.md
@@ -1,8 +1,8 @@
 ---
-title: {{ title }}
-slug:
-category:
-tags:
-cover:
-description:
+title: ''
+slug: ''
+category: ''
+tags: []
+cover: ''
+description: ''
 ---
scaffolds/post.md
@@ -1,9 +0,0 @@
----
-title: {{ title }}
-slug:
-category:
-tags:
-cover:
-description:
-published: {{ date }}
----
scripts/locale/en.js
@@ -1,43 +0,0 @@
-export default {
-  fileExist: 'File {path} already exists',
-  chooseAction: 'Please choose an action:',
-  actions: {
-    useNewName: 'Use a new file name',
-    overwrite: 'Overwrite existing file',
-    exit: 'Exit program',
-  },
-  draftsTitle: 'Draft List (Page {current}/{total}):',
-  paginationTip:
-    'Enter n(next) or p(previous) to navigate pages, or enter a number to select draft',
-  noDrafts: 'No drafts found',
-  selectDraft: 'Please select a draft number to publish: ',
-  readDraftsError: 'Error reading draft files:',
-  publishSuccess: 'Published to: {path}',
-  publishError: 'Error publishing {path}:',
-  invalidSelection: 'Invalid selection, please enter a valid number',
-  inputOption: 'Enter option ({countStart}-{countEnd}): ',
-  invalidOption: 'Invalid option, exiting program',
-  timezoneError: 'Timezone format error:',
-  created: {
-    post: 'Created article: {path}',
-    draft: 'Created draft: {path}',
-  },
-  cli: {
-    description: 'Create a new article or draft',
-    typeArg: 'Creation type (post or draft)',
-    titleArg: 'Article title',
-    dirOption: 'Create article in directory format',
-    timezoneOption: 'Specify timezone',
-    helpOption: 'Display help information',
-    showHelp: '(use --help for more information)',
-    examples: `
-Examples:
-  $ new post "My First Post" -t "+08:00"       Create a new article using UTC+8 timezone
-  $ new draft "Draft Post" -d -t "asia/tokyo"  Create a draft using directory format and Tokyo timezone
-  $ new post "Second Post"                     Create an article using local timezone`,
-    error: 'Error:',
-    typeError: 'Error: type must be post or draft',
-    timezoneWarning: 'Warning: timezone parameter is ignored in draft mode',
-    pubDescription: 'Publish draft to article',
-  },
-};
scripts/locale/index.js
@@ -1,30 +0,0 @@
-import en from './en.js';
-import zhCN from './zh-cn.js';
-import os from 'os';
-
-function getSystemLanguage() {
-  // 按照优先级尝试不同的方式获取系统语言
-  const lang =
-    process.env.LANG || process.env.LANGUAGE || process.env.LC_ALL || os.locale() || 'en-US';
-
-  return lang.toLowerCase();
-}
-
-function format(str, params) {
-  return str.replace(/\{(\w+)\}/g, (match, key) => params[key] || match);
-}
-
-export function t(key, params = {}) {
-  const lang = getSystemLanguage();
-  const messages = lang.startsWith('zh') ? zhCN : en;
-
-  // 支持嵌套键值,如 'cli.description'
-  const value = key.split('.').reduce((obj, k) => obj?.[k], messages);
-
-  if (value === undefined) {
-    console.warn(`Translation key not found: ${key}`);
-    return key;
-  }
-
-  return params ? format(value, params) : value;
-}
scripts/locale/zh-cn.js
@@ -1,42 +0,0 @@
-export default {
-  fileExist: '文件 {path} 已存在',
-  chooseAction: '请选择操作:',
-  actions: {
-    useNewName: '使用新的文件名',
-    overwrite: '覆盖原文件',
-    exit: '退出程序',
-  },
-  draftsTitle: '草稿列表 (第 {current}/{total} 页):',
-  paginationTip: '输入 n(下一页) 或 p(上一页) 翻页,或输入编号选择草稿',
-  noDrafts: '没有找到任何草稿',
-  selectDraft: '请选择要发布的草稿编号: ',
-  readDraftsError: '读取草稿文件出错:',
-  publishSuccess: '已发布到: {path}',
-  publishError: '发布 {path} 时出错:',
-  invalidSelection: '无效的选择,请输入正确的编号',
-  inputOption: '请输入选项 ({countStart}-{countEnd}): ',
-  invalidOption: '无效的选项,退出程序',
-  timezoneError: '时区格式错误:',
-  created: {
-    post: '已创建文章: {path}',
-    draft: '已创建草稿: {path}',
-  },
-  cli: {
-    description: '创建新的文章或草稿',
-    typeArg: '创建类型 (post 或 draft)',
-    titleArg: '文章标题',
-    dirOption: '创建目录形式的文章',
-    timezoneOption: '指定时区',
-    helpOption: '显示帮助信息',
-    showHelp: '(使用 --help 查看更多信息)',
-    examples: `
-示例:
-  $ new post "My First Post" -t "+08:00"       创建一篇新文章,使用东八区时间
-  $ new draft "Draft Post" -d -t "asia/tokyo"  创建一篇草稿,使用目录形式与东京时区
-  $ new post "Second Post"                     创建一篇文章,使用本地时区`,
-    error: '错误:',
-    typeError: '错误: 类型必须是 post 或 draft',
-    timezoneWarning: '警告:草稿模式下 timezone 参数无效',
-    pubDescription: '发布草稿到文章',
-  },
-};
scripts/config.ts
@@ -0,0 +1,13 @@
+export interface Config {
+  draftsDir: string;
+  postsDir: string;
+  draftStructure: 'category' | 'flat';
+  postStructure: 'category' | 'flat';
+}
+
+export const config: Config = {
+  draftsDir: 'src/content/drafts',
+  postsDir: 'src/content/posts',
+  draftStructure: 'flat',
+  postStructure: 'category',
+};
scripts/new.mjs
@@ -1,138 +0,0 @@
-import { t } from './locale/index.js';
-import { checkFileExists, getFilePath, parseTimezoneOffset, sanitizeTitle } from './utils.mjs';
-import { Command } from 'commander';
-import dayjs from 'dayjs';
-import { promises as fs } from 'fs';
-import path from 'path';
-import { createInterface } from 'readline/promises';
-
-const program = new Command();
-const rl = createInterface({
-  input: process.stdin,
-  output: process.stdout,
-});
-
-// 处理文件名冲突
-async function handleFileConflict(contentDir, sanitizedTitle, isDir) {
-  console.log(t('fileExist', { title: sanitizedTitle }));
-  console.log(t('chooseAction'));
-  console.log('1. ', t('actions.useNewName'));
-  console.log('2. ', t('actions.overwrite'));
-  console.log('3. ', t('actions.exit'));
-
-  const answer = await rl.question(t('inputOption', { countStart: 1, countEnd: 3 }));
-
-  switch (answer.trim()) {
-    case '1': {
-      let counter = 1;
-      while ((await checkFileExists(contentDir, sanitizedTitle, counter)).exists) {
-        counter++;
-      }
-      const newTitle = `${sanitizedTitle}-${counter}`;
-      return getFilePath(contentDir, newTitle, isDir);
-    }
-    case '2':
-      return getFilePath(contentDir, sanitizedTitle, isDir);
-    case '3':
-      rl.close();
-      process.exit(0);
-      break;
-    default:
-      console.log(t('invalidOption'));
-      rl.close();
-      process.exit(1);
-  }
-}
-
-// 格式化时间
-function formatDateTime(offset) {
-  const now = offset ? dayjs().utcOffset(offset) : dayjs();
-  return now.format('YYYY-MM-DDTHH:mm:ssZ');
-}
-
-// 创建文章
-async function createArticle(type, title, options) {
-  const template = await fs.readFile(`scaffolds/${type}.md`, 'utf-8');
-  const sanitizedTitle = sanitizeTitle(title);
-  const contentDir = path.join('src/content', type === 'post' ? 'posts' : 'drafts');
-
-  await fs.mkdir(contentDir, { recursive: true });
-  const { exists } = await checkFileExists(contentDir, sanitizedTitle);
-  let filepath = getFilePath(contentDir, sanitizedTitle, options.dir);
-
-  // 如果文件已存在,处理冲突
-  if (exists) {
-    filepath = await handleFileConflict(contentDir, sanitizedTitle, options.dir);
-  }
-
-  // 如果需要创建目录,确保目录存在
-  if (options.dir) {
-    await fs.mkdir(path.dirname(filepath), { recursive: true });
-  }
-
-  // 处理模板
-  let content = template.replace('{{ title }}', title);
-
-  // 对于文章类型,添加发布时间
-  if (type === 'post') {
-    let formattedDate;
-    if (options.timezone) {
-      try {
-        const offset = parseTimezoneOffset(options.timezone);
-        formattedDate = formatDateTime(offset);
-      } catch (error) {
-        console.error(t('timezoneError'), error.message);
-        rl.close();
-        process.exit(1);
-      }
-    } else {
-      formattedDate = formatDateTime();
-    }
-    content = content.replace('{{ date }}', formattedDate);
-  }
-
-  // 写入文件
-  await fs.writeFile(filepath, content);
-  console.log(t(`created.${type}`, { path: filepath }));
-  rl.close();
-}
-
-program
-  .name('new')
-  .description(t('cli.description'))
-  .argument('<type>', t('cli.typeArg'))
-  .argument('<title>', t('cli.titleArg'))
-  .option('-d, --dir', t('cli.dirOption'), false)
-  .option('-t, --timezone <tz>', t('cli.timezoneOption'))
-  .helpOption('-h, --help', t('cli.helpOption'))
-  .showHelpAfterError(t('cli.showHelp'))
-  .addHelpText('after', t('cli.examples'));
-
-// 在解析之前添加错误处理
-program.showHelpAfterError();
-
-try {
-  program.parse();
-} catch (error) {
-  console.error(t('cli.error'), error.message);
-  program.help();
-}
-
-const options = program.opts();
-const [type, title] = program.args;
-
-if (!['post', 'draft'].includes(type)) {
-  console.error(t('cli.typeError'));
-  program.help();
-  process.exit(1);
-}
-
-if (type === 'draft' && options.timezone) {
-  console.log(t('cli.timezoneWarning'));
-}
-
-// 执行创建
-createArticle(type, title, options).catch((error) => {
-  console.error(t('cli.error'), error.message);
-  process.exit(1);
-});
scripts/new.ts
@@ -0,0 +1,241 @@
+import L from '../i18n/i18n-node';
+import type { Locales } from '../i18n/i18n-types';
+import { config as scriptConfig } from './config';
+import { changeFrontmatter, findAvailableFileName, slugify } from './utils';
+import type { AvailableFileNameInfo } from './utils';
+import { Command, OptionValues } from '@commander-js/extra-typings';
+import { ExitPromptError } from '@inquirer/core';
+import { input, select } from '@inquirer/prompts';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { osLocaleSync } from 'os-locale';
+
+// Define CLI options interface
+interface CLIOptions extends OptionValues {
+  title?: string;
+  category?: string;
+  tags?: string;
+}
+
+const program = new Command<[], CLIOptions>();
+
+// Determine current locale
+const availableLocalesForScript: Locales[] = ['en', 'zh-CN'];
+let detectedSystemLocale: string = 'en';
+try {
+  detectedSystemLocale = osLocaleSync().replace('_', '-');
+} catch {
+  console.warn('Failed to detect system locale, defaulting to en.');
+}
+const currentLocale: Locales = availableLocalesForScript.includes(
+  detectedSystemLocale as Locales
+)
+  ? (detectedSystemLocale as Locales)
+  : 'en';
+
+program
+  .name('new-article')
+  .description('Create a new draft article')
+  .option('-t, --title <titleString>', 'Article title')
+  .option('-c, --category <categoryString>', 'Article category (optional)')
+  .option('-T, --tags <tagsString>', 'Article tags, comma-separated (optional)')
+  .action(async (options: CLIOptions) => {
+    const { title: cliTitle, category: cliCategory, tags: cliTags } = options;
+
+    let title = cliTitle;
+    let category = cliCategory;
+    let tags = cliTags;
+
+    try {
+      // Interactive mode
+      if (!title) {
+        title = await input({ message: L[currentLocale].cli.new.prompt.title() });
+      }
+      if (category === undefined && title) {
+        category = await input({ message: L[currentLocale].cli.new.prompt.category() });
+      }
+      if (tags === undefined && title) {
+        tags = await input({ message: L[currentLocale].cli.new.prompt.tags() });
+      }
+    } catch (error) {
+      if (error instanceof ExitPromptError) {
+        console.log(L[currentLocale].cli.info.cancelled_by_user());
+        process.exit(0);
+      }
+      throw error;
+    }
+
+    if (!title || title.trim() === '') {
+      console.error(L[currentLocale].cli.new.error.no_title());
+      process.exit(1);
+    }
+
+    let slug = slugify(title, true); // Used for frontmatter
+    const baseSlugForFile = slugify(title); // Used for filename generation, might be different from frontmatter slug
+    const fileExtension = '.md';
+    const currentFileName = `${baseSlugForFile}${fileExtension}`;
+
+    let targetDir = path.resolve(process.cwd(), scriptConfig.draftsDir);
+    if (scriptConfig.draftStructure === 'category' && category && category.trim() !== '') {
+      const categorySlug = slugify(category);
+      targetDir = path.join(targetDir, categorySlug);
+    }
+
+    try {
+      await fs.mkdir(targetDir, { recursive: true });
+      const filePath = path.join(targetDir, currentFileName);
+      let finalFilePath = filePath;
+
+      let fileExists = false;
+      try {
+        await fs.access(filePath);
+        fileExists = true;
+      } catch {
+        // File does not exist, can proceed
+      }
+
+      if (fileExists) {
+        const action = await select({
+          message: L[currentLocale].cli.prompt.file_exists({ filePath }),
+          choices: [
+            {
+              name: L[currentLocale].cli.action.rename(),
+              value: 'rename',
+            },
+            {
+              name: L[currentLocale].cli.action.overwrite(),
+              value: 'overwrite',
+            },
+            {
+              name: L[currentLocale].cli.new.action.exit(),
+              value: 'exit',
+            },
+          ],
+        });
+
+        if (action === 'exit') {
+          console.log(L[currentLocale].cli.info.cancelled_by_user());
+          process.exit(0);
+        } else if (action === 'rename') {
+          const suggestedNameInfo: AvailableFileNameInfo = await findAvailableFileName(
+            targetDir,
+            baseSlugForFile,
+            fileExtension
+          );
+          let userConfirmedNewName = false;
+          while (!userConfirmedNewName) {
+            try {
+              const newNameInput = await input({
+                message: L[currentLocale].cli.prompt.enter_new_name(),
+                default: suggestedNameInfo.fileName,
+              });
+
+              if (!newNameInput || newNameInput.trim() === '') {
+                console.error(L[currentLocale].cli.error.empty_filename());
+                continue;
+              }
+
+              let potentialNewFileName = newNameInput.trim();
+              if (!potentialNewFileName.endsWith(fileExtension)) {
+                potentialNewFileName += fileExtension;
+              }
+
+              const potentialNewFilePath = path.join(targetDir, potentialNewFileName);
+
+              if (potentialNewFilePath === filePath) {
+                console.error(
+                  L[currentLocale].cli.error.rename_to_original_conflict({
+                    fileName: potentialNewFileName,
+                  })
+                );
+                continue;
+              }
+
+              try {
+                await fs.access(potentialNewFilePath);
+                console.error(
+                  L[currentLocale].cli.error.file_exists({
+                    filePath: potentialNewFileName,
+                  })
+                );
+              } catch {
+                finalFilePath = potentialNewFilePath;
+                slug = slug + `${suggestedNameInfo.counter}`;
+                userConfirmedNewName = true;
+              }
+            } catch (error) {
+              if (error instanceof ExitPromptError) {
+                console.log(L[currentLocale].cli.info.cancelled_by_user());
+                process.exit(0);
+              }
+              throw error; // Re-throw other errors
+            }
+          }
+        } else if (action === 'overwrite') {
+          // finalFilePath, finalFileName, and finalNameWithoutExt remain as initially calculated
+          console.log(L[currentLocale].cli.new.info.overwrite({ filePath }));
+        }
+      }
+
+      const scaffoldPath = path.resolve(process.cwd(), 'scaffolds/draft.md');
+      let content = await fs.readFile(scaffoldPath, 'utf-8');
+
+      const frontmatterChanges: Record<string, unknown> = {
+        title: title.replaceAll(/"/g, '\\"'),
+        slug: slug,
+      };
+
+      if (category && category.trim() !== '') {
+        frontmatterChanges.category = category.trim();
+      } else {
+        frontmatterChanges.category = '';
+      }
+
+      if (tags && tags.trim() !== '') {
+        const tagsArray = tags
+          .split(',')
+          .map((tag) => tag.trim())
+          .filter((tag) => tag);
+        if (tagsArray.length > 0) {
+          frontmatterChanges.tags = tagsArray;
+        } else {
+          frontmatterChanges.tags = [];
+        }
+      } else {
+        frontmatterChanges.tags = [];
+      }
+
+      content = changeFrontmatter(content, frontmatterChanges);
+
+      await fs.writeFile(finalFilePath, content);
+      console.log(L[currentLocale].cli.new.info.success_created({ filePath: finalFilePath }));
+    } catch (error) {
+      console.error(
+        L[currentLocale].cli.error.create_file({ message: (error as Error).message })
+      );
+      process.exit(1);
+    }
+  });
+
+async function main() {
+  try {
+    await program.parseAsync(process.argv);
+  } catch (error) {
+    if (!(error instanceof ExitPromptError)) {
+      console.error(
+        L[currentLocale].cli.error.unexpected({
+          message: (error as Error).message || String(error),
+        }),
+        error
+      );
+    }
+    process.exit(1);
+  }
+}
+
+process.on('SIGINT', async () => {
+  console.log(L[currentLocale].cli.info.cancelled_by_user());
+  process.exit(0);
+});
+
+main();
scripts/pub.mjs
@@ -1,224 +0,0 @@
-import { t } from './locale/index.js';
-import { checkFileExists, getFilePath } from './utils.mjs';
-import { Command } from 'commander';
-import dayjs from 'dayjs';
-import { promises as fs } from 'fs';
-import path from 'path';
-import { createInterface } from 'readline/promises';
-
-const program = new Command();
-const rl = createInterface({
-  input: process.stdin,
-  output: process.stdout,
-});
-const DRAFTS_DIR = 'src/content/drafts';
-const POSTS_DIR = 'src/content/posts';
-
-// 格式化时间
-function formatDateTime() {
-  return dayjs().format('YYYY-MM-DDTHH:mm:ssZ');
-}
-
-// 更新文章内容,添加发布时间
-async function updateArticleContent(filePath) {
-  const content = await fs.readFile(filePath, 'utf-8');
-  const now = formatDateTime();
-
-  // 如果内容中已经包含 published 字段,则不修改
-  if (content.includes('published:')) {
-    return content;
-  }
-
-  // 在 frontmatter 的末尾(第一个 --- 之后,第二个 --- 之前)添加 published 字段
-  const parts = content.split('---');
-  if (parts.length < 3) {
-    return content; // 如果文件格式不正确,返回原内容
-  }
-
-  const [, frontmatter, ...bodyParts] = parts;
-  return `---${frontmatter.trimEnd()}\npublished: ${now}\n---${bodyParts.join('---')}`;
-}
-
-// 列出文件并分页显示
-async function listDraftsWithPagination(drafts, page = 1, pageSize = 10) {
-  const start = (page - 1) * pageSize;
-  const end = start + pageSize;
-  const totalPages = Math.ceil(drafts.length / pageSize);
-
-  console.log(t('draftsTitle', { current: page, total: totalPages }));
-  drafts.slice(start, end).forEach((draft, index) => {
-    console.log(`${start + index + 1}. ${draft}`);
-  });
-
-  if (totalPages > 1) {
-    console.log(t('paginationTip'));
-  }
-
-  return totalPages;
-}
-
-// 处理文件冲突
-async function handleFileConflict(title, isDir) {
-  console.log(t('fileExist', { path: title }));
-  console.log(t('chooseAction'));
-  console.log('1. ', t('actions.useNewName'));
-  console.log('2. ', t('actions.overwrite'));
-  console.log('3. ', t('actions.exit'));
-
-  const answer = await rl.question(t('inputOption', { countStart: 1, countEnd: 3 }));
-
-  switch (answer.trim()) {
-    case '1': {
-      let counter = 1;
-      while ((await checkFileExists(POSTS_DIR, title, counter)).exists) {
-        counter++;
-      }
-      return getFilePath(POSTS_DIR, `${title}-${counter}`, isDir);
-    }
-    case '2':
-      return getFilePath(POSTS_DIR, title, isDir);
-    case '3':
-      rl.close();
-      process.exit(0);
-      break;
-    default:
-      console.log(t('invalidOption'));
-      rl.close();
-      process.exit(1);
-  }
-}
-
-// 获取所有草稿文件
-async function getAllDrafts() {
-  const drafts = [];
-
-  try {
-    const files = await fs.readdir(DRAFTS_DIR, { withFileTypes: true });
-
-    for (const file of files) {
-      const title = file.name.endsWith('.md') ? file.name.slice(0, -3) : file.name;
-      // 使用 checkFileExists 检查文件
-      const { exists, filePath } = await checkFileExists(DRAFTS_DIR, title);
-      if (exists && filePath) {
-        drafts.push(path.relative(DRAFTS_DIR, filePath));
-      }
-    }
-
-    return drafts;
-  } catch (error) {
-    console.error(t('readDraftsError'), error);
-    process.exit(1);
-  }
-}
-
-// 发布文章
-async function publishDraft(draftPath) {
-  const fullDraftPath = path.join(DRAFTS_DIR, draftPath);
-  let destPath = path.join(POSTS_DIR, draftPath);
-  const isDir = draftPath.includes('index.md');
-  const title = isDir
-    ? path.basename(path.dirname(draftPath))
-    : path.basename(draftPath, '.md');
-
-  const { exists } = await checkFileExists(POSTS_DIR, title);
-  if (exists) {
-    destPath = await handleFileConflict(title, isDir);
-    if (!destPath) {
-      console.log(t('invalidOption'));
-      process.exit(1);
-    }
-  }
-  try {
-    // 确保目标目录存在
-    await fs.mkdir(path.dirname(destPath), { recursive: true });
-
-    // 如果是目录形式,需要复制整个目录
-    if (isDir) {
-      const draftDir = path.dirname(fullDraftPath);
-      const destDir = path.dirname(destPath);
-
-      // 复制目录内所有文件
-      const files = await fs.readdir(draftDir);
-      for (const file of files) {
-        const srcFile = path.join(draftDir, file);
-        const destFile = path.join(destDir, file);
-
-        if (file === 'index.md') {
-          // 更新并写入文章内容
-          const updatedContent = await updateArticleContent(srcFile);
-          await fs.writeFile(destFile, updatedContent);
-        } else {
-          await fs.copyFile(srcFile, destFile);
-        }
-      }
-
-      // 删除源目录
-      await fs.rm(draftDir, { recursive: true });
-    } else {
-      // 更新并写入文章内容
-      const updatedContent = await updateArticleContent(fullDraftPath);
-      await fs.writeFile(destPath, updatedContent);
-      await fs.unlink(fullDraftPath);
-    }
-
-    console.log(t('publishSuccess', { path: destPath }));
-  } catch (error) {
-    console.error(t('publishError', { path: draftPath }), error);
-    process.exit(1);
-  }
-}
-
-async function main() {
-  // 获取所有草稿
-  const drafts = await getAllDrafts();
-
-  if (drafts.length === 0) {
-    console.log(t('noDrafts'));
-    rl.close();
-    return;
-  }
-
-  let currentPage = 1;
-  const totalPages = await listDraftsWithPagination(drafts, currentPage);
-
-  while (true) {
-    const answer = await rl.question(t('selectDraft'));
-
-    if (answer.toLowerCase() === 'n' && currentPage < totalPages) {
-      currentPage++;
-      await listDraftsWithPagination(drafts, currentPage);
-      continue;
-    }
-
-    if (answer.toLowerCase() === 'p' && currentPage > 1) {
-      currentPage--;
-      await listDraftsWithPagination(drafts, currentPage);
-      continue;
-    }
-
-    const selection = parseInt(answer);
-    if (isNaN(selection) || selection < 1 || selection > drafts.length) {
-      console.log(t('invalidSelection'));
-      continue;
-    }
-
-    const selectedDraft = drafts[selection - 1];
-    await publishDraft(selectedDraft);
-    break;
-  }
-
-  rl.close();
-}
-
-program
-  .name('pub')
-  .description(t('cli.pubDescription'))
-  .helpOption('-h, --help', t('cli.helpOption'))
-  .showHelpAfterError(t('cli.showHelp'));
-
-program.parse();
-
-main().catch((error) => {
-  console.error(t('cli.error'), error);
-  process.exit(1);
-});
scripts/pub.ts
@@ -0,0 +1,293 @@
+import L from '../i18n/i18n-node';
+import type { Locales } from '../i18n/i18n-types';
+import { config as scriptConfig } from './config';
+import { findAvailableFileName, slugify } from './utils';
+import type { AvailableFileNameInfo } from './utils';
+import { ExitPromptError } from '@inquirer/core';
+import { input, select } from '@inquirer/prompts';
+import dayjs from 'dayjs';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { osLocaleSync } from 'os-locale';
+
+const availableLocalesForScript: Locales[] = ['en', 'zh-CN'];
+let detectedSystemLocale: string = 'en';
+try {
+  detectedSystemLocale = osLocaleSync().replace('_', '-');
+} catch {
+  console.warn('Failed to detect system locale, defaulting to en.');
+}
+const currentLocale: Locales = availableLocalesForScript.includes(
+  detectedSystemLocale as Locales
+)
+  ? (detectedSystemLocale as Locales)
+  : 'en';
+
+async function listDrafts(draftsDirPath: string): Promise<string[]> {
+  try {
+    const dirents = await fs.readdir(draftsDirPath, { withFileTypes: true });
+    const files = await Promise.all(
+      dirents.map(async (dirent) => {
+        const res = path.resolve(draftsDirPath, dirent.name);
+        if (dirent.isDirectory()) {
+          const subFiles = await listDrafts(res);
+          return subFiles.map((sf) => path.join(dirent.name, sf));
+        }
+        return dirent.isFile() && dirent.name.endsWith('.md') ? dirent.name : null;
+      })
+    );
+    return files.flat().filter((file) => file !== null) as string[];
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+      return [];
+    }
+    throw error;
+  }
+}
+
+async function updateFrontmatterWithPublishedDate(filePath: string): Promise<void> {
+  let content = await fs.readFile(filePath, 'utf-8');
+  const publishedDate = dayjs().format('YYYY-MM-DDTHH:mm:ssZ');
+
+  const frontmatterRegex = /^---([\s\S]*?^---\s*)/m;
+  const match = content.match(frontmatterRegex);
+
+  if (match) {
+    let fmString = match[1];
+    const publishedLineRegex = /^published:.*$/m;
+
+    if (publishedLineRegex.test(fmString)) {
+      fmString = fmString.replace(publishedLineRegex, `published: ${publishedDate}`);
+    } else {
+      fmString = fmString.replace(/^(---)$/m, `published: ${publishedDate}\n---`);
+    }
+    content = content.replace(frontmatterRegex, '---' + fmString);
+  } else {
+    throw new Error('Frontmatter not found in the file.');
+  }
+  await fs.writeFile(filePath, content, 'utf-8');
+}
+
+async function run() {
+  const draftsDir = path.resolve(process.cwd(), scriptConfig.draftsDir);
+  let postsDir = path.resolve(process.cwd(), scriptConfig.postsDir); // Made postsDir mutable
+
+  let selectedDraftRelativePath: string | undefined;
+
+  try {
+    const draftFiles = await listDrafts(draftsDir);
+
+    if (draftFiles.length === 0) {
+      console.log(L[currentLocale].cli.pub.info.no_drafts_found());
+      return;
+    }
+
+    selectedDraftRelativePath = await select({
+      message: L[currentLocale].cli.pub.prompt.select(),
+      choices: draftFiles.map((file) => ({ name: file, value: file })),
+    });
+
+    let fileName = path.basename(selectedDraftRelativePath);
+    const finalSourcePath = path.join(draftsDir, selectedDraftRelativePath);
+    const fileExtension = path.extname(fileName);
+    const baseNameWithoutExt = path.basename(fileName, fileExtension);
+
+    let finalDestinationPath = path.join(postsDir, fileName);
+
+    if (scriptConfig.postStructure === 'category') {
+      try {
+        const draftContent = await fs.readFile(finalSourcePath, 'utf-8');
+        // Basic frontmatter parsing to find category
+        const fmRegex = /^---([\s\S]*?)^---/m;
+        const fmMatch = draftContent.match(fmRegex);
+        let category: string | undefined;
+        if (fmMatch && fmMatch[1]) {
+          const fmLines = fmMatch[1].split('\n');
+          const categoryLine = fmLines.find((line) => line.trim().startsWith('category:'));
+          if (categoryLine) {
+            // Extract category value, remove potential quotes, and trim whitespace
+            category = categoryLine
+              .substring(categoryLine.indexOf(':') + 1)
+              .trim()
+              .replace(/^['"]|['"]$/g, '');
+          }
+        }
+
+        if (slugify(category || '') !== '') {
+          postsDir = path.join(postsDir, slugify(category || ''));
+          finalDestinationPath = path.join(postsDir, fileName);
+        }
+      } catch (e) {
+        console.warn(
+          L[currentLocale].cli.error.generic({
+            message: `Failed to read or parse category from draft: ${(e as Error).message}`,
+          })
+        );
+        // Proceed with flat structure if category parsing fails
+      }
+    }
+
+    await fs.mkdir(postsDir, { recursive: true });
+
+    let proceedWithPublish = false;
+    try {
+      await fs.access(finalDestinationPath); // Check if destination file exists
+
+      const action = await select({
+        message: L[currentLocale].cli.prompt.file_exists({
+          filePath: finalDestinationPath,
+        }),
+        choices: [
+          {
+            name: L[currentLocale].cli.action.rename(), // Reusing from 'new' script i18n
+            value: 'rename',
+          },
+          {
+            name: L[currentLocale].cli.action.overwrite(), // Reusing from 'new' script i18n
+            value: 'overwrite',
+          },
+          {
+            name: L[currentLocale].cli.pub.action.exit(), // Reusing from 'new' script i18n
+            value: 'exit',
+          },
+        ],
+      });
+
+      if (action === 'exit') {
+        console.log(L[currentLocale].cli.pub.info.publish_cancelled());
+        return;
+      } else if (action === 'overwrite') {
+        proceedWithPublish = true;
+      } else if (action === 'rename') {
+        const suggestedNameInfo: AvailableFileNameInfo = await findAvailableFileName(
+          postsDir, // Target directory for posts
+          baseNameWithoutExt,
+          fileExtension
+        );
+
+        let userConfirmedNewName = false;
+        while (!userConfirmedNewName) {
+          try {
+            const newNameInput = await input({
+              message: L[currentLocale].cli.prompt.enter_new_name(), // Reusing
+              default: suggestedNameInfo.fileName,
+            });
+
+            if (!newNameInput || newNameInput.trim() === '') {
+              console.error(L[currentLocale].cli.error.empty_filename()); // Reusing
+              continue;
+            }
+
+            let potentialNewFileName = newNameInput.trim();
+            if (path.extname(potentialNewFileName) !== fileExtension) {
+              potentialNewFileName =
+                path.basename(potentialNewFileName, path.extname(potentialNewFileName)) +
+                fileExtension;
+            }
+
+            const potentialNewDestPath = path.join(postsDir, potentialNewFileName);
+
+            if (potentialNewDestPath === finalDestinationPath) {
+              console.error(
+                L[currentLocale].cli.error.rename_to_original_conflict({
+                  // Reusing
+                  fileName: potentialNewFileName,
+                })
+              );
+              continue;
+            }
+
+            try {
+              await fs.access(potentialNewDestPath);
+              console.error(
+                L[currentLocale].cli.error.file_exists({
+                  // Reusing
+                  filePath: potentialNewDestPath,
+                })
+              );
+            } catch {
+              // File does not exist, this name is good
+              fileName = potentialNewFileName; // Update fileName for the destination
+              finalDestinationPath = potentialNewDestPath;
+              proceedWithPublish = true;
+              userConfirmedNewName = true;
+            }
+          } catch (error) {
+            if (error instanceof ExitPromptError) {
+              console.log(L[currentLocale].cli.info.cancelled_by_user());
+              process.exit(0);
+            }
+            throw error;
+          }
+        }
+      }
+    } catch {
+      // File does not exist at destination, can proceed directly
+      proceedWithPublish = true;
+    }
+
+    if (!proceedWithPublish) {
+      console.log(L[currentLocale].cli.pub.info.publish_cancelled());
+      return;
+    }
+
+    await fs.rename(finalSourcePath, finalDestinationPath);
+    await updateFrontmatterWithPublishedDate(finalDestinationPath);
+
+    console.log(
+      L[currentLocale].cli.pub.info.success_article_published({
+        source: selectedDraftRelativePath,
+        destination: finalDestinationPath,
+      })
+    );
+
+    const sourceDir = path.dirname(finalSourcePath);
+    if (sourceDir !== draftsDir) {
+      try {
+        const filesInSourceDir = await fs.readdir(sourceDir);
+        if (filesInSourceDir.length === 0) {
+          await fs.rmdir(sourceDir);
+          console.log(
+            L[currentLocale].cli.pub.info.info_empty_dir_removed({ dirPath: sourceDir })
+          );
+        }
+      } catch (err) {
+        // Ignore if directory removal fails (e.g. not empty, permissions)
+        console.warn(`Could not remove directory ${sourceDir}:`, err);
+      }
+    }
+  } catch (error) {
+    if (error instanceof ExitPromptError) {
+      console.log(L[currentLocale].cli.info.cancelled_by_user());
+      process.exit(0);
+    } else {
+      console.error(
+        L[currentLocale].cli.pub.error.publish_article({ message: (error as Error).message })
+      );
+      process.exit(1);
+    }
+  }
+}
+
+async function main() {
+  try {
+    await run();
+  } catch (error) {
+    if (!(error instanceof ExitPromptError)) {
+      console.error(
+        L[currentLocale].cli.error.unexpected({
+          message: (error as Error).message || String(error),
+        }),
+        error
+      );
+    }
+    process.exit(1);
+  }
+}
+
+process.on('SIGINT', async () => {
+  console.log(L[currentLocale].cli.info.cancelled_by_user());
+  process.exit(0);
+});
+
+main();
scripts/utils.mjs
@@ -1,108 +0,0 @@
-import dayjs from 'dayjs';
-import timezone from 'dayjs/plugin/timezone.js';
-import utc from 'dayjs/plugin/utc.js';
-import { promises as fs } from 'fs';
-import path from 'path';
-
-dayjs.extend(utc);
-dayjs.extend(timezone);
-
-/**
- * 将不同格式的时区字符串转换为标准时区偏移量
- * @param {string} timezone - 时区字符串,支持以下格式:
- *   1. 标准时区名称 (如 "Asia/Shanghai")
- *   2. 仅小时偏移 (如 "+8", "-10", "+08")
- *   3. 完整偏移 (如 "+08:00", "-7:30")
- * @returns {string} 标准时区偏移量 (如 "+08:00", "-07:30")
- */
-export function parseTimezoneOffset(timezone) {
-  // 尝试匹配完整的时区偏移格式 (+08:00)
-  const fullOffsetMatch = timezone.match(/^([+-])(\d{1,2}):(\d{2})$/);
-  if (fullOffsetMatch) {
-    const [, sign, hours, minutes] = fullOffsetMatch;
-    const paddedHours = hours.padStart(2, '0');
-    return `${sign}${paddedHours}:${minutes}`;
-  }
-
-  // 尝试匹配仅小时的偏移格式 (+8, +08)
-  const hourOffsetMatch = timezone.match(/^([+-])(\d{1,2})$/);
-  if (hourOffsetMatch) {
-    const [, sign, hours] = hourOffsetMatch;
-    const paddedHours = hours.padStart(2, '0');
-    return `${sign}${paddedHours}:00`;
-  }
-
-  // 处理标准时区名称 (如 "Asia/Shanghai")
-  try {
-    // 使用 dayjs 获取指定时区的偏移量
-    const date = dayjs().tz(timezone);
-    if (!date.isValid()) {
-      throw new Error('Invalid timezone');
-    }
-
-    const offset = date.utcOffset();
-    const hours = Math.floor(Math.abs(offset) / 60);
-    const minutes = Math.abs(offset) % 60;
-    const sign = offset >= 0 ? '+' : '-';
-    return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
-  } catch {
-    throw new Error(`Invalid timezone format: ${timezone}`);
-  }
-}
-
-/**
- * 将标题转换为合法的文件名
- * @param {string} title - 原始标题
- * @returns {string} 转换后的合法文件名,不包含特殊字符
- */
-export function sanitizeTitle(title) {
-  return title
-    .replace(/[/\\:*?"<>|]/g, '-') // 替换文件系统不允许的字符
-    .replace(/[. ]+/g, '-') // 将一个或多个点号/空格替换为单个连字符
-    .replace(/^-+|-+$/g, ''); // 移除首尾的连字符
-}
-
-/**
- * 获取文件路径
- * @param {string} contentDir - 内容目录路径
- * @param {string} title - 文件名
- * @param {boolean} isDir - 是否创建为目录形式
- * @returns {string} 完整的文件路径
- */
-export function getFilePath(contentDir, title, isDir) {
-  return isDir
-    ? path.join(contentDir, title, 'index.md')
-    : path.join(contentDir, `${title}.md`);
-}
-
-/**
- * 检查文件是否存在,同时检查单文件和目录两种形式
- * @param {string} contentDir - 内容目录路径
- * @param {string} sanitizedTitle - 已转换的合法文件名
- * @param {number} [counter] - 可选的编号,用于检查带编号的文件名
- * @returns {Promise<{exists: boolean, filePath: string|null}>} 文件存在状态和路径
- */
-export async function checkFileExists(contentDir, sanitizedTitle, counter) {
-  const title = counter ? `${sanitizedTitle}-${counter}` : sanitizedTitle;
-  const filePath = getFilePath(contentDir, title, false);
-  const dirPath = getFilePath(contentDir, title, true);
-
-  try {
-    const results = await Promise.all([
-      fs
-        .access(filePath)
-        .then(() => true)
-        .catch(() => false),
-      fs
-        .access(dirPath)
-        .then(() => true)
-        .catch(() => false),
-    ]);
-    return {
-      exists: results[0] || results[1],
-      filePath: results[0] ? filePath : results[1] ? dirPath : null,
-    };
-  } catch {
-    return { exists: false, filePath: null };
-  }
-}
scripts/utils.ts
@@ -0,0 +1,131 @@
+import { ParseFrontmatterOptions, parseFrontmatter } from '@astrojs/markdown-remark';
+import yaml, { type DumpOptions } from 'js-yaml';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+/**
+ * Converts a string into a URL-friendly slug.
+ *
+ * - Converts to lowercase.
+ * - Replaces spaces and non-alphanumeric characters (excluding hyphens) with hyphens.
+ * - Removes leading/trailing hyphens.
+ * - Collapses multiple consecutive hyphens into a single hyphen.
+ * @param text The string to slugify.
+ * @param hard A boolean indicating the type of slugification.
+ *   - `true`: Only allows alphanumeric characters and hyphens.
+ *   - `false`: Only replaces invalid path characters (e.g., `\ / : * ? " < > |`) with hyphens.
+ * @returns The slugified string.
+ */
+export function slugify(text: string, hard: boolean = false): string {
+  if (!text) {
+    return '';
+  }
+  if (hard) {
+    return text
+      .toString()
+      .toLowerCase()
+      .trim()
+      .replace(/\s+/g, '-') // Replace spaces with -
+      .replace(/[^\w-]+/g, '-') // Replace non-alphanumeric characters with -
+      .replace(/--+/g, '-') // Replace multiple - with single -
+      .replace(/^-+/, '') // Trim - from start of text
+      .replace(/-+$/, ''); // Trim - from end of text
+  } else {
+    return text
+      .toString()
+      .toLowerCase()
+      .trim()
+      .replace(/\s+/g, '-') // Replace spaces with -
+      .replace(/[\\/:*?"<>|]/g, '-') // Replace invalid path characters with -
+      .replace(/--+/g, '-') // Replace multiple - with single -
+      .replace(/^-+/, '') // Trim - from start of text
+      .replace(/-+$/, ''); // Trim - from end of text
+  }
+}
+
+/**
+ * Change the frontmatter of a given markdown string and returns the changed string.
+ *
+ * @param code The markdown string to be changed.
+ * @param changedPairs The KV pairs to be changed in the frontmatter.
+ * @param parseOption The option for parsing the frontmatter.
+ * @param dumpOption The option for dumping the frontmatter.
+ * @returns The changed markdown string.
+ */
+export function changeFrontmatter(
+  code: string,
+  changedPairs: Record<string, unknown>,
+  parseOption?: ParseFrontmatterOptions,
+  dumpOption?: DumpOptions
+): string {
+  const { frontmatter, content } = parseFrontmatter(code, parseOption);
+  const newFrontmatter = { ...frontmatter, ...changedPairs };
+  const raw = `---\n${yaml.dump(newFrontmatter, dumpOption)}---${content}`;
+  return raw;
+}
+
+/**
+ * Information about an available file name.
+ */
+export interface AvailableFileNameInfo {
+  /** The full file name including extension and counter if used (e.g., 'my-article-1.md') */
+  fileName: string;
+  /** The file name without extension, including counter if used (e.g., 'my-article-1') */
+  nameWithoutExt: string;
+  /** The counter used, or null if the original name was available. */
+  counter: number | null;
+}
+
+/**
+ * Finds an available filename in a directory by appending a counter if the base name already exists.
+ * e.g., if 'file.md' exists, it will try 'file-1.md', 'file-2.md', and so on.
+ * The baseName is the initial slug, and the counter is appended to this slug.
+ * @param directory The directory to check for the file.
+ * @param baseSlug The initial slug of the file (without extension or counter, e.g., 'my-article').
+ * @param extension The file extension (e.g., '.md').
+ * @returns A promise that resolves to an object containing the available full file name, name without extension, and the counter used.
+ */
+export async function findAvailableFileName(
+  directory: string,
+  baseSlug: string,
+  extension: string
+): Promise<AvailableFileNameInfo> {
+  let counter = 0;
+  let currentNameWithoutExt = baseSlug;
+  let currentFileName = baseSlug + extension;
+  let filePath = path.join(directory, currentFileName);
+  let usedCounter: number | null = null;
+
+  // Check if the initial name (without counter) is available
+  try {
+    await fs.access(filePath);
+    // File exists, start counter from 1
+    counter = 1;
+    usedCounter = counter;
+    currentNameWithoutExt = `${baseSlug}-${counter}`;
+    currentFileName = currentNameWithoutExt + extension;
+    filePath = path.join(directory, currentFileName);
+  } catch {
+    // Initial file does not exist, return it
+    return { fileName: currentFileName, nameWithoutExt: currentNameWithoutExt, counter: null };
+  }
+
+  // If initial name was taken, find next available
+  while (true) {
+    try {
+      await fs.access(filePath);
+      counter++;
+      usedCounter = counter;
+      currentNameWithoutExt = `${baseSlug}-${counter}`;
+      currentFileName = currentNameWithoutExt + extension;
+      filePath = path.join(directory, currentFileName);
+    } catch {
+      // File does not exist, this name is available
+      return {
+        fileName: currentFileName,
+        nameWithoutExt: currentNameWithoutExt,
+        counter: usedCounter,
+      };
+    }
+  }
+}
.typesafe-i18n.json
@@ -0,0 +1,7 @@
+{
+  "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json",
+  "baseLocale": "en",
+  "adapter": "node",
+  "esmImports": true,
+  "outputPath": "./i18n/"
+}
package.json
@@ -7,10 +7,11 @@
     "build": "astro build",
     "preview": "astro preview",
     "astro": "astro",
+    "typesafe-i18n": "typesafe-i18n",
     "lint": "eslint ./src --fix && stylelint ./src/**/*.{scss,css,astro} --fix && astro check",
     "format": "prettier --write ./src",
-    "new": "node scripts/new.mjs",
-    "pub": "node scripts/pub.mjs"
+    "new": "tsx scripts/new.ts",
+    "pub": "tsx scripts/pub.ts"
   },
   "dependencies": {
     "@astrojs/markdown-remark": "^6.3.1",
@@ -69,20 +70,28 @@
   "devDependencies": {
     "@astrojs/check": "^0.9.4",
     "@astrojs/ts-plugin": "^1.10.4",
+    "@commander-js/extra-typings": "^14.0.0",
     "@eslint/js": "^9.27.0",
     "@iconify/types": "^2.0.0",
+    "@inquirer/core": "^10.1.11",
+    "@inquirer/prompts": "^7.5.1",
     "@trivago/prettier-plugin-sort-imports": "^5.2.2",
     "@types/hast": "^3.0.4",
+    "@types/js-yaml": "^4.0.9",
     "@types/markdown-it": "^14.1.2",
     "@types/mdast": "^4.0.4",
+    "@types/node": "^22.15.18",
     "@types/sanitize-html": "^2.16.0",
     "@types/unist": "^3.0.3",
     "@typescript-eslint/parser": "^8.32.1",
     "astro-eslint-parser": "^1.2.2",
-    "commander": "^13.1.0",
+    "commander": "^14.0.0",
     "eslint": "^9.27.0",
     "eslint-plugin-astro": "^1.3.1",
     "globals": "^15.15.0",
+    "inquirer": "^12.6.1",
+    "js-yaml": "^4.1.0",
+    "os-locale": "^6.0.2",
     "postcss-html": "^1.8.0",
     "prettier": "^3.5.3",
     "prettier-plugin-astro": "^0.14.1",
@@ -90,6 +99,8 @@
     "prettier-plugin-tailwindcss": "^0.6.11",
     "stylelint": "^16.19.1",
     "stylelint-config-html": "^1.1.0",
+    "tsx": "^4.19.4",
+    "typesafe-i18n": "^5.26.2",
     "typescript-eslint": "^8.32.1"
   },
   "pnpm": {