feat(infra): route package (#10353)

This commit is contained in:
forehalo
2025-04-23 09:49:10 +00:00
parent e501e35d3d
commit 64997d4a0e
14 changed files with 654 additions and 18 deletions
+36
View File
@@ -0,0 +1,36 @@
# Routes
## Usage
### Path Factories
```ts
import { FACTORIES } from '@affine/routes';
const path = FACTORIES.workspace.doc({ workspaceId: '123', docId: '456' });
// ^^^^ with typecheck
```
### Register router
```tsx
import { ROUTES } from '@affine/routes';
function Routes() {
return <Router path={ROUTES.admin.index} element={} />;
}
```
### Path Parameter
```ts
import { RouteParamsTypes } from '@affine/routes';
function Doc() {
const { workspaceId, docId } = useParams<RouteParamsTypes['workspace']['doc']>();
}
function Attachment() {
const { workspaceId, docId, attachmentId } = useParams<RouteParamsTypes['workspace']['doc']['attachment']>();
}
```
+260
View File
@@ -0,0 +1,260 @@
import fs from 'node:fs';
import { join } from 'node:path/posix';
import { Path, prettier } from '@affine-tools/utils';
import { parse } from 'path-to-regexp';
const curdir = Path.dir(import.meta.url);
const inputFile = curdir.join('routes.json').value;
const routerOutputFile = curdir.join('src', 'routes.ts').value;
interface RawRoutesSchema {
route: string;
children: {
[key: string]: string | RawRoutesSchema;
};
}
interface RouteSchema {
name: string;
fromParent: string;
fromRoot: string;
children: Array<RouteSchema>;
}
function loadRoutesSchema(): RouteSchema {
const rawSchema = JSON.parse(
fs.readFileSync(inputFile, 'utf-8')
) as RawRoutesSchema;
const build = (
name: string,
schema: RawRoutesSchema | string,
fromRoot: string = ''
): RouteSchema => {
if (typeof schema === 'string') {
return {
name,
fromParent: schema,
fromRoot: join(fromRoot, schema),
children: [],
};
}
const absolute = join(fromRoot, schema.route);
return {
name,
fromParent: schema.route,
fromRoot: absolute,
children: Object.entries(schema.children).map(([key, value]) => {
return build(key, value, absolute);
}),
};
};
return build('home', rawSchema);
}
interface BuiltRouteSchema {
name: string;
type?: string;
factory: string;
fromRoot: string;
fromParent: string;
children: BuiltRouteSchema[];
parent: BuiltRouteSchema | null;
}
function buildRoutes(
schema: RouteSchema,
parent: BuiltRouteSchema | null = null
): BuiltRouteSchema {
const { tokens } = parse(schema.fromRoot);
const types: string[] = [];
const factories: string[] = [];
for (const token of tokens) {
switch (token.type) {
case 'param':
case 'wildcard':
types.push(token.name);
factories.push(`\${params.${token.name}}`);
break;
case 'text': {
factories.push(token.value);
break;
}
}
}
// [a, b, c] -> '{ a: string; b: string; c: string }'
const type = types.length
? `{ ${types.map(type => `${type}: string`).join('; ')} }`
: undefined;
const builtResult: BuiltRouteSchema = {
name: schema.name,
type,
factory: factories.join(''),
fromRoot: schema.fromRoot,
fromParent: schema.fromParent,
parent: parent,
children: [],
};
builtResult.children = schema.children.map(child =>
buildRoutes(child, builtResult)
);
return builtResult;
}
function printSchemaTypes(schema: BuiltRouteSchema, level: number = 1): string {
const types: string[] = [];
if (schema.type) {
if (schema.children.length) {
types.push(`index: ${schema.type};`);
} else {
return schema.type;
}
}
for (const child of schema.children) {
const childType = printSchemaTypes(child, level + 1);
if (childType) {
types.push(`${child.name}: ${childType};`);
}
}
if (types.length > 0) {
const output = `{ ${types.join('\n')} }`;
return level === 1 ? `export interface RouteParamsTypes ${output}` : output;
}
return '';
}
function printAbsolutePaths(schema: BuiltRouteSchema, level = 1): string {
const absolutes: string[] = [];
if (schema.children.length) {
absolutes.push(`index: '${schema.fromRoot}'`);
} else {
return `'${schema.fromRoot}'`;
}
for (const child of schema.children) {
const childRoute = printAbsolutePaths(child, level + 1);
absolutes.push(`${child.name}: ${childRoute}`);
}
const output = `{ ${absolutes.join('\n,')} }`;
return level === 1 ? `export const ROUTES = ${output}` : output;
}
function printRelativePaths(schema: BuiltRouteSchema, level = 1): string {
const relatives: string[] = [];
if (schema.children.length) {
relatives.push(`index: '${schema.fromParent}'`);
} else {
return `'${schema.fromParent}'`;
}
for (const child of schema.children) {
const childRoute = printRelativePaths(child, level + 1);
relatives.push(`${child.name}: ${childRoute}`);
}
const output = `{ ${relatives.join('\n,')} }`;
return level === 1 ? `export const RELATIVE_ROUTES = ${output}` : output;
}
function printFactories(schema: BuiltRouteSchema): string {
const factories: string[] = [];
const factory = schema.type
? `(params: ${schema.type}) => \`${schema.factory}\``
: `() => '${schema.factory}'`;
let parent: BuiltRouteSchema | null = schema.parent;
let nameFromRoot: string[] = [schema.name];
while (parent) {
// ignore home
if (parent.parent) {
nameFromRoot = [parent.name, ...nameFromRoot];
}
parent = parent.parent;
}
if (schema.children.length) {
// with children, we do
const visitor = nameFromRoot.join('_');
// 1. const workspace_doc = () => 'workspace/doc'
factories.push(`const ${visitor} = ${factory}`);
for (const child of schema.children) {
// 2. generate children for workspace_doc
factories.push(printFactories(child));
}
// 3. workspace.doc = workspace_doc
const parentNameFromRoot = nameFromRoot.slice(0, -1).join('_');
if (parentNameFromRoot) {
factories.push(`${parentNameFromRoot}.${schema.name} = ${visitor}`);
}
} else {
// without children, we directly
const parentNameFromRoot = nameFromRoot.slice(0, -1).join('_');
if (parentNameFromRoot) {
// parent.child = () => 'child'
factories.push(`${parentNameFromRoot}.${schema.name} = ${factory}`);
} else {
// const route = () => 'route'
factories.push(`const ${schema.name} = ${factory}`);
}
}
const output = factories.join('\n');
if (!schema.parent) {
const firstLevelNames = schema.children.map(child => child.name);
firstLevelNames.push(schema.name);
return `${output}\nexport const FACTORIES = { ${firstLevelNames.join(
', '
)} };`;
}
return output;
}
async function printRoutes(schema: BuiltRouteSchema) {
const parts = {
['Path Parameter Types']: printSchemaTypes,
['Absolute Paths']: printAbsolutePaths,
['Relative Paths']: printRelativePaths,
['Path Factories']: printFactories,
};
const content = await prettier(
Object.entries(parts)
.map(([key, print]) => {
return `// #region ${key}\n${print(schema)}\n// #endregion`;
})
.join('\n\n'),
'typescript'
);
fs.writeFileSync(routerOutputFile, content, 'utf-8');
}
async function build() {
const schema = loadRoutesSchema();
const builtSchema = buildRoutes(schema);
await printRoutes(builtSchema);
}
await build();
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@affine/routes",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/routes.ts"
},
"scripts": {
"build": "r build.ts"
},
"devDependencies": {
"@affine-tools/cli": "workspace:*",
"@affine-tools/utils": "workspace:*",
"path-to-regexp": "^8.2.0",
"query-string": "^9.1.1",
"vitest": "^3.0.6"
},
"optionalDependencies": {
"react-router-dom": "^6.28.0"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "./schema.json",
"route": "/",
"children": {
"admin": {
"route": "admin",
"children": {
"accounts": "accounts",
"ai": "ai",
"settings": {
"route": "settings",
"children": {
"module": ":module"
}
},
"about": "about",
"notFound": "404"
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "AFFiNE Routes Schema",
"definitions": {
"route": {
"type": "string",
"description": "route path"
},
"children": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"required": ["route", "children"],
"properties": {
"route": { "$ref": "#/definitions/route" },
"children": { "$ref": "#/definitions/children" }
}
}
]
}
}
},
"properties": {
"route": { "$ref": "#/definitions/route" },
"children": { "$ref": "#/definitions/children" }
}
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { FACTORIES } from '../routes';
describe('PATH_FACTORIES', () => {
it('should generate correct paths', () => {
expect(
FACTORIES.admin.settings.module({
module: 'auth',
})
).toBe('/admin/settings/auth');
});
});
+44
View File
@@ -0,0 +1,44 @@
// #region Path Parameter Types
export interface RouteParamsTypes {
admin: { settings: { module: { module: string } } };
}
// #endregion
// #region Absolute Paths
export const ROUTES = {
index: '/',
admin: {
index: '/admin',
accounts: '/admin/accounts',
ai: '/admin/ai',
settings: { index: '/admin/settings', module: '/admin/settings/:module' },
about: '/admin/about',
},
};
// #endregion
// #region Relative Paths
export const RELATIVE_ROUTES = {
index: '/',
admin: {
index: 'admin',
accounts: 'accounts',
ai: 'ai',
settings: { index: 'settings', module: ':module' },
about: 'about',
},
};
// #endregion
// #region Path Factories
const home = () => '/';
const admin = () => '/admin';
admin.accounts = () => '/admin/accounts';
admin.ai = () => '/admin/ai';
const admin_settings = () => '/admin/settings';
admin_settings.module = (params: { module: string }) =>
`/admin/settings/${params.module}`;
admin.settings = admin_settings;
admin.about = () => '/admin/about';
export const FACTORIES = { admin, home };
// #endregion
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../../tsconfig.web.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../../tools/cli" },
{ "path": "../../../tools/utils" }
]
}