Skip to content

Commit

Permalink
feat(router): Integrated router generation plug-in and configuration …
Browse files Browse the repository at this point in the history
…support, and implemented the function of simplifying routing files
  • Loading branch information
Xy2002 committed Dec 24, 2024
1 parent 04c91d0 commit 7d82337
Show file tree
Hide file tree
Showing 6 changed files with 350 additions and 2 deletions.
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 { writeAi } from './write_ai';
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 writeAi({ 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

0 comments on commit 7d82337

Please sign in to comment.