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