♻️ refactor: restructure electron build workflow with i18n codemod

- Remove desktop-build-electron.yml workflow
- Add verify-electron-codemod.yml workflow for i18n transformation
- Add i18nDynamicToStatic modifier for dynamic to static i18n conversion
- Update build workflows to use new i18n modifier approach
- Update README and package.json configurations
This commit is contained in:
Innei
2026-01-28 01:21:10 +08:00
committed by arvinxx
parent 91155fd379
commit 8188e2d9f0
15 changed files with 603 additions and 141 deletions

View File

@@ -1,85 +0,0 @@
name: Desktop Next Build
on:
workflow_dispatch:
push:
branches:
- next
pull_request:
paths:
- 'apps/desktop/**'
- 'scripts/electronWorkflow/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'bun.lockb'
- 'src/**'
- 'packages/**'
- '.github/workflows/desktop-build-electron.yml'
concurrency:
group: desktop-electron-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
NODE_VERSION: 24.11.1
BUN_VERSION: 1.2.23
jobs:
build-next:
name: Build desktop Next bundle
runs-on: ubuntu-latest
env:
NODE_OPTIONS: --max-old-space-size=8192
UPDATE_CHANNEL: nightly
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID || 'dummy-desktop-project' }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL || 'https://analytics.example.com' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-
${{ runner.os }}-pnpm-store-
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Install desktop dependencies
run: |
cd apps/desktop
bun run install-isolated
- name: Build desktop Next.js bundle
run: bun run desktop:build-electron

View File

@@ -123,7 +123,7 @@ jobs:
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
- name: Build artifact on macOS
run: npm run desktop:build
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: ${{ inputs.channel }}
APP_URL: http://localhost:3015
@@ -193,7 +193,7 @@ jobs:
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
- name: Build artifact on Windows
run: npm run desktop:build
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: ${{ inputs.channel }}
APP_URL: http://localhost:3015
@@ -246,7 +246,7 @@ jobs:
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
- name: Build artifact on Linux
run: npm run desktop:build
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: ${{ inputs.channel }}
APP_URL: http://localhost:3015

View File

@@ -131,7 +131,7 @@ jobs:
# 注意fork 的 PR 无法访问 secrets会构建未签名版本
- name: Build artifact on macOS
if: runner.os == 'macOS'
run: npm run desktop:build
run: npm run desktop:package:app
env:
# 设置更新通道PR构建为nightly否则为stable
UPDATE_CHANNEL: 'nightly'
@@ -155,7 +155,7 @@ jobs:
# 注意fork 的 PR 无法访问 secrets会构建未签名版本
- name: Build artifact on Windows
if: runner.os == 'Windows'
run: npm run desktop:build
run: npm run desktop:package:app
env:
# 设置更新通道PR构建为nightly否则为stable
UPDATE_CHANNEL: 'nightly'
@@ -171,7 +171,7 @@ jobs:
# Linux 平台构建处理
- name: Build artifact on Linux
if: runner.os == 'Linux'
run: npm run desktop:build
run: npm run desktop:package:app
env:
# 设置更新通道PR构建为nightly否则为stable
UPDATE_CHANNEL: 'nightly'

View File

@@ -101,7 +101,7 @@ jobs:
# macOS 构建
- name: Build artifact on macOS
if: runner.os == 'macOS'
run: npm run desktop:build
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: beta
APP_URL: http://localhost:3015
@@ -119,7 +119,7 @@ jobs:
# Windows 构建
- name: Build artifact on Windows
if: runner.os == 'Windows'
run: npm run desktop:build
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: beta
APP_URL: http://localhost:3015
@@ -133,7 +133,7 @@ jobs:
# Linux 构建
- name: Build artifact on Linux
if: runner.os == 'Linux'
run: npm run desktop:build
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: beta
APP_URL: http://localhost:3015

View File

@@ -188,7 +188,7 @@ jobs:
# macOS 构建
- name: Build artifact on macOS
if: runner.os == 'macOS'
run: npm run desktop:build
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: stable
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
@@ -208,7 +208,7 @@ jobs:
# Windows 构建
- name: Build artifact on Windows
if: runner.os == 'Windows'
run: npm run desktop:build
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: stable
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
@@ -225,7 +225,7 @@ jobs:
- name: Build artifact on Linux
if: runner.os == 'Linux'
run: |
npm run desktop:build
npm run desktop:package:app
tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C out .
env:
UPDATE_CHANNEL: stable

