chore: 重构脚本工具链,使用pnpm (#101)

移除 copy-to-vault.mjs 脚本,简化项目构建流程。
更新 version-bump.mjs 使用 node: 前缀导入,并
修复 Beta 版本发布时的 versions.json 更新逻辑。
规范化 sync-i18n.mjs 代码风格,统一使用单引号
和标准缩进格式,提升代码可维护性。
This commit is contained in:
RavenHogWarts 2026-03-04 19:49:27 +08:00 committed by GitHub
parent 5e790eae6e
commit ffd29e7ee2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 5972 additions and 8479 deletions

3
.gitignore vendored
View file

@ -29,4 +29,5 @@ Thumbs.db
*.bat
*.zip
.env
dist
dist
.backup/

8040
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,14 +4,13 @@
"description": "An enhanced code editor using Ace editor, providing syntax highlighting, code folding, and other advanced editing features.",
"main": "main.js",
"scripts": {
"dev": "node scripts/esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node scripts/esbuild.config.mjs production",
"build:local": "tsc -noEmit -skipLibCheck && node scripts/esbuild.config.mjs production && node scripts/copy-to-vault.mjs",
"version": "npm run build && node scripts/version-bump.mjs && npm run changelog:u && npm i",
"dev": "node scripts/deploy.mjs link && node scripts/esbuild.config.mjs dev",
"build": "tsc -noEmit -skipLibCheck && node scripts/esbuild.config.mjs prod && node scripts/deploy.mjs copy",
"version": "pnpm run build && node scripts/version-bump.mjs && pnpm run changelog:u && pnpm i",
"changelog:u": "conventional-changelog -p angular -i CHANGELOG.md -s -u -n ./scripts/changelog-option.js",
"changelog:all": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0 -n ./scripts/changelog-option.js",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"i18n:typesafe": "typesafe-i18n",
"i18n:sync": "node scripts/sync-i18n.mjs"
},
@ -25,37 +24,39 @@
"engines": {
"node": ">=18.x"
},
"overrides": {
"@conventional-changelog/git-client": "^2.0.0"
"pnpm": {
"overrides": {
"@conventional-changelog/git-client": "^2.0.0"
}
},
"devDependencies": {
"@types/node": "^25.0.2",
"@types/react": "^19.2.7",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"builtin-modules": "5.0.0",
"conventional-changelog-cli": "^5.0.0",
"esbuild": "0.27.1",
"eslint": "^9.39.2",
"esbuild": "0.27.3",
"eslint": "^10.0.2",
"eslint-plugin-obsidianmd": "^0.1.9",
"fs-extra": "^11.3.2",
"obsidian": "^1.11.0",
"postcss": "^8.5.6",
"postcss-nesting": "^13.0.2",
"fs-extra": "^11.3.4",
"obsidian": "^1.12.3",
"postcss": "^8.5.8",
"postcss-nesting": "^14.0.0",
"tslib": "2.8.1",
"typescript": "^5.9.3"
},
"dependencies": {
"@types/ace": "^0.0.52",
"ace-builds": "^1.43.5",
"dotenv": "^17.2.3",
"html-react-parser": "^5.2.10",
"lucide-react": "^0.561.0",
"ace-builds": "^1.43.6",
"dotenv": "^17.3.1",
"html-react-parser": "^5.2.17",
"lucide-react": "^0.577.0",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react": "^19.2.4",
"react-ace": "^14.0.1",
"react-dom": "^19.2.3",
"typesafe-i18n": "^5.26.2"
"react-dom": "^19.2.4",
"typesafe-i18n": "^5.27.1"
}
}

5432
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,88 +0,0 @@
import dotenv from "dotenv";
import { existsSync } from "fs";
import { copyFile, mkdir, readFile } from "fs/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, "..");
dotenv.config({ quiet: true });
const VAULT_PATH = process.env.VAULT_PATH;
if (!VAULT_PATH) {
throw new Error(
"VAULT_PATH is not defined. Please create a .env file in the project root and add the line: VAULT_PATH=/path/to/your/vault"
);
}
const fileConfig = [
{
name: "manifest.json",
sourcePath: projectRoot,
},
{
name: "main.js",
sourcePath: projectRoot,
},
{
name: "styles.css",
sourcePath: projectRoot,
},
];
async function copyToVault() {
try {
// 获取 manifest.json 配置,从 fileConfig 中找到对应文件
const manifestConfig = fileConfig.find(
(file) => file.name === "manifest.json"
);
if (!manifestConfig) {
throw new Error("manifest.json not found in fileConfig");
}
const manifestPath = join(
manifestConfig.sourcePath,
manifestConfig.name
);
const manifestContent = await readFile(manifestPath, "utf8");
const manifest = JSON.parse(manifestContent);
const pluginId = manifest.id;
if (!pluginId) {
throw new Error("Plugin ID not found in manifest.json");
}
const targetPluginDir = join(
VAULT_PATH,
".obsidian",
"plugins",
pluginId
);
if (!existsSync(targetPluginDir)) {
await mkdir(targetPluginDir, { recursive: true });
console.log(`创建目录: ${targetPluginDir}`);
}
// 复制文件
for (const fileInfo of fileConfig) {
const sourcePath = join(fileInfo.sourcePath, fileInfo.name);
const destPath = join(targetPluginDir, fileInfo.name);
if (!existsSync(sourcePath)) {
console.warn(`警告: 源文件不存在 ${sourcePath}`);
continue;
}
await copyFile(sourcePath, destPath);
// console.log(`复制文件: ${fileInfo.name} -> ${destPath}`);
}
console.log(`插件 ${pluginId} 已成功复制到 vault`);
} catch (error) {
console.error("复制文件时出错:", error);
process.exit(1);
}
}
copyToVault();

304
scripts/deploy.mjs Normal file
View file

@ -0,0 +1,304 @@
// scripts/deploy.mjs
// 统一的部署脚本:支持多种部署模式
// 用法: node scripts/deploy.mjs [link|copy] [--vault-path=/path/to/vault]
import dotenv from "dotenv";
import fs from "fs-extra";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const distDir = path.join(projectRoot, "dist");
const manifestPath = path.join(projectRoot, "manifest.json");
const envPath = path.join(projectRoot, ".env");
const backupDir = path.join(projectRoot, ".backup");
// ==================== 参数解析 ====================
const args = process.argv.slice(2);
const deployMode = args[0] || "link"; // link | copy
const customVaultPath = args
.find((arg) => arg.startsWith("--vault-path="))
?.split("=")[1];
// ==================== 工具函数 ====================
const log = {
success: (msg) => console.log(`${msg}`),
error: (msg) => console.error(`${msg}`),
info: (msg) => console.log(` ${msg}`),
warn: (msg) => console.warn(`⚠️ ${msg}`),
};
/**
* 检查并加载环境配置
*/
function loadVaultPath() {
if (customVaultPath) {
return path.resolve(customVaultPath);
}
if (!fs.existsSync(envPath)) {
log.warn(".env 文件不存在,跳过部署");
process.exit(0);
}
dotenv.config({ path: envPath });
const vaultPath = process.env.VAULT_PATH;
if (!vaultPath) {
log.error(
"未设置 VAULT_PATH请在 .env 文件中设置或使用 --vault-path 参数",
);
process.exit(1);
}
return path.resolve(vaultPath);
}
/**
* 读取插件ID
*/
function getPluginId() {
// 优先从 dist/manifest.json 读取(构建后)
const distManifestPath = path.join(distDir, "manifest.json");
const sourceManifestPath = manifestPath;
const manifestFile = fs.existsSync(distManifestPath)
? distManifestPath
: sourceManifestPath;
if (!fs.existsSync(manifestFile)) {
log.error("找不到 manifest.json 文件");
process.exit(1);
}
try {
const manifest = fs.readJsonSync(manifestFile);
if (!manifest.id) {
throw new Error("manifest.json 中没有 id 字段");
}
return manifest.id;
} catch (error) {
log.error(`读取 manifest.json 失败: ${error.message}`);
process.exit(1);
}
}
/**
* 确保 dist 目录和必要文件存在
*/
function ensureDistReady() {
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// 确保 .hotreload 文件存在(用于 Obsidian 热重载)
const hotreloadPath = path.join(distDir, ".hotreload");
if (!fs.existsSync(hotreloadPath)) {
fs.writeFileSync(hotreloadPath, "");
}
}
/**
* 备份 data.json 到独立备份目录
*/
function backupDataJson(targetPluginDir) {
const dataJsonPath = path.join(targetPluginDir, "data.json");
if (fs.existsSync(dataJsonPath)) {
fs.mkdirSync(backupDir, { recursive: true });
const backupPath = path.join(backupDir, "data.json");
fs.copyFileSync(dataJsonPath, backupPath);
log.info("已备份 data.json 到 .backup/");
return true;
}
return false;
}
/**
* 递归复制目录
*/
function copyDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
// ==================== 部署模式 ====================
/**
* 模式1: link - 创建软链接开发模式
* Windows 使用 junctionLinux/Mac 使用 symlink
*/
async function deployLink(vaultPath, pluginId) {
const targetPluginDir = path.join(
vaultPath,
".obsidian",
"plugins",
pluginId,
);
// 检查是否指向同一位置(避免循环链接)
if (path.resolve(targetPluginDir) === path.resolve(distDir)) {
log.info("目标路径与 dist 目录相同,跳过链接");
process.exit(0);
}
// 处理已存在的目标路径
if (fs.existsSync(targetPluginDir)) {
const stats = fs.lstatSync(targetPluginDir);
if (stats.isSymbolicLink()) {
const linkTarget = fs.readlinkSync(targetPluginDir);
const resolvedLink = path.resolve(
path.dirname(targetPluginDir),
linkTarget,
);
if (resolvedLink === path.resolve(distDir)) {
log.success(`软链接已存在: dist → ${pluginId}`);
return;
}
} else if (stats.isDirectory()) {
// 备份 data.json 到独立备份目录
backupDataJson(targetPluginDir);
}
// 删除旧的路径
fs.rmSync(targetPluginDir, { recursive: true, force: true });
}
// 确保父目录存在
fs.mkdirSync(path.dirname(targetPluginDir), { recursive: true });
// 创建软链接
try {
const linkType = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(distDir, targetPluginDir, linkType);
log.success(`软链接已创建: dist → ${pluginId}`);
// 自动从备份恢复 data.json 到 dist/
const backupPath = path.join(backupDir, "data.json");
if (fs.existsSync(backupPath)) {
const distDataJsonPath = path.join(distDir, "data.json");
fs.copyFileSync(backupPath, distDataJsonPath);
log.success("已从 .backup/ 恢复 data.json 到 dist/");
}
} catch (error) {
log.error(`创建软链接失败: ${error.message}`);
process.exit(1);
}
}
/**
* 模式2: copy - 完整复制生产构建
* 复制整个 dist 目录保护用户的 data.json
*/
async function deployCopy(vaultPath, pluginId) {
const targetPluginDir = path.join(
vaultPath,
".obsidian",
"plugins",
pluginId,
);
// 检查是否指向同一位置
if (path.resolve(targetPluginDir) === path.resolve(distDir)) {
log.info("目标路径与 dist 目录相同,跳过复制");
process.exit(0);
}
// 确保 dist 目录存在
if (!fs.existsSync(distDir)) {
log.error("dist 目录不存在,请先运行构建命令");
process.exit(1);
}
// 保护 data.json双重保护机制
// 1. 内存临时保存(用于立即恢复)
// 2. 备份到 .backup/ (用于意外情况恢复)
let dataJsonContent = null;
const dataJsonPath = path.join(targetPluginDir, "data.json");
if (fs.existsSync(dataJsonPath)) {
try {
dataJsonContent = fs.readFileSync(dataJsonPath, "utf8");
log.info("已保存现有的 data.json");
// 同时备份到 .backup/ 作为安全保障
fs.mkdirSync(backupDir, { recursive: true });
fs.writeFileSync(path.join(backupDir, "data.json"), dataJsonContent);
log.info("已备份到 .backup/data.json");
} catch (error) {
log.warn(`读取 data.json 失败: ${error.message}`);
}
}
// 删除旧的目标目录
if (fs.existsSync(targetPluginDir)) {
fs.rmSync(targetPluginDir, { recursive: true, force: true });
}
// 复制整个 dist 目录
try {
copyDirRecursive(distDir, targetPluginDir);
const fileCount = fs.readdirSync(distDir).length;
log.success(`已复制 ${fileCount} 个文件到 ${pluginId}`);
} catch (error) {
log.error(`复制失败: ${error.message}`);
process.exit(1);
}
// 恢复 data.json
if (dataJsonContent) {
try {
fs.writeFileSync(
path.join(targetPluginDir, "data.json"),
dataJsonContent,
);
log.success("已恢复 data.json");
} catch (error) {
log.error(`恢复 data.json 失败: ${error.message}`);
}
}
}
// ==================== 主函数 ====================
async function main() {
try {
log.info(`部署模式: ${deployMode}`);
ensureDistReady();
const vaultPath = loadVaultPath();
const pluginId = getPluginId();
log.info(`目标 Vault: ${vaultPath}`);
log.info(`插件 ID: ${pluginId}`);
switch (deployMode) {
case "link":
await deployLink(vaultPath, pluginId);
break;
case "copy":
await deployCopy(vaultPath, pluginId);
break;
default:
log.error(`未知的部署模式: ${deployMode}`);
log.info("支持的模式: link | copy");
process.exit(1);
}
} catch (error) {
log.error(`部署失败: ${error.message}`);
process.exit(1);
}
}
main();

View file

@ -1,10 +1,10 @@
import builtins from "builtin-modules";
import esbuild from "esbuild";
import process from "process";
import fs from "fs-extra";
import path from "node:path";
import process from "node:process";
import postcss from "postcss";
import postcssNesting from "postcss-nesting";
import builtins from "builtin-modules";
import fs from "fs-extra";
import path from "path";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -12,31 +12,42 @@ if you want to view the source, please visit the github repository of this plugi
*/
`;
const prod = process.argv[2] === "production";
// 参数解析:支持 dev 和 prod 两种模式
// 用法: node esbuild.config.mjs [dev|prod|production]
const args = process.argv.slice(2);
const mode = args[0] || "dev";
const prod = mode === "production" || mode === "prod";
const outDir = "dist";
/**
* CSS 文件重命名插件
* 在构建完成后 esbuild 生成的 main.css 重命名为 styles.css
* @returns {Object} esbuild 插件对象
*/
const renamePlugin = () => ({
name: "rename-plugin",
setup(build) {
build.onEnd(async (result) => {
const file = build.initialOptions.outfile;
const parent = path.dirname(file);
const cssFileName = path.join(parent, "main.css");
const newCssFileName = path.join(parent, "styles.css");
build.onEnd(async () => {
const src = path.join(outDir, "main.css");
const dest = path.join(outDir, "styles.css");
if (fs.existsSync(cssFileName)) {
if (fs.existsSync(src)) {
try {
if (fs.existsSync(newCssFileName)) {
fs.unlinkSync(newCssFileName);
}
fs.renameSync(cssFileName, newCssFileName);
} catch (e) {
console.error("Failed to rename CSS file:", e);
fs.moveSync(src, dest, { overwrite: true });
} catch (error) {
console.error("Failed to rename CSS file:", error);
}
}
});
},
});
/**
* CSS 处理插件
* 使用 PostCSS postcss-nesting 插件处理 CSS 文件支持 CSS 嵌套语法
* 在构建过程中自动处理所有 .css 文件
* @returns {Object} esbuild 插件对象
*/
const cssReBuild = () => ({
name: "css-rebuild",
@ -55,14 +66,8 @@ const cssReBuild = () => ({
from: args.path, // 指定源文件路径,用于生成正确的源映射
});
// 获取输出目录路径
// build.initialOptions.outfile 是最终 JS 输出文件的路径
const outDir = path.dirname(build.initialOptions.outfile);
// 确保输出目录存在,如果不存在则创建
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
fs.ensureDirSync(outDir);
// 返回处理后的 CSS 内容
// contents: 处理后的 CSS 代码
@ -86,6 +91,56 @@ const cssReBuild = () => ({
},
});
// 复制 manifest.json 到输出目录
function copyManifest() {
// 确保输出目录存在,如果不存在则创建
fs.ensureDirSync(outDir);
const src = path.join(process.cwd(), "manifest.json");
const dest = path.join(process.cwd(), outDir, "manifest.json");
try {
fs.copyFileSync(src, dest);
console.log("✓ 已复制 manifest.json 到 dist 目录");
} catch (e) {
console.error("⚠ 复制 manifest.json 失败:", e.message);
}
}
// 监听 manifest.json 变化(带防抖和内容比较,避免重复触发)
function watchManifest() {
const manifestPath = path.join(process.cwd(), "manifest.json");
if (!fs.existsSync(manifestPath)) {
return;
}
let copyTimer = null;
let lastContent = fs.readFileSync(manifestPath, "utf8");
fs.watch(manifestPath, (eventType) => {
if (eventType === "change") {
// 防抖:延迟 100ms 执行,避免短时间内多次触发
if (copyTimer) {
clearTimeout(copyTimer);
}
copyTimer = setTimeout(() => {
try {
const newContent = fs.readFileSync(manifestPath, "utf8");
// 只有内容真正改变时才复制
if (newContent !== lastContent) {
lastContent = newContent;
copyManifest();
}
} catch (e) {
console.error("⚠ 读取 manifest.json 失败:", e.message);
}
copyTimer = null;
}, 100);
}
});
}
// 创建 esbuild context
const context = await esbuild.context({
banner: {
js: banner,
@ -114,16 +169,25 @@ const context = await esbuild.context({
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
outdir: outDir,
minify: prod,
drop: prod ? ["console"] : [],
loader: {
".ttf": "base64",
},
});
// 初始构建时复制 manifest.json
copyManifest();
if (prod) {
// 生产模式:构建一次后退出
await context.rebuild();
console.log("✅ 生产构建完成");
process.exit(0);
} else {
// 开发模式:监听文件变化
console.log("👀 开发模式启动,监听文件变化...");
watchManifest();
await context.watch();
}

View file

@ -1,143 +0,0 @@
import { spawn } from "child_process";
import dotenv from "dotenv";
import { existsSync } from "fs";
import { lstat, readFile, symlink, unlink } from "fs/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, "..");
dotenv.config({ quiet: true });
const VAULT_PATH = process.env.VAULT_PATH;
if (!VAULT_PATH) {
throw new Error(
"VAULT_PATH is not defined. Please create a .env file in the project root and add the line: VAULT_PATH=/path/to/your/vault"
);
}
const manifestPath = join(projectRoot, "manifest.json");
// 检查是否以管理员身份运行
function isAdmin() {
if (process.platform !== "win32") return true;
try {
const { execSync } = require("child_process");
execSync("net session >nul 2>&1", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
// 尝试以管理员身份重新运行
async function runAsAdmin() {
return new Promise((resolve, reject) => {
const scriptPath = process.argv[1];
const args = process.argv.slice(2);
// 使用 PowerShell 以管理员身份运行
const psCommand = `Start-Process -FilePath "node" -ArgumentList "${scriptPath}",${args
.map((arg) => `"${arg}"`)
.join(",")} -Verb RunAs -Wait`;
const ps = spawn("powershell.exe", ["-Command", psCommand], {
stdio: "inherit",
});
ps.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`PowerShell 退出码: ${code}`));
}
});
ps.on("error", reject);
});
}
async function linkDataJson() {
try {
// 从 manifest.json 读取插件 ID
const manifestContent = await readFile(manifestPath, "utf8");
const manifest = JSON.parse(manifestContent);
const pluginId = manifest.id;
if (!pluginId) {
throw new Error("无法从 manifest.json 中获取插件 ID");
}
// 构建路径
const vaultPluginDir = join(
VAULT_PATH,
".obsidian",
"plugins",
pluginId
);
const sourceDataPath = join(vaultPluginDir, "data.json");
const targetDataPath = join(rootDir, "data.json");
// 检查源文件是否存在
if (!existsSync(sourceDataPath)) {
console.log(`警告: 源文件不存在: ${sourceDataPath}`);
console.log("请确保插件已在测试库中运行过并生成了 data.json 文件");
return;
}
// 检查目标位置是否已有文件或链接
if (existsSync(targetDataPath)) {
try {
const stats = await lstat(targetDataPath);
if (stats.isSymbolicLink()) {
console.log("删除现有的软链接...");
await unlink(targetDataPath);
} else {
console.log(
"警告: 目标位置已存在普通文件,请手动处理后重试"
);
console.log(`文件路径: ${targetDataPath}`);
return;
}
} catch (error) {
console.error("检查目标文件时出错:", error);
return;
}
}
// 创建软链接
try {
await symlink(sourceDataPath, targetDataPath, "file");
console.log(`成功创建软链接:`);
console.log(` 源文件: ${sourceDataPath}`);
console.log(` 目标位置: ${targetDataPath}`);
console.log("");
console.log(
"现在项目根目录的 data.json 将实时反映测试库中的插件配置!"
);
} catch (symlinkError) {
if (symlinkError.code === "EPERM") {
console.log("软链接创建失败,权限不足。");
// 检查是否以管理员身份运行
if (!isAdmin()) {
console.log("尝试以管理员身份重新运行...");
try {
await runAsAdmin();
return;
} catch (adminError) {
console.log("自动提权失败:", adminError.message);
}
}
} else {
throw symlinkError;
}
}
} catch (error) {
console.error("执行过程中出错:", error);
process.exit(1);
}
}
linkDataJson();

View file

@ -3,136 +3,113 @@
* 深度合并多删少补保证与基准文件始终一致
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "..");
const I18N_DIR = path.join(ROOT_DIR, "src", "i18n");
const ROOT_DIR = path.resolve(__dirname, '..');
const I18N_DIR = path.join(ROOT_DIR, 'src', 'i18n');
// 日志
const log = {
success: (msg) => console.log(`\x1b[32m✔\x1b[0m ${msg}`),
error: (msg) => console.log(`\x1b[31m✖\x1b[0m ${msg}`),
success: (msg) => console.log(`\x1b[32m✔\x1b[0m ${msg}`),
error: (msg) => console.log(`\x1b[31m✖\x1b[0m ${msg}`),
};
/**
* 读取 typesafe-i18n 配置
*/
function loadConfig() {
const configPath = path.join(ROOT_DIR, ".typesafe-i18n.json");
if (!fs.existsSync(configPath)) {
throw new Error(".typesafe-i18n.json 配置文件不存在");
}
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
const configPath = path.join(ROOT_DIR, '.typesafe-i18n.json');
if (!fs.existsSync(configPath)) {
throw new Error('.typesafe-i18n.json 配置文件不存在');
}
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
/**
* TypeScript 文件中提取翻译对象
*/
function extractTranslationObject(filePath) {
if (!fs.existsSync(filePath)) return null;
if (!fs.existsSync(filePath)) return null;
const content = fs.readFileSync(filePath, "utf-8");
const match = content.match(
/const\s+\w+\s*=\s*(\{[\s\S]*?\})\s*satisfies\s+BaseTranslation/
);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/const\s+\w+\s*=\s*(\{[\s\S]*?\})\s*satisfies\s+BaseTranslation/);
if (!match) return null;
if (!match) return null;
try {
return new Function(`return ${match[1]}`)();
} catch {
return null;
}
try {
return new Function(`return ${match[1]}`)();
} catch {
return null;
}
}
/**
* 获取对象的所有扁平化键路径
*/
function getKeys(obj, prefix = "") {
const keys = new Set();
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
getKeys(value, fullKey).forEach((k) => keys.add(k));
} else {
keys.add(fullKey);
}
}
return keys;
function getKeys(obj, prefix = '') {
const keys = new Set();
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
getKeys(value, fullKey).forEach(k => keys.add(k));
} else {
keys.add(fullKey);
}
}
return keys;
}
/**
* 深度合并按照基准结构重建对象
*/
function deepMerge(baseObj, targetObj) {
const result = {};
for (const key of Object.keys(baseObj)) {
const baseValue = baseObj[key];
const targetValue = targetObj?.[key];
if (
typeof baseValue === "object" &&
baseValue !== null &&
!Array.isArray(baseValue)
) {
result[key] = deepMerge(
baseValue,
typeof targetValue === "object" ? targetValue : {}
);
} else if (
targetValue !== undefined &&
typeof targetValue !== "object"
) {
result[key] = targetValue;
} else {
result[key] = `TODO: ${baseValue}`;
}
}
return result;
const result = {};
for (const key of Object.keys(baseObj)) {
const baseValue = baseObj[key];
const targetValue = targetObj?.[key];
if (typeof baseValue === 'object' && baseValue !== null && !Array.isArray(baseValue)) {
result[key] = deepMerge(baseValue, typeof targetValue === 'object' ? targetValue : {});
} else if (targetValue !== undefined && typeof targetValue !== 'object') {
result[key] = targetValue;
} else {
result[key] = `TODO: ${baseValue}`;
}
}
return result;
}
/**
* 将对象序列化为 TypeScript 格式
*/
function toTypeScript(obj, indent = 1) {
const pad = "\t".repeat(indent);
const lines = Object.keys(obj).map((key) => {
const value = obj[key];
const formattedKey = /[^a-zA-Z0-9_]/.test(key) ? `"${key}"` : key;
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
return `${pad}${formattedKey}: ${toTypeScript(value, indent + 1)},`;
}
const escaped = String(value)
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n");
return `${pad}${formattedKey}: "${escaped}",`;
});
return `{\n${lines.join("\n")}\n${"\t".repeat(indent - 1)}}`;
const pad = '\t'.repeat(indent);
const lines = Object.keys(obj).map(key => {
const value = obj[key];
const formattedKey = /[^a-zA-Z0-9_]/.test(key) ? `"${key}"` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return `${pad}${formattedKey}: ${toTypeScript(value, indent + 1)},`;
}
const escaped = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `${pad}${formattedKey}: "${escaped}",`;
});
return `{\n${lines.join('\n')}\n${'\t'.repeat(indent - 1)}}`;
}
/**
* 生成翻译文件内容
*/
function generateContent(locale, obj) {
const varName = locale.replace(/-/g, "_");
return `import type { BaseTranslation } from "../i18n-types";
const varName = locale.replace(/-/g, '_');
return `import type { BaseTranslation } from '../i18n-types'
const ${varName} = ${toTypeScript(obj)} satisfies BaseTranslation;
@ -144,77 +121,63 @@ export default ${varName};
* 主函数
*/
function main() {
const { baseLocale } = loadConfig();
const { baseLocale } = loadConfig();
// 获取所有 locale 目录
const locales = fs.readdirSync(I18N_DIR, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name);
// 获取所有 locale 目录
const locales = fs
.readdirSync(I18N_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
if (!locales.includes(baseLocale)) {
throw new Error(`基准语言 "${baseLocale}" 目录不存在`);
}
if (!locales.includes(baseLocale)) {
throw new Error(`基准语言 "${baseLocale}" 目录不存在`);
}
// 解析基准语言
const baseObj = extractTranslationObject(path.join(I18N_DIR, baseLocale, 'index.ts'));
if (!baseObj) {
throw new Error('无法解析基准语言文件');
}
// 解析基准语言
const baseObj = extractTranslationObject(
path.join(I18N_DIR, baseLocale, "index.ts")
);
if (!baseObj) {
throw new Error("无法解析基准语言文件");
}
const baseKeys = getKeys(baseObj);
let syncedCount = 0;
const baseKeys = getKeys(baseObj);
let syncedCount = 0;
// 同步其他语言
for (const locale of locales.filter(l => l !== baseLocale)) {
const filePath = path.join(I18N_DIR, locale, 'index.ts');
// 检查文件是否存在,不存在则生成初始文件
if (!fs.existsSync(filePath)) {
const initialContent = generateContent(locale, deepMerge(baseObj, {}));
fs.writeFileSync(filePath, initialContent, 'utf-8');
log.success(`${locale} (新建,+${baseKeys.size} 个键)`);
syncedCount++;
continue;
}
// 同步其他语言
for (const locale of locales.filter((l) => l !== baseLocale)) {
const filePath = path.join(I18N_DIR, locale, "index.ts");
const targetObj = extractTranslationObject(filePath);
// 检查文件是否存在,不存在则生成初始文件
if (!fs.existsSync(filePath)) {
const initialContent = generateContent(
locale,
deepMerge(baseObj, {})
);
fs.writeFileSync(filePath, initialContent, "utf-8");
log.success(`${locale} (新建,+${baseKeys.size} 个键)`);
syncedCount++;
continue;
}
if (!targetObj) {
log.error(`无法解析: ${locale}/index.ts`);
continue;
}
const targetObj = extractTranslationObject(filePath);
const targetKeys = getKeys(targetObj);
const missing = [...baseKeys].filter(k => !targetKeys.has(k));
const extra = [...targetKeys].filter(k => !baseKeys.has(k));
if (!targetObj) {
log.error(`无法解析: ${locale}/index.ts`);
continue;
}
if (missing.length === 0 && extra.length === 0) continue;
const targetKeys = getKeys(targetObj);
const missing = [...baseKeys].filter((k) => !targetKeys.has(k));
const extra = [...targetKeys].filter((k) => !baseKeys.has(k));
// 执行同步
fs.writeFileSync(filePath, generateContent(locale, deepMerge(baseObj, targetObj)), 'utf-8');
const changes = [];
if (missing.length > 0) changes.push(`+${missing.length}`);
if (extra.length > 0) changes.push(`-${extra.length}`);
log.success(`${locale} (${changes.join(', ')})`);
syncedCount++;
}
if (missing.length === 0 && extra.length === 0) continue;
// 执行同步
fs.writeFileSync(
filePath,
generateContent(locale, deepMerge(baseObj, targetObj)),
"utf-8"
);
const changes = [];
if (missing.length > 0) changes.push(`+${missing.length}`);
if (extra.length > 0) changes.push(`-${extra.length}`);
log.success(`${locale} (${changes.join(", ")})`);
syncedCount++;
}
log.success(
syncedCount > 0
? `同步完成,已更新 ${syncedCount} 个语言文件`
: "所有语言已同步"
);
log.success(syncedCount > 0 ? `同步完成,已更新 ${syncedCount} 个语言文件` : '所有语言已同步');
}
main();

View file

@ -1,6 +1,5 @@
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "fs";
import readline from "readline";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import readline from "node:readline";
// 检查是否为直接调用模式
const directVersion = process.argv[2];
@ -194,8 +193,10 @@ function updateAllFiles(version, isBeta = false) {
// 2. 更新 manifest.json 或 manifest-beta.json
const minAppVersion = updateManifestJson(version, isBeta);
// 3. 更新 versions.json
updateVersionsJson(version, minAppVersion);
// 3. 更新 versions.json (仅非Beta版本)
if (!isBeta) {
updateVersionsJson(version, minAppVersion);
}
// 提示提交更改
if (isFirstRelease) {
@ -205,9 +206,7 @@ function updateAllFiles(version, isBeta = false) {
}
if (isBeta) {
console.log(
`git add package.json manifest-beta.json versions.json`
);
console.log(`git add package.json manifest-beta.json`);
} else {
console.log(`git add package.json manifest.json versions.json`);
}