mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
build: add assertions to electron workflow modifiers (#11003)
- Add post-condition assertions to all file modification operations - Add verify-desktop-patch.yml workflow for CI validation - Add invariant, updateFile, writeFileEnsuring, removePathEnsuring utilities - Improve error messages and validation in workflow scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
841f3e4db5
commit
a763f12fd3
7 changed files with 420 additions and 217 deletions
4
.github/workflows/manual-build-desktop.yml
vendored
4
.github/workflows/manual-build-desktop.yml
vendored
|
|
@ -62,13 +62,9 @@ jobs:
|
|||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
|
||||
version:
|
||||
name: Determine version
|
||||
|
|
|
|||
55
.github/workflows/verify-desktop-patch.yml
vendored
Normal file
55
.github/workflows/verify-desktop-patch.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
name: Verify Desktop Patch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next
|
||||
- dev
|
||||
paths:
|
||||
- 'scripts/electronWorkflow/**'
|
||||
- 'src/libs/next/config/**'
|
||||
- 'src/app/**'
|
||||
- 'src/layout/**'
|
||||
- 'src/components/mdx/**'
|
||||
- 'src/features/DevPanel/**'
|
||||
- 'src/server/translation.ts'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/electronWorkflow/**'
|
||||
- 'src/libs/next/config/**'
|
||||
- 'src/app/**'
|
||||
- 'src/layout/**'
|
||||
- 'src/components/mdx/**'
|
||||
- 'src/features/DevPanel/**'
|
||||
- 'src/server/translation.ts'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Desktop patch smoke test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
|
||||
- name: Verify desktop patch
|
||||
run: bun scripts/electronWorkflow/modifiers/index.mts
|
||||
|
|
@ -1,19 +1,14 @@
|
|||
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);
|
||||
}
|
||||
};
|
||||
import {
|
||||
isDirectRun,
|
||||
normalizeEol,
|
||||
removePathEnsuring,
|
||||
runStandalone,
|
||||
updateFile,
|
||||
writeFileEnsuring,
|
||||
} from './utils.mjs';
|
||||
|
||||
const desktopOnlyVariantsPage = `import { DynamicLayoutProps } from '@/types/next';
|
||||
|
||||
|
|
@ -35,6 +30,11 @@ const stripDevPanel = (code: string) => {
|
|||
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/;
|
||||
|
|
@ -43,6 +43,8 @@ const removeSecurityTab = (code: string) => {
|
|||
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();
|
||||
|
|
@ -130,6 +132,12 @@ const removeSpeedInsightsAndAnalytics = (code: string) => {
|
|||
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 removeClerkLogic = (code: string) => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
|
@ -244,6 +252,10 @@ const removeClerkLogic = (code: string) => {
|
|||
return result;
|
||||
};
|
||||
|
||||
const assertClerkLogicRemoved = (code: string) =>
|
||||
!/\bNEXT_PUBLIC_ENABLE_CLERK_AUTH\b/.test(code) &&
|
||||
!/\bauthEnv\.NEXT_PUBLIC_ENABLE_CLERK_AUTH\b/.test(code);
|
||||
|
||||
const removeManifestFromMetadata = (code: string) => {
|
||||
const ast = parse(Lang.Tsx, code);
|
||||
const root = ast.root();
|
||||
|
|
@ -320,65 +332,90 @@ const removeManifestFromMetadata = (code: string) => {
|
|||
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');
|
||||
if (fs.existsSync(variantsPagePath)) {
|
||||
console.log(' Processing src/app/[variants]/page.tsx...');
|
||||
await fs.writeFile(variantsPagePath, desktopOnlyVariantsPage);
|
||||
}
|
||||
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');
|
||||
if (fs.existsSync(globalProviderPath)) {
|
||||
console.log(' Processing src/layout/GlobalProvider/index.tsx...');
|
||||
await rewriteFile(globalProviderPath, stripDevPanel);
|
||||
}
|
||||
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');
|
||||
if (fs.existsSync(securityDirPath)) {
|
||||
console.log(' Deleting src/app/[variants]/(main)/settings/security directory...');
|
||||
await fs.remove(securityDirPath);
|
||||
}
|
||||
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',
|
||||
);
|
||||
if (fs.existsSync(settingsContentPath)) {
|
||||
console.log(' Processing src/app/[variants]/(main)/settings/features/SettingsContent.tsx...');
|
||||
await rewriteFile(settingsContentPath, removeSecurityTab);
|
||||
}
|
||||
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');
|
||||
if (fs.existsSync(variantsLayoutPath)) {
|
||||
console.log(' Processing src/app/[variants]/layout.tsx...');
|
||||
await rewriteFile(variantsLayoutPath, removeSpeedInsightsAndAnalytics);
|
||||
}
|
||||
console.log(' Processing src/app/[variants]/layout.tsx...');
|
||||
await updateFile({
|
||||
filePath: variantsLayoutPath,
|
||||
name: 'modifyAppCode:removeSpeedInsightsAndAnalytics',
|
||||
transformer: removeSpeedInsightsAndAnalytics,
|
||||
assertAfter: assertSpeedInsightsAndAnalyticsRemoved,
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
console.log(' Processing src/layout/AuthProvider/index.tsx...');
|
||||
await updateFile({
|
||||
filePath: authProviderPath,
|
||||
name: 'modifyAppCode:removeClerkLogic',
|
||||
transformer: removeClerkLogic,
|
||||
assertAfter: assertClerkLogicRemoved,
|
||||
});
|
||||
|
||||
// 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");
|
||||
}
|
||||
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';",
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
console.log(' Processing src/app/[variants]/metadata.ts...');
|
||||
await updateFile({
|
||||
filePath: metadataPath,
|
||||
name: 'modifyAppCode:removeManifestFromMetadata',
|
||||
transformer: removeManifestFromMetadata,
|
||||
assertAfter: assertMetadataManifestRemoved,
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
|
|
|
|||
|
|
@ -1,41 +1,44 @@
|
|||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
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/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();
|
||||
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;
|
||||
|
||||
// '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"',
|
||||
},
|
||||
});
|
||||
// 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;
|
||||
|
||||
if (useServer) {
|
||||
// Find the statement containing this string
|
||||
let curr = useServer.parent();
|
||||
while (curr) {
|
||||
if (curr.kind() === 'expression_statement') {
|
||||
|
|
@ -45,10 +48,11 @@ export const cleanUpCode = async (TEMP_DIR: string) => {
|
|||
if (curr.kind() === 'program') break;
|
||||
curr = curr.parent();
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, root.text());
|
||||
}
|
||||
return root.text();
|
||||
},
|
||||
assertAfter: (code) => !hasUseServerDirective(code),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Lang, parse } from '@ast-grep/napi';
|
|||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
interface Edit {
|
||||
end: number;
|
||||
|
|
@ -11,123 +11,149 @@ interface Edit {
|
|||
}
|
||||
|
||||
export const modifyNextConfig = async (TEMP_DIR: string) => {
|
||||
const nextConfigPath = path.join(TEMP_DIR, 'next.config.ts');
|
||||
if (!fs.existsSync(nextConfigPath)) return;
|
||||
const defineConfigPath = path.join(TEMP_DIR, 'src', 'libs', 'next', 'config', 'define-config.ts');
|
||||
const legacyNextConfigPath = path.join(TEMP_DIR, 'next.config.ts');
|
||||
|
||||
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[] = [];
|
||||
const nextConfigPath = fs.existsSync(defineConfigPath) ? defineConfigPath : legacyNextConfigPath;
|
||||
if (!fs.existsSync(nextConfigPath)) {
|
||||
throw new Error(`[modifyNextConfig] next config not found: ${nextConfigPath}`);
|
||||
}
|
||||
|
||||
// Find nextConfig declaration
|
||||
const nextConfigDecl = root.find({
|
||||
rule: {
|
||||
pattern: 'const nextConfig: NextConfig = { $$$ }',
|
||||
},
|
||||
});
|
||||
console.log(` Processing ${path.relative(TEMP_DIR, nextConfigPath)}...`);
|
||||
await updateFile({
|
||||
filePath: nextConfigPath,
|
||||
name: 'modifyNextConfig',
|
||||
transformer: (code) => {
|
||||
const ast = parse(Lang.TypeScript, code);
|
||||
const root = ast.root();
|
||||
const edits: Edit[] = [];
|
||||
|
||||
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',",
|
||||
// Find nextConfig declaration
|
||||
const nextConfigDecl = root.find({
|
||||
rule: {
|
||||
pattern: 'const nextConfig: NextConfig = { $$$ }',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!nextConfigDecl) {
|
||||
throw new Error('[modifyNextConfig] nextConfig declaration not found');
|
||||
}
|
||||
|
||||
// Remove withPWA wrapper
|
||||
const withPWA = root.find({
|
||||
rule: {
|
||||
pattern: 'withPWA($A)',
|
||||
// 1. Remove redirects
|
||||
const redirectsPair = nextConfigDecl.find({
|
||||
rule: {
|
||||
pattern: 'redirects: $A',
|
||||
},
|
||||
});
|
||||
if (redirectsPair) {
|
||||
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');
|
||||
});
|
||||
if (headersMethod) {
|
||||
const range = headersMethod.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
}
|
||||
|
||||
// 3. Remove spread element
|
||||
const spreads = nextConfigDecl.findAll({
|
||||
rule: {
|
||||
kind: 'spread_element',
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
if (spreadToRemove) {
|
||||
const range = spreadToRemove.range();
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: '' });
|
||||
}
|
||||
|
||||
// 4. 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',",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
},
|
||||
assertAfter: (code) => /output\s*:\s*['"]export['"]/.test(code) && !/withPWA\s*\(/.test(code),
|
||||
});
|
||||
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' },
|
||||
{ lang: Lang.TypeScript, path: 'src/libs/next/config/define-config.ts' },
|
||||
{ lang: Lang.TypeScript, path: 'next.config.ts' },
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
import { isDirectRun, removePathEnsuring, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
export const modifyRoutes = async (TEMP_DIR: string) => {
|
||||
// 1. Delete routes
|
||||
|
|
@ -40,7 +39,10 @@ export const modifyRoutes = async (TEMP_DIR: string) => {
|
|||
|
||||
for (const file of filesToDelete) {
|
||||
const fullPath = path.join(TEMP_DIR, file);
|
||||
await fs.remove(fullPath);
|
||||
await removePathEnsuring({
|
||||
name: `modifyRoutes:delete:${file}`,
|
||||
path: fullPath,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Modify desktopRouter.config.tsx
|
||||
|
|
@ -48,39 +50,44 @@ export const modifyRoutes = async (TEMP_DIR: string) => {
|
|||
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();
|
||||
console.log(' Processing src/app/[variants]/router/desktopRouter.config.tsx...');
|
||||
await updateFile({
|
||||
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();
|
||||
const changelogNode = root.find({
|
||||
rule: {
|
||||
pattern: "{ path: 'changelog', $$$ }",
|
||||
},
|
||||
});
|
||||
if (changelogNode) {
|
||||
changelogNode.replace('');
|
||||
}
|
||||
}
|
||||
await fs.writeFile(routerConfigPath, root.text());
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
assertAfter: (code) => !/\bchangelog\b/.test(code),
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,84 @@ interface ValidationTarget {
|
|||
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue