create-vuepress-theme-plume
Version:
The cli for create vuepress-theme-plume's project
729 lines (728 loc) • 22.5 kB
JavaScript
import { createRequire } from "node:module";
import cac from "cac";
import path from "node:path";
import process from "node:process";
import { cancel, confirm, group, intro, outro, select, spinner, text } from "@clack/prompts";
import { attemptAsync, kebabCase, sleep } from "@pengzhanbo/utils";
import spawn from "nano-spawn";
import colors from "picocolors";
import fs from "node:fs";
import _sortPackageJson from "sort-package-json";
import { fileURLToPath } from "node:url";
import fs$1 from "node:fs/promises";
import handlebars from "handlebars";
import osLocale from "os-locale";
//#region package.json
var version = "1.0.0-rc.204";
//#endregion
//#region src/constants.ts
/**
* Language options for VuePress configuration
*
* 语言选项,用于 VuePress 配置
*/
const languageOptions = [{
label: "English",
value: "en-US"
}, {
label: "简体中文",
value: "zh-CN"
}];
/**
* Bundler options for VuePress build tool
*
* 构建器选项,用于 VuePress 构建工具
*/
const bundlerOptions = [{
label: "Vite",
value: "vite"
}, {
label: "Webpack",
value: "webpack"
}];
/**
* Deployment options for hosting platforms
*
* 部署选项,用于托管平台
*/
const deployOptions = [
{
label: "Custom",
value: "custom"
},
{
label: "GitHub Pages",
value: "github"
},
{
label: "Vercel",
value: "vercel"
},
{
label: "Netlify",
value: "netlify"
}
];
//#endregion
//#region src/utils/fs.ts
/**
* Read all files from a directory recursively
*
* 递归读取目录下的所有文件
*
* @param root - Root directory path to read from / 要读取的根目录路径
* @returns Array of file objects / 文件对象数组
*/
async function readFiles(root) {
const filepaths = await fs$1.readdir(root, { recursive: true });
const files = [];
for (const file of filepaths) {
const filepath = path.join(root, file);
if ((await fs$1.stat(filepath)).isFile()) files.push({
filepath: file,
content: await fs$1.readFile(filepath, "utf-8")
});
}
return files;
}
/**
* Write files to target directory
*
* 将文件写入目标目录
*
* @param files - Array of file objects to write / 要写入的文件对象数组
* @param target - Target directory path / 目标目录路径
* @param rewrite - Optional function to rewrite file paths / 可选的文件路径重写函数
*/
async function writeFiles(files, target, rewrite) {
for (const { filepath, content } of files) {
let root = path.join(target, filepath).replace(/\.handlebars$/, "");
if (rewrite) root = rewrite(root);
await fs$1.mkdir(path.dirname(root), { recursive: true });
await fs$1.writeFile(root, content);
}
}
/**
* Read and parse JSON file
*
* 读取并解析 JSON 文件
*
* @param filepath - Path to JSON file / JSON 文件路径
* @returns Parsed JSON object or null if parsing fails / 解析后的 JSON 对象,解析失败返回 null
*/
async function readJsonFile(filepath) {
try {
const content = await fs$1.readFile(filepath, "utf-8");
return JSON.parse(content);
} catch {
return null;
}
}
//#endregion
//#region src/utils/getPackageManager.ts
/**
* Detect the current package manager from environment variables.
*
* 从环境变量检测当前使用的包管理器。
*
* @returns The detected package manager name / 检测到的包管理器名称
* @example
* // When using pnpm
* const pm = getPackageManager() // returns 'pnpm'
*
* // When using npm
* const pm = getPackageManager() // returns 'npm'
*/
function getPackageManager() {
return (process.env?.npm_config_user_agent || "npm").split("/")[0];
}
//#endregion
//#region src/utils/index.ts
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Resolve path relative to the project root
*
* 相对于项目根目录解析路径
*
* @param args - Path segments to resolve / 要解析的路径段
* @returns Resolved absolute path / 解析后的绝对路径
*/
const resolve = (...args) => path.resolve(__dirname, "../", ...args);
/**
* Get template directory path
*
* 获取模板目录路径
*
* @param dir - Subdirectory name within templates / templates 中的子目录名称
* @returns Resolved template directory path / 解析后的模板目录路径
*/
const getTemplate = (dir) => resolve("templates", dir);
//#endregion
//#region src/packageJson.ts
/**
* Sort package.json fields in a consistent order.
*
* 按一致顺序排序 package.json 字段。
*
* @param json - Package.json object to sort / 要排序的 package.json 对象
* @returns Sorted package.json object / 排序后的 package.json 对象
*/
function sortPackageJson(json) {
return _sortPackageJson(json, { sortOrder: [
"name",
"type",
"version",
"private",
"description",
"packageManager",
"author",
"license",
"scripts",
"devDependencies",
"dependencies",
"pnpm"
] });
}
/**
* Create package.json file for VuePress project
*
* 为 VuePress 项目创建 package.json 文件
*
* @param mode - Operation mode (init or create) / 操作模式(初始化或创建)
* @param pkg - Existing package.json data / 现有的 package.json 数据
* @param data - Resolved configuration data / 解析后的配置数据
* @param data.packageManager - Package manager to use / 要使用的包管理器
* @param data.siteName - Site name / 站点名称
* @param data.siteDescription - Site description / 站点描述
* @param data.docsDir - Documentation directory path / 文档目录路径
* @param data.bundler - Bundler to use / 要使用的打包器
* @param data.injectNpmScripts - Whether to inject npm scripts / 是否注入 npm 脚本
*
* @returns File object with package.json content / 包含 package.json 内容的文件对象
*/
async function createPackageJson(mode, pkg, { packageManager, docsDir, siteName, siteDescription, bundler, injectNpmScripts }) {
if (mode === 1) {
pkg.name = kebabCase(siteName);
pkg.type = "module";
pkg.version = "1.0.0";
pkg.description = siteDescription;
if (packageManager !== "npm") {
let [, version] = await attemptAsync(getPackageManagerVersion, packageManager);
if (version) {
if (packageManager === "yarn" && version.startsWith("1")) version = "4.10.3";
pkg.packageManager = `${packageManager}@${version}`;
if (packageManager === "pnpm" && version.startsWith("10")) pkg.pnpm = { onlyBuiltDependencies: ["@parcel/watcher"] };
}
}
const [, userInfo] = await attemptAsync(getUserInfo);
if (userInfo) pkg.author = userInfo.username + (userInfo.email ? ` <${userInfo.email}>` : "");
pkg.license = "MIT";
pkg.engines = { node: "^20.19.0 || >=22.0.0" };
}
if (injectNpmScripts) {
pkg.scripts ??= {};
pkg.scripts = {
...pkg.scripts,
"docs:dev": `vuepress dev ${docsDir}`,
"docs:dev-clean": `vuepress dev ${docsDir} --clean-cache --clean-temp`,
"docs:build": `vuepress build ${docsDir} --clean-cache --clean-temp`,
"docs:preview": `http-server ${docsDir}/.vuepress/dist`
};
if (mode === 1) pkg.scripts["vp-update"] = `${packageManager === "npm" ? "npx" : `${packageManager} dlx`} vp-update`;
}
pkg.devDependencies ??= {};
const hasDep = (dep) => pkg.devDependencies?.[dep] || pkg.dependencies?.[dep];
const context = await readJsonFile(resolve("package.json"));
const meta = context["plume-deps"];
pkg.devDependencies[`@vuepress/bundler-${bundler}`] = `${meta.vuepress}`;
pkg.devDependencies.vuepress = `${meta.vuepress}`;
pkg.devDependencies["vuepress-theme-plume"] = `${context.version}`;
const deps = ["http-server"];
if (!hasDep("vue")) deps.push("vue");
deps.push("typescript");
for (const dep of deps) pkg.devDependencies[dep] = meta[dep];
return {
filepath: "package.json",
content: JSON.stringify(sortPackageJson(pkg), null, 2)
};
}
/**
* Get user information from git global configuration.
*
* 从 git 全局配置获取用户信息。
*
* @returns User information object with username and email / 包含用户名和邮箱的用户信息对象
* @throws Error if git command fails / 如果 git 命令失败则抛出错误
*/
async function getUserInfo() {
const { output: username } = await spawn("git", [
"config",
"--global",
"user.name"
]);
const { output: email } = await spawn("git", [
"config",
"--global",
"user.email"
]);
return {
username,
email
};
}
/**
* Get the version of a package manager.
*
* 获取包管理器的版本。
*
* @param pkg - Package manager name (npm, yarn, pnpm) / 包管理器名称
* @returns Version string of the package manager / 包管理器的版本字符串
* @throws Error if package manager command fails / 如果包管理器命令失败则抛出错误
*/
async function getPackageManagerVersion(pkg) {
const { output } = await spawn(pkg, ["--version"]);
return output;
}
//#endregion
//#region src/render.ts
handlebars.registerHelper("removeLeadingSlash", (path) => path.replace(/^\//, ""));
handlebars.registerHelper("equal", (a, b) => a === b);
/**
* Create render function with Handlebars template engine
*
* 使用 Handlebars 模板引擎创建渲染函数
*
* @param result - Resolved configuration data / 解析后的配置数据
* @returns Render function that processes Handlebars templates / 处理 Handlebars 模板的渲染函数
*/
function createRender(result) {
const data = {
...result,
name: kebabCase(result.siteName),
isEN: result.defaultLanguage === "en-US",
locales: result.defaultLanguage === "en-US" ? [{
path: "/",
lang: "en-US",
isEn: true,
prefix: "en"
}, {
path: "/zh/",
lang: "zh-CN",
isEn: false,
prefix: "zh"
}] : [{
path: "/",
lang: "zh-CN",
isEn: false,
prefix: "zh"
}, {
path: "/en/",
lang: "en-US",
isEn: true,
prefix: "en"
}]
};
return function render(source) {
try {
return handlebars.compile(source)(data);
} catch (e) {
console.error(e);
return source;
}
};
}
//#endregion
//#region src/generate.ts
/**
* Generate VuePress project files
*
* 生成 VuePress 项目文件
*
* @param mode - Operation mode (init or create) / 操作模式(初始化或创建)
* @param data - Resolved configuration data / 解析后的配置数据
* @param cwd - Current working directory / 当前工作目录
*/
async function generate(mode, data, cwd = process.cwd()) {
let userPkg = {};
if (mode === 0) {
const pkgPath = path.join(cwd, "package.json");
if (fs.existsSync(pkgPath)) userPkg = await readJsonFile(pkgPath) || {};
}
const fileList = [
await createPackageJson(mode, userPkg, data),
...await createDocsFiles(data),
...updateFileListTarget(await readFiles(getTemplate(".vuepress")), `${data.docsDir}/.vuepress`)
];
if (mode === 1) {
fileList.push(...await readFiles(getTemplate("common")));
if (data.packageManager === "pnpm") fileList.push({
filepath: "pnpm-workspace.yaml",
content: "shamefullyHoist: true\nshellEmulator: true\n"
});
if (data.packageManager === "yarn") {
const { output } = await spawn("yarn", ["--version"]);
if (output.startsWith("2")) fileList.push({
filepath: ".yarnrc.yml",
content: "nodeLinker: 'node-modules'\n"
});
}
}
if (data.git) {
const gitFiles = await readFiles(getTemplate("git"));
if (mode === 0) {
const gitignorePath = path.join(cwd, ".gitignore");
const docs = data.docsDir;
if (fs.existsSync(gitignorePath)) {
const content = await fs.promises.readFile(gitignorePath, "utf-8");
fileList.push({
filepath: ".gitignore",
content: `${content}\n${docs}/.vuepress/.cache\n${docs}/.vuepress/.temp\n${docs}/.vuepress/dist\n`
});
fileList.push(...gitFiles.filter(({ filepath }) => filepath !== ".gitignore"));
} else fileList.push(...gitFiles);
} else fileList.push(...gitFiles);
}
if (data.packageManager === "yarn") fileList.push({
filepath: ".yarnrc.yml",
content: "nodeLinker: 'node-modules'\n"
});
if (data.deploy !== "custom") fileList.push(...await readFiles(getTemplate(`deploy/${data.deploy}`)));
const render = createRender(data);
const renderedFiles = fileList.map((file) => {
if (file.filepath.endsWith(".handlebars")) file.content = render(file.content);
return file;
});
const ext = data.useTs ? "" : userPkg.type !== "module" ? ".mjs" : ".js";
const REG_EXT = /\.ts$/;
await writeFiles(renderedFiles, mode === 1 ? path.join(cwd, data.root) : cwd, (filepath) => {
if (filepath.endsWith(".d.ts")) return filepath;
if (ext) return filepath.replace(REG_EXT, ext);
return filepath;
});
}
/**
* Create documentation files based on configuration
*
* 根据配置创建文档文件
*
* @param data - Resolved configuration data / 解析后的配置数据
* @returns Array of file objects / 文件对象数组
*/
async function createDocsFiles(data) {
const fileList = [];
if (data.multiLanguage) {
const enDocs = await readFiles(getTemplate("docs/en"));
const zhDocs = await readFiles(getTemplate("docs/zh"));
if (data.defaultLanguage === "en-US") {
fileList.push(...enDocs);
fileList.push(...updateFileListTarget(zhDocs, "zh"));
} else {
fileList.push(...zhDocs);
fileList.push(...updateFileListTarget(enDocs, "en"));
}
} else if (data.defaultLanguage === "en-US") fileList.push(...await readFiles(getTemplate("docs/en")));
else fileList.push(...await readFiles(getTemplate("docs/zh")));
return updateFileListTarget(fileList, data.docsDir);
}
/**
* Update file list target path
*
* 更新文件列表的目标路径
*
* @param fileList - Array of files / 文件数组
* @param target - Target directory path / 目标目录路径
* @returns Updated file array / 更新后的文件数组
*/
function updateFileListTarget(fileList, target) {
return fileList.map(({ filepath, content }) => ({
filepath: path.join(target, filepath),
content
}));
}
//#endregion
//#region src/locales/index.ts
/**
* Locale configurations for different languages.
*
* 不同语言的本地化配置。
*
* Maps language codes to their respective locale strings.
*
* 将语言代码映射到相应的本地化字符串。
*/
const locales = {
"zh-CN": {
"question.root": "您想在哪里初始化 VuePress?",
"question.site.name": "站点名称:",
"question.site.description": "站点描述信息:",
"question.bundler": "请选择打包工具",
"question.multiLanguage": "是否使用多语言?",
"question.defaultLanguage": "请选择站点默认语言",
"question.useTs": "是否使用 TypeScript?",
"question.injectNpmScripts": "是否注入 npm 脚本?",
"question.deploy": "部署方式:",
"question.git": "是否初始化 git 仓库?",
"question.installDeps": "是否安装依赖?",
"spinner.start": "🚀 正在创建...",
"spinner.stop": "🎉 创建成功!",
"spinner.git": "📄 初始化 git 仓库...",
"spinner.install": "📦 安装依赖...",
"spinner.command": "🔨 执行以下命令即可启动:",
"hint.cancel": "操作已取消。",
"hint.root": "文件路径不能是绝对路径,不能包含父路径。",
"hint.root.illegal": "文件夹不能包含特殊字符。"
},
"en-US": {
"question.root": "Where would you want to initialize VuePress?",
"question.site.name": "Site Name:",
"question.site.description": "Site Description:",
"question.bundler": "Select a bundler",
"question.multiLanguage": "Do you want to use multiple languages?",
"question.defaultLanguage": "Select the default language of the site",
"question.useTs": "Use TypeScript?",
"question.injectNpmScripts": "Inject npm scripts?",
"question.deploy": "Deploy type:",
"question.git": "Initialize a git repository?",
"question.installDeps": "Install dependencies?",
"spinner.start": "🚀 Creating...",
"spinner.stop": "🎉 Create success!",
"spinner.git": "📄 Initializing git repository...",
"spinner.install": "📦 Installing dependencies...",
"spinner.command": "🔨 Execute the following command to start:",
"hint.cancel": "Operation cancelled.",
"hint.root": "The path cannot be an absolute path, and cannot contain the parent path.",
"hint.root.illegal": "Project names cannot contain special characters."
}
};
//#endregion
//#region src/translate.ts
/**
* Create a translate instance with specified language
*
* 创建指定语言的翻译实例
*
* @param lang - Language code / 语言代码
* @returns Translate interface / 翻译接口
*/
function createTranslate(lang) {
let current = lang || "en-US";
return {
setLang: (lang) => {
current = lang;
},
t: (key) => locales[current][key]
};
}
const translate = createTranslate();
/**
* Get translated string by key
*
* 根据键获取翻译后的字符串
*
* @param key - Locale key / 本地化键名
* @returns Translated string / 翻译后的字符串
*/
const t = translate.t;
/**
* Set current language
*
* 设置当前语言
*
* @param lang - Language code to set / 要设置的语言代码
*/
const setLang = translate.setLang;
//#endregion
//#region src/prompt.ts
const require = createRequire(process.cwd());
const REG_DIR_CHAR = /[<>:"\\|?*[\]]/;
/**
* Prompt user for project configuration
*
* 提示用户输入项目配置
*
* @param mode - Operation mode (init or create) / 操作模式(初始化或创建)
* @param root - Optional root directory path / 可选的根目录路径
* @returns Resolved prompt result / 解析后的提示结果
*/
async function prompt(mode, root) {
let hasTs = false;
if (mode === 0) try {
hasTs = !!require.resolve("typescript");
} catch {}
return await group({
displayLang: async () => {
const locale = osLocale();
if (locale === "zh-CN" || locale === "zh-Hans") {
setLang("zh-CN");
return "zh-CN";
}
if (locale === "en-US") {
setLang("en-US");
return "en-US";
}
const lang = await select({
message: "Select a language to display / 选择显示语言",
options: languageOptions
});
if (typeof lang === "string") setLang(lang);
return lang;
},
root: async () => {
if (root) return root;
const DEFAULT_ROOT = mode === 0 ? "./docs" : "./my-project";
return await text({
message: t("question.root"),
placeholder: DEFAULT_ROOT,
validate(value) {
if (value?.startsWith("/") || value?.startsWith("..")) return t("hint.root");
if (value && REG_DIR_CHAR.test(value)) return t("hint.root.illegal");
},
defaultValue: DEFAULT_ROOT
});
},
siteName: () => text({
message: t("question.site.name"),
placeholder: "My Vuepress Site",
defaultValue: "My Vuepress Site"
}),
siteDescription: () => text({ message: t("question.site.description") }),
multiLanguage: () => confirm({
message: t("question.multiLanguage"),
initialValue: false
}),
defaultLanguage: () => select({
message: t("question.defaultLanguage"),
options: languageOptions
}),
useTs: async () => {
if (mode === 0) return hasTs;
if (hasTs) return true;
return await confirm({
message: t("question.useTs"),
initialValue: true
});
},
injectNpmScripts: async () => {
if (mode === 1) return true;
return await confirm({
message: t("question.injectNpmScripts"),
initialValue: true
});
},
bundler: () => select({
message: t("question.bundler"),
options: bundlerOptions
}),
deploy: async () => {
if (mode === 0) return "custom";
return await select({
message: t("question.deploy"),
options: deployOptions,
initialValue: "custom"
});
},
git: async () => {
if (mode === 0) return false;
return confirm({
message: t("question.git"),
initialValue: true
});
},
install: () => confirm({
message: t("question.installDeps"),
initialValue: true
})
}, { onCancel: () => {
cancel(t("hint.cancel"));
process.exit(0);
} });
}
//#endregion
//#region src/run.ts
/**
* Run the CLI workflow for VuePress project initialization or creation
*
* 执行 VuePress 项目初始化或创建的 CLI 工作流程
*
* @param mode - Operation mode (init or create) / 操作模式(初始化或创建)
* @param root - Root directory path / 根目录路径
*/
async function run(mode, root) {
intro(colors.cyan("Welcome to VuePress and vuepress-theme-plume !"));
const data = resolveData(await prompt(mode, root), mode);
const progress = spinner();
progress.start(t("spinner.start"));
try {
await generate(mode, data);
} catch (e) {
console.error(`${colors.red("generate files error: ")}\n`, e);
process.exit(1);
}
await sleep(200);
const cwd = path.join(process.cwd(), data.root);
if (data.git) {
progress.message(t("spinner.git"));
try {
await spawn("git", ["init"], { cwd });
} catch (e) {
console.error(`${colors.red("git init error: ")}\n`, e);
process.exit(1);
}
}
const pm = data.packageManager;
if (data.install) {
progress.message(t("spinner.install"));
try {
await spawn(pm, ["install"], { cwd });
} catch (e) {
console.error(`${colors.red("install dependencies error: ")}\n`, e);
process.exit(1);
}
}
const cdCommand = mode === 1 ? colors.green(`cd ${data.root}`) : "";
const runCommand = colors.green(`${pm} run docs:dev`);
const installCommand = colors.green(`${pm} install`);
progress.stop(t("spinner.stop"));
if (mode === 1) outro(`${t("spinner.command")}
${cdCommand}
${data.install ? "" : `${installCommand} && `}${runCommand}`);
}
/**
* Resolve prompt result into final configuration data.
*
* 将提示结果解析为最终配置数据。
*
* @param result - Prompt result from user input / 用户输入的提示结果
* @param mode - Operation mode (init or create) / 操作模式(初始化或创建)
* @returns Resolved configuration data / 解析后的配置数据
*/
function resolveData(result, mode) {
return {
...result,
packageManager: getPackageManager(),
docsDir: mode === 1 ? "docs" : result.root.replace(/^\.\//, "").replace(/\/$/, ""),
siteDescription: result.siteDescription || ""
};
}
//#endregion
//#region src/index.ts
/**
* VuePress Theme Plume CLI Entry Point
*
* VuePress Theme Plume CLI 入口文件
*
* This module provides command-line interface for creating and initializing
* VuePress projects with vuepress-theme-plume.
*
* 本模块提供用于创建和初始化 VuePress 项目的命令行接口。
*
* @module cli
*/
const cli = cac("create-vuepress-theme-plume");
cli.command("[root]", "create a new vuepress-theme-plume project / 创建新的 vuepress-theme-plume 项目").action((root) => run(1, root));
cli.command("init [root]", "Initial vuepress-theme-plume in the existing project / 在现有项目中初始化 vuepress-theme-plume").action((root) => run(0, root));
cli.help();
cli.version(version);
cli.parse();
//#endregion
export {};