From 8188e2d9f0bb661617de2f185bc08aea24d46aac Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 28 Jan 2026 01:21:10 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20restructure=20?= =?UTF-8?q?electron=20build=20workflow=20with=20i18n=20codemod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .github/workflows/desktop-build-electron.yml | 85 ---- .github/workflows/manual-build-desktop.yml | 6 +- .github/workflows/pr-build-desktop.yml | 6 +- .github/workflows/release-desktop-beta.yml | 6 +- .github/workflows/release-desktop-stable.yml | 6 +- .github/workflows/verify-electron-codemod.yml | 109 +++++ apps/desktop/README.md | 23 +- apps/desktop/README.zh-CN.md | 23 +- apps/desktop/package.json | 18 +- apps/desktop/scripts/update-test/README.md | 4 +- package.json | 18 +- .../electronWorkflow/buildDesktopChannel.ts | 2 +- scripts/electronWorkflow/buildElectron.ts | 6 +- .../modifiers/i18nDynamicToStatic.mts | 430 ++++++++++++++++++ scripts/electronWorkflow/modifiers/index.mts | 2 + 15 files changed, 603 insertions(+), 141 deletions(-) delete mode 100644 .github/workflows/desktop-build-electron.yml create mode 100644 .github/workflows/verify-electron-codemod.yml create mode 100644 scripts/electronWorkflow/modifiers/i18nDynamicToStatic.mts diff --git a/.github/workflows/desktop-build-electron.yml b/.github/workflows/desktop-build-electron.yml deleted file mode 100644 index a44c38c939..0000000000 --- a/.github/workflows/desktop-build-electron.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/manual-build-desktop.yml b/.github/workflows/manual-build-desktop.yml index 75c3992fd7..d492d465d3 100644 --- a/.github/workflows/manual-build-desktop.yml +++ b/.github/workflows/manual-build-desktop.yml @@ -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 diff --git a/.github/workflows/pr-build-desktop.yml b/.github/workflows/pr-build-desktop.yml index 54f576e24f..bfc980d28e 100644 --- a/.github/workflows/pr-build-desktop.yml +++ b/.github/workflows/pr-build-desktop.yml @@ -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' diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index dc2bccde6f..77227869b0 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -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 diff --git a/.github/workflows/release-desktop-stable.yml b/.github/workflows/release-desktop-stable.yml index 14aca449cf..7c5dde3320 100644 --- a/.github/workflows/release-desktop-stable.yml +++ b/.github/workflows/release-desktop-stable.yml @@ -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 diff --git a/.github/workflows/verify-electron-codemod.yml b/.github/workflows/verify-electron-codemod.yml new file mode 100644 index 0000000000..4ca4d46bb5 --- /dev/null +++ b/.github/workflows/verify-electron-codemod.yml @@ -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 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index b012519b22..1c9fdd50cc 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 diff --git a/apps/desktop/README.zh-CN.md b/apps/desktop/README.zh-CN.md index f6de58211a..d4fbf05337 100644 --- a/apps/desktop/README.zh-CN.md +++ b/apps/desktop/README.zh-CN.md @@ -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 # 本地测试打包 ``` ## 🎯 发布渠道 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d4033540ee..ddc0eca3fe 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/scripts/update-test/README.md b/apps/desktop/scripts/update-test/README.md index 1f50704a2a..44ad87f26a 100644 --- a/apps/desktop/scripts/update-test/README.md +++ b/apps/desktop/scripts/update-test/README.md @@ -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),可以使用根目录脚本: diff --git a/package.json b/package.json index 96703abc2e..bd89b88a45 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/electronWorkflow/buildDesktopChannel.ts b/scripts/electronWorkflow/buildDesktopChannel.ts index 0009726554..153bc8a931 100644 --- a/scripts/electronWorkflow/buildDesktopChannel.ts +++ b/scripts/electronWorkflow/buildDesktopChannel.ts @@ -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); diff --git a/scripts/electronWorkflow/buildElectron.ts b/scripts/electronWorkflow/buildElectron.ts index a8e371b2b9..1787efa16b 100644 --- a/scripts/electronWorkflow/buildElectron.ts +++ b/scripts/electronWorkflow/buildElectron.ts @@ -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; } diff --git a/scripts/electronWorkflow/modifiers/i18nDynamicToStatic.mts b/scripts/electronWorkflow/modifiers/i18nDynamicToStatic.mts new file mode 100644 index 0000000000..a75669d373 --- /dev/null +++ b/scripts/electronWorkflow/modifiers/i18nDynamicToStatic.mts @@ -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(); + + 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 => { + 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 = 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> = { + '${metadata.defaultLang}': defaultNamespaceModules, +${localeEntries} +}; +`; +}; + +const buildElectronUiResourcesContent = (metadata: I18nMetadata) => { + const { imports, mapEntries } = generateBusinessUiImports(metadata); + + return `${imports} + +export type UILocaleResources = Record>; + +export const businessUiResources: Record = { +${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, []); +} diff --git a/scripts/electronWorkflow/modifiers/index.mts b/scripts/electronWorkflow/modifiers/index.mts index 0c86b3aacc..b0d172a51e 100644 --- a/scripts/electronWorkflow/modifiers/index.mts +++ b/scripts/electronWorkflow/modifiers/index.mts @@ -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);