Commit 3bf9d93
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",