Commit 3bf9d93

HPCesia <me@hpcesia.com>
2025-02-15 15:43:10
refactor: new and pub scripts
1 parent 5ddd162
scripts/locale/en.js
@@ -0,0 +1,43 @@
+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
@@ -0,0 +1,30 @@
+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
@@ -0,0 +1,42 @@
+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/new.mjs
@@ -1,242 +1,138 @@
-#!/usr/bin/env node
-import fs from 'node:fs/promises';
-import path from 'node:path';
-import readline from 'node:readline';
-
-function parseArgs(args) {
-  const result = {
-    type: null,
-    name: null,
-    isDir: false,
-    timezone: null,
-  };
-
-  for (let i = 0; i < args.length; i++) {
-    const arg = args[i];
-    if (arg === '--dir') {
-      result.isDir = true;
-      continue;
-    }
-    if (arg.startsWith('--timezone=')) {
-      result.timezone = arg.split('=')[1];
-      continue;
-    }
-    if (!result.type) {
-      result.type = arg;
-    } else if (!result.name) {
-      result.name = arg;
-    }
-  }
-  return result;
-}
-
-const { type, name, isDir, timezone } = parseArgs(process.argv.slice(2));
-
-if (!type || !name) {
-  console.error('Usage: pnpm new [post|draft] [name] [--dir] [--timezone=offset|locale]');
-  console.error('Examples:');
-  console.error('  pnpm new post "My Post" --timezone=+08:00');
-  console.error('  pnpm new post "My Post" --timezone=Asia/Shanghai');
-  process.exit(1);
-}
-
-if (type !== 'post' && type !== 'draft') {
-  console.error('Type must be either "post" or "draft"');
-  process.exit(1);
-}
-
-const rl = readline.createInterface({
+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,
 });
 