View File

@@ -0,0 +1,109 @@
name: Verify Electron i18n Codemod
on:
pull_request:
push:
branches:
- main
- dev
concurrency:
group: verify-electron-codemod-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
NODE_VERSION: 24.11.1
BUN_VERSION: 1.2.23
jobs:
verify-codemod:
name: Verify i18n codemod on temp workspace
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-
${{ runner.os }}-pnpm-store-
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Run i18n codemod in temp workspace
shell: bash
run: |
set -euo pipefail
TMP_DIR="$(mktemp -d)"
echo "Using TEMP_DIR=$TMP_DIR"
echo "TMP_DIR=$TMP_DIR" >> "$GITHUB_ENV"
mkdir -p "$TMP_DIR/src/utils/i18n" "$TMP_DIR/src/libs" "$TMP_DIR/src/const" "$TMP_DIR/src/locales/default"
cp -R scripts "$TMP_DIR/"
cp src/utils/i18n/loadI18nNamespaceModule.ts "$TMP_DIR/src/utils/i18n/"
cp src/libs/getUILocaleAndResources.ts "$TMP_DIR/src/libs/"
cp src/const/locale.ts "$TMP_DIR/src/const/"
cp src/locales/resources.ts "$TMP_DIR/src/locales/"
cp src/locales/default/index.ts "$TMP_DIR/src/locales/default/"
cp -R locales "$TMP_DIR/"
bun "$TMP_DIR/scripts/electronWorkflow/modifiers/i18nDynamicToStatic.mts" "$TMP_DIR"
- name: Assert dynamic imports removed
shell: bash
run: |
set -euo pipefail
test -n "${TMP_DIR:-}"
if grep -F 'import(`@/locales/default/${ns}`)' "$TMP_DIR/src/utils/i18n/loadI18nNamespaceModule.ts"; then
echo "Found dynamic default locale import in loadI18nNamespaceModule.ts"
exit 1
fi
if grep -F 'import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`)' "$TMP_DIR/src/utils/i18n/loadI18nNamespaceModule.ts"; then
echo "Found dynamic locale import in loadI18nNamespaceModule.ts"
exit 1
fi
if grep -F 'await import(`@/../locales/${locale}/ui.json`)' "$TMP_DIR/src/libs/getUILocaleAndResources.ts"; then
echo "Found dynamic ui.json import in getUILocaleAndResources.ts"
exit 1
fi
if grep -F "await import('@lobehub/ui/es/i18n/resources/index')" "$TMP_DIR/src/libs/getUILocaleAndResources.ts"; then
echo "Found dynamic @lobehub/ui import in getUILocaleAndResources.ts"
exit 1
fi

View File

