mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ feat: refactor desktop implement with brand new 2.0
This commit is contained in:
134
scripts/electronWorkflow/buildNextApp.mts
Normal file
134
scripts/electronWorkflow/buildNextApp.mts
Normal file
@@ -0,0 +1,134 @@
|
||||
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'];
|
||||
|
||||
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 --webpack', {
|
||||
cwd: TEMP_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=6144',
|
||||
},
|
||||
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);
|
||||
} 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;
|
||||
});
|
||||
394
scripts/electronWorkflow/modifiers/appCode.mts
Normal file
394
scripts/electronWorkflow/modifiers/appCode.mts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
|
||||
const rewriteFile = async (filePath: string, transformer: (code: string) => string) => {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
|
||||
const original = await fs.readFile(filePath, 'utf8');
|
||||
const updated = transformer(original);
|
||||
|
||||
if (updated !== original) {
|
||||
await fs.writeFile(filePath, updated);
|
||||
}
|
||||
};
|
||||
|
||||
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 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 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 removeClerkLogic = (code: string) => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
const edits: Array<{ start: number; end: number; text: string }> = [];
|
||||
|
||||
// Remove Clerk import - try multiple patterns
|
||||
const clerkImportPatterns = [
|
||||
{ pattern: 'import Clerk from $SOURCE' },
|
||||
{ pattern: "import Clerk from './Clerk'" },
|
||||
{ pattern: "import Clerk from './Clerk/index'" },
|
||||
];
|
||||
|
||||
for (const pattern of clerkImportPatterns) {
|
||||
const clerkImport = root.find({
|
||||
rule: pattern,
|
||||
});
|
||||
if (clerkImport) {
|
||||
const range = clerkImport.range();
|
||||
edits.push({ start: range.start.index, end: range.end.index, text: '' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const findClerkIfStatement = () => {
|
||||
const directMatch = root.find({
|
||||
rule: {
|
||||
pattern: 'if (authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH) { $$$ }',
|
||||
},
|
||||
});
|
||||
|
||||
if (directMatch) return directMatch;
|
||||
|
||||
const allIfStatements = root.findAll({
|
||||
rule: {
|
||||
kind: 'if_statement',
|
||||
},
|
||||
});
|
||||
|
||||
for (const ifStmt of allIfStatements) {
|
||||
const condition = ifStmt.find({
|
||||
rule: {
|
||||
pattern: 'authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH',
|
||||
},
|
||||
});
|
||||
|
||||
if (condition) return ifStmt;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const clerkIfStatement = findClerkIfStatement();
|
||||
|
||||
if (clerkIfStatement) {
|
||||
const ifRange = clerkIfStatement.range();
|
||||
const elseClause = clerkIfStatement.find({
|
||||
rule: {
|
||||
kind: 'else_clause',
|
||||
},
|
||||
});
|
||||
|
||||
if (elseClause) {
|
||||
const elseIfStmt = elseClause.find({
|
||||
rule: {
|
||||
kind: 'if_statement',
|
||||
},
|
||||
});
|
||||
|
||||
if (elseIfStmt) {
|
||||
// Promote the first else-if to a top-level if and keep the rest of the chain
|
||||
const elseRange = elseClause.range();
|
||||
const replacement = code
|
||||
.slice(elseRange.start.index, elseRange.end.index)
|
||||
.replace(/^\s*else\s+/, '');
|
||||
|
||||
edits.push({
|
||||
start: ifRange.start.index,
|
||||
end: ifRange.end.index,
|
||||
text: replacement,
|
||||
});
|
||||
} else {
|
||||
const elseBlock = elseClause.find({
|
||||
rule: {
|
||||
kind: 'statement_block',
|
||||
},
|
||||
});
|
||||
|
||||
if (elseBlock) {
|
||||
edits.push({
|
||||
start: ifRange.start.index,
|
||||
end: ifRange.end.index,
|
||||
text: code.slice(elseBlock.range().start.index, elseBlock.range().end.index),
|
||||
});
|
||||
} else {
|
||||
edits.push({ start: ifRange.start.index, end: ifRange.end.index, text: '' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
edits.push({ start: ifRange.start.index, end: ifRange.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 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;
|
||||
};
|
||||
|
||||
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');
|
||||
if (fs.existsSync(variantsPagePath)) {
|
||||
console.log(' Processing src/app/[variants]/page.tsx...');
|
||||
await fs.writeFile(variantsPagePath, desktopOnlyVariantsPage);
|
||||
}
|
||||
|
||||
// 2. Remove DevPanel from src/layout/GlobalProvider/index.tsx
|
||||
const globalProviderPath = path.join(TEMP_DIR, 'src/layout/GlobalProvider/index.tsx');
|
||||
if (fs.existsSync(globalProviderPath)) {
|
||||
console.log(' Processing src/layout/GlobalProvider/index.tsx...');
|
||||
await rewriteFile(globalProviderPath, stripDevPanel);
|
||||
}
|
||||
|
||||
// 3. Delete src/app/[variants]/(main)/settings/security directory
|
||||
const securityDirPath = path.join(TEMP_DIR, 'src/app/[variants]/(main)/settings/security');
|
||||
if (fs.existsSync(securityDirPath)) {
|
||||
console.log(' Deleting src/app/[variants]/(main)/settings/security directory...');
|
||||
await fs.remove(securityDirPath);
|
||||
}
|
||||
|
||||
// 4. Remove Security tab wiring from SettingsContent
|
||||
const settingsContentPath = path.join(
|
||||
TEMP_DIR,
|
||||
'src/app/[variants]/(main)/settings/features/SettingsContent.tsx',
|
||||
);
|
||||
if (fs.existsSync(settingsContentPath)) {
|
||||
console.log(' Processing src/app/[variants]/(main)/settings/features/SettingsContent.tsx...');
|
||||
await rewriteFile(settingsContentPath, removeSecurityTab);
|
||||
}
|
||||
|
||||
// 5. Remove SpeedInsights and Analytics from src/app/[variants]/layout.tsx
|
||||
const variantsLayoutPath = path.join(TEMP_DIR, 'src/app/[variants]/layout.tsx');
|
||||
if (fs.existsSync(variantsLayoutPath)) {
|
||||
console.log(' Processing src/app/[variants]/layout.tsx...');
|
||||
await rewriteFile(variantsLayoutPath, removeSpeedInsightsAndAnalytics);
|
||||
}
|
||||
|
||||
// 6. Remove Clerk logic from src/layout/AuthProvider/index.tsx
|
||||
const authProviderPath = path.join(TEMP_DIR, 'src/layout/AuthProvider/index.tsx');
|
||||
if (fs.existsSync(authProviderPath)) {
|
||||
console.log(' Processing src/layout/AuthProvider/index.tsx...');
|
||||
await rewriteFile(authProviderPath, removeClerkLogic);
|
||||
}
|
||||
|
||||
// 7. Replace mdx Image component with next/image export
|
||||
const mdxImagePath = path.join(TEMP_DIR, 'src/components/mdx/Image.tsx');
|
||||
if (fs.existsSync(mdxImagePath)) {
|
||||
console.log(' Processing src/components/mdx/Image.tsx...');
|
||||
await fs.writeFile(mdxImagePath, "export { default } from 'next/image';\n");
|
||||
}
|
||||
|
||||
// 8. Remove manifest from metadata
|
||||
const metadataPath = path.join(TEMP_DIR, 'src/app/[variants]/metadata.ts');
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
console.log(' Processing src/app/[variants]/metadata.ts...');
|
||||
await rewriteFile(metadataPath, removeManifestFromMetadata);
|
||||
}
|
||||
};
|
||||
|
||||
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/layout/AuthProvider/index.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/metadata.ts' },
|
||||
]);
|
||||
}
|
||||
61
scripts/electronWorkflow/modifiers/cleanUp.mts
Normal file
61
scripts/electronWorkflow/modifiers/cleanUp.mts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
|
||||
export const cleanUpCode = async (TEMP_DIR: string) => {
|
||||
// Remove 'use server'
|
||||
const filesToRemoveUseServer = [
|
||||
'src/components/mdx/Image.tsx',
|
||||
'src/features/DevPanel/CacheViewer/getCacheEntries.ts',
|
||||
'src/server/translation.ts',
|
||||
];
|
||||
|
||||
for (const file of filesToRemoveUseServer) {
|
||||
const filePath = path.join(TEMP_DIR, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log(` Processing ${file}...`);
|
||||
const code = await fs.readFile(filePath, 'utf8');
|
||||
const ast = parse(Lang.TypeScript, code);
|
||||
const root = ast.root();
|
||||
|
||||
// 'use server' is usually an expression statement at the top
|
||||
// We look for the literal string 'use server' or "use server"
|
||||
const useServer =
|
||||
root.find({
|
||||
rule: {
|
||||
pattern: "'use server'",
|
||||
},
|
||||
}) ||
|
||||
root.find({
|
||||
rule: {
|
||||
pattern: '"use server"',
|
||||
},
|
||||
});
|
||||
|
||||
if (useServer) {
|
||||
// Find the statement containing this string
|
||||
let curr = useServer.parent();
|
||||
while (curr) {
|
||||
if (curr.kind() === 'expression_statement') {
|
||||
curr.replace('');
|
||||
break;
|
||||
}
|
||||
if (curr.kind() === 'program') break;
|
||||
curr = curr.parent();
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, root.text());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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' },
|
||||
]);
|
||||
}
|
||||
27
scripts/electronWorkflow/modifiers/index.mts
Normal file
27
scripts/electronWorkflow/modifiers/index.mts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Lang } from '@ast-grep/napi';
|
||||
import path from 'node:path';
|
||||
|
||||
import { modifyAppCode } from './appCode.mjs';
|
||||
import { cleanUpCode } from './cleanUp.mjs';
|
||||
import { modifyNextConfig } from './nextConfig.mjs';
|
||||
import { modifyRoutes } from './routes.mjs';
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
|
||||
export const modifySourceForElectron = async (TEMP_DIR: string) => {
|
||||
await modifyNextConfig(TEMP_DIR);
|
||||
await modifyAppCode(TEMP_DIR);
|
||||
await modifyRoutes(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' },
|
||||
]);
|
||||
}
|
||||
133
scripts/electronWorkflow/modifiers/nextConfig.mts
Normal file
133
scripts/electronWorkflow/modifiers/nextConfig.mts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
|
||||
interface Edit {
|
||||
end: number;
|
||||
start: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const modifyNextConfig = async (TEMP_DIR: string) => {
|
||||
const nextConfigPath = path.join(TEMP_DIR, 'next.config.ts');
|
||||
if (!fs.existsSync(nextConfigPath)) return;
|
||||
|
||||
console.log(' Processing next.config.ts...');
|
||||
const code = await fs.readFile(nextConfigPath, 'utf8');
|
||||
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) {
|
||||
// 1. Remove redirects
|
||||
const redirectsProp = nextConfigDecl.find({
|
||||
rule: {
|
||||
kind: 'property_identifier',
|
||||
regex: '^redirects$',
|
||||
},
|
||||
});
|
||||
if (redirectsProp) {
|
||||
let curr = redirectsProp.parent();
|
||||
while (curr) {
|
||||
if (curr.kind() === 'pair') {
|
||||
const range = curr.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
break;
|
||||
}
|
||||
if (curr.kind() === 'object') break;
|
||||
curr = curr.parent();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove headers
|
||||
const headersProp = nextConfigDecl.find({
|
||||
rule: {
|
||||
kind: 'property_identifier',
|
||||
regex: '^headers$',
|
||||
},
|
||||
});
|
||||
if (headersProp) {
|
||||
let curr = headersProp.parent();
|
||||
while (curr) {
|
||||
if (curr.kind() === 'pair' || curr.kind() === 'method_definition') {
|
||||
const range = curr.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
break;
|
||||
}
|
||||
if (curr.kind() === 'object') break;
|
||||
curr = curr.parent();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove spread element
|
||||
const spread = nextConfigDecl.find({
|
||||
rule: {
|
||||
kind: 'spread_element',
|
||||
},
|
||||
});
|
||||
if (spread) {
|
||||
const range = spread.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
}
|
||||
|
||||
// 4. Inject output: 'export'
|
||||
const objectNode = nextConfigDecl.find({
|
||||
rule: { kind: 'object' },
|
||||
});
|
||||
|
||||
if (objectNode) {
|
||||
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',",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove withPWA wrapper
|
||||
const withPWA = root.find({
|
||||
rule: {
|
||||
pattern: 'withPWA($A)',
|
||||
},
|
||||
});
|
||||
if (withPWA) {
|
||||
const inner = withPWA.getMatch('A');
|
||||
if (inner) {
|
||||
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, '{');
|
||||
// 3. Trailing comma before closing brace is valid in JS/TS
|
||||
|
||||
await fs.writeFile(nextConfigPath, newCode);
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('modifyNextConfig', modifyNextConfig, [
|
||||
{ lang: Lang.TypeScript, path: process.cwd() + '/next.config.ts' },
|
||||
]);
|
||||
}
|
||||
90
scripts/electronWorkflow/modifiers/routes.mts
Normal file
90
scripts/electronWorkflow/modifiers/routes.mts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone } 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]/(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 fs.remove(fullPath);
|
||||
}
|
||||
|
||||
// 2. Modify desktopRouter.config.tsx
|
||||
const routerConfigPath = path.join(
|
||||
TEMP_DIR,
|
||||
'src/app/[variants]/router/desktopRouter.config.tsx',
|
||||
);
|
||||
if (fs.existsSync(routerConfigPath)) {
|
||||
console.log(' Processing src/app/[variants]/router/desktopRouter.config.tsx...');
|
||||
const code = await fs.readFile(routerConfigPath, 'utf8');
|
||||
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();
|
||||
}
|
||||
}
|
||||
await fs.writeFile(routerConfigPath, root.text());
|
||||
}
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('modifyRoutes', modifyRoutes, [
|
||||
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
|
||||
]);
|
||||
}
|
||||
67
scripts/electronWorkflow/modifiers/utils.mts
Normal file
67
scripts/electronWorkflow/modifiers/utils.mts
Normal file
@@ -0,0 +1,67 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
18
scripts/electronWorkflow/moveNextExports.ts
Normal file
18
scripts/electronWorkflow/moveNextExports.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
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!`);
|
||||
@@ -1,69 +0,0 @@
|
||||
/* eslint-disable unicorn/no-process-exit */
|
||||
import fs from 'fs-extra';
|
||||
import { execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
|
||||
// 定义源目录和目标目录
|
||||
const sourceDir: string = path.join(rootDir, '.next/standalone');
|
||||
const targetDir: string = path.join(rootDir, 'apps/desktop/dist/next');
|
||||
|
||||
// 向 sourceDir 写入 .env 文件
|
||||
const env = fs.readFileSync(path.join(rootDir, '.env.desktop'), 'utf8');
|
||||
|
||||
fs.writeFileSync(path.join(sourceDir, '.env'), env, 'utf8');
|
||||
console.log(`⚓️ Inject .env successful`);
|
||||
|
||||
// 确保目标目录的父目录存在
|
||||
fs.ensureDirSync(path.dirname(targetDir));
|
||||
|
||||
// 如果目标目录已存在,先删除它
|
||||
if (fs.existsSync(targetDir)) {
|
||||
console.log(`🗑️ Target directory ${targetDir} already exists, deleting...`);
|
||||
try {
|
||||
fs.removeSync(targetDir);
|
||||
console.log(`✅ Old target directory removed successfully`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Failed to delete target directory: ${error}`);
|
||||
console.log('🔄 Trying to delete using system command...');
|
||||
try {
|
||||
if (os.platform() === 'win32') {
|
||||
execSync(`rmdir /S /Q "${targetDir}"`, { stdio: 'inherit' });
|
||||
} else {
|
||||
execSync(`rm -rf "${targetDir}"`, { stdio: 'inherit' });
|
||||
}
|
||||
console.log('✅ Successfully deleted old target directory');
|
||||
} catch (cmdError) {
|
||||
console.error(`❌ Unable to delete target directory, might need manual cleanup: ${cmdError}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🚚 Moving ${sourceDir} to ${targetDir}...`);
|
||||
|
||||
try {
|
||||
// 使用 fs-extra 的 move 方法
|
||||
fs.moveSync(sourceDir, targetDir, { overwrite: true });
|
||||
console.log(`✅ Directory moved successfully!`);
|
||||
} catch (error) {
|
||||
console.error('❌ fs-extra move failed:', error);
|
||||
console.log('🔄 Trying to move using system command...');
|
||||
|
||||
try {
|
||||
// 使用系统命令进行移动
|
||||
if (os.platform() === 'win32') {
|
||||
execSync(`move "${sourceDir}" "${targetDir}"`, { stdio: 'inherit' });
|
||||
} else {
|
||||
execSync(`mv "${sourceDir}" "${targetDir}"`, { stdio: 'inherit' });
|
||||
}
|
||||
console.log('✅ System command move completed successfully!');
|
||||
} catch (mvError) {
|
||||
console.error('❌ Failed to move directory:', mvError);
|
||||
console.log('💡 Try running manually: sudo mv ' + sourceDir + ' ' + targetDir);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🎉 Move completed!`);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { consola } from 'consola';
|
||||
import { colors } from 'consola/utils';
|
||||
import { unset } from 'es-toolkit/compat';
|
||||
import { diff } from 'just-diff';
|
||||
import { unset } from 'lodash';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import dotenvExpand from 'dotenv-expand';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
|
||||
|
||||
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());
|
||||
}
|
||||
// 创建需要排除的特性映射
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
const partialBuildPages = [
|
||||
@@ -62,22 +69,24 @@ const partialBuildPages = [
|
||||
/**
|
||||
* 删除指定的目录
|
||||
*/
|
||||
const removeDirectories = async () => {
|
||||
export const runPrebuild = async (targetDir: string = 'src') => {
|
||||
// 遍历 partialBuildPages 数组
|
||||
for (const page of partialBuildPages) {
|
||||
// 检查是否需要禁用该功能
|
||||
if (page.disabled) {
|
||||
for (const dirPath of page.paths) {
|
||||
const fullPath = path.resolve(process.cwd(), dirPath);
|
||||
// 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 ${dirPath} successfully`);
|
||||
console.log(`♻️ Removed ${relativePath} successfully`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove directory ${dirPath}:`, error);
|
||||
console.error(`Failed to remove directory ${relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +94,12 @@ const removeDirectories = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 执行删除操作
|
||||
console.log('Starting prebuild cleanup...');
|
||||
await removeDirectories();
|
||||
console.log('Prebuild cleanup completed.');
|
||||
// Check if the script is being run directly
|
||||
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMainModule) {
|
||||
// 执行删除操作
|
||||
console.log('Starting prebuild cleanup...');
|
||||
await runPrebuild();
|
||||
console.log('Prebuild cleanup completed.');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { kebabCase } from 'lodash';
|
||||
import { kebabCase } from 'es-toolkit/compat';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
|
||||
29
scripts/registerDesktopEnv.cjs
Normal file
29
scripts/registerDesktopEnv.cjs
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Desktop env preloader for Next.js.
|
||||
*
|
||||
* Why: Next.js only auto-loads `.env*` (e.g. `.env`, `.env.local`, `.env.development`),
|
||||
* but our desktop build expects `.env.desktop`.
|
||||
*
|
||||
* This file is intended to be used via Node's `-r` (require) flag so it runs
|
||||
* BEFORE Next.js loads its own env config:
|
||||
*
|
||||
* node -r ./scripts/registerDesktopEnv.cjs ./node_modules/next/dist/bin/next build
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const dotenv = require('dotenv');
|
||||
const dotenvExpand = require('dotenv-expand');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
|
||||
|
||||
if (isDesktop) {
|
||||
const cwd = process.cwd();
|
||||
|
||||
const envDesktop = path.join(cwd, '.env.desktop');
|
||||
const envDesktopLocal = path.join(cwd, '.env.desktop.local');
|
||||
|
||||
if (fs.existsSync(envDesktop)) dotenvExpand.expand(dotenv.config({ path: envDesktop }));
|
||||
if (fs.existsSync(envDesktopLocal))
|
||||
dotenvExpand.expand(dotenv.config({ override: true, path: envDesktopLocal }));
|
||||
}
|
||||
251
scripts/replaceComponentImports.ts
Normal file
251
scripts/replaceComponentImports.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
interface ReplaceConfig {
|
||||
/** 要替换的组件列表 */
|
||||
components: string[];
|
||||
/** 是否为 dry-run 模式(仅预览,不实际修改) */
|
||||
dryRun?: boolean;
|
||||
/** 文件扩展名白名单 */
|
||||
fileExtensions?: string[];
|
||||
/** 原始包名 */
|
||||
fromPackage: string;
|
||||
/** 要扫描的目录 */
|
||||
targetDir: string;
|
||||
/** 目标包名 */
|
||||
toPackage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归获取目录下所有文件
|
||||
*/
|
||||
function getAllFiles(dir: string, extensions: string[]): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
function walk(currentPath: string) {
|
||||
const items = readdirSync(currentPath);
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = join(currentPath, item);
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// 跳过 node_modules 等目录
|
||||
if (!['node_modules', '.git', 'dist', 'build', '.next'].includes(item)) {
|
||||
walk(fullPath);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const hasValidExtension = extensions.some((ext) => fullPath.endsWith(ext));
|
||||
if (hasValidExtension) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 import 语句,提取导入的组件
|
||||
*/
|
||||
function parseImportStatement(line: string, packageName: string) {
|
||||
// 匹配 import { ... } from 'package'
|
||||
const importRegex = new RegExp(
|
||||
`import\\s+{([^}]+)}\\s+from\\s+['"]${packageName.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')}['"]`,
|
||||
);
|
||||
const match = line.match(importRegex);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const importContent = match[1];
|
||||
const components = importContent
|
||||
.split(',')
|
||||
.map((item) => {
|
||||
const trimmed = item.trim();
|
||||
// 处理 as 别名: ComponentName as AliasName
|
||||
const asMatch = trimmed.match(/^(\w+)(?:\s+as\s+(\w+))?/);
|
||||
return asMatch
|
||||
? {
|
||||
alias: asMatch[2] || null,
|
||||
name: asMatch[1],
|
||||
raw: trimmed,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean) as Array<{ alias: string | null; name: string; raw: string }>;
|
||||
|
||||
return {
|
||||
components,
|
||||
fullMatch: match[0],
|
||||
indentation: line.match(/^\s*/)?.[0] || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换文件中的 import 语句
|
||||
*/
|
||||
function replaceImportsInFile(filePath: string, config: ReplaceConfig): boolean {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
let modified = false;
|
||||
const newLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseImportStatement(line, config.fromPackage);
|
||||
|
||||
if (!parsed) {
|
||||
newLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 找出需要替换的组件和保留的组件
|
||||
const toReplace = parsed.components.filter((comp) => config.components.includes(comp.name));
|
||||
const toKeep = parsed.components.filter((comp) => !config.components.includes(comp.name));
|
||||
|
||||
if (toReplace.length === 0) {
|
||||
// 没有需要替换的组件
|
||||
newLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
modified = true;
|
||||
|
||||
// 生成新的 import 语句
|
||||
const { indentation } = parsed;
|
||||
|
||||
// 如果有保留的组件,保留原来的 import
|
||||
if (toKeep.length > 0) {
|
||||
const keepImports = toKeep.map((c) => c.raw).join(', ');
|
||||
newLines.push(`${indentation}import { ${keepImports} } from '${config.fromPackage}';`);
|
||||
}
|
||||
|
||||
// 添加新的 import
|
||||
const replaceImports = toReplace.map((c) => c.raw).join(', ');
|
||||
newLines.push(`${indentation}import { ${replaceImports} } from '${config.toPackage}';`);
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
const newContent = newLines.join('\n');
|
||||
if (!config.dryRun) {
|
||||
writeFileSync(filePath, newContent, 'utf8');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行替换
|
||||
*/
|
||||
function executeReplace(config: ReplaceConfig) {
|
||||
const extensions = config.fileExtensions || ['.ts', '.tsx', '.js', '.jsx'];
|
||||
const files = getAllFiles(config.targetDir, extensions);
|
||||
|
||||
console.log(`\n🔍 扫描目录: ${config.targetDir}`);
|
||||
console.log(`📦 从 "${config.fromPackage}" 替换到 "${config.toPackage}"`);
|
||||
console.log(`🎯 目标组件: ${config.components.join(', ')}`);
|
||||
console.log(`📄 找到 ${files.length} 个文件\n`);
|
||||
|
||||
if (config.dryRun) {
|
||||
console.log('🔔 [DRY RUN 模式] 仅预览,不会实际修改文件\n');
|
||||
}
|
||||
|
||||
let modifiedCount = 0;
|
||||
const modifiedFiles: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const wasModified = replaceImportsInFile(file, config);
|
||||
if (wasModified) {
|
||||
modifiedCount++;
|
||||
modifiedFiles.push(relative(process.cwd(), file));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ 完成!');
|
||||
console.log(`📝 修改了 ${modifiedCount} 个文件\n`);
|
||||
|
||||
if (modifiedFiles.length > 0) {
|
||||
console.log('修改的文件:');
|
||||
for (const file of modifiedFiles) {
|
||||
console.log(` - ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 主函数 ============
|
||||
|
||||
/**
|
||||
* 从命令行参数解析配置
|
||||
*/
|
||||
function parseArgs(): ReplaceConfig | null {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`
|
||||
使用方法:
|
||||
bun run scripts/replaceComponentImports.ts [选项]
|
||||
|
||||
选项:
|
||||
--components <comp1,comp2,...> 要替换的组件列表(逗号分隔)
|
||||
--from <package> 原始包名
|
||||
--to <package> 目标包名
|
||||
--dir <directory> 要扫描的目录(默认: src)
|
||||
--ext <.ext1,.ext2,...> 文件扩展名(默认: .ts,.tsx,.js,.jsx)
|
||||
--dry-run 仅预览,不实际修改文件
|
||||
--help, -h 显示帮助信息
|
||||
|
||||
示例:
|
||||
# 将 antd 的 Skeleton 和 Empty 替换为 @lobehub/ui
|
||||
bun run scripts/replaceComponentImports.ts \\
|
||||
--components Skeleton,Empty \\
|
||||
--from antd \\
|
||||
--to @lobehub/ui \\
|
||||
--dir src
|
||||
|
||||
# 仅预览,不修改
|
||||
bun run scripts/replaceComponentImports.ts \\
|
||||
--components Skeleton,Empty \\
|
||||
--from antd \\
|
||||
--to @lobehub/ui \\
|
||||
--dry-run
|
||||
`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const getArgValue = (flag: string): string | undefined => {
|
||||
const index = args.indexOf(flag);
|
||||
return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined;
|
||||
};
|
||||
|
||||
const componentsStr = getArgValue('--components');
|
||||
const fromPackage = getArgValue('--from');
|
||||
const toPackage = getArgValue('--to');
|
||||
const targetDir = getArgValue('--dir') || 'src';
|
||||
const extStr = getArgValue('--ext');
|
||||
const dryRun = args.includes('--dry-run');
|
||||
|
||||
if (!componentsStr || !fromPackage || !toPackage) {
|
||||
console.error('❌ 错误: 必须指定 --components, --from 和 --to 参数');
|
||||
console.error('使用 --help 查看帮助信息');
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
components: componentsStr.split(',').map((c) => c.trim()),
|
||||
dryRun,
|
||||
fileExtensions: extStr ? extStr.split(',').map((e) => e.trim()) : undefined,
|
||||
fromPackage,
|
||||
targetDir,
|
||||
toPackage,
|
||||
};
|
||||
}
|
||||
|
||||
// 执行脚本
|
||||
const config = parseArgs();
|
||||
if (config) {
|
||||
executeReplace(config);
|
||||
}
|
||||
33
scripts/runNextDesktop.mts
Normal file
33
scripts/runNextDesktop.mts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import dotenvExpand from 'dotenv-expand';
|
||||
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';
|
||||
|
||||
if (isDesktop) {
|
||||
const envDesktop = path.resolve(process.cwd(), '.env.desktop');
|
||||
const envDesktopLocal = path.resolve(process.cwd(), '.env.desktop.local');
|
||||
|
||||
if (existsSync(envDesktop)) dotenvExpand.expand(dotenv.config({ path: envDesktop }));
|
||||
if (existsSync(envDesktopLocal))
|
||||
dotenvExpand.expand(dotenv.config({ override: true, path: envDesktopLocal }));
|
||||
}
|
||||
|
||||
const nextBin = path.resolve(process.cwd(), 'node_modules', 'next', 'dist', 'bin', 'next');
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const child = spawn(process.execPath, [nextBin, ...args], {
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (typeof code === 'number') {
|
||||
process.exitCode = code;
|
||||
return;
|
||||
}
|
||||
|
||||
process.exitCode = signal ? 1 : 0;
|
||||
});
|
||||
Reference in New Issue
Block a user