-const question = (query) => new Promise((resolve) => rl.question(query, resolve));
-
-function sanitizeFilename(filename) {
-  const basename = filename.replace(/\.md$/, '');
-
-  return basename
-    .replace(/[<>:"/\\|?*.,\s]+/g, '-')
-    .replace(/^-+|-+$/g, '')
-    .replace(/-{2,}/g, '-');
-}
-
-async function checkFileExists(filePath) {
-  try {
-    await fs.access(filePath);
-    return true;
-  } catch {
-    return false;
-  }
-}
-
-async function findAvailableFilename(basePath, baseName) {
-  let counter = 1;
-  let filePath = basePath;
+// 处理文件名冲突
+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'));
 
-  while (await checkFileExists(filePath)) {
-    if (isDir) {
-      const dirName = `${baseName}-${counter}`;
-      filePath = path.join(path.dirname(path.dirname(basePath)), dirName, 'index.md');
-    } else {
-      filePath = path.join(path.dirname(basePath), `${baseName}-${counter}.md`);
-    }
-    counter++;
-  }
-
-  return filePath;
-}
-
-async function handleExistingFile(targetPath) {
-  console.log('\nFile already exists:', targetPath);
-  console.log('1. Overwrite');
-  console.log('2. Use a different name (auto-numbered)');
-  console.log('3. Cancel\n');
-
-  const answer = await question('Choose an option: ');
+  const answer = await rl.question(t('inputOption', { countStart: 1, countEnd: 3 }));
 
   switch (answer.trim()) {
-    case '1':
-      return targetPath;
+    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 await findAvailableFilename(
-        targetPath,
-        path.basename(isDir ? path.dirname(targetPath) : targetPath, '.md')
-      );
+      return getFilePath(contentDir, sanitizedTitle, isDir);
     case '3':
       rl.close();
-      console.log('\nOperation cancelled');
       process.exit(0);
       break;
     default:
-      console.log('\nInvalid option, operation cancelled');
+      console.log(t('invalidOption'));
       rl.close();
       process.exit(1);
   }
 }
 
-function validateTimezone(timezone) {
-  if (!timezone) return null;
+// 格式化时间
+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');
 
-  // 验证时区偏移格式 (+/-HH:mm)
-  if (/^[+-]\d{2}:\d{2}$/.test(timezone)) {
-    const [hours, minutes] = timezone.slice(1).split(':').map(Number);
-    if (hours <= 23 && minutes <= 59) {
-      return { type: 'offset', value: timezone };
-    }
+  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);
   }
 
-  // 验证时区名称格式
-  try {
-    Intl.DateTimeFormat('en-US', { timeZone: timezone });
-    return { type: 'timezone', value: timezone };
-  } catch {
-    console.error(`Invalid timezone: ${timezone}`);
-    console.error('Example formats:');
-    console.error('  Offset: +08:00, -05:30');
-    console.error('  Name: Asia/Shanghai, America/New_York');
-    process.exit(1);
+  // 如果需要创建目录,确保目录存在
+  if (options.dir) {
+    await fs.mkdir(path.dirname(filepath), { recursive: true });
   }
-}
 
-async function main() {
-  try {
-    const templatePath = path.resolve('scaffolds', `${type}.md`);
-    const template = await fs.readFile(templatePath, 'utf-8');
-
-    let targetDate = new Date();
-    let offsetStr;
-
-    if (timezone) {
-      const validTimezone = validateTimezone(timezone);
-      if (validTimezone.type === 'offset') {
-        // 如果是时区偏移,需要根据偏移调整时间
-        // 解析偏移
-        const [, sign, hours, minutes] = validTimezone.value
-          .match(/([+-])(\d{2}):(\d{2})/)
-          .map((v, i) => (i > 1 ? parseInt(v) : v));
-
-        // 将本地时间转换为 UTC
-        const utcTime = targetDate.getTime() + targetDate.getTimezoneOffset() * 60000;
-
-        // 从 UTC 调整到目标时区
-        const targetTime = utcTime + (sign === '+' ? 1 : -1) * (hours * 60 + minutes) * 60000;
-        targetDate = new Date(targetTime);
-        offsetStr = validTimezone.value;
-      } else {
-        // 如果是时区名称,使用该时区的时间
-        const utcDate = new Date(targetDate.getTime() - targetDate.getTimezoneOffset() * 60000);
-        const formatter = new Intl.DateTimeFormat('en-US', {
-          timeZone: validTimezone.value,
-          year: 'numeric',
-          month: '2-digit',
-          day: '2-digit',
-          hour: '2-digit',
-          minute: '2-digit',
-          second: '2-digit',
-          hour12: false,
-          timeZoneName: 'short',
-        });
-
-        const localeDateParts = formatter.formatToParts(utcDate);
-        const timezonePart = localeDateParts.find((part) => part.type === 'timeZoneName').value;
-        const match = timezonePart.match(/GMT([+-]\d{1,2})(?::?(\d{2})?)?/);
-        if (match) {
-          const [, hours, minutes = '00'] = match;
-          offsetStr = `${hours.padStart(2, '0')}:${minutes}`;
-          if (!offsetStr.startsWith('+') && !offsetStr.startsWith('-')) {
-            offsetStr = '+' + offsetStr;
-          }
-        }
-
-        targetDate = new Date(
-          targetDate.toLocaleString('en-US', { timeZone: validTimezone.value })
-        );
+  // 处理模板
+  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 {
-      // 使用系统默认时区
-      const offset = -targetDate.getTimezoneOffset();
-      const offsetHours = Math.floor(Math.abs(offset) / 60)
-        .toString()
-        .padStart(2, '0');
-      const offsetMinutes = (Math.abs(offset) % 60).toString().padStart(2, '0');
-      offsetStr = `${offset >= 0 ? '+' : '-'}${offsetHours}:${offsetMinutes}`;
+      formattedDate = formatDateTime();
     }
+    content = content.replace('{{ date }}', formattedDate);
+  }
 
-    const now = targetDate.toLocaleString('sv').replace(' ', 'T') + offsetStr;
-    const variables = {
-      title: name,
-      date: now,
-    };
-
-    const content = template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
-      return variables[key] || '';
-    });
-
-    const targetDir = path.resolve('src', 'content', `${type}s`);
-    const sanitizedName = sanitizeFilename(name);
-    let targetPath;
+  // 写入文件
+  await fs.writeFile(filepath, content);
+  console.log(t(`created.${type}`, { path: filepath }));
+  rl.close();
+}
 
-    if (isDir) {
-      targetPath = path.join(targetDir, sanitizedName, 'index.md');
-    } else {
-      targetPath = path.join(targetDir, `${sanitizedName}.md`);
-    }
+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();
+}
 
-    if (await checkFileExists(targetPath)) {
-      targetPath = await handleExistingFile(targetPath);
-    }
+const options = program.opts();
+const [type, title] = program.args;
 
-    await fs.mkdir(path.dirname(targetPath), { recursive: true });
+if (!['post', 'draft'].includes(type)) {
+  console.error(t('cli.typeError'));
+  program.help();
+  process.exit(1);
+}
 
-    await fs.writeFile(targetPath, content, 'utf-8');
-    console.log(`\nSuccessfully created ${type}: ${targetPath}`);
-    rl.close();
-  } catch (error) {
-    console.error('Error:', error.message);
-    rl.close();
-    process.exit(1);
-  }
+if (type === 'draft' && options.timezone) {
+  console.log(t('cli.timezoneWarning'));
 }
 