@@ -29,7 +29,7 @@ LobeHub Desktop is a cross-platform desktop application for [LobeChat](https://g
pnpm install-isolated
# Start development server
pnpm electron:dev
pnpm dev
# Type checking
pnpm type-check
@@ -51,19 +51,20 @@ cp .env.desktop .env
### Build Commands
| Command | Description |
| ------------------ | --------------------------------------- |
| `pnpm build` | Build for all platforms |
| `pnpm build:mac` | Build for macOS (Intel + Apple Silicon) |
| `pnpm build:win` | Build for Windows |
| `pnpm build:linux` | Build for Linux |
| `pnpm build-local` | Local development build |
| Command | Description |
| -------------------------- | ------------------------------------------- |
| `pnpm build:main` | Build main/preload (dist output only) |
| `pnpm package:mac` | Package for macOS (Intel + Apple Silicon) |
| `pnpm package:win` | Package for Windows |
| `pnpm package:linux` | Package for Linux |
| `pnpm package:local` | Local packaging build (no ASAR) |
| `pnpm package:local:reuse` | Local packaging build reusing existing dist |
### Development Workflow
```bash
# 1. Development
pnpm electron:dev # Start with hot reload
pnpm dev # Start with hot reload
# 2. Code Quality
pnpm lint # ESLint checking
@@ -74,8 +75,8 @@ pnpm type-check # TypeScript validation
pnpm test # Run Vitest tests
# 4. Build & Package
pnpm build # Production build
pnpm build-local # Local testing build
pnpm build:main # Production build (dist only)
pnpm package:local # Local testing package
```
## 🎯 Release Channels

View File

@@ -29,7 +29,7 @@ LobeHub Desktop 是 [LobeChat](https://github.com/lobehub/lobe-chat) 的跨平
pnpm install-isolated
# 启动开发服务器
pnpm electron:dev
pnpm dev
# 类型检查
pnpm type-check
@@ -51,19 +51,20 @@ cp .env.desktop .env
### 构建命令
| 命令 | 描述 |
| ------------------ | ---------------------------------- |
| `pnpm build` | 构建所有平台 |
| `pnpm build:mac` | 构建 macOS (Intel + Apple Silicon) |
| `pnpm build:win` | 构建 Windows |
| `pnpm build:linux` | 构建 Linux |
| `pnpm build-local` | 本地开发构建 |
| 命令 | 描述 |
| -------------------------- | ---------------------------------- |
| `pnpm build:main` | 构建 main/preload仅产出 dist |
| `pnpm package:mac` | 打包 macOS (Intel + Apple Silicon) |
| `pnpm package:win` | 打包 Windows |
| `pnpm package:linux` | 打包 Linux |
| `pnpm package:local` | 本地打包(不打 ASAR |
| `pnpm package:local:reuse` | 本地打包复用已有 dist |
### 开发工作流
```bash
# 1. 开发
pnpm electron:dev # 启动热重载开发服务器
pnpm dev # 启动热重载开发服务器
# 2. 代码质量
pnpm lint # ESLint 检查
@@ -74,8 +75,8 @@ pnpm type-check # TypeScript 验证
pnpm test # 运行 Vitest 测试
# 4. 构建和打包
pnpm build # 生产构建
pnpm build-local # 本地测试构建
pnpm build:main # 生产构建(仅 dist
pnpm package:local # 本地测试打包
```
## 🎯 发布渠道

View File

@@ -11,16 +11,10 @@
"author": "LobeHub",
"main": "./dist/main/index.js",
"scripts": {
"build": "electron-vite build",
"build:linux": "npm run build && electron-builder --linux --config electron-builder.mjs --publish never",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.mjs --publish never",
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
"build:win": "npm run build && electron-builder --win --config electron-builder.mjs --publish never",
"build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"build:main": "electron-vite build",
"build:run-unpack": "electron .",
"dev": "electron-vite dev",
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev",
"electron:dev": "electron-vite dev",
"electron:run-unpack": "electron .",
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run dev",
"format": "prettier --write ",
"i18n": "tsx scripts/i18nWorkflow/index.ts && lobe-i18n",
"postinstall": "electron-builder install-app-deps",
@@ -32,6 +26,12 @@
"lint:md": "remark . --silent --output",
"lint:style": "stylelint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
"lint:ts": "eslint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
"package:linux": "npm run build:main && electron-builder --linux --config electron-builder.mjs --publish never",
"package:local": "npm run build:main && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"package:local:reuse": "electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"package:mac": "npm run build:main && electron-builder --mac --config electron-builder.mjs --publish never",
"package:mac:local": "npm run build:main && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
"package:win": "npm run build:main && electron-builder --win --config electron-builder.mjs --publish never",
"start": "electron-vite preview",
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "vitest --run",

View File

@@ -41,8 +41,8 @@ chmod +x *.sh
cd ../..
# 构建未签名的本地测试包
bun run build
bun run build-local
bun run build:main
bun run package:local
```
如果需要模拟 CI 的渠道构建Nightly / Beta / Stable可以使用根目录脚本

View File

@@ -37,7 +37,6 @@
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
"build:analyze": "NODE_OPTIONS=--max-old-space-size=81920 ANALYZE=true next build --webpack",
"build:docker": "npm run prebuild && NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build --webpack && npm run build-sitemap",
"build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=8192 NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
"build:vercel": "tsx scripts/prebuild.mts && npm run lint:ts && npm run lint:style && npm run type-check:tsc && npm run lint:circular && cross-env NODE_OPTIONS=--max-old-space-size=6144 next build --webpack && npm run postbuild",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
@@ -46,16 +45,21 @@
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
"db:studio": "drizzle-kit studio",
"db:visualize": "dbdocs build docs/development/database-schema.dbml --project lobe-chat",
"desktop:build": "npm run desktop:build-next && npm run desktop:prepare-dist && npm run desktop:build-electron",
"desktop:build:all": "npm run desktop:build:renderer:all && npm run desktop:build:main",
"desktop:build:main": "npm run build:main --prefix=./apps/desktop",
"desktop:build:renderer": "cross-env NODE_OPTIONS=--max-old-space-size=8192 NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
"desktop:build:renderer:all": "npm run desktop:build:renderer && npm run desktop:build:renderer:prepare",
"desktop:build:renderer:prepare": "tsx scripts/electronWorkflow/moveNextExports.ts",
"desktop:build-channel": "tsx scripts/electronWorkflow/buildDesktopChannel.ts",
"desktop:build-electron": "tsx scripts/electronWorkflow/buildElectron.ts",
"desktop:build-local": "npm run desktop:build-next && npm run desktop:prepare-dist && npm run build-local --prefix=./apps/desktop",
"desktop:build-next": "npm run build:electron",
"desktop:prepare-dist": "tsx scripts/electronWorkflow/moveNextExports.ts",
"desktop:main:build": "npm run desktop:main:build --prefix=./apps/desktop",
"desktop:package:app": "npm run desktop:build:renderer:all && npm run desktop:package:app:platform",
"desktop:package:app:platform": "tsx scripts/electronWorkflow/buildElectron.ts",
"desktop:package:local": "npm run desktop:build:renderer:all && npm run package:local --prefix=./apps/desktop",
"desktop:package:local:reuse": "npm run package:local:reuse --prefix=./apps/desktop",
"dev": "next dev -p 3010",
"dev:bun": "bun --bun next dev -p 3010",
"dev:desktop": "cross-env NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/runNextDesktop.mts dev -p 3015",
"dev:desktop:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev --prefix=./apps/desktop",
"dev:desktop:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run dev --prefix=./apps/desktop",
"dev:docker": "docker compose -f docker-compose/dev/docker-compose.yml up -d --wait postgresql redis rustfs searxng",
"dev:docker:down": "docker compose -f docker-compose/dev/docker-compose.yml down",
"dev:docker:reset": "docker compose -f docker-compose/dev/docker-compose.yml down -v && rm -rf docker-compose/dev/data && npm run dev:docker && pnpm db:migrate",

View File

@@ -115,7 +115,7 @@ const main = async () => {
try {
runCommand(`npm run workflow:set-desktop-version ${version} ${channel}`);
runCommand('npm run desktop:build', { UPDATE_CHANNEL: channel });
runCommand('npm run desktop:package:app', { UPDATE_CHANNEL: channel });
} catch (error) {
console.error('❌ Build failed:', error);
process.exit(1);

View File

@@ -17,17 +17,17 @@ const buildElectron = () => {
// Determine build command based on platform
switch (platform) {
case 'darwin': {
buildCommand = 'npm run build:mac --prefix=./apps/desktop';
buildCommand = 'npm run package:mac --prefix=./apps/desktop';
console.log('📦 Building macOS desktop application...');
break;
}
case 'win32': {
buildCommand = 'npm run build:win --prefix=./apps/desktop';
buildCommand = 'npm run package:win --prefix=./apps/desktop';
console.log('📦 Building Windows desktop application...');
break;
}
case 'linux': {
buildCommand = 'npm run build:linux --prefix=./apps/desktop';
buildCommand = 'npm run package:linux --prefix=./apps/desktop';
console.log('📦 Building Linux desktop application...');
break;
}

View File

@@ -0,0 +1,430 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile, writeFileEnsuring } from './utils.mjs';
interface I18nMetadata {
defaultLang: string;
locales: string[];
namespaces: string[];
}
type CodeEdit = { end: number; start: number; text: string };
const toIdentifier = (value: string) => value.replaceAll(/\W/g, '_');
const extractDefaultLang = (code: string): string => {
const match = code.match(/export const DEFAULT_LANG = '([^']+)'/);
if (!match) throw new Error('[convertI18nDynamicToStatic] Failed to extract DEFAULT_LANG');
return match[1];
};
const extractLocales = (code: string): string[] => {
const match = code.match(/export const locales = \[([\S\s]*?)] as const;/);
if (!match) throw new Error('[convertI18nDynamicToStatic] Failed to extract locales array');
const locales: string[] = [];
const regex = /'([^']+)'/g;
let result: RegExpExecArray | null;
// eslint-disable-next-line no-cond-assign
while ((result = regex.exec(match[1])) !== null) {
locales.push(result[1]);
}
invariant(locales.length > 0, '[convertI18nDynamicToStatic] No locales found');
return locales;
};
const extractNamespaces = (code: string): string[] => {
const match = code.match(/const resources = {([\S\s]*?)} as const;/);
if (!match)
throw new Error('[convertI18nDynamicToStatic] Failed to extract default resources map');
const namespaces = new Set<string>();
for (const rawLine of match[1].split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('//')) continue;
const withoutComma = line.replace(/,$/, '').trim();
if (withoutComma.includes(':')) {
const keyPart = withoutComma.split(':')[0].trim();
const keyMatch = keyPart.match(/^'([^']+)'$/);
if (keyMatch) namespaces.add(keyMatch[1]);
continue;
}
const identifierMatch = withoutComma.match(/^(\w+)$/);
if (identifierMatch) namespaces.add(identifierMatch[1]);
}
invariant(namespaces.size > 0, '[convertI18nDynamicToStatic] No namespaces found');
return [...namespaces].sort();
};
const loadI18nMetadata = async (TEMP_DIR: string): Promise<I18nMetadata> => {
const defaultLangPath = path.join(TEMP_DIR, 'src/const/locale.ts');
const localesPath = path.join(TEMP_DIR, 'src/locales/resources.ts');
const defaultResourcesPath = path.join(TEMP_DIR, 'src/locales/default/index.ts');
const [defaultLangCode, localesCode, defaultResourcesCode] = await Promise.all([
fs.readFile(defaultLangPath, 'utf8'),
fs.readFile(localesPath, 'utf8'),
fs.readFile(defaultResourcesPath, 'utf8'),
]);
const defaultLang = extractDefaultLang(defaultLangCode);
const locales = extractLocales(localesCode);
const namespaces = extractNamespaces(defaultResourcesCode);
return { defaultLang, locales, namespaces };
};
const generateLocaleNamespaceImports = (metadata: I18nMetadata) => {
const importLines: string[] = [];
const localeEntries: string[] = [];
for (const locale of metadata.locales) {
if (locale === metadata.defaultLang) continue;
const namespaceEntries: string[] = [];
for (const ns of metadata.namespaces) {
const alias = `locale_${toIdentifier(locale)}__${toIdentifier(ns)}`;
importLines.push(`import ${alias} from '@/../locales/${locale}/${ns}.json';`);
namespaceEntries.push(` '${ns}': { default: ${alias} },`);
}
localeEntries.push(` '${locale}': {\n${namespaceEntries.join('\n')}\n },`);
}
return {
imports: importLines.join('\n'),
localeEntries: localeEntries.join('\n'),
};
};
const generateBusinessUiImports = (metadata: I18nMetadata) => {
const importLines: string[] = [];
const mapEntries: string[] = [];
for (const locale of metadata.locales) {
const alias = `ui_${toIdentifier(locale)}`;
importLines.push(`import ${alias} from '@/../locales/${locale}/ui.json';`);
mapEntries.push(` '${locale}': ${alias} as UILocaleResources,`);
}
return {
imports: importLines.join('\n'),
mapEntries: mapEntries.join('\n'),
};
};
const buildElectronI18nMapContent = (metadata: I18nMetadata) => {
const { imports, localeEntries } = generateLocaleNamespaceImports(metadata);
return `import defaultResources from '@/locales/default';
${imports}
export type LocaleNamespaceModule = { default: unknown };
const toModule = (resource: unknown): LocaleNamespaceModule => ({ default: resource });
export const defaultNamespaceModules: Record<string, LocaleNamespaceModule> = Object.fromEntries(
Object.entries(defaultResources).map(([ns, resource]) => [ns, toModule(resource)]),
);
export const getDefaultNamespaceModule = (ns: string): LocaleNamespaceModule => {
const resource = defaultResources[ns as keyof typeof defaultResources] ?? defaultResources.common;
return toModule(resource);
};
export const staticLocaleNamespaceMap: Record<string, Record<string, LocaleNamespaceModule>> = {
'${metadata.defaultLang}': defaultNamespaceModules,
${localeEntries}
};
`;
};
const buildElectronUiResourcesContent = (metadata: I18nMetadata) => {
const { imports, mapEntries } = generateBusinessUiImports(metadata);
return `${imports}
export type UILocaleResources = Record<string, Record<string, string>>;
export const businessUiResources: Record<string, UILocaleResources> = {
${mapEntries}
};
`;
};
const applyEdits = (code: string, edits: CodeEdit[]): string => {
if (edits.length === 0) return code;
const sorted = [...edits].sort((a, b) => b.start - a.start);
let result = code;
for (const edit of sorted) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const ensureImportAfterLastImport = (code: string, importStatement: string): string => {
if (code.includes(importStatement)) return code;
const moduleMatch = importStatement.match(/from '([^']+)'/);
if (moduleMatch) {
const modulePath = moduleMatch[1];
const hasModuleImport = new RegExp(`from ['"]${modulePath}['"]`).test(code);
if (hasModuleImport) return code;
}
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
const imports = root.findAll({ rule: { kind: 'import_statement' } });
if (imports.length === 0) {
return `${importStatement}\n\n${code}`;
}
const lastImport = imports.at(-1)!;
const insertPos = lastImport.range().end.index;
return code.slice(0, insertPos) + `\n${importStatement}` + code.slice(insertPos);
};
const transformLoadNamespaceModule = (code: string) => {
const importStatement =
"import { defaultNamespaceModules, getDefaultNamespaceModule, staticLocaleNamespaceMap } from '@/utils/i18n/__electronI18nMap';";
let result = ensureImportAfterLastImport(code, importStatement);
const ast = parse(Lang.TypeScript, result);
const root = ast.root();
const edits: CodeEdit[] = [];
const defaultLangReturns = root.findAll({
rule: {
pattern: 'if (lng === defaultLang) return import(`@/locales/default/${ns}`);',
},
});
for (const node of defaultLangReturns) {
const range = node.range();
edits.push({
end: range.end.index,
start: range.start.index,
text: 'if (lng === defaultLang) return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);',
});
}
const dynamicLocaleReturns = root.findAll({
rule: {
pattern: 'return import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`);',
},
});
for (const node of dynamicLocaleReturns) {
const range = node.range();
edits.push({
end: range.end.index,
start: range.start.index,
text: 'return staticLocaleNamespaceMap[normalizeLocale(lng)]?.[ns] ?? getDefaultNamespaceModule(ns);',
});
}
const defaultFallbackReturns = root.findAll({
rule: {
pattern: 'return import(`@/locales/default/${ns}`);',
},
});
for (const node of defaultFallbackReturns) {
const range = node.range();
edits.push({
end: range.end.index,
start: range.start.index,
text: 'return getDefaultNamespaceModule(ns);',
});
}
result = applyEdits(result, edits);
// Fallback to robust function-level replacements if AST patterns did not match.
result = result.replace(
/export const loadI18nNamespaceModule = async[\S\s]*?};/m,
`export const loadI18nNamespaceModule = async (params: LoadI18nNamespaceModuleParams) => {
const { defaultLang, normalizeLocale, lng, ns } = params;
if (lng === defaultLang) return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);
try {
const normalizedLocale = normalizeLocale(lng);
const localeResources = staticLocaleNamespaceMap[normalizedLocale];
if (localeResources?.[ns]) return localeResources[ns];
return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);
} catch {
return getDefaultNamespaceModule(ns);
}
};`,
);
result = result.replace(
/export const loadI18nNamespaceModuleWithFallback = async[\S\s]*?};/m,
`export const loadI18nNamespaceModuleWithFallback = async (
params: LoadI18nNamespaceModuleWithFallbackParams,
) => {
const { onFallback, ...rest } = params;
try {
return await loadI18nNamespaceModule(rest);
} catch (error) {
onFallback?.({ error, lng: rest.lng, ns: rest.ns });
return getDefaultNamespaceModule(rest.ns);
}
};`,
);
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
};
const replaceFunctionBody = (code: string, functionName: string, newBody: string): string => {
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
const target = root.find({
rule: {
kind: 'variable_declarator',
pattern: `const ${functionName} = $EXPR`,
},
});
if (!target) return code;
const declaratorText = target.text();
const initMatch = declaratorText.match(/=\s*([\S\s]*)$/);
if (!initMatch) return code;
const initText = initMatch[1];
const initStart = declaratorText.indexOf(initText);
if (initStart < 0) return code;
const fullRange = target.range();
const initRange = {
end: fullRange.start.index + initStart + initText.length,
start: fullRange.start.index + initStart,
};
const updated = code.slice(0, initRange.start) + newBody + code.slice(initRange.end);
return updated;
};
const transformUiLocaleResources = (code: string) => {
const uiImportStatement = "import { en, zhCn } from '@lobehub/ui/es/i18n/resources/index';";
const businessImportStatement =
"import { businessUiResources } from '@/libs/__electronUiResources';";
let result = ensureImportAfterLastImport(code, uiImportStatement);
result = ensureImportAfterLastImport(result, businessImportStatement);
result = replaceFunctionBody(
result,
'loadBusinessResources',
`(locale: string): UILocaleResources | null => {
return businessUiResources[locale] ?? null;
}`,
);
result = replaceFunctionBody(
result,
'loadLobeUIBuiltinResources',
`(locale: string): UILocaleResources | null => {
if (locale.startsWith('zh')) return zhCn as UILocaleResources;
return en as UILocaleResources;
}`,
);
// Fallback to string replacements if AST patterns did not match.
result = result.replace(
/const loadBusinessResources = async[\S\s]*?};/m,
`const loadBusinessResources = (locale: string): UILocaleResources | null => {
return businessUiResources[locale] ?? null;
};`,
);
result = result.replace(
/const loadLobeUIBuiltinResources = async[\S\s]*?};/m,
`const loadLobeUIBuiltinResources = (locale: string): UILocaleResources | null => {
if (locale.startsWith('zh')) return zhCn as UILocaleResources;
return en as UILocaleResources;
};`,
);
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
};
export const convertI18nDynamicToStatic = async (TEMP_DIR: string) => {
console.log(' Converting i18n dynamic imports to static maps...');
const metadata = await loadI18nMetadata(TEMP_DIR);
const electronI18nMapPath = path.join(TEMP_DIR, 'src/utils/i18n/__electronI18nMap.ts');
const electronUiResourcesPath = path.join(TEMP_DIR, 'src/libs/__electronUiResources.ts');
const loadNamespacePath = path.join(TEMP_DIR, 'src/utils/i18n/loadI18nNamespaceModule.ts');
const uiLocalePath = path.join(TEMP_DIR, 'src/libs/getUILocaleAndResources.ts');
await fs.ensureFile(electronI18nMapPath);
await fs.ensureFile(electronUiResourcesPath);
await writeFileEnsuring({
assertAfter: (code) => !code.includes('import(`'),
filePath: electronI18nMapPath,
name: 'convertI18nDynamicToStatic.electronI18nMap',
text: buildElectronI18nMapContent(metadata),
});
await writeFileEnsuring({
assertAfter: (code) => !code.includes('await import('),
filePath: electronUiResourcesPath,
name: 'convertI18nDynamicToStatic.electronUiResources',
text: buildElectronUiResourcesContent(metadata),
});
await updateFile({
assertAfter: (code) =>
code.includes('@/utils/i18n/__electronI18nMap') &&
!code.includes('import(`@/locales/default/${ns}`)') &&
!code.includes('import(`@/locales/default/${rest.ns}`)') &&
!code.includes('import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`)'),
filePath: loadNamespacePath,
name: 'convertI18nDynamicToStatic.loadNamespace',
transformer: transformLoadNamespaceModule,
});
await updateFile({
assertAfter: (code) =>
code.includes('@/libs/__electronUiResources') &&
code.includes('@lobehub/ui/es/i18n/resources/index') &&
!code.includes('await import(`@/../locales/${locale}/ui.json`)') &&
!code.includes("await import('@lobehub/ui/es/i18n/resources/index')"),
filePath: uiLocalePath,
name: 'convertI18nDynamicToStatic.uiLocale',
transformer: transformUiLocaleResources,
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('convertI18nDynamicToStatic', convertI18nDynamicToStatic, []);
}

View File

@@ -4,6 +4,7 @@ import path from 'node:path';
import { modifyAppCode } from './appCode.mjs';
import { cleanUpCode } from './cleanUp.mjs';
import { convertDynamicToStatic } from './dynamicToStatic.mjs';
import { convertI18nDynamicToStatic } from './i18nDynamicToStatic.mjs';
import { convertNextDynamicToStatic } from './nextDynamicToStatic.mjs';
import { modifyNextConfig } from './nextConfig.mjs';
import { removeSuspenseFromConversation } from './removeSuspense.mjs';
@@ -19,6 +20,7 @@ export const modifySourceForElectron = async (TEMP_DIR: string) => {
await wrapChildrenWithClientOnly(TEMP_DIR);
await convertDynamicToStatic(TEMP_DIR);
await convertNextDynamicToStatic(TEMP_DIR);
await convertI18nDynamicToStatic(TEMP_DIR);
await convertSettingsContentToStatic(TEMP_DIR);
await removeSuspenseFromConversation(TEMP_DIR);
await modifyRoutes(TEMP_DIR);