Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat:simplify route file #81

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thin-buttons-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@umijs/tnf': patch
---

Integrated router generation plug-in and configuration support, and implemented the function of simplifying routing files
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { checkVersion, setNoDeprecation, setNodeTitle } from './fishkit/node';
import { mock } from './funplugins/mock/mock';
import { reactCompiler } from './funplugins/react_compiler/react_compiler';
import { reactScan } from './funplugins/react_scan/react_scan';
import { routerGenerator } from './funplugins/router_generator/router_generator';
import { PluginHookType, PluginManager } from './plugin/plugin_manager';
import { type Context, Mode } from './types';

Expand All @@ -29,6 +30,9 @@ async function buildContext(cwd: string): Promise<Context> {
mock({ paths: ['mock'], cwd }),
...(config.reactScan && isDev ? [reactScan()] : []),
...(config.reactCompiler ? [reactCompiler(config.reactCompiler)] : []),
...(config.router?.routeFileSimplify && isDev
? [routerGenerator(config.router.convention)]
: []),
];
const pluginManager = new PluginManager(plugins);

Expand Down
1 change: 1 addition & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const ConfigSchema = z
])
.optional(),
convention: RouterGeneratorConfig,
routeFileSimplify: z.boolean().optional(),
})
.optional(),
ssr: z
Expand Down
23 changes: 23 additions & 0 deletions src/funplugins/router_generator/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
type Config as baseConfig,
configSchema as generatorConfigSchema,
getConfig as getGeneratorConfig,
} from '@tanstack/router-generator';
import { z } from 'zod';

// 如果不这么做 TS会莫名其妙的报错
const configSchema: z.ZodType<
baseConfig & { enableRouteGeneration?: boolean }
> = generatorConfigSchema.extend({
enableRouteGeneration: z.boolean().optional(),
}) as z.ZodType<baseConfig & { enableRouteGeneration?: boolean }>;

export const getConfig = (
inlineConfig: Partial<z.infer<typeof configSchema>>,
root: string,
): z.infer<typeof configSchema> => {
const config = getGeneratorConfig(inlineConfig, root);
return configSchema.parse({ ...config, ...inlineConfig });
};

export type Config = z.infer<typeof configSchema>;
275 changes: 275 additions & 0 deletions src/funplugins/router_generator/router_generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { generator } from '@tanstack/router-generator';
import fsp from 'fs/promises';
import { existsSync } from 'node:fs';
import {
dirname,
extname,
isAbsolute,
join,
normalize,
relative,
resolve,
} from 'node:path';
import { FRAMEWORK_NAME } from '../../constants';
import type { Plugin } from '../../plugin/types';
import { getConfig } from './config';
import type { Config } from './config';

let lock = false;
const checkLock = () => lock;
const setLock = (bool: boolean) => {
lock = bool;
};

