UNPKG

create-vuepress-theme-plume

Version:

The cli for create vuepress-theme-plume's project

729 lines (728 loc) 22.5 kB
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 {};