-main();
+// 执行创建
+createArticle(type, title, options).catch((error) => {
+  console.error(t('cli.error'), error.message);
+  process.exit(1);
+});
scripts/pub.mjs
@@ -1,248 +1,191 @@
-#!/usr/bin/env node
-import fs from 'node:fs/promises';
-import path from 'node:path';
-import readline from 'node:readline';
-
-function parseArgs(args) {
-  const result = {
-    name: null,
-    timezone: null,
-  };
-
-  for (let i = 0; i < args.length; i++) {
-    const arg = args[i];
-    if (arg.startsWith('--timezone=')) {
-      result.timezone = arg.split('=')[1];
-      continue;
-    }
-    if (!result.name) {
-      result.name = arg;
-    }
-  }
-  return result;
-}
-
-function validateTimezone(timezone) {
-  if (!timezone) return null;
-
-  // 验证时区偏移格式 (+/-HH:mm)
-  if (/^[+-]\d{2}:\d{2}$/.test(timezone)) {
-    const [hours, minutes] = timezone.slice(1).split(':').map(Number);
-    if (hours <= 23 && minutes <= 59) {
-      return { type: 'offset', value: timezone };
-    }
-  }
+import { t } from './locale/index.js';
+import { checkFileExists, getFilePath } from './utils.mjs';
+import { Command } from 'commander';
+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';
+
+// 列出文件并分页显示
+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}`);
+  });
 
-  // 验证时区名称格式
-  try {
-    Intl.DateTimeFormat('en-US', { timeZone: timezone });
-    return { type: 'timezone', value: timezone };
-  } catch {
-    console.error(`Invalid timezone: ${timezone}`);
-    console.error('Example formats:');
-    console.error('  Offset: +08:00, -05:30');
-    console.error('  Name: Asia/Shanghai, America/New_York');
-    process.exit(1);
+  if (totalPages > 1) {
+    console.log(t('paginationTip'));
   }
-}
-
-const { name, timezone } = parseArgs(process.argv.slice(2));
 
-if (!name) {
-  console.error('Usage: pnpm publish [name] [--timezone=offset|locale]');
-  console.error('Examples:');
-  console.error('  pnpm publish "My Post" --timezone=+08:00');
-  console.error('  pnpm publish "My Post" --timezone=Asia/Shanghai');
-  process.exit(1);
-}
-
-function sanitizeFilename(filename) {
-  const basename = filename.replace(/\.md$/, '');
-  return basename
-    .replace(/[<>:"/\\|?*.,\s]+/g, '-')
-    .replace(/^-+|-+$/g, '')
-    .replace(/-{2,}/g, '-');
+  return totalPages;
 }
 
-async function checkFileExists(filePath) {
-  try {
-    await fs.access(filePath);
-    return true;
-  } catch {
-    return false;
+// 处理文件冲突
+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 findDraftPath(draftDir, sanitizedName) {
-  // 检查常规文件
-  const regularPath = path.join(draftDir, `${sanitizedName}.md`);
-  const dirPath = path.join(draftDir, sanitizedName, 'index.md');
-
-  // 记录找到的所有匹配路径
-  const foundPaths = [];
-
-  if (await checkFileExists(regularPath)) {
-    foundPaths.push(regularPath);
-  }
-  if (await checkFileExists(dirPath)) {
-    foundPaths.push(dirPath);
-  }
-
-  if (foundPaths.length === 0) {
-    return null;
-  }
-
-  if (foundPaths.length === 1) {
-    return foundPaths[0];
-  }
+// 获取所有草稿文件
+async function getAllDrafts() {
+  const drafts = [];
 
-  console.log('\nMultiple drafts found with the same name:');
-  foundPaths.forEach((p, i) => {
-    console.log(`${i + 1}. ${p}`);
-  });
-
-  const rl = readline.createInterface({
-    input: process.stdin,
-    output: process.stdout,
-  });
-
-  const answer = await new Promise((resolve) => {
-    rl.question('\nChoose which draft to publish (enter number): ', resolve);
-  });
-  rl.close();
+  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));
+      }
+    }
 
-  const choice = parseInt(answer.trim()) - 1;
-  if (choice >= 0 && choice < foundPaths.length) {
-    return foundPaths[choice];
+    return drafts;
+  } catch (error) {
+    console.error(t('readDraftsError'), error);
+    process.exit(1);
   }
-
-  console.error('\nInvalid choice');
-  process.exit(1);
 }
 
-async function copyDirectory(src, dest) {
-  await fs.mkdir(dest, { recursive: true });
-  const entries = await fs.readdir(src, { withFileTypes: true });
-
-  for (const entry of entries) {
-    const srcPath = path.join(src, entry.name);
-    const destPath = path.join(dest, entry.name);
+// 发布文章
+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);
+        await fs.copyFile(srcFile, destFile);
+      }
 
-    if (entry.isDirectory()) {
-      await copyDirectory(srcPath, destPath);
+      // 删除源目录
+      await fs.rm(draftDir, { recursive: true });
     } else {
-      await fs.copyFile(srcPath, destPath);
+      // 移动单个文件
+      await fs.copyFile(fullDraftPath, destPath);
+      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() {
-  try {
-    const sanitizedName = sanitizeFilename(name);
-    const draftDir = path.resolve('src', 'content', 'drafts');
-    const postsDir = path.resolve('src', 'content', 'posts');
+  // 获取所有草稿
+  const drafts = await getAllDrafts();
 
-    const draftPath = await findDraftPath(draftDir, sanitizedName);
-    if (!draftPath) {
-      console.error(`\nError: Draft not found: ${sanitizedName}`);
-      process.exit(1);
-    }
+  if (drafts.length === 0) {
+    console.log(t('noDrafts'));
+    rl.close();
+    return;
+  }
 
-    const isDirDraft = path.basename(draftPath) === 'index.md';
-    let targetPath;
+  let currentPage = 1;
+  const totalPages = await listDraftsWithPagination(drafts, currentPage);
 
-    if (isDirDraft) {
-      targetPath = path.join(postsDir, path.basename(path.dirname(draftPath)), 'index.md');
-    } else {
-      targetPath = path.join(postsDir, `${sanitizedName}.md`);
+  while (true) {
+    const answer = await rl.question(t('selectDraft'));
+
+    if (answer.toLowerCase() === 'n' && currentPage < totalPages) {
+      currentPage++;
+      await listDraftsWithPagination(drafts, currentPage);
+      continue;
     }
 
-    const content = await fs.readFile(draftPath, 'utf-8');
-
-    let targetDate = new Date();
-    let offsetStr;
-
-    if (timezone) {
-      const validTimezone = validateTimezone(timezone);
-      if (validTimezone.type === 'offset') {
-        // 如果是时区偏移,需要根据偏移调整时间
-        // 解析偏移
-        const [, sign, hours, minutes] = validTimezone.value
-          .match(/([+-])(\d{2}):(\d{2})/)
-          .map((v, i) => (i > 1 ? parseInt(v) : v));
-
-        // 将本地时间转换为 UTC
-        const utcTime = targetDate.getTime() + targetDate.getTimezoneOffset() * 60000;
-
-        // 从 UTC 调整到目标时区
-        const targetTime = utcTime + (sign === '+' ? 1 : -1) * (hours * 60 + minutes) * 60000;
-        targetDate = new Date(targetTime);
-        offsetStr = validTimezone.value;
-      } else {
-        // 如果是时区名称,使用该时区的时间
-        const utcDate = new Date(targetDate.getTime() - targetDate.getTimezoneOffset() * 60000);
-        const formatter = new Intl.DateTimeFormat('en-US', {
-          timeZone: validTimezone.value,
-          year: 'numeric',
-          month: '2-digit',
-          day: '2-digit',
-          hour: '2-digit',
-          minute: '2-digit',
-          second: '2-digit',
-          hour12: false,
-          timeZoneName: 'short',
-        });
-
-        const localeDateParts = formatter.formatToParts(utcDate);
-        const timezonePart = localeDateParts.find((part) => part.type === 'timeZoneName').value;
-        const match = timezonePart.match(/GMT([+-]\d{1,2})(?::?(\d{2})?)?/);
-        if (match) {
-          const [, hours, minutes = '00'] = match;
-          offsetStr = `${hours.padStart(2, '0')}:${minutes}`;
-          if (!offsetStr.startsWith('+') && !offsetStr.startsWith('-')) {
-            offsetStr = '+' + offsetStr;
-          }
-        }
-
-        targetDate = new Date(
-          targetDate.toLocaleString('en-US', { timeZone: validTimezone.value })
-        );
-      }
-    } else {
-      // 使用系统默认时区
-      const offset = -targetDate.getTimezoneOffset();
-      const offsetHours = Math.floor(Math.abs(offset) / 60)
-        .toString()
-        .padStart(2, '0');
-      const offsetMinutes = (Math.abs(offset) % 60).toString().padStart(2, '0');
-      offsetStr = `${offset >= 0 ? '+' : '-'}${offsetHours}:${offsetMinutes}`;
+    if (answer.toLowerCase() === 'p' && currentPage > 1) {
+      currentPage--;
+      await listDraftsWithPagination(drafts, currentPage);
+      continue;
     }
 
-    const now = targetDate.toLocaleString('sv').replace(' ', 'T') + offsetStr;
-    const updatedContent = content.replace(/^(---[\s\S]*?)(---)/, (match, front, end) => {
-      if (!front.includes('published:')) {
-        return `${front}published: ${now}\n${end}`;
-      }
-      return match;
-    });
-
-    if (isDirDraft) {
-      const srcDir = path.dirname(draftPath);
-      const destDir = path.dirname(targetPath);
-      await copyDirectory(srcDir, destDir);
-      await fs.writeFile(targetPath, updatedContent, 'utf-8');
-      await fs.rm(srcDir, { recursive: true });
-    } else {
-      await fs.mkdir(postsDir, { recursive: true });
-      await fs.writeFile(targetPath, updatedContent, 'utf-8');
-      await fs.unlink(draftPath);
+    const selection = parseInt(answer);
+    if (isNaN(selection) || selection < 1 || selection > drafts.length) {
+      console.log(t('invalidSelection'));
+      continue;
     }
 
-    console.log(`\nSuccessfully published: ${targetPath}`);
-  } catch (error) {
-    console.error('Error:', error.message);
-    process.exit(1);
+    const selectedDraft = drafts[selection - 1];
+    await publishDraft(selectedDraft);
+    break;
   }
+
+  rl.close();
 }
 
-main();
+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/utils.mjs
@@ -0,0 +1,108 @@
+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
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/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 };
+  }
+}
package.json
@@ -67,6 +67,7 @@
     "@types/unist": "^3.0.3",
     "@typescript-eslint/parser": "^8.24.0",
     "astro-eslint-parser": "^1.2.1",
+    "commander": "^13.1.0",
     "eslint": "^9.20.1",
     "eslint-plugin-astro": "^1.3.1",
     "globals": "^15.15.0",