export const routerGenerator = (options: Partial<Config> = {}): Plugin => {
let ROOT: string = process.cwd();
let userConfig = options as Config;
const tmpPath = join(ROOT, `.${FRAMEWORK_NAME}`);
let routesDirectory: string = '';

const getRoutesDirectoryPath = () => {
return isAbsolute(routesDirectory)
? routesDirectory
: join(ROOT, routesDirectory);
};

const isRouteFile = (filename: string): boolean => {
const ext = extname(filename).toLowerCase();
return ext === '.tsx' || ext === '.jsx';
};

const shouldIgnoreFile = (filePath: string) => {
if (!userConfig.routeFileIgnorePattern) {
return false;
}
const pattern = new RegExp(userConfig.routeFileIgnorePattern);
return pattern.test(filePath);
};

const generateImportPath = (tmpPagePath: string, srcPagePath: string) => {
const tmpPageDir = dirname(tmpPagePath);
const importPath = relative(tmpPageDir, srcPagePath);
const importPathWithoutExt = importPath.replace(/\.(tsx|jsx)$/, '');
return importPathWithoutExt.startsWith('.')
? importPathWithoutExt
: `./${importPathWithoutExt}`;
};

const middlePageFilesGenerator = async (
dirPath: string,
pagesRootPath: string,
) => {
const files = await fsp.readdir(dirPath, { withFileTypes: true });

for (const file of files) {
if (shouldIgnoreFile(file.name)) {
continue;
}

const currentPath = join(dirPath, file.name);

if (file.isDirectory()) {
const relativePath = relative(pagesRootPath, currentPath);
const targetDir = join(tmpPath, 'pages', relativePath);
await fsp.mkdir(targetDir, { recursive: true });
await middlePageFilesGenerator(join(dirPath, file.name), pagesRootPath);
} else if (file.isFile() && isRouteFile(file.name)) {
const relativePath = relative(pagesRootPath, currentPath);
const targetPath = join(tmpPath, 'pages', relativePath);
await fsp.mkdir(dirname(targetPath), { recursive: true });
if (!existsSync(targetPath)) {
await fsp.writeFile(targetPath, '', 'utf-8');
}
}
}
};

function transformRouteFile(importPath: string, content: string) {
const isRootFile = content.includes('createRootRoute');

// 1. 替换 import 声明
content = content.replace(/@tanstack\/react-router/g, '@umijs/tnf/router');

// 2. 添加新的 import 语句
const importStatement = `import ImportComponent from '${importPath}'`;
if (!content.includes(importStatement)) {
content = `${importStatement}\n${content}`;
}

// 3. 替换 component: RouteComponent 为 component: ImportComponent
content = content.replace(
/component:\s*RouteComponent/g,
'component: ImportComponent',
);

if (isRootFile) {
content = content.replace(
/component:\s*RootComponent/g,
'component: ImportComponent',
);
}

// 4. 移除 RouteComponent 函数定义
content = content.replace(
/\s*function\s+RouteComponent\s*\(\)\s*{[\s\S]*?}\s*/g,
'',
);

if (isRootFile) {
content = content.replace(
/\s*function\s+RootComponent\s*\(\)\s*{[\s\S]*?}\s*/g,
'',
);
}

return content;
}

const getRelativePagePath = (currentPath: string, tmpPath: string) => {
return relative(join(tmpPath, 'pages'), currentPath);
};

const processRouteFile = async (
currentPath: string,
tmpPath: string,
routesDirectory: string,
) => {
try {
const relPath = getRelativePagePath(currentPath, tmpPath);
const importPath = generateImportPath(
currentPath,
join(routesDirectory, relPath),
);

const content = await fsp.readFile(currentPath, 'utf-8');
const transformedContent = transformRouteFile(importPath, content);
await fsp.writeFile(currentPath, transformedContent, 'utf-8');
} catch (error) {
console.error(`Failed to process route file: ${currentPath}`, error);
}
};

const modifyMiddlePageFiles = async (
dirPath: string,
pagesRootPath: string,
) => {
const files = await fsp.readdir(dirPath, { withFileTypes: true });

await Promise.all(
files
.map(async (file) => {
const currentPath = join(dirPath, file.name);

if (file.isDirectory()) {
return modifyMiddlePageFiles(
join(dirPath, file.name),
pagesRootPath,
);
}

if (file.isFile() && isRouteFile(file.name)) {
return processRouteFile(currentPath, tmpPath, routesDirectory);
}
})
.filter(Boolean),
);
};

const generate = async () => {
if (checkLock()) {
return;
}

setLock(true);

// 在tmpPath下生成pages目录 复制pages结构 但是不生成文件内容
// 因为如果要生成文件内容 必须要生成符合tanstack/react-router的规范的文件内容
// 因此 不需要生成文件内容 只需要新建文件 tanstack 会自动生成规范的文件内容
// 最后再修改文件内容 生成最终的中间文件
try {
const pagesPath = userConfig.routesDirectory;
await middlePageFilesGenerator(pagesPath, pagesPath);
// 临时修改 routesDirectory ,让 tanstack 生成路由文件
const middlePagesPath = join(tmpPath, 'pages');
userConfig.routesDirectory = middlePagesPath;
await generator(userConfig);
await modifyMiddlePageFiles(middlePagesPath, pagesPath);
// 还原 routesDirectory
userConfig.routesDirectory = routesDirectory;
} catch (err) {
console.error('router-generator error', err);
} finally {
setLock(false);
}
};

const handleFile = async (
file: string,
event: 'create' | 'update' | 'delete',
) => {
const filePath = isAbsolute(file) ? normalize(file) : join(ROOT, file);

// TODO: 这里需要处理配置文件的更新 因为tnf的特性,部分配置不能由用户直接更改
// if (filePath === join(ROOT, CONFIG_FILE_NAME)) {
// userConfig = getConfig(options, ROOT)
// return
// }

if (
event === 'update' &&
filePath === resolve(userConfig.generatedRouteTree)
) {
// skip generating routes if the generated route tree is updated
return;
}

const routesDirectoryPath = getRoutesDirectoryPath();
if (filePath.startsWith(routesDirectoryPath)) {
await generate();
}
};

const run: (cb: () => Promise<void> | void) => Promise<void> = async (cb) => {
if (userConfig.enableRouteGeneration ?? true) {
await cb();
}
};

return {
name: 'router-generator-plugin',
async watchChange(id, { event }) {
console.log('watchChange', id, event);
await run(async () => {
await handleFile(id, event);
});
},
async configResolved() {
const config: Partial<Config> = {
routeFileIgnorePrefix: '-',
routesDirectory: join(ROOT, 'src/pages'),
generatedRouteTree: join(tmpPath, 'routeTree.gen.ts'),
quoteStyle: 'single',
semicolons: false,
disableTypes: false,
addExtensions: false,
disableLogging: false,
disableManifestGeneration: false,
apiBase: '/api',
routeTreeFileHeader: [
'/* prettier-ignore-start */',
'/* eslint-disable */',
'// @ts-nocheck',
'// noinspection JSUnusedGlobalSymbols',
],
routeTreeFileFooter: ['/* prettier-ignore-end */'],
indexToken: 'index',
routeToken: 'route',
autoCodeSplitting: true,
...options,
};
userConfig = getConfig(config, ROOT);
routesDirectory = userConfig.routesDirectory;
await run(generate);
},
};
};
44 changes: 42 additions & 2 deletions src/sync/sync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'fs';
import { join, relative } from 'path';
import * as logger from '../fishkit/logger';
import type { Context } from '../types';
import { writeClientEntry } from './write_client_entry';
Expand All @@ -19,14 +20,53 @@ export async function sync(opts: SyncOptions) {
const { context, runAgain } = opts;
const { tmpPath } = context.paths;

const shouldKeepPath = (path: string, tmpPath: string) => {
const keepPaths = context.config.router?.routeFileSimplify
? ['routeTree.gen.ts', 'pages']
: [];

const relativePath = relative(tmpPath, path);

return keepPaths.some((keepPath) => {
return (
relativePath === keepPath || relativePath.startsWith(`${keepPath}/`)
);
});
};

const removeDirectoryContents = (dirPath: string, rootPath: string) => {
if (!fs.existsSync(dirPath)) return;

const files = fs.readdirSync(dirPath);

for (const file of files) {
const currentPath = join(dirPath, file);

if (shouldKeepPath(currentPath, rootPath)) {
if (fs.statSync(currentPath).isDirectory()) {
removeDirectoryContents(currentPath, rootPath);
}
continue;
}

if (fs.statSync(currentPath).isDirectory()) {
fs.rmSync(currentPath, { recursive: true, force: true });
} else {
fs.unlinkSync(currentPath);
}
}
};

if (!runAgain) {
fs.rmSync(tmpPath, { recursive: true, force: true });
removeDirectoryContents(tmpPath, tmpPath);
fs.mkdirSync(tmpPath, { recursive: true });
}

await writeDocs({ context });
await writeTypes({ context });
await writeRouteTree({ context });
if (!context.config?.router?.routeFileSimplify) {
await writeRouteTree({ context });
}
const globalStyleImportPath = writeGlobalStyle({ context });
const tailwindcssPath = await writeTailwindcss({ context });
writeRouter({ opts });
Expand Down
Loading