From 1dac0aaf1e1b16048c74d11ff62129059618e42e Mon Sep 17 00:00:00 2001 From: dangreen Date: Thu, 25 Jun 2026 15:06:08 +0400 Subject: [PATCH] feat(normalize-package-data): add package data normalizer --- packages/normalize-package-data/README.md | 80 ++++++++ .../normalize-package-data/oxlint.config.ts | 15 ++ packages/normalize-package-data/package.json | 60 ++++++ packages/normalize-package-data/src/index.ts | 2 + .../src/normalize.spec.ts | 121 ++++++++++++ .../normalize-package-data/src/normalize.ts | 174 ++++++++++++++++++ packages/normalize-package-data/src/types.ts | 28 +++ packages/normalize-package-data/src/utils.ts | 7 + .../tsconfig.build.json | 14 ++ packages/normalize-package-data/tsconfig.json | 11 ++ pnpm-lock.yaml | 26 +++ 11 files changed, 538 insertions(+) create mode 100644 packages/normalize-package-data/README.md create mode 100644 packages/normalize-package-data/oxlint.config.ts create mode 100644 packages/normalize-package-data/package.json create mode 100644 packages/normalize-package-data/src/index.ts create mode 100644 packages/normalize-package-data/src/normalize.spec.ts create mode 100644 packages/normalize-package-data/src/normalize.ts create mode 100644 packages/normalize-package-data/src/types.ts create mode 100644 packages/normalize-package-data/src/utils.ts create mode 100644 packages/normalize-package-data/tsconfig.build.json create mode 100644 packages/normalize-package-data/tsconfig.json diff --git a/packages/normalize-package-data/README.md b/packages/normalize-package-data/README.md new file mode 100644 index 0000000..d1b3553 --- /dev/null +++ b/packages/normalize-package-data/README.md @@ -0,0 +1,80 @@ +# @simple-libs/normalize-package-data + +[![ESM-only package][package]][package-url] +[![NPM version][npm]][npm-url] +[![Node version][node]][node-url] +[![Dependencies status][deps]][deps-url] +[![Install size][size]][size-url] +[![Build status][build]][build-url] +[![Coverage status][coverage]][coverage-url] + +[package]: https://img.shields.io/badge/package-ESM--only-ffe536.svg +[package-url]: https://nodejs.org/api/esm.html + +[npm]: https://img.shields.io/npm/v/@simple-libs/normalize-package-data.svg +[npm-url]: https://www.npmjs.com/package/@simple-libs/normalize-package-data + +[node]: https://img.shields.io/node/v/@simple-libs/normalize-package-data.svg +[node-url]: https://nodejs.org + +[deps]: https://img.shields.io/librariesio/release/npm/@simple-libs/normalize-package-data +[deps-url]: https://libraries.io/npm/@simple-libs%2Fnormalize-package-data + +[size]: https://packagephobia.com/badge?p=@simple-libs/normalize-package-data +[size-url]: https://packagephobia.com/result?p=@simple-libs/normalize-package-data + +[build]: https://img.shields.io/github/actions/workflow/status/TrigenSoftware/simple-libs/tests.yml?branch=main +[build-url]: https://github.com/TrigenSoftware/simple-libs/actions + +[coverage]: https://coveralls.io/repos/github/TrigenSoftware/simple-libs/badge.svg?branch=main +[coverage-url]: https://coveralls.io/github/TrigenSoftware/simple-libs?branch=main + +A small library to normalize package data. + +## Install + +```bash +# pnpm +pnpm add @simple-libs/normalize-package-data +# yarn +yarn add @simple-libs/normalize-package-data +# npm +npm i @simple-libs/normalize-package-data +``` + +## Usage + +```ts +import { normalizePackageData } from '@simple-libs/normalize-package-data' + +normalizePackageData({ + name: ' package ', + version: 'v1.2.3', + repository: 'conventional-changelog/conventional-changelog' +}) +/* { + name: 'package', + version: '1.2.3', + repository: { + type: 'git', + url: 'https://github.com/conventional-changelog/conventional-changelog' + }, + bugs: { + url: 'https://github.com/conventional-changelog/conventional-changelog/issues' + }, + homepage: 'https://github.com/conventional-changelog/conventional-changelog#readme' +} */ + +normalizePackageData({ + bugs: 'support@example.com', + homepage: 'example.com' +}) +/* { + name: '', + version: '', + bugs: { + email: 'support@example.com' + }, + homepage: 'http://example.com' +} */ +``` diff --git a/packages/normalize-package-data/oxlint.config.ts b/packages/normalize-package-data/oxlint.config.ts new file mode 100644 index 0000000..3958777 --- /dev/null +++ b/packages/normalize-package-data/oxlint.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@trigen/oxlint' +import testConfig from '@trigen/oxlint-config/test' +import tsTypeCheckedConfig from '@trigen/oxlint-config/typescript-type-checked' +import rootConfig from '../../oxlint.config.ts' + +export default defineConfig({ + extends: [ + rootConfig, + tsTypeCheckedConfig, + testConfig + ], + rules: { + 'typescript/no-explicit-any': 'off' + } +}) diff --git a/packages/normalize-package-data/package.json b/packages/normalize-package-data/package.json new file mode 100644 index 0000000..73fecf2 --- /dev/null +++ b/packages/normalize-package-data/package.json @@ -0,0 +1,60 @@ +{ + "name": "@simple-libs/normalize-package-data", + "type": "module", + "version": "1.0.0", + "description": "A small library to normalize package data.", + "author": { + "name": "Dan Onoshko", + "email": "danon0404@gmail.com", + "url": "https://github.com/dangreen" + }, + "license": "MIT", + "homepage": "https://github.com/TrigenSoftware/simple-libs/tree/main/packages/normalize-package-data#readme", + "funding": "https://ko-fi.com/dangreen", + "repository": { + "type": "git", + "url": "https://github.com/TrigenSoftware/simple-libs.git", + "directory": "packages/normalize-package-data" + }, + "bugs": { + "url": "https://github.com/TrigenSoftware/simple-libs/issues" + }, + "keywords": [ + "package", + "normalize" + ], + "engines": { + "node": ">=22" + }, + "exports": "./src/index.ts", + "publishConfig": { + "exports": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "directory": "package", + "linkDirectory": false + }, + "files": [ + "dist" + ], + "scripts": { + "clear:package": "del ./package", + "clear:dist": "del ./dist", + "clear": "del ./package ./dist ./coverage", + "prepublishOnly": "run build clear:package clean-publish", + "postpublish": "pnpm clear:package", + "build": "tsgo -p tsconfig.build.json", + "lint": "oxlint", + "test:unit": "vitest run --coverage", + "test:types": "tsgo --noEmit", + "test": "run -p lint test:unit test:types" + }, + "dependencies": { + "@simple-libs/hosted-git-info": "workspace:^", + "semver": "^7.8.5" + }, + "devDependencies": { + "@types/semver": "^7.7.1" + } +} diff --git a/packages/normalize-package-data/src/index.ts b/packages/normalize-package-data/src/index.ts new file mode 100644 index 0000000..2e6d721 --- /dev/null +++ b/packages/normalize-package-data/src/index.ts @@ -0,0 +1,2 @@ +export type * from './types.js' +export * from './normalize.js' diff --git a/packages/normalize-package-data/src/normalize.spec.ts b/packages/normalize-package-data/src/normalize.spec.ts new file mode 100644 index 0000000..04347a9 --- /dev/null +++ b/packages/normalize-package-data/src/normalize.spec.ts @@ -0,0 +1,121 @@ +import { + describe, + expect, + it +} from 'vitest' +import { normalizePackageData } from './normalize.js' +import type { PackageData } from './types.js' + +describe('normalize-package-data', () => { + it('should not mutate package data', () => { + const pkg: PackageData = { + name: ' package ', + version: 'v1.2.3', + repository: { + type: 'git', + url: 'git+https://github.com/conventional-changelog/conventional-changelog.git' + }, + bugs: 'https://github.com/conventional-changelog/conventional-changelog/issues', + homepage: 'example.com' + } + const normalized = normalizePackageData(pkg) + + expect(normalized).not.toBe(pkg) + expect(normalized.repository).not.toBe(pkg.repository) + expect(pkg).toEqual({ + name: ' package ', + version: 'v1.2.3', + repository: { + type: 'git', + url: 'git+https://github.com/conventional-changelog/conventional-changelog.git' + }, + bugs: 'https://github.com/conventional-changelog/conventional-changelog/issues', + homepage: 'example.com' + }) + }) + + it('should normalize package name', () => { + const pkg = { + name: ' package ' + } + const normalized = normalizePackageData(pkg) + + expect(normalized.name).toBe('package') + }) + + it('should normalize package version', () => { + const pkg = { + version: 'v1.2.3' + } + const normalized = normalizePackageData(pkg) + + expect(normalized.version).toBe('1.2.3') + }) + + it('should normalize string repository', () => { + const pkg: PackageData = { + repository: 'conventional-changelog/conventional-changelog' + } + const normalized = normalizePackageData(pkg) + + expect(normalized.repository).toEqual({ + type: 'git', + url: 'https://github.com/conventional-changelog/conventional-changelog' + }) + }) + + it('should normalize repository url', () => { + const pkg: PackageData = { + repository: { + type: 'git', + url: 'git+https://github.com/conventional-changelog/conventional-changelog.git' + } + } + const normalized = normalizePackageData(pkg) + + expect(normalized.repository).toEqual({ + type: 'git', + url: 'https://github.com/conventional-changelog/conventional-changelog' + }) + }) + + it('should normalize bugs from a hosted repository', () => { + const pkg: PackageData = { + repository: 'conventional-changelog/conventional-changelog' + } + const normalized = normalizePackageData(pkg) + + expect(normalized.bugs).toEqual({ + url: 'https://github.com/conventional-changelog/conventional-changelog/issues' + }) + }) + + it('should normalize string bugs', () => { + const pkg: PackageData = { + bugs: 'https://github.com/conventional-changelog/conventional-changelog/issues' + } + const normalized = normalizePackageData(pkg) + + expect(normalized.bugs).toEqual({ + url: 'https://github.com/conventional-changelog/conventional-changelog/issues' + }) + }) + + it('should normalize homepage from a hosted repository', () => { + const pkg: PackageData = { + repository: 'conventional-changelog/conventional-changelog' + } + const normalized = normalizePackageData(pkg) + + expect(normalized.homepage).toBe('https://github.com/conventional-changelog/conventional-changelog#readme') + }) + + it('should normalize homepage protocol', () => { + const pkg = { + homepage: 'example.com' + } + const normalized = normalizePackageData(pkg) + + expect(normalized.homepage).toBe('http://example.com') + }) +}) diff --git a/packages/normalize-package-data/src/normalize.ts b/packages/normalize-package-data/src/normalize.ts new file mode 100644 index 0000000..9e29adc --- /dev/null +++ b/packages/normalize-package-data/src/normalize.ts @@ -0,0 +1,174 @@ +import { parseHostedGitUrl } from '@simple-libs/hosted-git-info' +import cleanSemver from 'semver/functions/clean.js' +import validSemver from 'semver/functions/valid.js' +import type { + NormalizedPackageData, + PackageBugs, + PackageData, + PackageRepository +} from './types.js' +import { + hasProtocol, + isEmail +} from './utils.js' + +function normalizeName(name: PackageData['name']): NormalizedPackageData['name'] { + return name + ? String(name).trim() + : '' +} + +function normalizeVersion(version: PackageData['version']): NormalizedPackageData['version'] { + if (!version) { + return '' + } + + if (validSemver(version, true)) { + return cleanSemver(version, true) || version + } + + return version +} + +function normalizeRepository(repository: PackageData['repository']): NormalizedPackageData['repository'] { + if (!repository) { + return repository === null + ? null + : undefined + } + + const normalizedRepository: PackageRepository = typeof repository === 'string' + ? { + type: 'git', + url: repository + } + : { + ...repository + } + + if (!normalizedRepository.url) { + return normalizedRepository + } + + const hosted = parseHostedGitUrl(normalizedRepository.url) + + if (hosted) { + normalizedRepository.url = hosted.url + } + + return normalizedRepository +} + +function normalizeBugs( + bugs: PackageData['bugs'], + repository: NormalizedPackageData['repository'] +): NormalizedPackageData['bugs'] { + if (!bugs && repository?.url) { + const hosted = parseHostedGitUrl(repository.url) + + if (hosted?.type && hosted.project) { + return { + url: `${hosted.url}/issues` + } + } + + return bugs === null + ? null + : undefined + } + + if (!bugs) { + return bugs === null + ? null + : undefined + } + + if (typeof bugs === 'string') { + if (isEmail(bugs)) { + return { + email: bugs + } + } + + if (hasProtocol(bugs)) { + return { + url: bugs + } + } + + return undefined + } + + const normalizedBugs: PackageBugs = { + ...bugs + } + + if (!normalizedBugs.email && !normalizedBugs.url) { + return undefined + } + + return normalizedBugs +} + +function normalizeHomepage( + homepage: PackageData['homepage'], + repository: NormalizedPackageData['repository'] +): NormalizedPackageData['homepage'] { + if (!homepage && repository?.url) { + const hosted = parseHostedGitUrl(repository.url) + + if (hosted?.type && hosted.project) { + return `${hosted.url}#readme` + } + + return homepage + } + + if (!homepage) { + return homepage + } + + if (typeof homepage !== 'string') { + return undefined + } + + if (!hasProtocol(homepage)) { + return `http://${homepage}` + } + + return homepage +} + +/** + * Normalizes package metadata into the shape expected by npm-style package data. + * + * The input object is not mutated. Unknown fields are preserved, while known + * fields such as `name`, `version`, `repository`, `bugs`, and `homepage` are + * normalized when possible. + * + * @param pkg - Package metadata to normalize. + * @returns Normalized package metadata. + */ +export function normalizePackageData(pkg: PackageData): NormalizedPackageData { + const repository = normalizeRepository(pkg.repository) + const bugs = normalizeBugs(pkg.bugs, repository) + const homepage = normalizeHomepage(pkg.homepage, repository) + const normalizedPackage: NormalizedPackageData = { + ...pkg, + name: normalizeName(pkg.name), + version: normalizeVersion(pkg.version), + repository, + bugs, + homepage + } + + if (typeof bugs === 'undefined') { + delete normalizedPackage.bugs + } + + if (typeof homepage === 'undefined') { + delete normalizedPackage.homepage + } + + return normalizedPackage +} diff --git a/packages/normalize-package-data/src/types.ts b/packages/normalize-package-data/src/types.ts new file mode 100644 index 0000000..4e62e4e --- /dev/null +++ b/packages/normalize-package-data/src/types.ts @@ -0,0 +1,28 @@ +export interface PackageRepository { + type?: string + url?: string + directory?: string +} + +export interface PackageBugs { + url?: string + email?: string +} + +export interface PackageData { + name?: string + version?: string + repository?: string | PackageRepository | null + bugs?: string | PackageBugs | null + homepage?: string | null + [key: string]: any +} + +export interface NormalizedPackageData { + name: string + version: string + repository?: PackageRepository | null + bugs?: PackageBugs | null + homepage?: string | null + [key: string]: any +} diff --git a/packages/normalize-package-data/src/utils.ts b/packages/normalize-package-data/src/utils.ts new file mode 100644 index 0000000..9c381a6 --- /dev/null +++ b/packages/normalize-package-data/src/utils.ts @@ -0,0 +1,7 @@ +export function isEmail(value: string) { + return value.includes('@') && value.indexOf('@') < value.lastIndexOf('.') +} + +export function hasProtocol(value: string) { + return /^[a-z][a-z\d+.-]*:/i.test(value) +} diff --git a/packages/normalize-package-data/tsconfig.build.json b/packages/normalize-package-data/tsconfig.build.json new file mode 100644 index 0000000..8836a6d --- /dev/null +++ b/packages/normalize-package-data/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src" + ], + "exclude": [ + "**/*.config.ts", + "**/*.spec.ts" + ] +} diff --git a/packages/normalize-package-data/tsconfig.json b/packages/normalize-package-data/tsconfig.json new file mode 100644 index 0000000..2944633 --- /dev/null +++ b/packages/normalize-package-data/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": "../../" + }, + "include": [ + "src", + "*.ts" + ], + "exclude": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cf4ac1..a80e585 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,20 @@ importers: packages/hosted-git-info: publishDirectory: package + packages/normalize-package-data: + dependencies: + '@simple-libs/hosted-git-info': + specifier: workspace:^ + version: link:../hosted-git-info + semver: + specifier: ^7.8.5 + version: 7.8.5 + devDependencies: + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + publishDirectory: package + packages/stream-utils: devDependencies: '@types/node': @@ -624,6 +638,9 @@ packages: '@types/node@22.19.13': resolution: {integrity: sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@typescript-eslint/types@8.56.1': resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1701,6 +1718,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2465,6 +2487,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/semver@7.7.1': {} + '@typescript-eslint/types@8.56.1': {} '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260624.1': @@ -3474,6 +3498,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0