mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ perf(electron): add codemods to convert dynamic imports to static (#11690)
* ✨ feat(electron): add codemods to convert dynamic imports to static Add multiple modifiers for Electron build workflow: - dynamicToStatic: Convert dynamicElement() to static imports - nextDynamicToStatic: Convert next/dynamic (ssr: false) to static - wrapChildrenWithClientOnly: Wrap layout children with ClientOnly + Loading fallback - settingsContentToStatic: Handle SettingsContent componentMap pattern - removeSuspense: Remove Suspense wrappers from components - routes: Delete loading.tsx files and (mobile) directory Also add fallback prop support to ClientOnly component for better UX during hydration. * ✨ feat(electron): enhance settingsContentToStatic with business features support - Introduced a new function to check if business features are enabled via environment variables. - Updated import generation functions to conditionally include business-related imports based on the new feature flag. - Improved regex patterns for better matching of dynamic imports. - Added logging to indicate when business features are active, enhancing debugging and user awareness. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
273
scripts/electronWorkflow/modifiers/dynamicToStatic.mts
Normal file
273
scripts/electronWorkflow/modifiers/dynamicToStatic.mts
Normal file
@@ -0,0 +1,273 @@
|
||||
/* 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' },
|
||||
]);
|
||||
}
|
||||
@@ -3,14 +3,24 @@ import path from 'node:path';
|
||||
|
||||
import { modifyAppCode } from './appCode.mjs';
|
||||
import { cleanUpCode } from './cleanUp.mjs';
|
||||
import { convertDynamicToStatic } from './dynamicToStatic.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 convertSettingsContentToStatic(TEMP_DIR);
|
||||
await removeSuspenseFromConversation(TEMP_DIR);
|
||||
await modifyRoutes(TEMP_DIR);
|
||||
await modifyStaticExport(TEMP_DIR);
|
||||
await cleanUpCode(TEMP_DIR);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
233
scripts/electronWorkflow/modifiers/nextDynamicToStatic.mts
Normal file
233
scripts/electronWorkflow/modifiers/nextDynamicToStatic.mts
Normal file
@@ -0,0 +1,233 @@
|
||||
/* 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, []);
|
||||
}
|
||||
124
scripts/electronWorkflow/modifiers/removeSuspense.mts
Normal file
124
scripts/electronWorkflow/modifiers/removeSuspense.mts
Normal file
@@ -0,0 +1,124 @@
|
||||
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' },
|
||||
]);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const modifyRoutes = async (TEMP_DIR: string) => {
|
||||
|
||||
// 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',
|
||||
@@ -45,13 +46,25 @@ export const modifyRoutes = async (TEMP_DIR: string) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Modify desktopRouter.config.tsx
|
||||
// 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) => {
|
||||
@@ -86,7 +99,6 @@ export const modifyRoutes = async (TEMP_DIR: string) => {
|
||||
|
||||
return root.text();
|
||||
},
|
||||
assertAfter: (code) => !/\bchangelog\b/.test(code),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
148
scripts/electronWorkflow/modifiers/settingsContentToStatic.mts
Normal file
148
scripts/electronWorkflow/modifiers/settingsContentToStatic.mts
Normal file
@@ -0,0 +1,148 @@
|
||||
/* 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 isBusinessFeaturesEnabled = () => {
|
||||
const raw = process.env.ENABLE_BUSINESS_FEATURES;
|
||||
if (!raw) return false;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1';
|
||||
};
|
||||
|
||||
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[], keepBusinessTabs: boolean): string => {
|
||||
return imports
|
||||
.filter((imp) => keepBusinessTabs || !imp.importPath.includes('@/business/'))
|
||||
.map((imp) => `import ${imp.componentName} from '${imp.importPath}';`)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const generateStaticComponentMap = (
|
||||
imports: DynamicImportInfo[],
|
||||
keepBusinessTabs: boolean,
|
||||
): string => {
|
||||
const entries = imports
|
||||
.filter((imp) => keepBusinessTabs || !imp.importPath.includes('@/business/'))
|
||||
.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 keepBusinessTabs = isBusinessFeaturesEnabled();
|
||||
if (keepBusinessTabs) {
|
||||
console.log(
|
||||
' ENABLE_BUSINESS_FEATURES is enabled, preserving business Settings tabs in componentMap',
|
||||
);
|
||||
}
|
||||
|
||||
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, keepBusinessTabs);
|
||||
const staticComponentMap = generateStaticComponentMap(imports, keepBusinessTabs);
|
||||
|
||||
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' },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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,15 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import type React from 'react';
|
||||
import { type FC, type PropsWithChildren, useEffect, useState } from 'react';
|
||||
|
||||
const ClientOnly: FC<PropsWithChildren> = ({ children }) => {
|
||||
const ClientOnly: FC<PropsWithChildren<{ fallback?: React.ReactNode }>> = ({
|
||||
children,
|
||||
fallback = null,
|
||||
}) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!mounted) return fallback;
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user