From 897817344a12d8ef9c6bcb365dd9a04a2b67946c Mon Sep 17 00:00:00 2001 From: tolms Date: Thu, 22 May 2025 12:54:57 +0500 Subject: [PATCH] feat: added createUrl and createUrlsByPaths utilities --- services/paths-builder/README.md | 121 ++++++++++++++ services/paths-builder/npmignore | 1 + services/paths-builder/package.json | 36 +++++ services/paths-builder/rollup.config.js | 11 ++ services/paths-builder/src/index.ts | 1 + .../paths-builder/src/pathsBuilder.tests.ts | 114 +++++++++++++ .../paths-builder/src/pathsBuilder.types.ts | 28 ++++ .../src/pathsBuilder.utilities.ts | 151 ++++++++++++++++++ services/paths-builder/tsconfig.json | 10 ++ vitest.config.ts | 3 +- 10 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 services/paths-builder/README.md create mode 100644 services/paths-builder/npmignore create mode 100644 services/paths-builder/package.json create mode 100644 services/paths-builder/rollup.config.js create mode 100644 services/paths-builder/src/index.ts create mode 100644 services/paths-builder/src/pathsBuilder.tests.ts create mode 100644 services/paths-builder/src/pathsBuilder.types.ts create mode 100644 services/paths-builder/src/pathsBuilder.utilities.ts create mode 100644 services/paths-builder/tsconfig.json diff --git a/services/paths-builder/README.md b/services/paths-builder/README.md new file mode 100644 index 00000000..0510a3f8 --- /dev/null +++ b/services/paths-builder/README.md @@ -0,0 +1,121 @@ +# `@byndyusoft-ui/paths-build` + +## Установка + +```bash +npm i @byndyusoft-ui/paths-build +``` + +## Основные понятия + +**Route (маршрут, роут)** - конфигурация маршрута до экрана UI. Может включать в себя паф, правила перенаправления и др. + +**Path (паф)** - путь, строка по которой формируется URL: + +- статический `/about`, `/tasks` +- параметризированный `/tasks/:taskId` + +Параметризированный `path` может содержать: + +- опциональные параметры `/tasts/:taskId?` +- опциональные сегменты `/tasts/edit?` + +##Доступные методы + +- `generatePath(path, params?)` - метод получает паф и параметры для заполнения. Отдает готовый урл +- `createUrl(path, params?, options?)` - то же, что и метод `generatePath`. Можно задавать опции +- `createUrlsByPaths(paths, options?)` - метод получает объект с ключ-значениями пафов (ключ - название пафа, значение - паф) и отдает объект с ключ-значениями урлов (ключ - название урла, значение - метод формирования урла) + +```ts +options => { baseUrl?: string; } +``` + +## Особенности использования + +1. Параметры описываются через схему: `:paramName` +2. Тип параметра - `string`, `number` и `null` +3. Если параметры не заданы, они становятся `null`: + + ````ts + generatePath('/tasks/:taskId/', { taskId: '' }); // => /tasks/null/ + generatePath('/tasks/:taskId/', { taskId: null }); // => `/tasks/null/``` + ```` + +4. Если опциональные параметры не заданы, они пропускаются + ```ts + generatePath('/tasks/:taskId?'); // => '/tasks' + ``` +5. Опциональные сегменты остаются в url: + + ```ts + createUrl('/tasks/edit?'); // => /tasks/edit + ``` + +6. Слеш в конце пафа не вырезается, если задан: + + ```ts + createUrl('/tasks/'); // => /tasks/ + ``` + +7. Константа `paths` для метода `createUrlsByPaths` обязательно должна быть создана как `as const`: + +```ts +const paths = { + users: '/users', + userComment: '/users/:userId/comments/:commentId/' +} as const; // <= important +``` + +## Примеры использования + +### generatePath + +```ts +generatePath('/tasks'); // => '/tasks' +generatePath('/tasks/'); // => '/tasks/' +generatePath('/tasks/:taskId', { taskId: 1 }); // => '/tasks/1' +generatePath('/tasks/:taskId/comments/:commentId/', { taskId: 1, commentId: 5 }); // => '/tasks/1/comments/5/' +generatePath('/tasks/:taskId/comments/:commentId?/', { taskId: 1 }); // => '/tasks/1/comments/' +); +``` + +### createUrl + +```ts +createUrl('/tasks'); // => '/tasks' +createUrl('/tasks/:taskId', { taskId: 1 }); // => '/tasks/1' +createUrl('/tasks/:taskId', { taskId: 1 }, { baseUrl: 'http://test.com' }); // => 'http://test.com/tasks/1' +``` + +### createUrlsByPaths + +Пример 1: + +```ts +const paths = { + users: '/users', + userComment: '/users/:userId/comments/:commentId' +} as const; + +const urls = createUrlsByPaths(paths); + +resultWithBaseUrl.users(); // => `/users` +resultWithBaseUrl.userComment({ userId: 1, commentId: 2 }); // => '/users/1/comments/2' +``` + +Пример 2: + +```ts +const paths = { + users: '/users', + userComment: '/users/:userId/comments/:commentId' +} as const; + +const urls = createUrlsByPaths(paths, { baseUrl: 'http://test.com' }); + +resultWithBaseUrl.users(); // => `http://test.com/users` +resultWithBaseUrl.userComment({ userId: 1, commentId: 2 }); // => 'http://test.com/users/1/comments/2' + +resultWithBaseUrl.users({ baseUrl: 'http://another-site.com' }); // => 'http://another-site.com/users' +resultWithBaseUrl.userComment({ userId: 1, commentId: 2 }, { baseUrl: 'http://another-site.com' }); // => 'http://another-site.com/users/1/comments/2' +``` diff --git a/services/paths-builder/npmignore b/services/paths-builder/npmignore new file mode 100644 index 00000000..e8310385 --- /dev/null +++ b/services/paths-builder/npmignore @@ -0,0 +1 @@ +src \ No newline at end of file diff --git a/services/paths-builder/package.json b/services/paths-builder/package.json new file mode 100644 index 00000000..a3f4b559 --- /dev/null +++ b/services/paths-builder/package.json @@ -0,0 +1,36 @@ +{ + "name": "@byndyusoft-ui/paths-builder", + "version": "0.0.1", + "description": "Byndyusoft UI PathsBuilder Service", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "paths" + ], + "author": "Byndyusoft Frontend Developer ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/services/paths-builder#readme", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "rollup --config", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js", + "lint:check": "npm run eslint:check && npm run prettier:check", + "test": "jest --config ../../jest.config.js --roots services/paths-builder/src", + "eslint:check": "eslint src --config ../../eslint.config.js", + "eslint:fix": "eslint src --config ../../eslint.config.js --fix", + "prettier:check": "prettier --check '**/*.{ts,tsx,css,scss,json}' '!**/dist/**'", + "prettier:fix": "prettier --write '**/*.{ts,tsx,css,scss,json}' '!**/dist/**'" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/services/paths-builder/rollup.config.js b/services/paths-builder/rollup.config.js new file mode 100644 index 00000000..3d97c6a4 --- /dev/null +++ b/services/paths-builder/rollup.config.js @@ -0,0 +1,11 @@ +import typescript from '@rollup/plugin-typescript'; +import baseConfig from '../../rollup.base.config'; + +export default { + ...baseConfig, + input: ['src/index.ts'], + plugins: [ + ...baseConfig.plugins, + typescript({ tsconfig: './tsconfig.json', exclude: ['node_modules'] }) + ] +}; diff --git a/services/paths-builder/src/index.ts b/services/paths-builder/src/index.ts new file mode 100644 index 00000000..bee69828 --- /dev/null +++ b/services/paths-builder/src/index.ts @@ -0,0 +1 @@ +export { createUrl, createUrlsByPaths, generatePath } from './pathsBuilder.utilities'; diff --git a/services/paths-builder/src/pathsBuilder.tests.ts b/services/paths-builder/src/pathsBuilder.tests.ts new file mode 100644 index 00000000..d98496df --- /dev/null +++ b/services/paths-builder/src/pathsBuilder.tests.ts @@ -0,0 +1,114 @@ +import { createUrl, createUrlsByPaths, generatePath, isPathExtendsParams } from './pathsBuilder.utilities'; + +describe('services/paths-builder', () => { + describe('isPathExtendsParams', () => { + test('should return correct result', () => { + expect(isPathExtendsParams('/users')).toBeFalsy(); + expect(isPathExtendsParams('/users/comments/')).toBeFalsy(); + expect(isPathExtendsParams('/users/comments?/')).toBeFalsy(); + expect(isPathExtendsParams('/users/comments?')).toBeFalsy(); + expect(isPathExtendsParams('/users/comments/?queryParam1=1&queryParam2=2')).toBeFalsy(); + expect(isPathExtendsParams('tasks/:taskId')).toBeTruthy(); + expect(isPathExtendsParams('tasks/:taskId?')).toBeTruthy(); + expect(isPathExtendsParams('/tasks/:taskId/edit?')).toBeTruthy(); + expect(isPathExtendsParams('/tasks/:taskId/comments/:commentId/?queryParam1=1&queryParam2=2')).toBeTruthy(); + }); + }); + + describe('generatePath', () => { + test('should return correct result', () => { + // without params + expect(generatePath('/tasks/')).toBe('/tasks/'); + expect(generatePath('/tasks')).toBe('/tasks'); + expect(generatePath('tasks/')).toBe('tasks/'); + expect(generatePath('tasks')).toBe('tasks'); + expect(generatePath('/tasks/?queryParam1=1&queryParam2=2')).toBe('/tasks/?queryParam1=1&queryParam2=2'); + expect(generatePath('tasks/?queryParam1=1&queryParam2=2')).toBe('tasks/?queryParam1=1&queryParam2=2'); + + // with params + expect(generatePath('/tasks/:taskId/', { taskId: 1 })).toBe('/tasks/1/'); + expect(generatePath('tasks/:taskId/', { taskId: 1 })).toBe('tasks/1/'); + expect(generatePath('/tasks/:taskId', { taskId: 1 })).toBe('/tasks/1'); + expect(generatePath('tasks/:taskId', { taskId: 1 })).toBe('tasks/1'); + expect(generatePath('/tasks/:taskId/comments/:commentId', { taskId: 1, commentId: 5 })).toBe( + '/tasks/1/comments/5' + ); + expect( + generatePath('/tasks/:taskId/comments/:commentId/?queryParam1=1&queryParam2=2', { + taskId: 1, + commentId: 5 + }) + ).toBe('/tasks/1/comments/5/?queryParam1=1&queryParam2=2'); + + // with empty required params + expect(generatePath('/tasks/:taskId/', { taskId: '' })).toBe('/tasks/null/'); + expect(generatePath('/tasks/:taskId/', { taskId: null })).toBe('/tasks/null/'); + + // without required params + // @ts-ignore + expect(() => generatePath('/tasks/:taskId')).toThrow('Missing ":taskId" param'); + // @ts-ignore + expect(() => generatePath('/tasks/:taskId/comments/:commentId')).toThrow('Missing ":taskId" param'); + + // with optional params + // @ts-ignore + expect(generatePath('/tasks/:taskId?')).toBe('/tasks'); + // @ts-ignore + expect(generatePath('/tasks/:taskId?/')).toBe('/tasks/'); + expect(generatePath('/tasks/:taskId?/', { taskId: '' })).toBe('/tasks/'); + expect(generatePath('/tasks/:taskId?/', { taskId: null })).toBe('/tasks/'); + // @ts-ignore + expect(generatePath('/tasks/:taskId?/edit/?queryParam1=1&queryParam2=2')).toBe( + '/tasks/edit/?queryParam1=1&queryParam2=2' + ); + + // with optional segments + expect(generatePath('/tasks/edit?/')).toBe('/tasks/edit/'); + expect(generatePath('/tasks/:taskId/edit?', { taskId: 1 })).toBe('/tasks/1/edit'); + expect(generatePath('/tasks/:taskId/edit?/?queryParam1=1&queryParam2=2', { taskId: 1 })).toBe( + '/tasks/1/edit/?queryParam1=1&queryParam2=2' + ); + }); + }); + + describe('createUrl', () => { + test('should return correct result', () => { + expect(createUrl('/users')).toBe('/users'); + expect(createUrl('/users/:userId', { userId: 1 })).toBe('/users/1'); + expect(createUrl('/users', { baseUrl: '/v1' })).toBe('/v1/users'); + expect(createUrl('/users/:userId/', { userId: 1 }, { baseUrl: 'http://test.com' })).toBe( + 'http://test.com/users/1/' + ); + }); + }); + + describe('createUrlsByPaths', () => { + test('should return correct result', () => { + const paths = { + users: '/users', + userComment: '/users/:userId/comments/:commentId/' + } as const; + + const result = createUrlsByPaths(paths); + + expect(result.users()).toBe('/users'); + expect(result.userComment({ userId: 1, commentId: 2 })).toBe('/users/1/comments/2/'); + + const resultWithBaseUrl = createUrlsByPaths(paths, { baseUrl: 'http://test.com' }); + + // with baseUrl + expect(resultWithBaseUrl.users()).toBe('http://test.com/users'); + expect(resultWithBaseUrl.userComment({ userId: 1, commentId: 2 })).toBe( + 'http://test.com/users/1/comments/2/' + ); + + // with replaced baseUrl + expect(resultWithBaseUrl.users({ baseUrl: 'http://another-site.com' })).toBe( + 'http://another-site.com/users' + ); + expect( + resultWithBaseUrl.userComment({ userId: 1, commentId: 2 }, { baseUrl: 'http://another-site.com' }) + ).toBe('http://another-site.com/users/1/comments/2/'); + }); + }); +}); diff --git a/services/paths-builder/src/pathsBuilder.types.ts b/services/paths-builder/src/pathsBuilder.types.ts new file mode 100644 index 00000000..66bf1109 --- /dev/null +++ b/services/paths-builder/src/pathsBuilder.types.ts @@ -0,0 +1,28 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +type ExtractParam = Path extends `:${infer Param}` + ? Param extends `${infer Optional}?` + ? Optional + : Param + : never; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export type ExtractParams = Path extends `${infer Segment}/${infer Rest}` + ? ExtractParams | ExtractParams + : ExtractParam; + +export type TParamValue = number | string | null; + +export interface ICreateUrlOptions { + baseUrl?: string; +} + +export type TCreateUrlsByPaths> = { + [PathKey in keyof Paths]: ExtractParams extends never + ? (options?: ICreateUrlOptions) => string + : ( + params: { + [key in ExtractParams]: TParamValue; + }, + options?: ICreateUrlOptions + ) => string; +}; diff --git a/services/paths-builder/src/pathsBuilder.utilities.ts b/services/paths-builder/src/pathsBuilder.utilities.ts new file mode 100644 index 00000000..a7e9d7d7 --- /dev/null +++ b/services/paths-builder/src/pathsBuilder.utilities.ts @@ -0,0 +1,151 @@ +import { ExtractParams, ICreateUrlOptions, TCreateUrlsByPaths, TParamValue } from './pathsBuilder.types'; + +export function isPathExtendsParams(path: string): boolean { + const keyMatch = /:([\w-]+)(\??)/.exec(path); + + return keyMatch !== null; +} + +function generatePathWithoutParams(path: string): string { + const prefix = path.startsWith('/') ? '/' : ''; + const suffix = path.endsWith('/') ? '/' : ''; + + const segments = path + .split(/\/+/) + .map(segment => segment.replace(/\?$/g, '')) // Remove any optional markers from optional static segments + .filter(segment => !!segment); // Remove empty segments + + return prefix + segments.join('/') + suffix; +} + +function generatePathWithParams( + path: Path, + params: { + [key in ExtractParams]: TParamValue; + } +): string { + const prefix = path.startsWith('/') ? '/' : ''; + const suffix = path.endsWith('/') ? '/' : ''; + + const segments = path + .split(/\/+/) + .map(segment => { + // eslint-disable-next-line prefer-named-capture-group + const keyMatch = /^:([\w-]+)(\??)$/.exec(segment); + + if (keyMatch) { + const [, key, optional] = keyMatch; + const value = params[key as ExtractParams]; + const isOptional = optional === '?'; + + if (value === undefined) { + if (!isOptional) { + throw new Error(`Missing ":${key}" param`); + } + + return ''; + } else if (value === '' || value === null) { + return isOptional ? '' : 'null'; + } + + return String(value); + } + + // Remove any optional markers from optional static segments + return segment.replace(/\?$/g, ''); + }) + // Remove empty segments + .filter(segment => !!segment); + + return prefix + segments.join('/') + suffix; +} + +export function generatePath( + ...args: ExtractParams extends never + ? [path: Path] + : [ + path: Path, + params: { + [key in ExtractParams]: TParamValue; + } + ] +): string { + const [path, params] = args; + + return isPathExtendsParams(path) + ? generatePathWithParams( + path, + (params ?? {}) as { + [key in ExtractParams]: TParamValue; + } + ) + : generatePathWithoutParams(path); +} + +function appendBaseUrlToUrlString(url: string, baseUrl?: string): string { + return `${baseUrl || ''}${url}`; +} + +function createUrlWithoutParams(path: string, options: ICreateUrlOptions = {}): string { + const resultUrl = generatePathWithoutParams(path); + + return appendBaseUrlToUrlString(resultUrl, options.baseUrl); +} + +function createUrlWithParams( + path: Path, + params: { + [key in ExtractParams]: TParamValue; + }, + options: ICreateUrlOptions = {} +): string { + const resultUrl = generatePathWithParams(path, params); + + return appendBaseUrlToUrlString(resultUrl, options.baseUrl); +} + +export function createUrl( + ...args: ExtractParams extends never + ? [path: Path, options?: ICreateUrlOptions] + : [ + path: Path, + params: { + [key in ExtractParams]: TParamValue; + }, + options?: ICreateUrlOptions + ] +): string { + const [path, ...restArgs] = args; + + const hasParams = isPathExtendsParams(path); + const params = (restArgs[0] ?? {}) as { [key in ExtractParams]: TParamValue }; + const options = (restArgs[hasParams ? 1 : 0] ?? {}) as ICreateUrlOptions; + + const resultUrl = hasParams ? generatePathWithParams(path, params) : generatePathWithoutParams(path); + + return appendBaseUrlToUrlString(resultUrl, options.baseUrl); +} + +export function createUrlsByPaths>( + paths: Paths, + options: ICreateUrlOptions = {} +): TCreateUrlsByPaths { + const urls = {} as TCreateUrlsByPaths; + + Object.keys(paths).forEach((key: keyof Paths) => { + const currentPath = paths[key]; + + // @ts-ignore + urls[key] = isPathExtendsParams(currentPath) + ? ( + params: { + [key in ExtractParams]: TParamValue; + }, + currentOptions: ICreateUrlOptions = {} + ) => createUrlWithParams(currentPath, params, { ...options, ...currentOptions }) + : (currentOptions: ICreateUrlOptions = {}) => + createUrlWithoutParams(currentPath, { ...options, ...currentOptions }); + }); + + return urls; +} diff --git a/services/paths-builder/tsconfig.json b/services/paths-builder/tsconfig.json new file mode 100644 index 00000000..1ff3185d --- /dev/null +++ b/services/paths-builder/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": ["src"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 8bdd1c02..63393d72 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,8 @@ export default defineConfig({ 'hooks/use-timeout', //workspace example. 'hooks/*' plus separate vitest.config.ts in each workspace like in README.md will work too. getWorkspaceConfig('components'), getWorkspaceConfig('hooks'), - getWorkspaceConfig('packages') + getWorkspaceConfig('packages'), + getWorkspaceConfig('services') ] } });