refactor: new and pub scripts

This commit is contained in:
HPCesia 2025-02-15 23:43:10 +08:00
parent 5ddd1621e8
commit 3bf9d9371b
7 changed files with 497 additions and 434 deletions

View File

@ -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",

43
scripts/locale/en.js Normal file
View File

@ -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',
},
};

30
scripts/locale/index.js Normal file
View File

@ -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;
}

42
scripts/locale/zh-cn.js Normal file
View File

@ -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: '发布草稿到文章',
},
};

View File

@ -1,242 +1,138 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';
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';
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({
const program = new Command();
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const question = (query) => new Promise((resolve) => rl.question(query, resolve));
// 处理文件名冲突
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'));
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;
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;
// 验证时区偏移格式 (+/-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 };
}
}
// 验证时区名称格式
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);
}
// 格式化时间
function formatDateTime(offset) {
const now = offset ? dayjs().utcOffset(offset) : dayjs();
return now.format('YYYY-MM-DDTHH:mm:ssZ');
}
async function main() {
try {
const templatePath = path.resolve('scaffolds', `${type}.md`);
const template = await fs.readFile(templatePath, 'utf-8');
// 创建文章
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');
let targetDate = new Date();
let offsetStr;
await fs.mkdir(contentDir, { recursive: true });
const { exists } = await checkFileExists(contentDir, sanitizedTitle);
let filepath = getFilePath(contentDir, sanitizedTitle, options.dir);
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));
// 如果文件已存在,处理冲突
if (exists) {
filepath = await handleFileConflict(contentDir, sanitizedTitle, options.dir);
}
// 将本地时间转换为 UTC
const utcTime = targetDate.getTime() + targetDate.getTimezoneOffset() * 60000;
// 如果需要创建目录,确保目录存在
if (options.dir) {
await fs.mkdir(path.dirname(filepath), { recursive: true });
}
// 从 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',
});
// 处理模板
let content = template.replace('{{ title }}', title);
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 })
);
// 对于文章类型,添加发布时间
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();
}
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;
if (isDir) {
targetPath = path.join(targetDir, sanitizedName, 'index.md');
} else {
targetPath = path.join(targetDir, `${sanitizedName}.md`);
}
if (await checkFileExists(targetPath)) {
targetPath = await handleExistingFile(targetPath);
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
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);
content = content.replace('{{ date }}', formattedDate);
}
// 写入文件
await fs.writeFile(filepath, content);
console.log(t(`created.${type}`, { path: filepath }));
rl.close();
}
main();
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);
});

View File

@ -1,248 +1,191 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';
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';
function parseArgs(args) {
const result = {
name: null,
timezone: null,
};
const program = new Command();
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
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;
}
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}`);
});
if (totalPages > 1) {
console.log(t('paginationTip'));
}
return result;
return totalPages;
}
function validateTimezone(timezone) {
if (!timezone) return null;
// 处理文件冲突
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'));
// 验证时区偏移格式 (+/-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 };
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 {
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');
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);
}
}
const { name, timezone } = parseArgs(process.argv.slice(2));
// 发布文章
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');
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, '-');
}
async function checkFileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
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];
}
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();
const choice = parseInt(answer.trim()) - 1;
if (choice >= 0 && choice < foundPaths.length) {
return foundPaths[choice];
}
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);
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
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);
}
// 删除源目录
await fs.rm(draftDir, { recursive: true });
} else {
// 移动单个文件
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);
}
const isDirDraft = path.basename(draftPath) === 'index.md';
let targetPath;
if (isDirDraft) {
targetPath = path.join(postsDir, path.basename(path.dirname(draftPath)), 'index.md');
} else {
targetPath = path.join(postsDir, `${sanitizedName}.md`);
}
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}`;
}
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);
}
console.log(`\nSuccessfully published: ${targetPath}`);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
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();
}
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);
});

108
scripts/utils.mjs Normal file
View File

@ -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 };
}
}