mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
♻️ refactor: migrate frontend from Next.js App Router to Vite SPA (#12404)
* init plan
* 📝 docs: update SPA plan for dev mode Worker cross-origin handling
- Clarified the handling of Worker cross-origin issues in dev mode, emphasizing the need for `workerPatch` to wrap cross-origin URLs as blob URLs.
- Enhanced the explanation of the dev mode's resource URL rewriting process for better understanding.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 refactor: Phase 1 - 环境变量整治
- Fix Pyodide env var mismatch (NEXT_PUBLIC_PYPI_INDEX_URL → pythonEnv.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL)
- Consolidate python.ts to use pythonEnv instead of direct process.env
- Remove NEXT_PUBLIC_ prefix from server-side MARKET_BASE_URL (5 files)
* 🏗️ chore: Phase 2 - Vite 工程搭建
- Add vite.config.ts with dual build (desktop/mobile via MOBILE env)
- Add index.html SPA template with __SERVER_CONFIG__ placeholder
- Add entry.desktop.tsx and entry.mobile.tsx SPA entry points
- Add dev:spa, dev:spa:mobile, build:spa, build:spa:copy scripts
- Install @vitejs/plugin-react and linkedom
* ♻️ refactor: Phase 3 - 第一方包 Next.js 解耦
- Replace next/link with <a> in builtin-tool-web-browsing (4 files, external links)
- Replace next/image with <img> in builtin-tool-agent-builder/InstallPlugin.tsx
- Add Vite import.meta.env compat for isDesktop in const/version.ts, builtin-tool-gtd, builtin-tool-group-management
* ♻️ refactor: Phase 4a - Auth 页面改用直接 next/navigation 和 next/link
- 9 auth files: @/libs/next/navigation → next/navigation
- 5 auth files: @/libs/next/Link → next/link
- Auth pages remain in Next.js App Router, need direct Next.js imports
* ♻️ refactor: Phase 4b - Next.js 抽象层替换为 react-router-dom/vanilla React
- navigation.ts: useRouter/usePathname/useSearchParams/useParams → react-router-dom
- navigation.ts: redirect/notFound → custom error throws
- navigation.ts: useServerInsertedHTML → no-op for SPA
- Link.tsx: next/link → react-router-dom Link adapter (href→to, external→<a>)
- Image.tsx: next/image → <img> wrapper with fill/style support
- dynamic.tsx: next/dynamic → React.lazy + Suspense wrapper
* ✨ feat: Phase 5 - 新建 SPAGlobalProvider
- Create SPAServerConfig type (analyticsConfig, clientEnv, theme, featureFlags, locale)
- Add window.__SERVER_CONFIG__ and __MOBILE__ to global.d.ts
- Create SPAGlobalProvider (client-only Provider tree mirroring GlobalProvider)
- Includes AuthProvider for user session support
- Update entry.desktop.tsx and entry.mobile.tsx to wrap with SPAGlobalProvider
* ♻️ refactor: add SPA catch-all route handler with Vite dev proxy
- Create (spa)/[[...path]]/route.ts for serving SPA HTML
- Dev mode: proxy Vite dev server, rewrite asset URLs, inject Worker patch
- Prod mode: read pre-built HTML templates
- Build SPAServerConfig with analytics, theme, clientEnv, featureFlags
- Update middleware to pass SPA routes through to catch-all
* ♻️ refactor: skip auth checks for SPA routes in middleware
SPA pages are all public (no sensitive data in HTML).
Auth is handled client-side by SPAGlobalProvider's AuthProvider.
Only Next.js auth routes and API endpoints go through session checks.
* ♻️ refactor: replace Next.js-specific analytics with vanilla JS
- Google.tsx: replace @next/third-parties/google with direct gtag script
- ReactScan.tsx: replace react-scan/monitoring/next with generic script
- Desktop.tsx: replace next/script with native script injection
* ♻️ refactor: migrate @t3-oss/env-nextjs to @t3-oss/env-core
Replace framework-specific env validation with framework-agnostic version.
Add clientPrefix where client schemas exist.
* ♻️ refactor: replace next-mdx-remote/rsc with react-markdown
Use client-side react-markdown for MDX rendering instead of
Next.js RSC-dependent next-mdx-remote.
* 🔧 chore: update build scripts and Dockerfile for SPA integration
- build:docker now includes SPA build + copy steps
- dev defaults to Vite SPA, dev:next for Next.js backend
- Dockerfile copies public/spa/ assets for production
- Add public/spa/ to .gitignore (build artifact)
* 🗑️ chore: remove old Next.js route segment files and serwist PWA
- Delete [variants] page.tsx, error.tsx, not-found.tsx, loading.tsx
- Delete root loading.tsx and empty [[...path]] directory
- Delete unused loaders directory
- Remove @serwist/next PWA wrapper from Next.js config
* plan2
* ✨ feat: add locale detection script to index.html for SPA dev mode
* ♻️ refactor: remove locale and theme from SPAServerConfig
* ✨ feat: add [locale] segment with force-static and SEO meta generation
* ♻️ refactor: remove theme/locale reads from SPAGlobalProvider
* ✨ feat: set vite base to /spa/ for production builds
* ✨ feat: auto-generate spaHtmlTemplates from vite build output
* 🔧 chore: register dev:next task in turbo.json for parallel dev startup
* ♻️ refactor: rename (spa) route group to spa segment, rewrite SPA routes via middleware
* ✨ feat: add Vite-compatible i18n/locale modules with import.meta.glob and resolve aliases
* 🔧 fix: use custom Vite plugin for module redirects instead of resolve.alias
* very important
* build
* 🔧 chore: update build scripts and clean up Vite configuration by removing unused plugin and code
Signed-off-by: Innei <tukon479@gmail.com>
* 🗑️ refactor: remove all electron modifier scripts
Modifiers are no longer needed with Vite SPA renderer build.
* ✨ feat: add Vite renderer entry to electron-vite config
Add renderer build configuration to electron-vite, replacing the old
Next.js shadow workspace build flow. Delete buildNextApp.mts and
moveNextExports.ts, update package.json scripts accordingly.
* ✨ feat: add .desktop suffix files for eager i18n loading
Create 4 .desktop files that use import.meta.glob({ eager: true })
for synchronous locale access in Electron desktop builds, replacing
the async lazy-loading used in web SPA builds.
* 🔧 refactor: adapt Electron main process for Vite renderer
Replace nextExportDir with rendererDir, update protocol from
app://next to app://renderer, simplify file resolution to SPA
fallback pattern, update _next/ asset paths to /assets/.
* 🔧 chore: update electron-builder files config for Vite renderer
Replace dist/next references with dist/renderer, remove Next.js
specific exclusion rules no longer applicable to Vite output.
* 🗑️ chore: remove @ast-grep/napi dependency
No longer needed after removing electron modifier scripts.
* 🔧 refactor: unify isDesktop to __ELECTRON__ compile-time constant
Remove NEXT_PUBLIC_IS_DESKTOP_APP and VITE_IS_DESKTOP_APP env vars.
Unify isDesktop in @lobechat/const using __ELECTRON__ defined by Vite.
Re-export from builtin-tool packages. Scripts use DESKTOP_BUILD.
* update
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 refactor: use electron-vite ELECTRON_RENDERER_URL instead of hardcoded port 3015
Replace hardcoded http://localhost:3015 with process.env.ELECTRON_RENDERER_URL
injected by electron-vite dev server. Clean up stale Next.js references.
* 🐛 fix: use local renderer-entry shim to resolve Vite root path issue
HTML entry ../../src/entry.desktop.tsx resolves to /src/entry.desktop.tsx
in URL space, which Vite cannot find within apps/desktop/ root. Add a
local shim that imports across root via module resolver instead.
* 🔧 refactor: extract shared renderer Vite config into sharedRendererConfig
Deduplicate plugins (nodeModuleStub, platformResolve, tsconfigPaths) and
define (__MOBILE__, __ELECTRON__, process.env) between root vite.config.ts
and electron.vite.config.ts renderer section.
* 🔧 refactor: move all renderer plugins and optimizeDeps into shared config
sharedRendererPlugins now includes react, codeInspectorPlugin alongside
nodeModuleStub, platformResolve, tsconfigPaths. Add sharedOptimizeDeps
for pre-bundling list. Both root and electron configs consume shared only.
* 🐛 fix: set electron renderer root to monorepo root for correct glob resolution
import.meta.glob with absolute paths (e.g. /node_modules/antd/...) resolved
within apps/desktop/ instead of monorepo root. Change renderer root to ROOT_DIR,
add electronDesktopHtmlPlugin middleware to rewrite / to /apps/desktop/index.html,
and remove the now-unnecessary renderer-entry.ts shim.
* desktop vite !!
Signed-off-by: Innei <tukon479@gmail.com>
* sync import !!
Signed-off-by: Innei <tukon479@gmail.com>
* clean ci!!
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 refactor: update SPA path structure and clean up dependencies
- Changed the path in .gitignore and related files from [locale] to [variants] for SPA templates.
- Updated index.html to set body height to 100%.
- Cleaned up package.json by removing unused dependencies and reorganizing devDependencies.
- Refactored RendererUrlManager to use a constant for SPA entry HTML path.
- Removed obsolete route.ts file from the SPA structure.
- Adjusted proxy configuration to reflect the new SPA path structure.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 chore: update build script to include mobile SPA build
- Modified the build script in package.json to add the mobile SPA build step.
- Ensured the build process accommodates both desktop and mobile SPA versions.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 chore: update build scripts and improve file encoding consistency
- Modified the build script in package.json to ensure the SPA copy step runs after the build.
- Updated file encoding in generateSpaTemplates.mts from 'utf-8' to 'utf8' for consistency.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 fix: correct Blob import syntax and update global server config type
- Fixed the Blob import syntax in route.ts to ensure proper module loading.
- Updated the global server configuration type in global.d.ts for improved type safety.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 test: update RendererUrlManager test to reflect new file path
- Modified the mock implementation in RendererUrlManager.test.ts to check for the updated file path '/mock/export/out/apps/desktop/index.html'.
- Adjusted the expected resolved path in the test to match the new structure.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 refactor: remove catch-all example file and update imports
- Deleted the catch-all example file `catch-all.eg.ts` to streamline the codebase.
- Updated import paths in `ClientResponsiveLayout.tsx` and `ClientResponsiveContent/index.tsx` to use the new dynamic import location.
- Added type declarations for HTML templates in `spaHtmlTemplates.d.ts`.
- Adjusted `tsconfig.json` to include the updated file structure.
- Enhanced type definitions in `global.d.ts` and fixed locale loading in `locale.vite.ts`.
Signed-off-by: Innei <tukon479@gmail.com>
* e2e
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 chore: remove unused build script for Vercel deployment
- Deleted the `build:vercel` script from package.json to streamline the build process.
- Ensured the remaining build scripts are organized and relevant.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 config: update Vite build input for mobile support
- Changed the build input path in vite.config.ts to conditionally use 'index.mobile.html' for mobile builds, enhancing support for mobile SPA versions.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 feat: add compatibility checks for import maps and cascade layers
- Implemented functions to check for browser support of import maps and CSS cascade layers.
- Redirected users to a compatibility page if their browser does not support the required features.
- Updated the build script in package.json to use the experimental analyze command for better performance.
Signed-off-by: Innei <tukon479@gmail.com>
* chore: rename
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 feat: refactor authentication layout and introduce global providers
- Created a new `RootLayout` component to streamline the layout structure.
- Removed the old layout file for variants and integrated necessary features into the new layout.
- Added `AuthGlobalProvider` to manage authentication context and server configurations.
- Introduced language and theme selection components for enhanced user experience.
- Updated various components to utilize the new context and improve modularity.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 config: exclude build artifacts from serverless functions
- Updated the `next.config.ts` to exclude SPA, desktop, and mobile build artifacts from serverless functions.
- Added paths for `public/spa/**`, `dist/**`, `apps/desktop/build/**`, and `packages/database/migrations/**` to the exclusion list.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 config: refine exclusion of build artifacts from serverless functions
- Updated `next.config.ts` to specify exclusion paths for desktop and mobile build artifacts.
- Changed exclusions from `dist/**` and `apps/desktop/build/**` to `dist/desktop/**`, `dist/mobile/**`, and `apps/desktop/**` for better clarity and organization.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 fix: update BrowserRouter basename for local development
- Modified the `ClientRouter` component to conditionally set the `basename` of `BrowserRouter` based on the `__DEBUG_PROXY__` variable, improving local development experience.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 feat: implement mobile SPA workflow and S3 asset management
- Added a new workflow for building and uploading mobile SPA assets to S3, including environment variable configurations in `.env.example`.
- Updated `package.json` to include a new script for the mobile SPA workflow.
- Enhanced the Vite configuration to support dynamic CDN base paths.
- Refactored the template generation script to handle mobile HTML templates more effectively.
- Introduced new modules for uploading assets to S3 and generating mobile HTML templates.
Signed-off-by: Innei <tukon479@gmail.com>
* 🐛 fix: extract origin from MOBILE_S3_PUBLIC_DOMAIN to prevent double key prefix
* 🔧 fix: update mobile HTML template to use the latest asset versions
- Modified the mobile HTML template to reference the updated JavaScript asset version for improved functionality.
- Ensured consistency in the template structure while maintaining existing styles and scripts.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 chore: update dependencies and refine service worker integration
- Removed outdated dependencies related to Serwist from package.json and tsconfig.json.
- Added vite-plugin-pwa to enhance PWA capabilities in the Vite configuration.
- Updated service worker registration logic in the PWA installation component.
- Introduced a new local development proxy route for debugging purposes.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 chore: refactor development scripts and remove Turbo configuration
- Updated the `dev` script in `package.json` to use a new startup sequence script for improved development workflow.
- Removed the outdated `turbo.json` configuration file as it is no longer needed.
- Introduced `devStartupSequence.mts` to manage the startup of Next.js and Vite processes concurrently.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 feat: update entry points and introduce debug proxy for local development
- Changed the main entry point in `index.html` from `entry.desktop.tsx` to `entry.web.tsx` for improved web compatibility.
- Added an `initialize.ts` file to enable `immer`'s `enableMapSet` functionality.
- Introduced a new `__DEBUG_PROXY__` variable in global types to support local development proxy features.
- Implemented a debug proxy route to facilitate local development with dynamic HTML injection and script handling.
- Removed outdated mobile routing components to streamline the codebase.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 refactor: replace BrowserRouter with RouterProvider for improved routing
- Updated entry points for desktop, mobile, and web to utilize RouterProvider and createAppRouter for better routing management.
- Removed the deprecated renderRoutes function in favor of a more streamlined router configuration.
- Enhanced router setup to support error boundaries and dynamic routing.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 refactor: remove direct access handling for SPA routes in proxy configuration
- Eliminated the handling of direct access to pre-rendered SPA pages in the proxy configuration.
- Simplified the request processing logic by removing checks for SPA routes, streamlining the middleware response flow.
Signed-off-by: Innei <tukon479@gmail.com>
* update
* 🔧 refactor: enhance Worker instantiation logic in mobile HTML template
* 🐛 fix: remove duplicate waitForPageWorkspaceReady calls in page CRUD e2e steps
* 🔧 refactor: simplify createTracePayload function by using btoa for base64 encoding
* 🔧 refactor: specify locales in import.meta.glob for dayjs and antd
* 🔧 refactor: replace Node.js Buffer with web-compatible btoa for base64 encoding in file upload
* 🐛 fix: disable consistent-type-imports rule for mdx files to prevent eslint crash
* 🔧 refactor: add height style to root div for consistent layout
* 🔧 refactor: replace btoa with Buffer for base64 encoding in trace and file upload handling
* 🔧 refactor: extract nextjsOnlyRoutes to a separate file for better organization
* 🔧 refactor: enable Immer MapSet plugin in tests for better state management
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 refactor: integrate sharedRollupOutput configuration and increase cache size for better performance
Signed-off-by: Innei <tukon479@gmail.com>
* 🗑️ chore: remove obsolete desktop.routes.test.ts file as it is no longer needed
Signed-off-by: Innei <tukon479@gmail.com>
* 🐛 fix: use cross-env for env vars in npm scripts (Windows CI)
Co-authored-by: Cursor <cursoragent@cursor.com>
* 🔧 chore: update Dockerfile for web-only build and adjust npm scripts to use pnpm
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 chore: enhance Dockerfile prebuild process with environment checks and add new dependencies
- Updated Dockerfile to include environment checks before removing desktop-only code.
- Added new dependencies in package.json: @aws-sdk/client-bedrock-runtime, @opentelemetry/auto-instrumentations-node, @opentelemetry/resources, @opentelemetry/sdk-metrics, and ajv.
- Configured Rollup to exclude @aws-sdk/client-bedrock-runtime from the SPA bundle.
- Introduced dockerPrebuild.mts script for environment variable validation and information logging.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 chore: enhance Vite and Electron configurations with environment loading and trace encoding improvements
- Updated Vite and Electron configurations to load environment variables using loadEnv.
- Modified trace encoding in utils to use TextEncoder for better compatibility.
- Adjusted sharedRendererConfig to expose only necessary public environment variables.
Signed-off-by: Innei <tukon479@gmail.com>
* 🗑️ chore: remove plans directory (migrated to discussion)
* ♻️ refactor: inject NEXT_PUBLIC_* env per key in Vite define
Co-authored-by: Cursor <cursoragent@cursor.com>
* ✨ feat: add loading screen with animation to enhance user experience
- Introduced a loading screen with a brand logo and animations for better visual feedback during loading times.
- Implemented CSS styles for the loading screen and animations in index.html.
- Removed the loading screen from the DOM once the layout is ready using useLayoutEffect in SPAGlobalProvider.
Signed-off-by: Innei <tukon479@gmail.com>
* 🗑️ chore: remove unnecessary external dependency from Vite configuration
- Eliminated the external dependency '@aws-sdk/client-bedrock-runtime' from the Vite configuration to streamline the build process for the SPA bundle.
Signed-off-by: Innei <tukon479@gmail.com>
* ✨ feat: add web app manifest link in index.html and enable PWA support in Vite configuration
- Added a link to the web app manifest in index.html to enhance PWA capabilities.
- Enabled manifest support in Vite configuration for improved service worker functionality.
Signed-off-by: Innei <tukon479@gmail.com>
* 🔧 chore: update link rel attributes for improved SEO and consistency
- Modified link rel attributes in multiple components to remove 'noreferrer' and standardize to 'nofollow'.
- Adjusted imports in PageContent components for better organization.
Signed-off-by: Innei <tukon479@gmail.com>
* update provider
* ✨ feat: enhance loading experience and update package dependencies
- Added a loading screen with animations and a brand logo in index.html for improved user feedback during loading times.
- Introduced CSS styles for the loading screen and animations.
- Updated package.json files across multiple packages to include "@lobechat/const" as a dependency.
Signed-off-by: Innei <tukon479@gmail.com>
* fix: update proxy
Signed-off-by: Innei <tukon479@gmail.com>
* 🗑️ chore: remove GlobalLayout and Locale components
- Deleted GlobalLayout and Locale components from the GlobalProvider directory to streamline the codebase.
- This removal is part of a refactor to simplify the layout structure and improve maintainability.
Signed-off-by: Innei <tukon479@gmail.com>
* chore: clean up console logs and improve component structure
- Removed unnecessary console log statements from AgentForkTag components in both agent and community directories to enhance code cleanliness.
- Refactored UserAgentList component for better readability by restructuring the useUserDetailContext hook and adjusting the layout of Flexbox components.
Signed-off-by: Innei <tukon479@gmail.com>
* chore: remove console log from MemoryAnalysis component
* chore: update mobile HTML template with new asset links
- Replaced the previous asset links in the mobile HTML template with updated versions to ensure the latest resources are utilized.
- Adjusted the link rel attributes for module preloading to enhance performance and loading efficiency.
Signed-off-by: Innei <tukon479@gmail.com>
* fix: correct variable assignment in createClientTaskThread integration test
- Updated the assignment of the second parent message in the createClientTaskThread integration test to improve clarity and ensure proper data handling.
- Changed the variable name from 'secondParentMsg' to 'inserted' for better context before extracting the first message from the inserted results.
Signed-off-by: Innei <tukon479@gmail.com>
* refactor: simplify authentication check in define-config
- Removed the dependency on the isDesktop variable in the authentication check to streamline the logic.
- Enhanced the clarity of the redirection process for protected routes by focusing solely on the isLoggedIn status.
Signed-off-by: Innei <tukon479@gmail.com>
* ✨ feat(dev): enhance local development setup with debug proxy instructions
- Added detailed instructions for starting the development environment in CLAUDE.md, including commands for SPA and full-stack modes.
- Updated README.md and README.zh-CN.md to reflect new commands and the debug proxy URL for local development.
- Introduced a Vite plugin to print the debug proxy URL upon server start, facilitating easier local development against the production backend.
- Corrected the debug proxy route in entry.web.tsx and define-config.ts for consistency.
This improves the developer experience by providing clear guidance and tools for local development.
Signed-off-by: Innei <tukon479@gmail.com>
* optimize perf
* optimize perf
* optimize perf
* remove speedy plugin
* add dayjs vendor
* Revert "remove speedy plugin"
This reverts commit bf986afeb1.
---------
Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import net from 'node:net';
|
||||
|
||||
const NEXT_HOST = 'localhost';
|
||||
|
||||
/**
|
||||
* Parse the Next.js dev port from the `dev:next` script in the nearest package.json.
|
||||
* Supports both `--port <n>` and `-p <n>` flags. Falls back to 3010.
|
||||
*/
|
||||
const resolveNextPort = (): number => {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'));
|
||||
const devNext: string | undefined = pkg?.scripts?.['dev:next'];
|
||||
if (devNext) {
|
||||
const match = devNext.match(/(?:--port|-p)\s+(\d+)/);
|
||||
if (match) return Number(match[1]);
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
return 3010;
|
||||
};
|
||||
|
||||
const NEXT_PORT = resolveNextPort();
|
||||
const NEXT_ROOT_URL = `http://${NEXT_HOST}:${NEXT_PORT}/`;
|
||||
const NEXT_READY_TIMEOUT_MS = 180_000;
|
||||
const NEXT_READY_RETRY_MS = 400;
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
let nextProcess: ChildProcess | undefined;
|
||||
let viteProcess: ChildProcess | undefined;
|
||||
let shuttingDown = false;
|
||||
|
||||
const runNpmScript = (scriptName: string) =>
|
||||
spawn(npmCommand, ['run', scriptName], {
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const isPortOpen = (host: string, port: number) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const onDone = (result: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
socket.once('connect', () => onDone(true));
|
||||
socket.once('error', () => onDone(false));
|
||||
socket.setTimeout(1_000, () => onDone(false));
|
||||
});
|
||||
|
||||
const waitForNextReady = async () => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < NEXT_READY_TIMEOUT_MS) {
|
||||
if (await isPortOpen(NEXT_HOST, NEXT_PORT)) return;
|
||||
await wait(NEXT_READY_RETRY_MS);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Next server was not ready within ${NEXT_READY_TIMEOUT_MS / 1000}s on ${NEXT_HOST}:${NEXT_PORT}`,
|
||||
);
|
||||
};
|
||||
|
||||
const prewarmNextRootCompile = async () => {
|
||||
const response = await fetch(NEXT_ROOT_URL, { signal: AbortSignal.timeout(120_000) });
|
||||
console.log(`✅ Next prewarm request finished (${response.status}) ${NEXT_ROOT_URL}`);
|
||||
};
|
||||
|
||||
const runNextBackgroundTasks = () => {
|
||||
setTimeout(() => {
|
||||
console.log(`🔁 Next server URL: ${NEXT_ROOT_URL}`);
|
||||
}, 2_000);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await waitForNextReady();
|
||||
await prewarmNextRootCompile();
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Next prewarm skipped:', error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const terminateChild = (child?: ChildProcess) => {
|
||||
if (!child || child.killed) return;
|
||||
child.kill('SIGTERM');
|
||||
};
|
||||
|
||||
const shutdownAll = (signal: NodeJS.Signals) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
terminateChild(viteProcess);
|
||||
terminateChild(nextProcess);
|
||||
|
||||
process.exitCode = signal === 'SIGINT' ? 130 : 143;
|
||||
};
|
||||
|
||||
const watchChildExit = (child: ChildProcess, name: 'next' | 'vite') => {
|
||||
child.once('exit', (code, signal) => {
|
||||
if (!shuttingDown) {
|
||||
console.error(
|
||||
`❌ ${name} exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`,
|
||||
);
|
||||
shutdownAll('SIGTERM');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
process.once('SIGINT', () => shutdownAll('SIGINT'));
|
||||
process.once('SIGTERM', () => shutdownAll('SIGTERM'));
|
||||
|
||||
nextProcess = runNpmScript('dev:next');
|
||||
watchChildExit(nextProcess, 'next');
|
||||
|
||||
viteProcess = runNpmScript('dev:spa');
|
||||
watchChildExit(viteProcess, 'vite');
|
||||
runNextBackgroundTasks();
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => nextProcess?.once('exit', resolve)),
|
||||
new Promise((resolve) => viteProcess?.once('exit', resolve)),
|
||||
]);
|
||||
};
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error('❌ dev startup sequence failed:', error);
|
||||
shutdownAll('SIGTERM');
|
||||
});
|
||||
@@ -1,36 +1,27 @@
|
||||
/**
|
||||
* Docker build pre-check: required env vars + env info.
|
||||
* Run before build:docker in Dockerfile (checkDeprecatedAuth, checkRequiredEnvVars, printEnvInfo).
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import dotenvExpand from 'dotenv-expand';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Use createRequire for CommonJS module compatibility
|
||||
const require = createRequire(import.meta.url);
|
||||
const { checkDeprecatedAuth } = require('./_shared/checkDeprecatedAuth.js');
|
||||
|
||||
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
|
||||
const isBundleAnalyzer = process.env.ANALYZE === 'true' && process.env.CI === 'true';
|
||||
const isServerDB = !!process.env.DATABASE_URL;
|
||||
dotenvExpand.expand(dotenv.config());
|
||||
|
||||
if (isDesktop) {
|
||||
dotenvExpand.expand(dotenv.config({ path: '.env.desktop' }));
|
||||
dotenvExpand.expand(dotenv.config({ override: true, path: '.env.desktop.local' }));
|
||||
} else {
|
||||
dotenvExpand.expand(dotenv.config());
|
||||
}
|
||||
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
|
||||
const isServerDB = !!process.env.DATABASE_URL;
|
||||
|
||||
const AUTH_SECRET_DOC_URL =
|
||||
'https://lobehub.com/docs/self-hosting/environment-variables/auth#auth-secret';
|
||||
const KEY_VAULTS_SECRET_DOC_URL =
|
||||
'https://lobehub.com/docs/self-hosting/environment-variables/basic#key-vaults-secret';
|
||||
|
||||
/**
|
||||
* Check for required environment variables in server database mode
|
||||
*/
|
||||
const checkRequiredEnvVars = () => {
|
||||
function checkRequiredEnvVars(): void {
|
||||
if (isDesktop || !isServerDB) return;
|
||||
|
||||
const missingVars: { docUrl: string; name: string }[] = [];
|
||||
@@ -59,9 +50,9 @@ const checkRequiredEnvVars = () => {
|
||||
console.error('═'.repeat(70) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getCommandVersion = (command: string): string | null => {
|
||||
function getCommandVersion(command: string): string | null {
|
||||
try {
|
||||
return execSync(`${command} --version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
.trim()
|
||||
@@ -69,13 +60,12 @@ const getCommandVersion = (command: string): string | null => {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const printEnvInfo = () => {
|
||||
function printEnvInfo(): void {
|
||||
console.log('\n📋 Build Environment Info:');
|
||||
console.log('─'.repeat(50));
|
||||
|
||||
// Runtime versions
|
||||
console.log(` Node.js: ${process.version}`);
|
||||
console.log(` npm: ${getCommandVersion('npm') ?? 'not installed'}`);
|
||||
|
||||
@@ -85,7 +75,6 @@ const printEnvInfo = () => {
|
||||
const pnpmVersion = getCommandVersion('pnpm');
|
||||
if (pnpmVersion) console.log(` pnpm: ${pnpmVersion}`);
|
||||
|
||||
// Auth-related env vars
|
||||
console.log('\n Auth Environment Variables:');
|
||||
console.log(` APP_URL: ${process.env.APP_URL ?? '(not set)'}`);
|
||||
console.log(` VERCEL_URL: ${process.env.VERCEL_URL ?? '(not set)'}`);
|
||||
@@ -96,7 +85,6 @@ const printEnvInfo = () => {
|
||||
console.log(` AUTH_EMAIL_VERIFICATION: ${process.env.AUTH_EMAIL_VERIFICATION ?? '(not set)'}`);
|
||||
console.log(` AUTH_ENABLE_MAGIC_LINK: ${process.env.AUTH_ENABLE_MAGIC_LINK ?? '(not set)'}`);
|
||||
|
||||
// Check SSO providers configuration
|
||||
const ssoProviders = process.env.AUTH_SSO_PROVIDERS;
|
||||
console.log(` AUTH_SSO_PROVIDERS: ${ssoProviders ?? '(not set)'}`);
|
||||
|
||||
@@ -129,108 +117,8 @@ const printEnvInfo = () => {
|
||||
}
|
||||
|
||||
console.log('─'.repeat(50));
|
||||
};
|
||||
|
||||
// 创建需要排除的特性映射
|
||||
|
||||
const partialBuildPages = [
|
||||
// no need for bundle analyzer (frontend only)
|
||||
{
|
||||
name: 'backend-routes',
|
||||
disabled: isBundleAnalyzer,
|
||||
paths: ['src/app/(backend)'],
|
||||
},
|
||||
// no need for desktop
|
||||
// {
|
||||
// name: 'changelog',
|
||||
// disabled: isDesktop,
|
||||
// paths: ['src/app/[variants]/(main)/changelog'],
|
||||
// },
|
||||
{
|
||||
name: 'auth',
|
||||
disabled: isDesktop,
|
||||
paths: ['src/app/[variants]/(auth)'],
|
||||
},
|
||||
// {
|
||||
// name: 'mobile',
|
||||
// disabled: isDesktop,
|
||||
// paths: ['src/app/[variants]/(main)/(mobile)'],
|
||||
// },
|
||||
{
|
||||
name: 'oauth',
|
||||
disabled: isDesktop,
|
||||
paths: ['src/app/[variants]/oauth', 'src/app/(backend)/oidc'],
|
||||
},
|
||||
{
|
||||
name: 'api-webhooks',
|
||||
disabled: isDesktop,
|
||||
paths: ['src/app/(backend)/api/webhooks'],
|
||||
},
|
||||
{
|
||||
name: 'market-auth',
|
||||
disabled: isDesktop,
|
||||
paths: ['src/app/market-auth-callback'],
|
||||
},
|
||||
{
|
||||
name: 'pwa',
|
||||
disabled: isDesktop,
|
||||
paths: ['src/manifest.ts', 'src/sitemap.tsx', 'src/robots.tsx', 'src/sw'],
|
||||
},
|
||||
// no need for web
|
||||
{
|
||||
name: 'desktop-devtools',
|
||||
disabled: !isDesktop,
|
||||
paths: ['src/app/desktop'],
|
||||
},
|
||||
{
|
||||
name: 'desktop-trpc',
|
||||
disabled: !isDesktop,
|
||||
paths: ['src/app/(backend)/trpc/desktop'],
|
||||
},
|
||||
];
|
||||
/* eslint-enable */
|
||||
|
||||
/**
|
||||
* 删除指定的目录
|
||||
*/
|
||||
export const runPrebuild = async (targetDir: string = 'src') => {
|
||||
// 遍历 partialBuildPages 数组
|
||||
for (const page of partialBuildPages) {
|
||||
// 检查是否需要禁用该功能
|
||||
if (page.disabled) {
|
||||
for (const dirPath of page.paths) {
|
||||
// Replace 'src' with targetDir
|
||||
const relativePath = dirPath.replace(/^src/, targetDir);
|
||||
const fullPath = path.resolve(process.cwd(), relativePath);
|
||||
|
||||
// 检查目录是否存在
|
||||
if (existsSync(fullPath)) {
|
||||
try {
|
||||
// 递归删除目录
|
||||
await rm(fullPath, { force: true, recursive: true });
|
||||
console.log(`♻️ Removed ${relativePath} successfully`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove directory ${relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the script is being run directly
|
||||
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMainModule) {
|
||||
// Check for deprecated auth env vars first - fail fast if found
|
||||
checkDeprecatedAuth();
|
||||
|
||||
// Check for required env vars in server database mode
|
||||
checkRequiredEnvVars();
|
||||
|
||||
printEnvInfo();
|
||||
// 执行删除操作
|
||||
console.log('\nStarting prebuild cleanup...');
|
||||
await runPrebuild();
|
||||
console.log('Prebuild cleanup completed.');
|
||||
}
|
||||
|
||||
checkDeprecatedAuth();
|
||||
checkRequiredEnvVars();
|
||||
printEnvInfo();
|
||||
@@ -1,172 +0,0 @@
|
||||
import fs from 'fs-extra';
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runPrebuild } from '../prebuild.mjs';
|
||||
import { modifySourceForElectron } from './modifiers/index.mjs';
|
||||
|
||||
const PROJECT_ROOT = process.cwd();
|
||||
const TEMP_DIR = path.join(PROJECT_ROOT, 'tmp', 'desktop-build');
|
||||
|
||||
const foldersToSymlink = [
|
||||
'node_modules',
|
||||
'packages',
|
||||
'public',
|
||||
'locales',
|
||||
'docs',
|
||||
'.cursor',
|
||||
'apps',
|
||||
];
|
||||
|
||||
const foldersToCopy = ['src', 'scripts'];
|
||||
|
||||
// Assets to remove from desktop build output (not needed for Electron app)
|
||||
const assetsToRemove = [
|
||||
// Icons & favicons
|
||||
'apple-touch-icon.png',
|
||||
'favicon.ico',
|
||||
'favicon-32x32.ico',
|
||||
'favicon-16x16.png',
|
||||
'favicon-32x32.png',
|
||||
|
||||
// SEO & sitemap
|
||||
'sitemap.xml',
|
||||
'sitemap-index.xml',
|
||||
'sitemap',
|
||||
'robots.txt',
|
||||
|
||||
// Incompatible pages
|
||||
'not-compatible.html',
|
||||
'not-compatible',
|
||||
|
||||
// Large media assets
|
||||
'videos',
|
||||
'screenshots',
|
||||
'og',
|
||||
];
|
||||
|
||||
const filesToCopy = [
|
||||
'package.json',
|
||||
'tsconfig.json',
|
||||
'next.config.ts',
|
||||
'pnpm-workspace.yaml',
|
||||
'bun.lockb',
|
||||
'.npmrc',
|
||||
'.bunfig.toml',
|
||||
'.eslintrc.js',
|
||||
'.eslintignore',
|
||||
'.prettierrc.cjs',
|
||||
'.prettierignore',
|
||||
'drizzle.config.ts',
|
||||
'postcss.config.js',
|
||||
'tailwind.config.ts',
|
||||
'tailwind.config.js',
|
||||
];
|
||||
|
||||
const build = async () => {
|
||||
console.log('🚀 Starting Electron App Build in Shadow Workspace...');
|
||||
console.log(`📂 Workspace: ${TEMP_DIR}`);
|
||||
|
||||
if (fs.existsSync(TEMP_DIR)) {
|
||||
await fs.remove(TEMP_DIR);
|
||||
}
|
||||
await fs.ensureDir(TEMP_DIR);
|
||||
|
||||
console.log('🔗 Symlinking dependencies and static assets...');
|
||||
for (const folder of foldersToSymlink) {
|
||||
const srcPath = path.join(PROJECT_ROOT, folder);
|
||||
const destPath = path.join(TEMP_DIR, folder);
|
||||
if (fs.existsSync(srcPath)) {
|
||||
await fs.ensureSymlink(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📋 Copying source code...');
|
||||
for (const folder of foldersToCopy) {
|
||||
const srcPath = path.join(PROJECT_ROOT, folder);
|
||||
const destPath = path.join(TEMP_DIR, folder);
|
||||
if (fs.existsSync(srcPath)) {
|
||||
await fs.copy(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📄 Copying configuration files...');
|
||||
const allFiles = await fs.readdir(PROJECT_ROOT);
|
||||
const envFiles = allFiles.filter((f) => f.startsWith('.env'));
|
||||
const files = [...filesToCopy, ...envFiles];
|
||||
for (const file of files) {
|
||||
const srcPath = path.join(PROJECT_ROOT, file);
|
||||
const destPath = path.join(TEMP_DIR, file);
|
||||
if (fs.existsSync(srcPath)) {
|
||||
await fs.copy(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✂️ Pruning desktop-incompatible code...');
|
||||
const relativeTempSrc = path.relative(PROJECT_ROOT, path.join(TEMP_DIR, 'src'));
|
||||
await runPrebuild(relativeTempSrc);
|
||||
|
||||
await modifySourceForElectron(TEMP_DIR);
|
||||
|
||||
console.log('🏗 Running next build in shadow workspace...');
|
||||
try {
|
||||
execSync('next build', {
|
||||
cwd: TEMP_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
// Pass PROJECT_ROOT to next.config.ts for outputFileTracingRoot
|
||||
// This fixes Turbopack symlink resolution when building in shadow workspace
|
||||
ELECTRON_BUILD_PROJECT_ROOT: PROJECT_ROOT,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=8192',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
console.log('📦 Extracting build artifacts...');
|
||||
const sourceOutDir = path.join(TEMP_DIR, 'out');
|
||||
const targetOutDir = path.join(PROJECT_ROOT, 'out');
|
||||
|
||||
// Clean up target directories
|
||||
if (fs.existsSync(targetOutDir)) {
|
||||
await fs.remove(targetOutDir);
|
||||
}
|
||||
|
||||
if (fs.existsSync(sourceOutDir)) {
|
||||
console.log('📦 Moving "out" directory...');
|
||||
await fs.move(sourceOutDir, targetOutDir);
|
||||
|
||||
// Remove unnecessary assets from desktop build
|
||||
console.log('🗑️ Removing unnecessary assets...');
|
||||
for (const asset of assetsToRemove) {
|
||||
const assetPath = path.join(targetOutDir, asset);
|
||||
if (fs.existsSync(assetPath)) {
|
||||
await fs.remove(assetPath);
|
||||
console.log(` Removed: ${asset}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 'out' directory not found. Using '.next' instead (fallback)?");
|
||||
const sourceNextDir = path.join(TEMP_DIR, '.next');
|
||||
const targetNextDir = path.join(PROJECT_ROOT, '.next');
|
||||
if (fs.existsSync(targetNextDir)) {
|
||||
await fs.remove(targetNextDir);
|
||||
}
|
||||
if (fs.existsSync(sourceNextDir)) {
|
||||
await fs.move(sourceNextDir, targetNextDir);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('❌ Build failed.');
|
||||
throw error;
|
||||
} finally {
|
||||
console.log('🧹 Cleaning up workspace...');
|
||||
await fs.remove(TEMP_DIR);
|
||||
}
|
||||
};
|
||||
|
||||
await build().catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
isDirectRun,
|
||||
normalizeEol,
|
||||
removePathEnsuring,
|
||||
runStandalone,
|
||||
updateFile,
|
||||
writeFileEnsuring,
|
||||
} from './utils.mjs';
|
||||
|
||||
const desktopOnlyVariantsPage = `import { DynamicLayoutProps } from '@/types/next';
|
||||
|
||||
import DesktopRouter from './router';
|
||||
|
||||
export default async (_props: DynamicLayoutProps) => {
|
||||
return <DesktopRouter />;
|
||||
};
|
||||
`;
|
||||
|
||||
const stripDevPanel = (code: string) => {
|
||||
let result = code.replace(/import DevPanel from ['"]@\/features\/DevPanel['"];\r?\n?/, '');
|
||||
|
||||
result = result.replace(
|
||||
/[\t ]*{process\.env\.NODE_ENV === 'development' && <DevPanel \/>}\s*\r?\n?/,
|
||||
'',
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const assertDevPanelStripped = (code: string) =>
|
||||
!/import\s+DevPanel\s+from\s+['"]@\/features\/DevPanel['"]/.test(code) &&
|
||||
!/<DevPanel\b/.test(code) &&
|
||||
!/NEXT_PUBLIC_ENABLE_DEV_PANEL|DevPanel\s*\/>/.test(code);
|
||||
|
||||
const removeSecurityTab = (code: string) => {
|
||||
const componentEntryRegex =
|
||||
/[\t ]*\[SettingsTabs\.Security]: dynamic\(\(\) => import\('\.\.\/security'\), {[\s\S]+?}\),\s*\r?\n/;
|
||||
const securityTabRegex = /[\t ]*SettingsTabs\.Security,\s*\r?\n/;
|
||||
|
||||
return code.replace(componentEntryRegex, '').replace(securityTabRegex, '');
|
||||
};
|
||||
|
||||
const assertSecurityTabRemoved = (code: string) => !/\bSettingsTabs\.Security\b/.test(code);
|
||||
|
||||
const removeSpeedInsightsAndAnalytics = (code: string) => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
const edits: Array<{ start: number; end: number; text: string }> = [];
|
||||
|
||||
// Remove SpeedInsights import
|
||||
const speedInsightsImport = root.find({
|
||||
rule: {
|
||||
pattern: 'import { SpeedInsights } from $SOURCE',
|
||||
},
|
||||
});
|
||||
if (speedInsightsImport) {
|
||||
const range = speedInsightsImport.range();
|
||||
edits.push({ start: range.start.index, end: range.end.index, text: '' });
|
||||
}
|
||||
|
||||
// Remove Analytics import
|
||||
const analyticsImport = root.find({
|
||||
rule: {
|
||||
pattern: 'import Analytics from $SOURCE',
|
||||
},
|
||||
});
|
||||
if (analyticsImport) {
|
||||
const range = analyticsImport.range();
|
||||
edits.push({ start: range.start.index, end: range.end.index, text: '' });
|
||||
}
|
||||
|
||||
// Remove Suspense block containing Analytics and SpeedInsights
|
||||
// Find all Suspense blocks and check which one contains Analytics or SpeedInsights
|
||||
const allSuspenseBlocks = root.findAll({
|
||||
rule: {
|
||||
pattern: '<Suspense fallback={null}>$$$</Suspense>',
|
||||
},
|
||||
});
|
||||
|
||||
for (const suspenseBlock of allSuspenseBlocks) {
|
||||
const hasAnalytics = suspenseBlock.find({
|
||||
rule: {
|
||||
pattern: '<Analytics />',
|
||||
},
|
||||
});
|
||||
|
||||
const hasSpeedInsights = suspenseBlock.find({
|
||||
rule: {
|
||||
pattern: '<SpeedInsights />',
|
||||
},
|
||||
});
|
||||
|
||||
if (hasAnalytics || hasSpeedInsights) {
|
||||
const range = suspenseBlock.range();
|
||||
edits.push({ start: range.start.index, end: range.end.index, text: '' });
|
||||
break; // Only remove the first matching Suspense block
|
||||
}
|
||||
}
|
||||
|
||||
// Remove inVercel variable if it's no longer used
|
||||
const inVercelVar = root.find({
|
||||
rule: {
|
||||
pattern: 'const inVercel = process.env.VERCEL === "1";',
|
||||
},
|
||||
});
|
||||
if (inVercelVar) {
|
||||
// Check if inVercel is still used elsewhere
|
||||
const allInVercelUsages = root.findAll({
|
||||
rule: {
|
||||
regex: 'inVercel',
|
||||
},
|
||||
});
|
||||
// If only the declaration remains, remove it
|
||||
if (allInVercelUsages.length === 1) {
|
||||
const range = inVercelVar.range();
|
||||
edits.push({ start: range.start.index, end: range.end.index, text: '' });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply edits
|
||||
if (edits.length === 0) return code;
|
||||
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
let result = code;
|
||||
for (const edit of edits) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const assertSpeedInsightsAndAnalyticsRemoved = (code: string) =>
|
||||
!/<Analytics\s*\/>/.test(code) &&
|
||||
!/<SpeedInsights\s*\/>/.test(code) &&
|
||||
!/import\s+\{\s*SpeedInsights\s*\}\s+from\b/.test(code) &&
|
||||
!/import\s+Analytics\s+from\b/.test(code);
|
||||
|
||||
const removeManifestFromMetadata = (code: string) => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
const edits: Array<{ start: number; end: number; text: string }> = [];
|
||||
|
||||
// Find generateMetadata function
|
||||
const generateMetadataFunc = root.find({
|
||||
rule: {
|
||||
pattern: 'export const generateMetadata = async ($$$) => { $$$ }',
|
||||
},
|
||||
});
|
||||
|
||||
if (!generateMetadataFunc) return code;
|
||||
|
||||
// Find return statement
|
||||
const returnStatement = generateMetadataFunc.find({
|
||||
rule: {
|
||||
kind: 'return_statement',
|
||||
},
|
||||
});
|
||||
|
||||
if (!returnStatement) return code;
|
||||
|
||||
// Find the object in return statement
|
||||
const returnObject = returnStatement.find({
|
||||
rule: {
|
||||
kind: 'object',
|
||||
},
|
||||
});
|
||||
|
||||
if (!returnObject) return code;
|
||||
|
||||
// Find all pair nodes (key-value pairs in the object)
|
||||
const allPairs = returnObject.findAll({
|
||||
rule: {
|
||||
kind: 'pair',
|
||||
},
|
||||
});
|
||||
|
||||
const keysToRemove = ['manifest', 'metadataBase'];
|
||||
|
||||
for (const pair of allPairs) {
|
||||
// Find the property_identifier or identifier
|
||||
const key = pair.find({
|
||||
rule: {
|
||||
any: [{ kind: 'property_identifier' }, { kind: 'identifier' }],
|
||||
},
|
||||
});
|
||||
|
||||
if (key && keysToRemove.includes(key.text())) {
|
||||
const range = pair.range();
|
||||
// Include the trailing comma if present
|
||||
const afterPair = code.slice(range.end.index, range.end.index + 10);
|
||||
const commaMatch = afterPair.match(/^,\s*/);
|
||||
const endIndex = commaMatch ? range.end.index + commaMatch[0].length : range.end.index;
|
||||
|
||||
edits.push({
|
||||
start: range.start.index,
|
||||
end: endIndex,
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply edits
|
||||
if (edits.length === 0) return code;
|
||||
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
let result = code;
|
||||
for (const edit of edits) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const assertMetadataManifestRemoved = (code: string) =>
|
||||
!/\bmanifest\s*:/.test(code) && !/\bmetadataBase\s*:/.test(code);
|
||||
|
||||
export const modifyAppCode = async (TEMP_DIR: string) => {
|
||||
// 1. Replace src/app/[variants]/page.tsx with a desktop-only entry
|
||||
const variantsPagePath = path.join(TEMP_DIR, 'src/app/[variants]/page.tsx');
|
||||
console.log(' Processing src/app/[variants]/page.tsx...');
|
||||
await writeFileEnsuring({
|
||||
filePath: variantsPagePath,
|
||||
name: 'modifyAppCode:variantsPage',
|
||||
text: desktopOnlyVariantsPage,
|
||||
assertAfter: (code) => normalizeEol(code) === normalizeEol(desktopOnlyVariantsPage),
|
||||
});
|
||||
|
||||
// 2. Remove DevPanel from src/layout/GlobalProvider/index.tsx
|
||||
const globalProviderPath = path.join(TEMP_DIR, 'src/layout/GlobalProvider/index.tsx');
|
||||
console.log(' Processing src/layout/GlobalProvider/index.tsx...');
|
||||
await updateFile({
|
||||
filePath: globalProviderPath,
|
||||
name: 'modifyAppCode:stripDevPanel',
|
||||
transformer: stripDevPanel,
|
||||
assertAfter: assertDevPanelStripped,
|
||||
});
|
||||
|
||||
// 3. Delete src/app/[variants]/(main)/settings/security directory
|
||||
const securityDirPath = path.join(TEMP_DIR, 'src/app/[variants]/(main)/settings/security');
|
||||
console.log(' Deleting src/app/[variants]/(main)/settings/security directory...');
|
||||
await removePathEnsuring({
|
||||
name: 'modifyAppCode:deleteSecurityDir',
|
||||
path: securityDirPath,
|
||||
});
|
||||
|
||||
// 4. Remove Security tab wiring from SettingsContent
|
||||
const settingsContentPath = path.join(
|
||||
TEMP_DIR,
|
||||
'src/app/[variants]/(main)/settings/features/SettingsContent.tsx',
|
||||
);
|
||||
console.log(' Processing src/app/[variants]/(main)/settings/features/SettingsContent.tsx...');
|
||||
await updateFile({
|
||||
filePath: settingsContentPath,
|
||||
name: 'modifyAppCode:removeSecurityTab',
|
||||
transformer: removeSecurityTab,
|
||||
assertAfter: assertSecurityTabRemoved,
|
||||
});
|
||||
|
||||
// 5. Remove SpeedInsights and Analytics from src/app/[variants]/layout.tsx
|
||||
const variantsLayoutPath = path.join(TEMP_DIR, 'src/app/[variants]/layout.tsx');
|
||||
console.log(' Processing src/app/[variants]/layout.tsx...');
|
||||
await updateFile({
|
||||
filePath: variantsLayoutPath,
|
||||
name: 'modifyAppCode:removeSpeedInsightsAndAnalytics',
|
||||
transformer: removeSpeedInsightsAndAnalytics,
|
||||
assertAfter: assertSpeedInsightsAndAnalyticsRemoved,
|
||||
});
|
||||
|
||||
// 6. Replace mdx Image component with next/image export
|
||||
const mdxImagePath = path.join(TEMP_DIR, 'src/components/mdx/Image.tsx');
|
||||
console.log(' Processing src/components/mdx/Image.tsx...');
|
||||
await writeFileEnsuring({
|
||||
filePath: mdxImagePath,
|
||||
name: 'modifyAppCode:replaceMdxImage',
|
||||
text: "export { default } from 'next/image';\n",
|
||||
assertAfter: (code) => normalizeEol(code).trim() === "export { default } from 'next/image';",
|
||||
});
|
||||
|
||||
// 7. Remove manifest from metadata
|
||||
const metadataPath = path.join(TEMP_DIR, 'src/app/[variants]/metadata.ts');
|
||||
console.log(' Processing src/app/[variants]/metadata.ts...');
|
||||
await updateFile({
|
||||
filePath: metadataPath,
|
||||
name: 'modifyAppCode:removeManifestFromMetadata',
|
||||
transformer: removeManifestFromMetadata,
|
||||
assertAfter: assertMetadataManifestRemoved,
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('modifyAppCode', modifyAppCode, [
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/page.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/layout/GlobalProvider/index.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/settings/features/SettingsContent.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/layout.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/metadata.ts' },
|
||||
]);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
const hasUseServerDirective = (code: string) =>
|
||||
/^\s*['"]use server['"]\s*;?/m.test(code.trimStart());
|
||||
|
||||
export const cleanUpCode = async (TEMP_DIR: string) => {
|
||||
// Remove 'use server'
|
||||
const filesToRemoveUseServer = [
|
||||
'src/features/DevPanel/CacheViewer/getCacheEntries.ts',
|
||||
'src/server/translation.ts',
|
||||
];
|
||||
|
||||
for (const file of filesToRemoveUseServer) {
|
||||
const filePath = path.join(TEMP_DIR, file);
|
||||
console.log(` Processing ${file}...`);
|
||||
await updateFile({
|
||||
filePath,
|
||||
name: `cleanUpCode:removeUseServer:${file}`,
|
||||
transformer: (code) => {
|
||||
// Prefer a deterministic text rewrite for directive prologue:
|
||||
// remove ONLY the top-level `'use server';` directive if present.
|
||||
const next = code.replace(/^\s*['"]use server['"]\s*;\s*\r?\n?/, '');
|
||||
if (next !== code) return next;
|
||||
|
||||
// Fallback to AST rewrite (in case of odd formatting)
|
||||
const ast = parse(Lang.TypeScript, code);
|
||||
const root = ast.root();
|
||||
|
||||
const useServer =
|
||||
root.find({
|
||||
rule: { pattern: "'use server'" },
|
||||
}) ||
|
||||
root.find({
|
||||
rule: { pattern: '"use server"' },
|
||||
});
|
||||
|
||||
if (!useServer) return code;
|
||||
|
||||
let curr = useServer.parent();
|
||||
while (curr) {
|
||||
if (curr.kind() === 'expression_statement') {
|
||||
curr.replace('');
|
||||
break;
|
||||
}
|
||||
if (curr.kind() === 'program') break;
|
||||
curr = curr.parent();
|
||||
}
|
||||
|
||||
return root.text();
|
||||
},
|
||||
assertAfter: (code) => !hasUseServerDirective(code),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('cleanUpCode', cleanUpCode, [
|
||||
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
|
||||
{ lang: Lang.TypeScript, path: 'src/features/DevPanel/CacheViewer/getCacheEntries.ts' },
|
||||
{ lang: Lang.TypeScript, path: 'src/server/translation.ts' },
|
||||
]);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import path from 'node:path';
|
||||
|
||||
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
interface ImportInfo {
|
||||
defaultImport?: string;
|
||||
namedImports: string[];
|
||||
}
|
||||
|
||||
interface DynamicElementInfo {
|
||||
componentName: string;
|
||||
end: number;
|
||||
importPath: string;
|
||||
isNamedExport: boolean;
|
||||
namedExport?: string;
|
||||
start: number;
|
||||
}
|
||||
|
||||
const toPascalCase = (str: string): string => {
|
||||
return str
|
||||
.split(/[_-]/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
};
|
||||
|
||||
const generateComponentName = (
|
||||
importPath: string,
|
||||
namedExport?: string,
|
||||
existingNames: Set<string> = new Set(),
|
||||
): string => {
|
||||
if (namedExport) {
|
||||
let name = namedExport;
|
||||
let counter = 1;
|
||||
while (existingNames.has(name)) {
|
||||
name = `${namedExport}${counter++}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
const segments = importPath
|
||||
.split('/')
|
||||
.filter((s) => s && !s.startsWith('.'))
|
||||
.map((s) => s.replace(/^\((.+)\)$/, '$1').replace(/^\[(.+)]$/, '$1'));
|
||||
|
||||
const meaningfulSegments = segments.slice(-3).filter(Boolean);
|
||||
|
||||
let baseName =
|
||||
meaningfulSegments.length > 0
|
||||
? meaningfulSegments.map((s) => toPascalCase(s)).join('') + 'Page'
|
||||
: 'Page';
|
||||
|
||||
let name = baseName;
|
||||
let counter = 1;
|
||||
while (existingNames.has(name)) {
|
||||
name = `${baseName}${counter++}`;
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
const extractDynamicElements = (code: string): DynamicElementInfo[] => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
||||
const results: DynamicElementInfo[] = [];
|
||||
const existingNames = new Set<string>();
|
||||
|
||||
const dynamicCalls = root.findAll({
|
||||
rule: {
|
||||
pattern: 'dynamicElement($IMPORT_FN, $DEBUG_ID)',
|
||||
},
|
||||
});
|
||||
|
||||
for (const call of dynamicCalls) {
|
||||
const range = call.range();
|
||||
const text = call.text();
|
||||
|
||||
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
|
||||
invariant(
|
||||
importMatch,
|
||||
`[convertDynamicToStatic] Failed to extract import path from dynamicElement call: ${text.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
const importPath = importMatch![1];
|
||||
|
||||
const thenMatch = text.match(/\.then\s*\(\s*\(\s*(\w+)\s*\)\s*=>\s*\1\.(\w+)\s*\)/);
|
||||
const namedExport = thenMatch ? thenMatch[2] : undefined;
|
||||
|
||||
const componentName = generateComponentName(importPath, namedExport, existingNames);
|
||||
existingNames.add(componentName);
|
||||
|
||||
results.push({
|
||||
componentName,
|
||||
end: range.end.index,
|
||||
importPath,
|
||||
isNamedExport: !!namedExport,
|
||||
namedExport,
|
||||
start: range.start.index,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const buildImportMap = (elements: DynamicElementInfo[]): Map<string, ImportInfo> => {
|
||||
const importMap = new Map<string, ImportInfo>();
|
||||
|
||||
for (const el of elements) {
|
||||
const existing = importMap.get(el.importPath) || { namedImports: [] };
|
||||
|
||||
if (el.isNamedExport && el.namedExport) {
|
||||
if (!existing.namedImports.includes(el.namedExport)) {
|
||||
existing.namedImports.push(el.namedExport);
|
||||
}
|
||||
} else {
|
||||
existing.defaultImport = el.componentName;
|
||||
}
|
||||
|
||||
importMap.set(el.importPath, existing);
|
||||
}
|
||||
|
||||
return importMap;
|
||||
};
|
||||
|
||||
const generateImportStatements = (importMap: Map<string, ImportInfo>): string => {
|
||||
const statements: string[] = [];
|
||||
|
||||
const sortedPaths = [...importMap.keys()].sort();
|
||||
|
||||
for (const importPath of sortedPaths) {
|
||||
const info = importMap.get(importPath)!;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (info.defaultImport) {
|
||||
parts.push(info.defaultImport);
|
||||
}
|
||||
|
||||
if (info.namedImports.length > 0) {
|
||||
parts.push(`{ ${info.namedImports.join(', ')} }`);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
statements.push(`import ${parts.join(', ')} from '${importPath}';`);
|
||||
}
|
||||
}
|
||||
|
||||
return statements.join('\n');
|
||||
};
|
||||
|
||||
const findImportInsertPosition = (code: string): number => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
||||
const imports = root.findAll({
|
||||
rule: {
|
||||
kind: 'import_statement',
|
||||
},
|
||||
});
|
||||
|
||||
invariant(imports.length > 0, '[convertDynamicToStatic] No import statements found in file');
|
||||
|
||||
const lastImport = imports.at(-1)!;
|
||||
return lastImport.range().end.index;
|
||||
};
|
||||
|
||||
const removeDynamicElementImport = (code: string): string => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
||||
const utilsRouterImport = root.find({
|
||||
rule: {
|
||||
kind: 'import_statement',
|
||||
pattern: "import { $$$IMPORTS } from '@/utils/router'",
|
||||
},
|
||||
});
|
||||
|
||||
if (!utilsRouterImport) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const importText = utilsRouterImport.text();
|
||||
|
||||
if (!importText.includes('dynamicElement')) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const importSpecifiers = utilsRouterImport.findAll({
|
||||
rule: {
|
||||
kind: 'import_specifier',
|
||||
},
|
||||
});
|
||||
|
||||
const specifiersToKeep = importSpecifiers
|
||||
.map((spec) => spec.text())
|
||||
.filter((text) => !text.includes('dynamicElement'));
|
||||
|
||||
if (specifiersToKeep.length === 0) {
|
||||
const range = utilsRouterImport.range();
|
||||
let endIndex = range.end.index;
|
||||
if (code[endIndex] === '\n') {
|
||||
endIndex++;
|
||||
}
|
||||
return code.slice(0, range.start.index) + code.slice(endIndex);
|
||||
}
|
||||
|
||||
const newImport = `import { ${specifiersToKeep.join(', ')} } from '@/utils/router';`;
|
||||
const range = utilsRouterImport.range();
|
||||
return code.slice(0, range.start.index) + newImport + code.slice(range.end.index);
|
||||
};
|
||||
|
||||
export const convertDynamicToStatic = async (TEMP_DIR: string) => {
|
||||
const routerConfigPath = path.join(
|
||||
TEMP_DIR,
|
||||
'src/app/[variants]/router/desktopRouter.config.tsx',
|
||||
);
|
||||
|
||||
console.log(' Processing dynamicElement → static imports...');
|
||||
|
||||
await updateFile({
|
||||
assertAfter: (code) => {
|
||||
const noDynamicElement = !/dynamicElement\s*\(/.test(code);
|
||||
const hasStaticImports = /^import .+ from ["']\.\.\/\(main\)/m.test(code);
|
||||
return noDynamicElement && hasStaticImports;
|
||||
},
|
||||
filePath: routerConfigPath,
|
||||
name: 'convertDynamicToStatic',
|
||||
transformer: (code) => {
|
||||
const elements = extractDynamicElements(code);
|
||||
|
||||
invariant(
|
||||
elements.length > 0,
|
||||
'[convertDynamicToStatic] No dynamicElement calls found in desktopRouter.config.tsx',
|
||||
);
|
||||
|
||||
console.log(` Found ${elements.length} dynamicElement calls`);
|
||||
|
||||
const importMap = buildImportMap(elements);
|
||||
const importStatements = generateImportStatements(importMap);
|
||||
|
||||
const edits: Array<{ end: number; start: number; text: string }> = [];
|
||||
|
||||
for (const el of elements) {
|
||||
edits.push({
|
||||
end: el.end,
|
||||
start: el.start,
|
||||
text: `<${el.componentName} />`,
|
||||
});
|
||||
}
|
||||
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
|
||||
let result = code;
|
||||
for (const edit of edits) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
|
||||
const insertPos = findImportInsertPosition(result);
|
||||
result = result.slice(0, insertPos) + '\n' + importStatements + result.slice(insertPos);
|
||||
|
||||
result = removeDynamicElementImport(result);
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('convertDynamicToStatic', convertDynamicToStatic, [
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
|
||||
]);
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
/* 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, []);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Lang } from '@ast-grep/napi';
|
||||
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';
|
||||
import { modifyRoutes } from './routes.mjs';
|
||||
import { convertSettingsContentToStatic } from './settingsContentToStatic.mjs';
|
||||
import { modifyStaticExport } from './staticExport.mjs';
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
import { wrapChildrenWithClientOnly } from './wrapChildrenWithClientOnly.mjs';
|
||||
|
||||
export const modifySourceForElectron = async (TEMP_DIR: string) => {
|
||||
await modifyNextConfig(TEMP_DIR);
|
||||
await modifyAppCode(TEMP_DIR);
|
||||
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);
|
||||
await modifyStaticExport(TEMP_DIR);
|
||||
await cleanUpCode(TEMP_DIR);
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('modifySourceForElectron', modifySourceForElectron, [
|
||||
{ lang: Lang.TypeScript, path: path.join(process.cwd(), 'next.config.ts') },
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/page.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/layout/GlobalProvider/index.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
|
||||
{ lang: Lang.TypeScript, path: 'src/features/DevPanel/CacheViewer/getCacheEntries.ts' },
|
||||
{ lang: Lang.TypeScript, path: 'src/server/translation.ts' },
|
||||
]);
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/* 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 } from './utils.mjs';
|
||||
|
||||
interface Edit {
|
||||
end: number;
|
||||
start: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const modifyNextConfig = async (TEMP_DIR: string) => {
|
||||
const defineConfigPath = path.join(TEMP_DIR, 'src', 'libs', 'next', 'config', 'define-config.ts');
|
||||
const legacyNextConfigPath = path.join(TEMP_DIR, 'next.config.ts');
|
||||
|
||||
const nextConfigPath = fs.existsSync(defineConfigPath) ? defineConfigPath : legacyNextConfigPath;
|
||||
if (!fs.existsSync(nextConfigPath)) {
|
||||
throw new Error(`[modifyNextConfig] next config not found: ${nextConfigPath}`);
|
||||
}
|
||||
|
||||
console.log(` Processing ${path.relative(TEMP_DIR, nextConfigPath)}...`);
|
||||
await updateFile({
|
||||
assertAfter: (code) => /output\s*:\s*["']export["']/.test(code) && !/withPWA\s*\(/.test(code),
|
||||
filePath: nextConfigPath,
|
||||
name: 'modifyNextConfig',
|
||||
transformer: (code) => {
|
||||
const ast = parse(Lang.TypeScript, code);
|
||||
const root = ast.root();
|
||||
const edits: Edit[] = [];
|
||||
|
||||
// Find nextConfig declaration
|
||||
const nextConfigDecl = root.find({
|
||||
rule: {
|
||||
pattern: 'const nextConfig: NextConfig = { $$$ }',
|
||||
},
|
||||
});
|
||||
if (!nextConfigDecl) {
|
||||
throw new Error('[modifyNextConfig] nextConfig declaration not found');
|
||||
}
|
||||
|
||||
// 1. Remove redirects
|
||||
const redirectsPair = nextConfigDecl
|
||||
.findAll({
|
||||
rule: {
|
||||
kind: 'pair',
|
||||
},
|
||||
})
|
||||
.find((node) => {
|
||||
const text = node.text();
|
||||
return text.startsWith('redirects:') || text.startsWith('redirects :');
|
||||
});
|
||||
invariant(redirectsPair, '[modifyNextConfig] redirects pair not found');
|
||||
{
|
||||
const range = redirectsPair!.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
}
|
||||
|
||||
// 2. Remove headers
|
||||
const headersMethod = nextConfigDecl
|
||||
.findAll({
|
||||
rule: {
|
||||
kind: 'method_definition',
|
||||
},
|
||||
})
|
||||
.find((node) => {
|
||||
const text = node.text();
|
||||
return text.startsWith('async headers') || text.startsWith('headers');
|
||||
});
|
||||
invariant(headersMethod, '[modifyNextConfig] headers method not found');
|
||||
{
|
||||
const range = headersMethod!.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
}
|
||||
|
||||
// 3. Remove webVitalsAttribution
|
||||
const webVitalsPair = nextConfigDecl
|
||||
.findAll({
|
||||
rule: {
|
||||
kind: 'pair',
|
||||
},
|
||||
})
|
||||
.find((node) => {
|
||||
const text = node.text();
|
||||
return (
|
||||
text.startsWith('webVitalsAttribution:') || text.startsWith('webVitalsAttribution :')
|
||||
);
|
||||
});
|
||||
invariant(webVitalsPair, '[modifyNextConfig] webVitalsAttribution pair not found');
|
||||
{
|
||||
const range = webVitalsPair!.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
}
|
||||
|
||||
// 4. Remove spread element
|
||||
const spreads = nextConfigDecl.findAll({
|
||||
rule: {
|
||||
kind: 'spread_element',
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
const isObjectLevelSpread = (node: any) => node.parent()?.kind() === 'object';
|
||||
|
||||
const standaloneSpread = spreads.find((node) => {
|
||||
if (!isObjectLevelSpread(node)) return false;
|
||||
const text = node.text();
|
||||
return text.includes('isStandaloneMode') && text.includes('standaloneConfig');
|
||||
});
|
||||
|
||||
const objectLevelSpread = standaloneSpread ? null : spreads.find(isObjectLevelSpread);
|
||||
|
||||
const spreadToRemove = standaloneSpread || objectLevelSpread;
|
||||
invariant(spreadToRemove, '[modifyNextConfig] spread element not found');
|
||||
{
|
||||
const range = spreadToRemove!.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
}
|
||||
|
||||
// 5. Inject/force output: 'export'
|
||||
const outputPair = nextConfigDecl.find({
|
||||
rule: {
|
||||
pattern: 'output: $A',
|
||||
},
|
||||
});
|
||||
if (outputPair) {
|
||||
const range = outputPair.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: "output: 'export'" });
|
||||
} else {
|
||||
const objectNode = nextConfigDecl.find({
|
||||
rule: { kind: 'object' },
|
||||
});
|
||||
if (!objectNode) {
|
||||
throw new Error('[modifyNextConfig] nextConfig object not found');
|
||||
}
|
||||
{
|
||||
const range = objectNode.range();
|
||||
// Insert after the opening brace `{
|
||||
edits.push({
|
||||
end: range.start.index + 1,
|
||||
start: range.start.index + 1,
|
||||
text: "\n output: 'export',",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Inject outputFileTracingRoot to fix symlink resolution for Turbopack
|
||||
// When building in shadow workspace (TEMP_DIR), symlinks (e.g., node_modules) point to PROJECT_ROOT
|
||||
// Turbopack's root defaults to TEMP_DIR, causing strip_prefix to fail for paths outside TEMP_DIR
|
||||
// Setting outputFileTracingRoot to PROJECT_ROOT allows Turbopack to correctly resolve these symlinks
|
||||
// We use ELECTRON_BUILD_PROJECT_ROOT env var which is set by buildNextApp.mts
|
||||
const outputFileTracingRootPair = nextConfigDecl.find({
|
||||
rule: {
|
||||
pattern: 'outputFileTracingRoot: $A',
|
||||
},
|
||||
});
|
||||
if (!outputFileTracingRootPair) {
|
||||
const objectNode = nextConfigDecl.find({
|
||||
rule: { kind: 'object' },
|
||||
});
|
||||
if (objectNode) {
|
||||
const range = objectNode.range();
|
||||
// Insert outputFileTracingRoot that reads from env var at build time
|
||||
// Falls back to current directory if not in electron build context
|
||||
edits.push({
|
||||
end: range.start.index + 1,
|
||||
start: range.start.index + 1,
|
||||
text: '\n outputFileTracingRoot: process.env.ELECTRON_BUILD_PROJECT_ROOT || process.cwd(),',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove withPWA wrapper
|
||||
const withPWA = root.find({
|
||||
rule: {
|
||||
pattern: 'withPWA($A)',
|
||||
},
|
||||
});
|
||||
if (withPWA) {
|
||||
const inner = withPWA.getMatch('A');
|
||||
if (!inner) {
|
||||
throw new Error('[modifyNextConfig] withPWA inner config not found');
|
||||
}
|
||||
{
|
||||
const range = withPWA.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: inner.text() });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply edits
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
let newCode = code;
|
||||
for (const edit of edits) {
|
||||
newCode = newCode.slice(0, edit.start) + edit.text + newCode.slice(edit.end);
|
||||
}
|
||||
|
||||
// Cleanup commas (syntax fix)
|
||||
// 1. Double commas ,, -> , (handle spaces/newlines between)
|
||||
newCode = newCode.replaceAll(/,(\s*,)+/g, ',');
|
||||
// 2. Leading comma in object { , -> {
|
||||
newCode = newCode.replaceAll(/{\s*,/g, '{');
|
||||
|
||||
return newCode;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('modifyNextConfig', modifyNextConfig, [
|
||||
{ lang: Lang.TypeScript, path: 'src/libs/next/config/define-config.ts' },
|
||||
{ lang: Lang.TypeScript, path: 'next.config.ts' },
|
||||
]);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import { glob } from 'glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { invariant, isDirectRun, runStandalone } from './utils.mjs';
|
||||
|
||||
interface DynamicImportInfo {
|
||||
componentName: string;
|
||||
end: number;
|
||||
importPath: string;
|
||||
start: number;
|
||||
}
|
||||
|
||||
const extractDynamicImports = (code: string): DynamicImportInfo[] => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
||||
const results: DynamicImportInfo[] = [];
|
||||
|
||||
const dynamicCalls = root.findAll({
|
||||
rule: {
|
||||
pattern: 'const $NAME = dynamic(() => import($PATH))',
|
||||
},
|
||||
});
|
||||
|
||||
for (const call of dynamicCalls) {
|
||||
const range = call.range();
|
||||
const text = call.text();
|
||||
|
||||
const nameMatch = text.match(/const\s+(\w+)\s*=/);
|
||||
invariant(
|
||||
nameMatch,
|
||||
`[convertNextDynamicToStatic] Failed to extract component name from dynamic call: ${text.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
|
||||
invariant(
|
||||
importMatch,
|
||||
`[convertNextDynamicToStatic] Failed to extract import path from dynamic call: ${text.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
results.push({
|
||||
componentName: nameMatch![1],
|
||||
end: range.end.index,
|
||||
importPath: importMatch![1],
|
||||
start: range.start.index,
|
||||
});
|
||||
}
|
||||
|
||||
const dynamicCallsWithOptions = root.findAll({
|
||||
rule: {
|
||||
pattern: 'const $NAME = dynamic(() => import($PATH), $OPTIONS)',
|
||||
},
|
||||
});
|
||||
|
||||
for (const call of dynamicCallsWithOptions) {
|
||||
const range = call.range();
|
||||
const text = call.text();
|
||||
|
||||
const nameMatch = text.match(/const\s+(\w+)\s*=/);
|
||||
invariant(
|
||||
nameMatch,
|
||||
`[convertNextDynamicToStatic] Failed to extract component name from dynamic call: ${text.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
|
||||
invariant(
|
||||
importMatch,
|
||||
`[convertNextDynamicToStatic] Failed to extract import path from dynamic call: ${text.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
const alreadyExists = results.some(
|
||||
(r) => r.componentName === nameMatch![1] && r.importPath === importMatch![1],
|
||||
);
|
||||
if (alreadyExists) continue;
|
||||
|
||||
results.push({
|
||||
componentName: nameMatch![1],
|
||||
end: range.end.index,
|
||||
importPath: importMatch![1],
|
||||
start: range.start.index,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const generateImportStatements = (imports: DynamicImportInfo[]): string => {
|
||||
const uniqueImports = new Map<string, string>();
|
||||
|
||||
for (const imp of imports) {
|
||||
if (!uniqueImports.has(imp.importPath)) {
|
||||
uniqueImports.set(imp.importPath, imp.componentName);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedPaths = [...uniqueImports.keys()].sort();
|
||||
|
||||
return sortedPaths
|
||||
.map((importPath) => {
|
||||
const componentName = uniqueImports.get(importPath)!;
|
||||
return `import ${componentName} from '${importPath}';`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const findImportInsertPosition = (code: string, filePath: string): number => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
||||
const imports = root.findAll({
|
||||
rule: {
|
||||
kind: 'import_statement',
|
||||
},
|
||||
});
|
||||
|
||||
invariant(
|
||||
imports.length > 0,
|
||||
`[convertNextDynamicToStatic] No import statements found in ${filePath}`,
|
||||
);
|
||||
|
||||
const lastImport = imports.at(-1)!;
|
||||
return lastImport.range().end.index;
|
||||
};
|
||||
|
||||
const removeDynamicImport = (code: string): string => {
|
||||
const patterns = [
|
||||
/import dynamic from ["']@\/libs\/next\/dynamic["'];\n?/g,
|
||||
/import dynamic from ["']next\/dynamic["'];\n?/g,
|
||||
];
|
||||
|
||||
let result = code;
|
||||
for (const pattern of patterns) {
|
||||
result = result.replace(pattern, '');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const removeUnusedLoadingImport = (code: string): string => {
|
||||
const codeWithoutImport = code.replaceAll(/import Loading from ["'][^"']+["'];?\n?/g, '');
|
||||
if (!/\bLoading\b/.test(codeWithoutImport)) {
|
||||
return code.replaceAll(/import Loading from ["'][^"']+["'];?\n?/g, '');
|
||||
}
|
||||
return code;
|
||||
};
|
||||
|
||||
const transformFile = (code: string, filePath: string): string => {
|
||||
const imports = extractDynamicImports(code);
|
||||
|
||||
if (imports.length === 0) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const importStatements = generateImportStatements(imports);
|
||||
|
||||
const edits: Array<{ end: number; start: number; text: string }> = [];
|
||||
|
||||
for (const imp of imports) {
|
||||
edits.push({
|
||||
end: imp.end,
|
||||
start: imp.start,
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
|
||||
let result = code;
|
||||
for (const edit of edits) {
|
||||
let endIndex = edit.end;
|
||||
while (result[endIndex] === '\n' || result[endIndex] === '\r') {
|
||||
endIndex++;
|
||||
}
|
||||
result = result.slice(0, edit.start) + result.slice(endIndex);
|
||||
}
|
||||
|
||||
const insertPos = findImportInsertPosition(result, filePath);
|
||||
if (importStatements) {
|
||||
result = result.slice(0, insertPos) + '\n' + importStatements + result.slice(insertPos);
|
||||
}
|
||||
|
||||
result = removeDynamicImport(result);
|
||||
result = removeUnusedLoadingImport(result);
|
||||
|
||||
result = result.replaceAll(/\n{3,}/g, '\n\n');
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const convertNextDynamicToStatic = async (TEMP_DIR: string) => {
|
||||
const appDirs = [
|
||||
{ dir: path.join(TEMP_DIR, 'src/app/(variants)'), label: 'src/app/(variants)' },
|
||||
{ dir: path.join(TEMP_DIR, 'src/app/[variants]'), label: 'src/app/[variants]' },
|
||||
];
|
||||
|
||||
console.log(' Processing next/dynamic → static imports...');
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
for (const { dir, label } of appDirs) {
|
||||
if (!(await fs.pathExists(dir))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = await glob('**/*.tsx', { cwd: dir });
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const code = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
if (!code.includes('dynamic(')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transformed = transformFile(code, `${label}/${file}`);
|
||||
|
||||
if (transformed !== code) {
|
||||
await fs.writeFile(filePath, transformed);
|
||||
processedCount++;
|
||||
console.log(` Transformed: ${label}/${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Processed ${processedCount} files with dynamic imports`);
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('convertNextDynamicToStatic', convertNextDynamicToStatic, []);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import path from 'node:path';
|
||||
|
||||
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
const removeSuspenseWrapper = (code: string): string => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
||||
const suspenseElements = root.findAll({
|
||||
rule: {
|
||||
has: {
|
||||
has: {
|
||||
kind: 'identifier',
|
||||
regex: '^Suspense$',
|
||||
},
|
||||
kind: 'jsx_opening_element',
|
||||
},
|
||||
kind: 'jsx_element',
|
||||
},
|
||||
});
|
||||
|
||||
if (suspenseElements.length === 0) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const edits: Array<{ end: number; start: number; text: string }> = [];
|
||||
|
||||
for (const suspense of suspenseElements) {
|
||||
const range = suspense.range();
|
||||
|
||||
const children = suspense.children();
|
||||
let childrenText = '';
|
||||
|
||||
for (const child of children) {
|
||||
const kind = child.kind();
|
||||
if (
|
||||
kind === 'jsx_element' ||
|
||||
kind === 'jsx_self_closing_element' ||
|
||||
kind === 'jsx_fragment'
|
||||
) {
|
||||
childrenText = child.text();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (childrenText) {
|
||||
edits.push({
|
||||
end: range.end.index,
|
||||
start: range.start.index,
|
||||
text: childrenText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
|
||||
let result = code;
|
||||
for (const edit of edits) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const removeUnusedImports = (code: string): string => {
|
||||
let result = code;
|
||||
|
||||
if (!result.includes('<Suspense')) {
|
||||
result = result.replaceAll(/,?\s*Suspense\s*,?/g, (match) => {
|
||||
if (match.startsWith(',') && match.endsWith(',')) {
|
||||
return ',';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
result = result.replaceAll(/{\s*,/g, '{');
|
||||
result = result.replaceAll(/,\s*}/g, '}');
|
||||
result = result.replaceAll(/{\s*}/g, '');
|
||||
result = result.replaceAll(/import\s+{\s*}\s+from\s+["'][^"']+["'];\n?/g, '');
|
||||
}
|
||||
|
||||
if (!result.includes('<Loading') && !result.includes('Loading />')) {
|
||||
result = result.replaceAll(
|
||||
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["'];\n?/g,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
result = result.replaceAll(/\n{3,}/g, '\n\n');
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const removeSuspenseFromConversation = async (TEMP_DIR: string) => {
|
||||
const filePath = path.join(
|
||||
TEMP_DIR,
|
||||
'src/app/[variants]/(main)/agent/features/Conversation/index.tsx',
|
||||
);
|
||||
|
||||
console.log(' Removing Suspense from Conversation/index.tsx...');
|
||||
|
||||
await updateFile({
|
||||
assertAfter: (code) => {
|
||||
const noSuspenseElement = !/<Suspense/.test(code);
|
||||
return noSuspenseElement;
|
||||
},
|
||||
filePath,
|
||||
name: 'removeSuspenseFromConversation',
|
||||
transformer: (code) => {
|
||||
invariant(
|
||||
/<Suspense/.test(code),
|
||||
'[removeSuspenseFromConversation] No Suspense element found in Conversation/index.tsx',
|
||||
);
|
||||
|
||||
let result = removeSuspenseWrapper(code);
|
||||
result = removeUnusedImports(result);
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('removeSuspenseFromConversation', removeSuspenseFromConversation, [
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/agent/features/Conversation/index.tsx' },
|
||||
]);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, removePathEnsuring, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
export const modifyRoutes = async (TEMP_DIR: string) => {
|
||||
// 1. Delete routes
|
||||
const filesToDelete = [
|
||||
// Backend API routes
|
||||
'src/app/(backend)/api',
|
||||
'src/app/(backend)/webapi',
|
||||
'src/app/(backend)/trpc',
|
||||
'src/app/(backend)/oidc',
|
||||
'src/app/(backend)/middleware',
|
||||
'src/app/(backend)/f',
|
||||
'src/app/(backend)/market',
|
||||
|
||||
// Auth & User routes
|
||||
'src/app/[variants]/(auth)',
|
||||
'src/app/[variants]/(mobile)',
|
||||
'src/app/[variants]/(main)/(mobile)/me',
|
||||
'src/app/[variants]/(main)/changelog',
|
||||
'src/app/[variants]/oauth',
|
||||
|
||||
// Other app roots
|
||||
'src/app/market-auth-callback',
|
||||
'src/app/manifest.ts',
|
||||
'src/app/robots.tsx',
|
||||
'src/app/sitemap.tsx',
|
||||
'src/app/sw.ts',
|
||||
|
||||
// Config files
|
||||
'src/instrumentation.ts',
|
||||
'src/instrumentation.node.ts',
|
||||
|
||||
// Desktop specific routes
|
||||
'src/app/desktop/devtools',
|
||||
'src/app/desktop/layout.tsx',
|
||||
];
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
const fullPath = path.join(TEMP_DIR, file);
|
||||
await removePathEnsuring({
|
||||
name: `modifyRoutes:delete:${file}`,
|
||||
path: fullPath,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Delete root loading.tsx files(not needed in Electron SPA)
|
||||
const loadingFiles = ['src/app/loading.tsx', 'src/app/[variants]/loading.tsx'];
|
||||
console.log(` Removing ${loadingFiles.length} root loading.tsx files...`);
|
||||
for (const file of loadingFiles) {
|
||||
const fullPath = path.join(TEMP_DIR, file);
|
||||
await removePathEnsuring({
|
||||
name: `modifyRoutes:delete:loading:${file}`,
|
||||
path: fullPath,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Modify desktopRouter.config.tsx
|
||||
const routerConfigPath = path.join(
|
||||
TEMP_DIR,
|
||||
'src/app/[variants]/router/desktopRouter.config.tsx',
|
||||
);
|
||||
console.log(' Processing src/app/[variants]/router/desktopRouter.config.tsx...');
|
||||
await updateFile({
|
||||
assertAfter: (code) => !/\bchangelog\b/.test(code),
|
||||
filePath: routerConfigPath,
|
||||
name: 'modifyRoutes:desktopRouterConfig',
|
||||
transformer: (code) => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
||||
const changelogNode = root.find({
|
||||
rule: {
|
||||
pattern: "{ path: 'changelog', $$$ }",
|
||||
},
|
||||
});
|
||||
if (changelogNode) {
|
||||
changelogNode.replace('');
|
||||
}
|
||||
|
||||
const changelogImport = root.find({
|
||||
rule: {
|
||||
pattern: "import('../(main)/changelog')",
|
||||
},
|
||||
});
|
||||
if (changelogImport) {
|
||||
// Find the closest object (route definition) and remove it
|
||||
let curr = changelogImport.parent();
|
||||
while (curr) {
|
||||
if (curr.kind() === 'object') {
|
||||
curr.replace('');
|
||||
break;
|
||||
}
|
||||
curr = curr.parent();
|
||||
}
|
||||
}
|
||||
|
||||
return root.text();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('modifyRoutes', modifyRoutes, [
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
|
||||
]);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import path from 'node:path';
|
||||
|
||||
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
interface DynamicImportInfo {
|
||||
componentName: string;
|
||||
end: number;
|
||||
importPath: string;
|
||||
key: string;
|
||||
start: number;
|
||||
}
|
||||
|
||||
const extractDynamicImportsFromMap = (code: string): DynamicImportInfo[] => {
|
||||
const results: DynamicImportInfo[] = [];
|
||||
|
||||
const regex =
|
||||
/\[SettingsTabs\.(\w+)]:\s*dynamic\(\s*\(\)\s*=>\s*import\(\s*["']([^"']+)["']\s*\)/g;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
const key = match[1];
|
||||
const importPath = match[2];
|
||||
|
||||
const componentName = key.charAt(0).toUpperCase() + key.slice(1) + 'Tab';
|
||||
|
||||
results.push({
|
||||
componentName,
|
||||
end: 0,
|
||||
importPath,
|
||||
key,
|
||||
start: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const generateStaticImports = (imports: DynamicImportInfo[]): string => {
|
||||
return imports.map((imp) => `import ${imp.componentName} from '${imp.importPath}';`).join('\n');
|
||||
};
|
||||
|
||||
const generateStaticComponentMap = (imports: DynamicImportInfo[]): string => {
|
||||
const entries = imports.map((imp) => ` [SettingsTabs.${imp.key}]: ${imp.componentName},`);
|
||||
|
||||
return `const componentMap: Record<string, React.ComponentType<{ mobile?: boolean }>> = {\n${entries.join('\n')}\n}`;
|
||||
};
|
||||
|
||||
export const convertSettingsContentToStatic = async (TEMP_DIR: string) => {
|
||||
const filePath = path.join(
|
||||
TEMP_DIR,
|
||||
'src/app/[variants]/(main)/settings/features/SettingsContent.tsx',
|
||||
);
|
||||
|
||||
console.log(' Processing SettingsContent.tsx dynamic imports...');
|
||||
|
||||
await updateFile({
|
||||
assertAfter: (code) => {
|
||||
const noDynamic = !/dynamic\(\s*\(\)\s*=>\s*import/.test(code);
|
||||
const hasStaticMap = /componentMap:\s*Record<string,/.test(code);
|
||||
return noDynamic && hasStaticMap;
|
||||
},
|
||||
filePath,
|
||||
name: 'convertSettingsContentToStatic',
|
||||
transformer: (code) => {
|
||||
const imports = extractDynamicImportsFromMap(code);
|
||||
|
||||
invariant(
|
||||
imports.length > 0,
|
||||
'[convertSettingsContentToStatic] No dynamic imports found in SettingsContent.tsx',
|
||||
);
|
||||
|
||||
console.log(` Found ${imports.length} dynamic imports in componentMap`);
|
||||
|
||||
const staticImports = generateStaticImports(imports);
|
||||
const staticComponentMap = generateStaticComponentMap(imports);
|
||||
|
||||
let result = code;
|
||||
|
||||
result = result.replace(/import dynamic from ["']@\/libs\/next\/dynamic["'];\n?/, '');
|
||||
|
||||
result = result.replace(
|
||||
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["'];\n?/,
|
||||
'',
|
||||
);
|
||||
|
||||
const ast = parse(Lang.Tsx, result);
|
||||
const root = ast.root();
|
||||
|
||||
const lastImport = root
|
||||
.findAll({
|
||||
rule: {
|
||||
kind: 'import_statement',
|
||||
},
|
||||
})
|
||||
.at(-1);
|
||||
|
||||
invariant(
|
||||
lastImport,
|
||||
'[convertSettingsContentToStatic] No import statements found in SettingsContent.tsx',
|
||||
);
|
||||
|
||||
const insertPos = lastImport!.range().end.index;
|
||||
result =
|
||||
result.slice(0, insertPos) +
|
||||
"\nimport type React from 'react';\n" +
|
||||
staticImports +
|
||||
result.slice(insertPos);
|
||||
|
||||
const componentMapRegex = /const componentMap = {[\S\s]*?\n};/;
|
||||
invariant(
|
||||
componentMapRegex.test(result),
|
||||
'[convertSettingsContentToStatic] componentMap declaration not found in SettingsContent.tsx',
|
||||
);
|
||||
|
||||
result = result.replace(componentMapRegex, staticComponentMap + ';');
|
||||
|
||||
result = result.replaceAll(/\n{3,}/g, '\n\n');
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('convertSettingsContentToStatic', convertSettingsContentToStatic, [
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/settings/features/SettingsContent.tsx' },
|
||||
]);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
/**
|
||||
* Remove the URL rewrite logic from the proxy middleware.
|
||||
* For Electron static export, we don't need URL rewriting since pages are pre-rendered.
|
||||
*/
|
||||
const removeUrlRewriteLogic = (code: string): string => {
|
||||
const ast = parse(Lang.TypeScript, code);
|
||||
const root = ast.root();
|
||||
const edits: Array<{ end: number; start: number; text: string }> = [];
|
||||
|
||||
// Find the defaultMiddleware arrow function
|
||||
const defaultMiddleware = root.find({
|
||||
rule: {
|
||||
pattern: 'const defaultMiddleware = ($REQ) => { $$$ }',
|
||||
},
|
||||
});
|
||||
|
||||
if (!defaultMiddleware) {
|
||||
console.warn(' ⚠️ defaultMiddleware not found, skipping URL rewrite removal');
|
||||
return code;
|
||||
}
|
||||
|
||||
// Replace the entire defaultMiddleware function with a simplified version
|
||||
// that just returns NextResponse.next() for non-API routes
|
||||
const range = defaultMiddleware.range();
|
||||
|
||||
const simplifiedMiddleware = `const defaultMiddleware = (request: NextRequest) => {
|
||||
const url = new URL(request.url);
|
||||
logDefault('Processing request: %s %s', request.method, request.url);
|
||||
|
||||
// skip all api requests
|
||||
if (backendApiEndpoints.some((path) => url.pathname.startsWith(path))) {
|
||||
logDefault('Skipping API request: %s', url.pathname);
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}`;
|
||||
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: simplifiedMiddleware });
|
||||
|
||||
// Apply edits
|
||||
if (edits.length === 0) return code;
|
||||
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
let result = code;
|
||||
for (const edit of edits) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const assertUrlRewriteRemoved = (code: string): boolean =>
|
||||
// Ensure the URL rewrite related code is removed
|
||||
!/NextResponse\.rewrite\(/.test(code) &&
|
||||
!/RouteVariants\.serializeVariants/.test(code) &&
|
||||
!/url\.pathname = nextPathname/.test(code);
|
||||
|
||||
/**
|
||||
* Rename [variants] directories to (variants) under src/app
|
||||
*/
|
||||
const renameVariantsDirectories = async (TEMP_DIR: string): Promise<void> => {
|
||||
const srcAppPath = path.join(TEMP_DIR, 'src', 'app');
|
||||
|
||||
// Recursively find and rename [variants] directories
|
||||
const renameRecursively = async (dir: string): Promise<void> => {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const oldPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.name === '[variants]') {
|
||||
const newPath = path.join(dir, '(variants)');
|
||||
|
||||
// If (variants) already exists, remove it first
|
||||
if (await fs.pathExists(newPath)) {
|
||||
console.log(` Removing existing: ${path.relative(TEMP_DIR, newPath)}`);
|
||||
await fs.remove(newPath);
|
||||
}
|
||||
|
||||
console.log(
|
||||
` Renaming: ${path.relative(TEMP_DIR, oldPath)} -> ${path.relative(TEMP_DIR, newPath)}`,
|
||||
);
|
||||
await fs.rename(oldPath, newPath);
|
||||
// Continue searching in the renamed directory
|
||||
await renameRecursively(newPath);
|
||||
} else {
|
||||
// Continue searching in subdirectories
|
||||
await renameRecursively(oldPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await renameRecursively(srcAppPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update all imports that reference [variants] to use (variants)
|
||||
*/
|
||||
const updateVariantsImports = async (TEMP_DIR: string): Promise<void> => {
|
||||
const srcPath = path.join(TEMP_DIR, 'src');
|
||||
|
||||
// Pattern to match imports containing [variants]
|
||||
const variantsImportPattern = /(\[variants])/g;
|
||||
|
||||
const processFile = async (filePath: string): Promise<void> => {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
if (!content.includes('[variants]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = content.replaceAll('[variants]', '(variants)');
|
||||
|
||||
if (updated !== content) {
|
||||
console.log(` Updated imports: ${path.relative(TEMP_DIR, filePath)}`);
|
||||
await fs.writeFile(filePath, updated);
|
||||
}
|
||||
};
|
||||
|
||||
const processDirectory = async (dir: string): Promise<void> => {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip node_modules and other non-source directories
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') {
|
||||
continue;
|
||||
}
|
||||
await processDirectory(fullPath);
|
||||
} else if (entry.isFile() && /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name)) {
|
||||
await processFile(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await processDirectory(srcPath);
|
||||
};
|
||||
|
||||
export const modifyStaticExport = async (TEMP_DIR: string): Promise<void> => {
|
||||
// 1. Remove URL rewrite logic from define-config.ts
|
||||
const defineConfigPath = path.join(TEMP_DIR, 'src', 'libs', 'next', 'proxy', 'define-config.ts');
|
||||
console.log(' Processing src/libs/next/proxy/define-config.ts...');
|
||||
await updateFile({
|
||||
assertAfter: assertUrlRewriteRemoved,
|
||||
filePath: defineConfigPath,
|
||||
name: 'modifyStaticExport:removeUrlRewrite',
|
||||
transformer: removeUrlRewriteLogic,
|
||||
});
|
||||
|
||||
// 2. Rename [variants] directories to (variants)
|
||||
console.log(' Renaming [variants] directories to (variants)...');
|
||||
await renameVariantsDirectories(TEMP_DIR);
|
||||
|
||||
// 3. Update all imports referencing [variants]
|
||||
console.log(' Updating imports referencing [variants]...');
|
||||
await updateVariantsImports(TEMP_DIR);
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('modifyStaticExport', modifyStaticExport, [
|
||||
{ lang: Lang.TypeScript, path: 'src/libs/next/proxy/define-config.ts' },
|
||||
]);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
interface ValidationTarget {
|
||||
lang: Lang;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface UpdateFileOptions {
|
||||
assertAfter?: (code: string) => boolean;
|
||||
filePath: string;
|
||||
name: string;
|
||||
transformer: (code: string) => string;
|
||||
}
|
||||
|
||||
interface WriteFileOptions {
|
||||
assertAfter?: (code: string) => boolean;
|
||||
filePath: string;
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface RemovePathOptions {
|
||||
name: string;
|
||||
path: string;
|
||||
requireExists?: boolean;
|
||||
}
|
||||
|
||||
export const invariant = (condition: unknown, message: string) => {
|
||||
if (!condition) throw new Error(message);
|
||||
};
|
||||
|
||||
export const normalizeEol = (code: string) => code.replaceAll('\r\n', '\n');
|
||||
|
||||
export const updateFile = async ({
|
||||
assertAfter,
|
||||
filePath,
|
||||
name,
|
||||
transformer,
|
||||
}: UpdateFileOptions) => {
|
||||
invariant(fs.existsSync(filePath), `[${name}] File not found: ${filePath}`);
|
||||
|
||||
const original = await fs.readFile(filePath, 'utf8');
|
||||
const updated = transformer(original);
|
||||
|
||||
if (assertAfter) {
|
||||
invariant(assertAfter(updated), `[${name}] Post-condition failed: ${filePath}`);
|
||||
}
|
||||
|
||||
if (updated !== original) {
|
||||
await fs.writeFile(filePath, updated);
|
||||
}
|
||||
};
|
||||
|
||||
export const writeFileEnsuring = async ({
|
||||
assertAfter,
|
||||
filePath,
|
||||
name,
|
||||
text,
|
||||
}: WriteFileOptions) => {
|
||||
await updateFile({
|
||||
assertAfter,
|
||||
filePath,
|
||||
name,
|
||||
transformer: () => text,
|
||||
});
|
||||
};
|
||||
|
||||
export const removePathEnsuring = async ({
|
||||
name,
|
||||
path: targetPath,
|
||||
requireExists,
|
||||
}: RemovePathOptions) => {
|
||||
const exists = await fs.pathExists(targetPath);
|
||||
if (requireExists) {
|
||||
invariant(exists, `[${name}] Path not found: ${targetPath}`);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
await fs.remove(targetPath);
|
||||
}
|
||||
|
||||
const stillExists = await fs.pathExists(targetPath);
|
||||
invariant(!stillExists, `[${name}] Failed to remove path: ${targetPath}`);
|
||||
};
|
||||
|
||||
export const isDirectRun = (importMetaUrl: string) => {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
|
||||
return importMetaUrl === pathToFileURL(entry).href;
|
||||
};
|
||||
|
||||
export const resolveTempDir = () => {
|
||||
const candidate = process.env.TEMP_DIR || process.argv[2];
|
||||
const resolved = candidate
|
||||
? path.resolve(candidate)
|
||||
: path.resolve(process.cwd(), 'tmp', 'desktop-build');
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new Error(`TEMP_DIR not found: ${resolved}`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const validateFiles = async (tempDir: string, targets: ValidationTarget[]) => {
|
||||
for (const target of targets) {
|
||||
const filePath = path.join(tempDir, target.path);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Skipped validation, missing file: ${target.path}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const code = await fs.readFile(filePath, 'utf8');
|
||||
parse(target.lang, code);
|
||||
console.log(` ✅ Validated: ${target.path}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const runStandalone = async (
|
||||
name: string,
|
||||
modifier: (tempDir: string) => Promise<void>,
|
||||
validateTargets: ValidationTarget[] = [],
|
||||
) => {
|
||||
try {
|
||||
const workdir = process.cwd();
|
||||
console.log(`▶️ Running ${name} with TEMP_DIR=${workdir}`);
|
||||
|
||||
await modifier(workdir);
|
||||
|
||||
if (validateTargets.length) {
|
||||
console.log('🔎 Validating modified files...');
|
||||
await validateFiles(workdir, validateTargets);
|
||||
}
|
||||
|
||||
console.log(`✅ ${name} completed`);
|
||||
} catch (error) {
|
||||
console.error(`❌ ${name} failed`, error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import path from 'node:path';
|
||||
|
||||
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
export const wrapChildrenWithClientOnly = async (TEMP_DIR: string) => {
|
||||
const layoutPath = path.join(TEMP_DIR, 'src/app/[variants]/layout.tsx');
|
||||
|
||||
console.log(' Wrapping children with ClientOnly in layout.tsx...');
|
||||
|
||||
await updateFile({
|
||||
assertAfter: (code) => {
|
||||
const hasClientOnlyImport =
|
||||
/import ClientOnly from ["']@\/components\/client\/ClientOnly["']/.test(code);
|
||||
const hasLoadingImport =
|
||||
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["']/.test(code);
|
||||
const hasClientOnlyWrapper = /<ClientOnly fallback={<Loading/.test(code);
|
||||
return hasClientOnlyImport && hasLoadingImport && hasClientOnlyWrapper;
|
||||
},
|
||||
filePath: layoutPath,
|
||||
name: 'wrapChildrenWithClientOnly',
|
||||
transformer: (code) => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
||||
let result = code;
|
||||
|
||||
const hasClientOnlyImport =
|
||||
/import ClientOnly from ["']@\/components\/client\/ClientOnly["']/.test(code);
|
||||
const hasLoadingImport =
|
||||
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["']/.test(code);
|
||||
|
||||
const lastImport = root
|
||||
.findAll({
|
||||
rule: {
|
||||
kind: 'import_statement',
|
||||
},
|
||||
})
|
||||
.at(-1);
|
||||
|
||||
invariant(
|
||||
lastImport,
|
||||
'[wrapChildrenWithClientOnly] No import statements found in layout.tsx',
|
||||
);
|
||||
|
||||
const insertPos = lastImport!.range().end.index;
|
||||
let importsToAdd = '';
|
||||
|
||||
if (!hasClientOnlyImport) {
|
||||
importsToAdd += "\nimport ClientOnly from '@/components/client/ClientOnly';";
|
||||
}
|
||||
if (!hasLoadingImport) {
|
||||
importsToAdd += "\nimport Loading from '@/components/Loading/BrandTextLoading';";
|
||||
}
|
||||
|
||||
if (importsToAdd) {
|
||||
result = result.slice(0, insertPos) + importsToAdd + result.slice(insertPos);
|
||||
}
|
||||
|
||||
const authProviderPattern = /<AuthProvider>\s*{children}\s*<\/AuthProvider>/;
|
||||
invariant(
|
||||
authProviderPattern.test(result),
|
||||
'[wrapChildrenWithClientOnly] Pattern <AuthProvider>{children}</AuthProvider> not found in layout.tsx',
|
||||
);
|
||||
|
||||
result = result.replace(
|
||||
authProviderPattern,
|
||||
`<AuthProvider>
|
||||
<ClientOnly fallback={<Loading />}>{children}</ClientOnly>
|
||||
</AuthProvider>`,
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('wrapChildrenWithClientOnly', wrapChildrenWithClientOnly, [
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/layout.tsx' },
|
||||
]);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
|
||||
const exportSourceDir = path.join(rootDir, 'out');
|
||||
const exportTargetDir = path.join(rootDir, 'apps/desktop/dist/next');
|
||||
|
||||
if (fs.existsSync(exportSourceDir)) {
|
||||
console.log(`📦 Copying Next export assets from ${exportSourceDir} to ${exportTargetDir}...`);
|
||||
fs.ensureDirSync(exportTargetDir);
|
||||
fs.copySync(exportSourceDir, exportTargetDir, { overwrite: true });
|
||||
console.log(`✅ Export assets copied successfully!`);
|
||||
} else {
|
||||
console.log(`ℹ️ No Next export output found at ${exportSourceDir}, skipping copy.`);
|
||||
}
|
||||
|
||||
console.log(`🎉 Export move completed!`);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
const desktopHtml = readFileSync(resolve(root, 'dist/desktop/index.html'), 'utf8');
|
||||
|
||||
const mobileHtmlPath = resolve(root, 'dist/mobile/index.mobile.html');
|
||||
const mobileHtmlFallbackPath = resolve(root, 'dist/mobile/index.html');
|
||||
const hasMobileBuild = existsSync(mobileHtmlPath) || existsSync(mobileHtmlFallbackPath);
|
||||
|
||||
let output: string;
|
||||
|
||||
if (hasMobileBuild) {
|
||||
// Docker: mobile build exists locally, inline the template
|
||||
const mobileHtml = readFileSync(
|
||||
existsSync(mobileHtmlPath) ? mobileHtmlPath : mobileHtmlFallbackPath,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
output = `// Auto-generated by scripts/generateSpaTemplates.mts after vite build
|
||||
// Do not edit manually
|
||||
|
||||
export const desktopHtmlTemplate = ${JSON.stringify(desktopHtml)};
|
||||
|
||||
export const mobileHtmlTemplate = ${JSON.stringify(mobileHtml)};
|
||||
`;
|
||||
} else {
|
||||
// Vercel: no mobile build, import from pre-committed source file
|
||||
output = `// Auto-generated by scripts/generateSpaTemplates.mts after vite build
|
||||
// Do not edit manually
|
||||
|
||||
import { mobileHtmlTemplate } from './mobileHtmlTemplate.source';
|
||||
|
||||
export const desktopHtmlTemplate = ${JSON.stringify(desktopHtml)};
|
||||
|
||||
export { mobileHtmlTemplate };
|
||||
`;
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
resolve(root, 'src/app/spa/[variants]/[[...path]]/spaHtmlTemplates.ts'),
|
||||
output,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
console.log(`Generated spaHtmlTemplates.ts (mobile from ${hasMobileBuild ? 'build' : 'source'})`);
|
||||
@@ -20,8 +20,6 @@ dotenvExpand.expand(dotenv.config({ override: true, path: `.env.${env}.local` })
|
||||
|
||||
const migrationsFolder = join(__dirname, '../../packages/database/migrations');
|
||||
|
||||
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
|
||||
|
||||
const runMigrations = async () => {
|
||||
const { serverDB } = await import('../../packages/database/src/server');
|
||||
|
||||
@@ -40,7 +38,7 @@ const runMigrations = async () => {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
||||
// only migrate database if the connection string is available
|
||||
if (!isDesktop && connectionString) {
|
||||
if (connectionString) {
|
||||
runMigrations().catch((err) => {
|
||||
console.error('❌ Database migrate failed:', err);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { generateMobileTemplate } from './template';
|
||||
import { uploadAssets } from './upload';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const root = resolve(__dirname, '../..');
|
||||
const distDir = resolve(root, 'dist/mobile');
|
||||
const assetsDir = resolve(distDir, 'assets');
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const publicDomain = new URL(requireEnv('MOBILE_S3_PUBLIC_DOMAIN')).origin;
|
||||
const timestamp = new Date().toISOString().replaceAll(/[-:]/g, '').replace('T', '-').slice(0, 15); // e.g. 20260226-153012
|
||||
const keyPrefix = (process.env.MOBILE_S3_KEY_PREFIX || `mobile/${timestamp}`).replaceAll(
|
||||
/^\/+|\/+$/g,
|
||||
'',
|
||||
);
|
||||
|
||||
// VITE_CDN_BASE = domain + optional key prefix, e.g. https://web-assets.lobehub.com/mobile/20260226-153012/
|
||||
const cdnBase = `${publicDomain.replace(/\/+$/, '')}/${keyPrefix}/`;
|
||||
|
||||
// Step 1: Build mobile SPA with CDN base
|
||||
console.log('=== Step 1: Building mobile SPA ===');
|
||||
execSync('vite build', {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
MOBILE: 'true',
|
||||
NODE_OPTIONS: '--max-old-space-size=8192',
|
||||
VITE_CDN_BASE: cdnBase,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (!existsSync(assetsDir)) {
|
||||
throw new Error(`Build output not found at ${assetsDir}`);
|
||||
}
|
||||
|
||||
// Step 2: Upload assets to S3
|
||||
console.log('\n=== Step 2: Uploading assets to S3 ===');
|
||||
await uploadAssets(assetsDir, {
|
||||
accessKeyId: requireEnv('MOBILE_S3_ACCESS_KEY_ID'),
|
||||
bucket: requireEnv('MOBILE_S3_BUCKET'),
|
||||
endpoint: requireEnv('MOBILE_S3_ENDPOINT'),
|
||||
keyPrefix: keyPrefix.replaceAll(/^\/+|\/+$/g, ''),
|
||||
publicDomain,
|
||||
region: process.env.MOBILE_S3_REGION || 'auto',
|
||||
secretAccessKey: requireEnv('MOBILE_S3_SECRET_ACCESS_KEY'),
|
||||
});
|
||||
|
||||
// Step 3: Generate mobile HTML template source file
|
||||
console.log('\n=== Step 3: Generating mobile template ===');
|
||||
generateMobileTemplate(distDir);
|
||||
|
||||
console.log('\n=== Workflow complete ===');
|
||||
console.log('Remember to commit mobileHtmlTemplate.source.ts to the repository.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Workflow failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const root = resolve(__dirname, '../..');
|
||||
|
||||
export function generateMobileTemplate(distDir: string) {
|
||||
const htmlPath = resolve(distDir, 'index.mobile.html');
|
||||
const htmlFallback = resolve(distDir, 'index.html');
|
||||
|
||||
const sourcePath = existsSync(htmlPath) ? htmlPath : htmlFallback;
|
||||
const html = readFileSync(sourcePath, 'utf8');
|
||||
|
||||
const output = `// Auto-generated by scripts/mobileSpaWorkflow
|
||||
// Do not edit manually
|
||||
|
||||
export const mobileHtmlTemplate = ${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
const outputPath = resolve(
|
||||
root,
|
||||
'src/app/spa/[variants]/[[...path]]/mobileHtmlTemplate.source.ts',
|
||||
);
|
||||
|
||||
writeFileSync(outputPath, output, 'utf8');
|
||||
console.log(`Generated mobileHtmlTemplate.source.ts`);
|
||||
return outputPath;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { basename, extname, join } from 'node:path';
|
||||
|
||||
import pMap from 'p-map';
|
||||
|
||||
import s3 from '../cdnWorkflow/s3';
|
||||
|
||||
interface UploadConfig {
|
||||
accessKeyId: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
keyPrefix: string;
|
||||
publicDomain: string;
|
||||
region: string;
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
function collectFiles(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectFiles(fullPath));
|
||||
} else {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function uploadAssets(assetsDir: string, config: UploadConfig) {
|
||||
const files = collectFiles(assetsDir);
|
||||
console.log(`Found ${files.length} files to upload`);
|
||||
|
||||
const client = s3.createS3Client({
|
||||
accessKeyId: config.accessKeyId,
|
||||
bucketName: config.bucket,
|
||||
endpoint: config.endpoint,
|
||||
pathPrefix: '',
|
||||
region: config.region,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
});
|
||||
|
||||
const results = await pMap(
|
||||
files,
|
||||
async (filePath) => {
|
||||
const relativePath = filePath.slice(assetsDir.length + 1);
|
||||
const key = `${config.keyPrefix}/assets/${relativePath}`;
|
||||
const buffer = readFileSync(filePath);
|
||||
const fileName = basename(filePath);
|
||||
const ext = extname(filePath);
|
||||
|
||||
console.log(`Uploading ${key}...`);
|
||||
|
||||
const result = await s3.createUploadTask({
|
||||
acl: 'public-read',
|
||||
bucketName: config.bucket,
|
||||
client,
|
||||
item: { buffer, extname: ext, fileName },
|
||||
path: key,
|
||||
urlPrefix: config.publicDomain,
|
||||
});
|
||||
|
||||
console.log(`Uploaded ${key} -> ${result.url}`);
|
||||
return result;
|
||||
},
|
||||
{ concurrency: 10 },
|
||||
);
|
||||
|
||||
console.log(`Successfully uploaded ${results.length} files`);
|
||||
return results;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const dotenv = require('dotenv');
|
||||
const dotenvExpand = require('dotenv-expand');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
|
||||
const isDesktop = process.env.DESKTOP_BUILD === 'true';
|
||||
|
||||
if (isDesktop) {
|
||||
const cwd = process.cwd();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
|
||||
const isDesktop = process.env.DESKTOP_BUILD === 'true';
|
||||
|
||||
if (isDesktop) {
|
||||
const envDesktop = path.resolve(process.cwd(), '.env.desktop');
|
||||
|
||||
Reference in New Issue
Block a user