mirror of
https://codeberg.org/HPCesia/AstralHalo.git
synced 2025-04-08 17:34:27 +08:00
refactor: new and pub scripts
This commit is contained in:
parent
5ddd1621e8
commit
3bf9d9371b
@ -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
43
scripts/locale/en.js
Normal 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
30
scripts/locale/index.js
Normal 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
42
scripts/locale/zh-cn.js
Normal 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: '发布草稿到文章',
|
||||
},
|
||||
};
|
316
scripts/new.mjs
316
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';
|
||||
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);
|
||||
});
|
||||
|
391
scripts/pub.mjs
391
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';
|
||||
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
108
scripts/utils.mjs
Normal 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 };
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user