diff --git a/.github/workflows/lint-pr-structure.yml b/.github/workflows/lint-pr-structure.yml index 1587668..77649d1 100644 --- a/.github/workflows/lint-pr-structure.yml +++ b/.github/workflows/lint-pr-structure.yml @@ -1,7 +1,7 @@ name: Lint Pull Request on: pull_request: - types: [opened, edited] + types: [opened, edited, synchronize] jobs: lint-pull-request: @@ -10,11 +10,11 @@ jobs: steps: - name: Lint Pull Request - uses: reaction-link/actions-lint-pull-request@v1 + uses: reaction-link/actions-lint-pull-request@v2 with: - config-bot-repotoken: ${{secrets.OUR_BOT_REPO_SCOPED_TOKEN}} - config-bot-login: MauriceArikoglu + access-token: ${{secrets.GITHUB_TOKEN}} + token-login: github-actions[bot] github-event: ${{toJson(github.event)}} - use-title-regex: '(Feature|Bugfix|Hotfix|Chore|Release)(\/REA-\d+)?:\s\w+' + use-title-regex: '(Chore|Feature|Bugfix|Hotfix|Release)(\/((SCN)-\d+(,(SCN)-\d+)*))?:\s\w+' use-approval-labels: '["Good Structure"]' - use-explanation-title: '["- There must be a title in this format: `Feature|Bugfix|Hotfix|Chore|Release: Title`","- If applicable, add your issue ticket (e.g. `REA-100`) with a slash in the title","- Use `Feature` for enhancements or new functionality","- Use `Bugfix` only if you fixed a known bug","- Use `Hotfix` for problems introduced by previous merges","- Use `Chore for updating grunt tasks etc; no production code change` for problems introduced by previous merges","- Use proper capitalization in your title"]' + use-explanation-title: '["- There must be a title in this format: `Chore|Feature|Bugfix|Hotfix|Release: Title`","- If applicable, add your issue ticket (e.g. `A-100`) with a slash in the title (Feature/A-100: Title)","- Use `Feature` for enhancements or new functionality","- Use `Bugfix` only if you fixed a known bug","- Use `Hotfix` for problems introduced by previous merges","- Use proper capitalization in your title"]' diff --git a/CHANGELOG.md b/CHANGELOG.md index f9def46..5ac47e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.6.0] - 2026-08-04 + +### Added + +- Added `@native-systems/utility/pattern` with ESM, CommonJS, and TypeScript declaration entry points. +- Added package-entry, export-map, browser-safety, and type-resolution coverage. + +### Changed + +- Kept the root entry point browser-safe by moving Handlebars-dependent pattern APIs to the dedicated `pattern` entry point. + +### Fixed + +- Removed duplicate declarations from the generated type definitions and enabled strict declaration validation. + ## [2.5.2] - 2026-03-16 ### Added diff --git a/__tests__/countryCodeToFlag.test.ts b/__tests__/countryCodeToFlag.test.ts index 87a04ea..295f084 100644 --- a/__tests__/countryCodeToFlag.test.ts +++ b/__tests__/countryCodeToFlag.test.ts @@ -1,15 +1,14 @@ -// describe('countryCodeToFlag', () => { -// it('returns a flag for a valid country code', () => { -// expect(countryCodeToFlag('de')).toBe('πŸ‡©πŸ‡ͺ'); -// expect(countryCodeToFlag('US')).toBe('πŸ‡ΊπŸ‡Έ'); -// }); -// -// it('uses only the first two letters', () => { -// expect(countryCodeToFlag('deu')).toBe('πŸ‡©πŸ‡ͺ'); -// }); -// -// it('returns fallback for empty or invalid input', () => { -// expect(countryCodeToFlag('')).toBe('🌐'); -// expect(countryCodeToFlag('1')).toBe('🌐'); -// }); -// }); +import { describe, expect, it } from '@jest/globals'; +import { countryCodeToFlag } from '../src/util/countryCodeToFlag'; + +describe('countryCodeToFlag', () => { + it('converts a country code to regional indicator symbols', () => { + expect(countryCodeToFlag('de')).toBe('πŸ‡©πŸ‡ͺ'); + expect(countryCodeToFlag('US')).toBe('πŸ‡ΊπŸ‡Έ'); + }); + + it('preserves the current behavior for non-empty input', () => { + expect(countryCodeToFlag('deu')).toBe('πŸ‡©πŸ‡ͺπŸ‡Ί'); + expect(countryCodeToFlag('')).toBe(''); + }); +}); diff --git a/__tests__/entrypoints.test.ts b/__tests__/entrypoints.test.ts new file mode 100644 index 0000000..cd96365 --- /dev/null +++ b/__tests__/entrypoints.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from '@jest/globals'; +import { + eqPlugin, + PatternCompiler, + stringPlugin, + urlPlugin, +} from '../src/pattern'; + +describe('@native-systems/utility/pattern', () => { + it('compiles templates with the PatternCompiler and plugins', () => { + const compiler = new PatternCompiler( + { name: 'Native Systems', role: 'admin' }, + (handlebars) => { + eqPlugin(handlebars); + stringPlugin(handlebars); + urlPlugin(handlebars); + } + ); + + expect(compiler.compile('Hello {{name}}')).toBe('Hello Native Systems'); + expect(compiler.compile('{{#if (eq role "admin")}}allowed{{/if}}')).toBe( + 'allowed' + ); + expect(compiler.compile('{{subString name 0 6}}')).toBe('Native'); + expect(compiler.compile('{{urlSafe name}}')).toBe('Native%20Systems'); + }); +}); diff --git a/__tests__/generatePrefix.test.ts b/__tests__/generatePrefix.test.ts index b2e5260..9d8b03d 100644 --- a/__tests__/generatePrefix.test.ts +++ b/__tests__/generatePrefix.test.ts @@ -1,60 +1,13 @@ -// import { generatePrefix } from '../../src/util/generatePrefix'; -// -// describe('generatePrefix', () => { -// test('handles camelCase names', () => { -// expect(generatePrefix('EventCo')).toBe('evc'); -// expect(generatePrefix('NextGenSoft')).toBe('ngs'); -// expect(generatePrefix('TechVision')).toBe('tv'); -// }); -// -// test('handles names with spaces', () => { -// expect(generatePrefix('OpenAI Labs')).toBe('oal'); -// expect(generatePrefix('Mega Data Corp')).toBe('mdc'); -// expect(generatePrefix('Alpha Beta Gamma')).toBe('abg'); -// }); -// -// test('handles all-uppercase names', () => { -// expect(generatePrefix('NASA')).toBe('nasa'); -// expect(generatePrefix('CERN')).toBe('cern'); -// expect(generatePrefix('IBM')).toBe('ibm'); -// }); -// -// test('handles all-lowercase names', () => { -// expect(generatePrefix('eventco')).toBe('eventco'); -// expect(generatePrefix('openai')).toBe('openai'); -// }); -// -// test('handles single-letter and short names', () => { -// expect(generatePrefix('X')).toBe('x'); -// expect(generatePrefix('AB')).toBe('ab'); -// expect(generatePrefix('A')).toBe('a'); -// }); -// -// test('handles numeric and alphanumeric names', () => { -// expect(generatePrefix('123Tech')).toBe('t'); -// expect(generatePrefix('Tech123')).toBe('t'); -// expect(generatePrefix('A1B2C3')).toBe('abc'); -// }); -// -// test('handles special characters gracefully', () => { -// expect(generatePrefix('Dev!Org#2024')).toBe('do'); -// expect(generatePrefix('Data&Science*Lab')).toBe('dsl'); -// expect(generatePrefix('AI@Open')).toBe('ao'); -// }); -// -// test('handles empty and invalid input with fallback', () => { -// expect(generatePrefix('')).toBe('org'); -// expect(generatePrefix('!!!')).toBe('org'); -// }); -// -// test('uses custom fallback when provided', () => { -// expect(generatePrefix('', 'fallback')).toBe('fallback'); -// expect(generatePrefix('###', 'abc')).toBe('abc'); -// }); -// -// test('ensures result is lowercase and url-safe', () => { -// const result = generatePrefix('OpenAI Labs'); -// expect(result).toMatch(/^[a-z0-9-]+$/); -// expect(result).toBe(result.toLowerCase()); -// }); -// }); +import { describe, expect, it } from '@jest/globals'; +import { generatePrefix } from '../src/util/generatePrefix'; + +describe('generatePrefix', () => { + it('generates a compact prefix from a camelCase name', () => { + expect(generatePrefix('EventCo')).toBe('evc'); + }); + + it('uses the default and custom fallback for empty names', () => { + expect(generatePrefix('')).toBe('org'); + expect(generatePrefix('', 'fallback')).toBe('fallback'); + }); +}); diff --git a/__tests__/package-exports.test.ts b/__tests__/package-exports.test.ts new file mode 100644 index 0000000..5a71379 --- /dev/null +++ b/__tests__/package-exports.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from '@jest/globals'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +type ConditionalExports = { + types: string; + import: string; + require: string; +}; + +const packageJson = JSON.parse( + readFileSync(resolve(__dirname, '../package.json'), 'utf8') +) as { + exports: { + '.': ConditionalExports; + './pattern': ConditionalExports; + }; +}; + +describe('package exports', () => { + it('resolves both root and pattern entrypoints for every module system', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/index.d.ts', + import: './dist/index.esm.js', + require: './dist/index.cjs', + }, + './pattern': { + types: './dist/pattern.d.ts', + import: './dist/pattern.esm.js', + require: './dist/pattern.cjs', + }, + }); + }); +}); diff --git a/__tests__/root-entrypoint.test.ts b/__tests__/root-entrypoint.test.ts new file mode 100644 index 0000000..5afd468 --- /dev/null +++ b/__tests__/root-entrypoint.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +jest.mock('handlebars', () => { + throw new Error('The browser-safe root entry must not load Handlebars'); +}); + +import { CookieHelper, DateFormatter, HttpStatus, parseTemplate } from '../src'; + +describe('@native-systems/utility root entry', () => { + it('loads without evaluating Handlebars-dependent modules', () => { + expect(parseTemplate('/users/{id}').expand({ id: '42' })).toBe('/users/42'); + expect(DateFormatter.formatFormal('2025-08-14T15:30:00Z')).toBe( + '14.08.2025' + ); + expect(HttpStatus.OK).toBe(200); + expect(CookieHelper).toBeDefined(); + }); +}); diff --git a/package.json b/package.json index 7463f31..3074703 100644 --- a/package.json +++ b/package.json @@ -10,28 +10,29 @@ "publishConfig": { "access": "public" }, - "version": "2.5.2", + "version": "2.6.0", "license": "MIT", "private": false, "engines": { "npm": "please-use-yarn", "yarn": "1.x" }, - "browserslist": [ - "last 3 versions", - "> 1%" - ], + "browserslist": ["last 3 versions", "> 1%"], "main": "dist/index.cjs", "module": "dist/index.esm.js", "typings": "dist/index.d.ts", - "files": [ - "dist", - "README.md" - ], + "files": ["dist", "README.md"], "exports": { - "import": "./dist/index.esm.js", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.esm.js", + "require": "./dist/index.cjs" + }, + "./pattern": { + "types": "./dist/pattern.d.ts", + "import": "./dist/pattern.esm.js", + "require": "./dist/pattern.cjs" + } }, "type": "module", "scripts": { @@ -46,6 +47,7 @@ "prepare": "husky", "pre-format": "lint-staged", "test": "jest", + "test:types": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node type-tests/package-imports.ts", "test:watch": "jest --watch" }, "lint-staged": { @@ -53,16 +55,9 @@ "prettier --config .prettierrc.json --write" ] }, - "keywords": [ - "typescript", - "react", - "next", - "utility" - ], + "keywords": ["typescript", "react", "next", "utility"], "release": { - "branches": [ - "main" - ] + "branches": ["main"] }, "dependencies": {}, "devDependencies": { diff --git a/rollup.config.js b/rollup.config.js index cfc3cdb..edd43ab 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,4 +1,4 @@ -import pkg from './package.json' with { type: 'json'}; +import pkg from './package.json' with { type: 'json' }; import { swc } from 'rollup-plugin-swc3'; import del from 'rollup-plugin-delete'; @@ -11,44 +11,114 @@ import { typescriptPaths } from 'rollup-plugin-typescript-paths'; import nodePolyfills from 'rollup-plugin-node-polyfills'; import path from 'node:path'; +const input = { + index: 'src/index.ts', + pattern: 'src/pattern.ts', +}; + +const external = Object.keys(pkg.peerDependencies || {}); + +const sharedPlugins = () => [ + nodeResolve(), + typescriptPaths({ + preserveExtensions: true, + }), + json(), + commonjs(), + nodePolyfills(), // Circular dependencies warning cant be fixed and will be ignored until further notice + // Transpile with swc + swc({ + jsc: { + baseUrl: path.resolve('./src'), + paths: { '@/*': ['*'] }, + parser: { + syntax: 'typescript', + tsx: true, + }, + target: 'es5', + loose: false, + minify: { + compress: false, + mangle: false, + }, + }, + }), + prettier(), +]; + +const declarationOptions = { + moduleName: pkg.name, + internal: [ + 'util/PatternCompiler/index', + 'util/PatternCompiler/Plugin/index', + ], + entries: { + 'pattern': { + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/IPatternCompiler': { + as: 'pattern', + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/PatternCompiler': { + as: 'pattern', + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/Plugin/CompilerPlugin': { + as: 'pattern', + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/Plugin/EqPlugin': { + as: 'pattern', + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/Plugin/MarkdownPlugin': { + as: 'pattern', + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/Plugin/StringPlugin': { + as: 'pattern', + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/Plugin/TimezonePlugin': { + as: 'pattern', + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/Plugin/UrlPlugin': { + as: 'pattern', + file: 'pattern.d.ts', + }, + 'util/PatternCompiler/Plugin/UserTimezonePlugin': { + as: 'pattern', + file: 'pattern.d.ts', + }, + }, +}; + const config = [ { - input: 'src/index.ts', - external: Object.keys(pkg.peerDependencies || {}), - plugins: [ - del({ targets: ['dist/*'] }), - nodeResolve(), - typescriptPaths({ - preserveExtensions: true, - }), - json(), - commonjs(), - nodePolyfills(), // Circular dependencies warning cant be fixed and will be ignored until further notice - // Transpile with swc - swc({ - jsc: { - baseUrl: path.resolve('./src'), - paths: { '@/*': ['*'] }, - parser: { - syntax: 'typescript', - tsx: true, - }, - target: 'es5', - loose: false, - minify: { - compress: false, - mangle: false, - }, - }, - }), - prettier(), - ], - output: [ - // Outputs the packaged lib in CommonJS format - { file: pkg.main, format: 'cjs', plugins: [flatDts()], exports: 'named' }, - // Outputs the packaged lib in ES Module format - { file: pkg.module, format: 'esm', plugins: [flatDts()], exports: 'named' }, - ], + input, + external, + plugins: [del({ targets: ['dist/*'] }), ...sharedPlugins()], + output: { + dir: 'dist', + format: 'esm', + entryFileNames: '[name].esm.js', + plugins: [flatDts(declarationOptions)], + exports: 'named', + }, + }, + { + input, + external, + plugins: sharedPlugins(), + output: { + dir: 'dist', + format: 'cjs', + entryFileNames: '[name].cjs', + chunkFileNames: '[name]-[hash].cjs', + exports: 'named', + }, }, ]; diff --git a/src/pattern.ts b/src/pattern.ts new file mode 100644 index 0000000..87eccbf --- /dev/null +++ b/src/pattern.ts @@ -0,0 +1,14 @@ +export type { + PatternView, + CompileFn, + IPatternCompiler, +} from './util/PatternCompiler/IPatternCompiler'; +export { PatternCompiler } from './util/PatternCompiler/PatternCompiler'; +export { type CompilerPlugin } from './util/PatternCompiler/Plugin/CompilerPlugin'; +export { eqPlugin } from './util/PatternCompiler/Plugin/EqPlugin'; +export { markdownPlugin } from './util/PatternCompiler/Plugin/MarkdownPlugin'; +export { stringPlugin } from './util/PatternCompiler/Plugin/StringPlugin'; +export { timezonePlugin } from './util/PatternCompiler/Plugin/TimezonePlugin'; +export { urlPlugin } from './util/PatternCompiler/Plugin/UrlPlugin'; +export { userTimezonePlugin } from './util/PatternCompiler/Plugin/UserTimezonePlugin'; +export { DateFormatter } from './util/PatternCompiler/Plugin/DateFormatter'; diff --git a/src/types/index.ts b/src/types/index.ts index 3c726f8..90a3771 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,34 +1,24 @@ -import type { Paginated } from '@/types/Paginated'; -import type { CancelableComponent } from '@/types/CancelableComponent'; -import type { CRUDCompontent } from '@/types/CRUDCompontent'; -import type { +export type { Paginated } from './Paginated'; +export type { CancelableComponent } from './CancelableComponent'; +export type { CRUDCompontent } from './CRUDCompontent'; +export type { UseStateComponent, DirectUseStateComponent, -} from '@/types/UseStateComponent'; +} from './UseStateComponent'; +export { HttpStatus } from './enums/HttpStatus'; +export { PostgresError } from './enums/PostgresError'; -import { HttpStatus, PostgresError } from './enums'; - -declare namespace native { - type Error = ErrorConstructor & { - status: number; - message: string; - error: string; - }; - - interface ErrorResponse extends Response { - isNativeError: boolean; - } +export type NativeError = ErrorConstructor & { + status: number; + message: string; + error: string; +}; - export { Error, ErrorResponse }; +export interface NativeErrorResponse extends Response { + isNativeError: boolean; } -export { - type native, - Paginated, - CancelableComponent, - CRUDCompontent, - UseStateComponent, - DirectUseStateComponent, - HttpStatus, - PostgresError, -}; +export namespace native { + export type Error = NativeError; + export type ErrorResponse = NativeErrorResponse; +} diff --git a/src/util/HttpHandler.ts b/src/util/HttpHandler.ts index c8d8b4a..b0ae9b7 100644 --- a/src/util/HttpHandler.ts +++ b/src/util/HttpHandler.ts @@ -1,5 +1,9 @@ import { logger } from '@/util/Logger'; -import { HttpStatus, native } from '@/types'; +import { + HttpStatus, + type NativeError, + type NativeErrorResponse, +} from '@/types'; import { NativeException } from '@/util/Exceptions'; class HttpHandler { @@ -24,7 +28,7 @@ class HttpHandler { } // Not printing native error here, but in the error function instead, since body can only be consumed once. (via res.json()) - const errorResponse: native.ErrorResponse = Object.assign(response, { + const errorResponse: NativeErrorResponse = Object.assign(response, { isNativeError: true, }); @@ -38,11 +42,9 @@ class HttpHandler { * @param response {Response | native.ErrorResponse} The Response object that errored. Can either be a default object, or the modified native version. * @throws {native.Error} Always throws an error, which is always of this type. */ - public static error( - response: Response | native.ErrorResponse - ): Promise { + public static error(response: Response | NativeErrorResponse): Promise { if ('isNativeError' in response && response.isNativeError) { - return response.json().then((error: native.Error) => { + return response.json().then((error: NativeError) => { logger.error('Request failed:', error.message); return Promise.reject(error); diff --git a/src/util/PatternCompiler/index.ts b/src/util/PatternCompiler/index.ts index 88acff7..4d25d25 100644 --- a/src/util/PatternCompiler/index.ts +++ b/src/util/PatternCompiler/index.ts @@ -1,3 +1,3 @@ export type { PatternView, CompileFn } from './IPatternCompiler'; export { PatternCompiler } from './PatternCompiler'; -export * from './Plugin'; +export * from './Plugin/index'; diff --git a/src/util/index.ts b/src/util/index.ts index 05b78ff..144b08f 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -3,7 +3,7 @@ export * from './Logger'; export * from './HttpHandler'; export * from './CookieHelper'; export * from './UrlTemplate'; -export * from './PatternCompiler'; +export { DateFormatter } from './PatternCompiler/Plugin/DateFormatter'; export * from './generatePrefix'; export * from './LabelMapper'; export * from './FormSanitizer'; diff --git a/type-tests/package-imports.ts b/type-tests/package-imports.ts new file mode 100644 index 0000000..eb7adca --- /dev/null +++ b/type-tests/package-imports.ts @@ -0,0 +1,25 @@ +import { + DateFormatter, + HttpHandler, + parseTemplate, + type native, +} from '@native-systems/utility'; +import { + eqPlugin, + PatternCompiler, + type CompilerPlugin, + type PatternView, +} from '@native-systems/utility/pattern'; + +const view: PatternView = { name: 'Native Systems' }; +const compiler = new PatternCompiler(view, () => {}); +const parsed = parseTemplate('/users/{id}').expand({ id: '42' }); +const formatted = DateFormatter.formatFormal('2025-08-14T15:30:00Z'); +const plugin: CompilerPlugin = eqPlugin; +declare const errorResponse: native.ErrorResponse; + +void compiler.compile('{{name}}'); +void plugin; +void HttpHandler.error(errorResponse); +void parsed; +void formatted;