feat(iso): add isomorphic path module with cross-platform utilities

This commit is contained in:
Juergen Kunz
2025-07-28 22:26:52 +00:00
parent ae736d5dbb
commit ff1e6242a3
7 changed files with 8822 additions and 3351 deletions

View File

@@ -1,141 +0,0 @@
# gitzone ci_default
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
cache:
paths:
- .npmci_cache/
key: '$CI_BUILD_STAGE'
stages:
- security
- test
- release
- metadata
before_script:
- npm install -g @shipzone/npmci
# ====================
# security stage
# ====================
mirror:
stage: security
script:
- npmci git mirror
only:
- tags
tags:
- lossless
- docker
- notpriv
auditProductionDependencies:
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
stage: security
script:
- npmci npm prepare
- npmci command npm install --production --ignore-scripts
- npmci command npm config set registry https://registry.npmjs.org
- npmci command npm audit --audit-level=high --only=prod --production
tags:
- docker
allow_failure: true
auditDevDependencies:
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
stage: security
script:
- npmci npm prepare
- npmci command npm install --ignore-scripts
- npmci command npm config set registry https://registry.npmjs.org
- npmci command npm audit --audit-level=high --only=dev
tags:
- docker
allow_failure: true
# ====================
# test stage
# ====================
testStable:
stage: test
script:
- npmci npm prepare
- npmci node install stable
- npmci npm install
- npmci npm test
coverage: /\d+.?\d+?\%\s*coverage/
tags:
- docker
testBuild:
stage: test
script:
- npmci npm prepare
- npmci node install stable
- npmci npm install
- npmci command npm run build
coverage: /\d+.?\d+?\%\s*coverage/
tags:
- docker
release:
stage: release
script:
- npmci node install stable
- npmci npm publish
only:
- tags
tags:
- lossless
- docker
- notpriv
# ====================
# metadata stage
# ====================
codequality:
stage: metadata
allow_failure: true
only:
- tags
script:
- npmci command npm install -g tslint typescript
- npmci npm prepare
- npmci npm install
- npmci command "tslint -c tslint.json ./ts/**/*.ts"
tags:
- lossless
- docker
- priv
trigger:
stage: metadata
script:
- npmci trigger
only:
- tags
tags:
- lossless
- docker
- notpriv
pages:
stage: metadata
script:
- npmci node install stable
- npmci npm prepare
- npmci command npm install -g @git.zone/tsdoc
- npmci npm install
- npmci command tsdoc
tags:
- lossless
- docker
- notpriv
only:
- tags
artifacts:
expire_in: 1 week
paths:
- public
allow_failure: true

18
changelog.md Normal file
View File

@@ -0,0 +1,18 @@
# Changelog
## [5.1.0] - 2025-01-28
### Added
- New isomorphic path module at `/iso` export path for cross-platform path operations
- `pathJoin()` function for joining paths across different platforms and environments
- `fileUrlToPath()` function for converting file:// URLs to system paths
- `pathToFileUrl()` function for converting system paths to file:// URLs
- `dirname()` function for extracting directory from paths and URLs
- Comprehensive test coverage for isomorphic path functions
### Changed
- Build system now uses tsfolders for multi-folder compilation
- Package exports now use modern exports field instead of main/typings
### Removed
- Removed .gitlab-ci.yml file

View File

@@ -1,14 +1,16 @@
{
"name": "@push.rocks/smartpath",
"version": "5.0.18",
"version": "5.1.0",
"private": false,
"description": "A library offering smart ways to handle file and directory paths.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"exports": {
".": "./dist_ts/index.js",
"./iso": "./dist_ts_iso/index.js"
},
"type": "module",
"scripts": {
"test": "(tstest test)",
"build": "(tsbuild --allowimplicitany)",
"test": "(tstest test/ --verbose)",
"build": "(tsbuild tsfolders)",
"buildDocs": "tsdoc"
},
"repository": {
@@ -59,5 +61,6 @@
],
"browserslist": [
"last 1 chrome versions"
]
}
],
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}

11630
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,37 @@ To begin utilizing `smartpath` in your project, start by importing it in your Ty
import * as smartpath from '@push.rocks/smartpath';
```
### Isomorphic Path Module
For cross-platform path operations that work in any JavaScript environment (Node.js, browsers, Deno, etc.), use the isomorphic module:
```typescript
import * as isoPath from '@push.rocks/smartpath/iso';
// Join paths with automatic platform detection
const joinedPath = isoPath.pathJoin('/home/user', 'documents', 'file.txt');
// Unix: /home/user/documents/file.txt
// Windows: C:\Users\documents\file.txt
// Convert file:// URLs to system paths
const systemPath = isoPath.fileUrlToPath('file:///home/user/file.txt');
// Unix: /home/user/file.txt
// Windows: C:\home\user\file.txt
// Convert system paths to file:// URLs
const fileUrl = isoPath.pathToFileUrl('/home/user/file.txt');
// Result: file:///home/user/file.txt
// Get directory from path or file URL
const dir = isoPath.dirname('/home/user/documents/file.txt');
// Result: /home/user/documents
```
The isomorphic module automatically detects the path style (Windows vs POSIX) and handles:
- file:// URL conversions
- Mixed path separators
- Cross-platform compatibility
- Proper handling of Windows drive letters and UNC paths
### Creating a Smartpath Instance
Instantiating a `Smartpath` object allows for the enrichment of path strings with additional context and manipulation capabilities:

145
test/test.iso.both.ts Normal file
View File

@@ -0,0 +1,145 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartpath from '../ts_iso/index.js';
tap.test('pathJoin - should join path segments correctly', async () => {
// Basic path joining
expect(smartpath.pathJoin('path', 'to', 'file.txt')).toEqual('path/to/file.txt');
expect(smartpath.pathJoin('/path', 'to', 'file.txt')).toEqual('/path/to/file.txt');
// Windows paths
expect(smartpath.pathJoin('C:', 'Users', 'test')).toEqual('C:\\Users\\test');
expect(smartpath.pathJoin('C:\\Users', 'test', 'file.txt')).toEqual('C:\\Users\\test\\file.txt');
// Empty segments
expect(smartpath.pathJoin('path', '', 'file.txt')).toEqual('path/file.txt');
expect(smartpath.pathJoin('', 'path', 'file.txt')).toEqual('path/file.txt');
// No segments
expect(smartpath.pathJoin()).toEqual('');
expect(smartpath.pathJoin('')).toEqual('');
// Single segment
expect(smartpath.pathJoin('path')).toEqual('path');
expect(smartpath.pathJoin('/path')).toEqual('/path');
// Multiple separators
expect(smartpath.pathJoin('path/', '/to/', '/file.txt')).toEqual('path/to/file.txt');
expect(smartpath.pathJoin('path\\\\', '\\\\to\\\\', '\\\\file.txt')).toEqual('path\\to\\file.txt');
// Root paths
expect(smartpath.pathJoin('/')).toEqual('/');
expect(smartpath.pathJoin('/', 'path')).toEqual('/path');
});
tap.test('pathJoin - should handle file:// URLs', async () => {
// Unix file URLs
expect(smartpath.pathJoin('file:///home/user', 'documents', 'file.txt')).toEqual('/home/user/documents/file.txt');
expect(smartpath.pathJoin('file:///home/user/', 'documents')).toEqual('/home/user/documents');
// Windows file URLs
expect(smartpath.pathJoin('file:///C:/Users', 'test', 'file.txt')).toEqual('C:\\Users\\test\\file.txt');
expect(smartpath.pathJoin('file:///D:/Projects/', 'app')).toEqual('D:\\Projects\\app');
// Mixed file URL and path
expect(smartpath.pathJoin('file:///home/user', '../test')).toEqual('/home/user/../test');
expect(smartpath.pathJoin('documents', 'file:///home/user/file.txt')).toEqual('documents/home/user/file.txt');
});
tap.test('fileUrlToPath - should convert file URLs to paths', async () => {
// Unix file URLs
expect(smartpath.fileUrlToPath('file:///home/user/file.txt')).toEqual('/home/user/file.txt');
expect(smartpath.fileUrlToPath('file:///home/user%20name/file.txt')).toEqual('/home/user name/file.txt');
// Windows file URLs
expect(smartpath.fileUrlToPath('file:///C:/Users/test/file.txt')).toEqual('C:\\Users\\test\\file.txt');
expect(smartpath.fileUrlToPath('file:///D:/My%20Documents/file.txt')).toEqual('D:\\My Documents\\file.txt');
// Not a file URL
expect(smartpath.fileUrlToPath('/home/user/file.txt')).toEqual('/home/user/file.txt');
expect(smartpath.fileUrlToPath('C:\\Users\\test\\file.txt')).toEqual('C:\\Users\\test\\file.txt');
expect(smartpath.fileUrlToPath('https://example.com')).toEqual('https://example.com');
});
tap.test('pathToFileUrl - should convert paths to file URLs', async () => {
// Unix paths
expect(smartpath.pathToFileUrl('/home/user/file.txt')).toEqual('file:///home/user/file.txt');
expect(smartpath.pathToFileUrl('/home/user name/file.txt')).toEqual('file:///home/user%20name/file.txt');
// Windows paths
expect(smartpath.pathToFileUrl('C:\\Users\\test\\file.txt')).toEqual('file:///C:/Users/test/file.txt');
expect(smartpath.pathToFileUrl('D:\\My Documents\\file.txt')).toEqual('file:///D:/My%20Documents/file.txt');
expect(smartpath.pathToFileUrl('C:/Users/test/file.txt')).toEqual('file:///C:/Users/test/file.txt');
// Already a file URL
expect(smartpath.pathToFileUrl('file:///home/user/file.txt')).toEqual('file:///home/user/file.txt');
// Relative paths (can't make file URLs from these)
expect(smartpath.pathToFileUrl('relative/path/file.txt')).toEqual('relative/path/file.txt');
expect(smartpath.pathToFileUrl('./file.txt')).toEqual('./file.txt');
// Special characters
expect(smartpath.pathToFileUrl('/path/with?query')).toEqual('file:///path/with%3Fquery');
expect(smartpath.pathToFileUrl('/path/with#hash')).toEqual('file:///path/with%23hash');
});
tap.test('dirname - should extract directory from paths and URLs', async () => {
// Unix paths
expect(smartpath.dirname('/home/user/file.txt')).toEqual('/home/user');
expect(smartpath.dirname('/home/user/')).toEqual('/home');
expect(smartpath.dirname('/file.txt')).toEqual('/');
expect(smartpath.dirname('/')).toEqual('/');
// Windows paths
expect(smartpath.dirname('C:\\Users\\test\\file.txt')).toEqual('C:\\Users\\test');
expect(smartpath.dirname('C:\\file.txt')).toEqual('C:\\');
expect(smartpath.dirname('C:\\')).toEqual('C:\\');
// File URLs
expect(smartpath.dirname('file:///home/user/file.txt')).toEqual('/home/user');
expect(smartpath.dirname('file:///C:/Users/test/file.txt')).toEqual('C:\\Users\\test');
// Relative paths
expect(smartpath.dirname('relative/path/file.txt')).toEqual('relative/path');
expect(smartpath.dirname('file.txt')).toEqual('.');
expect(smartpath.dirname('')).toEqual('.');
// Mixed separators
expect(smartpath.dirname('path\\to/file.txt')).toEqual('path\\to');
expect(smartpath.dirname('path/to\\file.txt')).toEqual('path/to');
});
tap.test('edge cases - should handle edge cases correctly', async () => {
// Non-string values filtered out
expect(smartpath.pathJoin('path', null as any, 'file.txt')).toEqual('path/file.txt');
expect(smartpath.pathJoin('path', undefined as any, 'file.txt')).toEqual('path/file.txt');
expect(smartpath.pathJoin('path', 123 as any, 'file.txt')).toEqual('path/file.txt');
// Very long paths
const longSegment = 'a'.repeat(100);
const result = smartpath.pathJoin(longSegment, longSegment, 'file.txt');
expect(result).toEqual(`${longSegment}/${longSegment}/file.txt`);
// Unicode characters
expect(smartpath.pathJoin('path', '文件夹', 'файл.txt')).toEqual('path/文件夹/файл.txt');
expect(smartpath.fileUrlToPath('file:///home/用户/文件.txt')).toEqual('/home/用户/文件.txt');
expect(smartpath.pathToFileUrl('/home/用户/文件.txt')).toEqual('file:///home/%E7%94%A8%E6%88%B7/%E6%96%87%E4%BB%B6.txt');
// Backslashes in Unix-style paths (should be preserved)
expect(smartpath.pathJoin('/home/user', 'path\\with\\backslashes')).toEqual('/home/user/path\\with\\backslashes');
});
tap.test('cross-platform behavior - should detect separators correctly', async () => {
// Should detect Windows paths and use backslashes
expect(smartpath.pathJoin('C:\\Users', 'test')).toEqual('C:\\Users\\test');
expect(smartpath.pathJoin('D:', 'Projects', 'app')).toEqual('D:\\Projects\\app');
// Should detect Unix paths and use forward slashes
expect(smartpath.pathJoin('/home', 'user')).toEqual('/home/user');
expect(smartpath.pathJoin('/var', 'log', 'app.log')).toEqual('/var/log/app.log');
// Mixed paths - first segment determines separator
expect(smartpath.pathJoin('C:\\Users', 'test/file.txt')).toEqual('C:\\Users\\test\\file.txt');
expect(smartpath.pathJoin('/home', 'user\\documents')).toEqual('/home/user\\documents');
});
await tap.start();

191
ts_iso/index.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* Cross-platform path.join function that works in any JavaScript environment
* Handles regular paths and file:// URLs from import.meta.url
* @param segments - Path segments to join
* @returns Joined path string
*/
export function pathJoin(...segments: string[]): string {
// Filter out empty strings and non-string values
const validSegments = segments.filter(segment =>
typeof segment === 'string' && segment.length > 0
);
// If no valid segments, return empty string
if (validSegments.length === 0) {
return '';
}
// Convert file:// URLs to paths
const processedSegments = validSegments.map(segment => {
return fileUrlToPath(segment);
});
// Detect if we're dealing with Windows-style paths
const isWindowsPath = processedSegments.some(segment => {
// Check for Windows drive letter
if (/^[a-zA-Z]:/.test(segment)) return true;
// Check if first segment has backslashes (indicating Windows)
if (processedSegments[0] === segment && segment.includes('\\')) return true;
return false;
});
// Choose separator and normalize function based on path style
const separator = isWindowsPath ? '\\' : '/';
// Normalize segments based on path style
const normalizedSegments = processedSegments.map((segment) => {
if (isWindowsPath) {
// On Windows, both / and \ are separators
return segment.replace(/[\/\\]+/g, '\\');
} else {
// On POSIX, only / is a separator, \ is literal
return segment.replace(/\/+/g, '/');
}
});
// Join segments and handle edge cases
let result = '';
for (let i = 0; i < normalizedSegments.length; i++) {
const segment = normalizedSegments[i];
if (i === 0) {
result = segment;
} else {
// Remove leading separator from segment if result already ends with one
let cleanSegment = segment;
if (segment.startsWith(separator)) {
cleanSegment = segment.slice(1);
}
// Add separator if result doesn't end with one
if (result && !result.endsWith(separator)) {
result += separator;
}
result += cleanSegment;
}
}
// Handle edge cases
if (result === '' && validSegments.some(s => s === '/' || s === '\\')) {
result = separator;
}
// Clean up multiple consecutive separators
if (isWindowsPath) {
result = result.replace(/\\+/g, '\\');
// Special case for UNC paths
if (result.startsWith('\\\\') && !result.startsWith('\\\\\\')) {
// Keep double backslash for UNC paths
} else if (result.match(/^\\[^\\]/)) {
// Single leading backslash on Windows (unusual but valid)
}
} else {
result = result.replace(/\/+/g, '/');
// Preserve leading slash for absolute paths
if (processedSegments[0].startsWith('/') && !result.startsWith('/')) {
result = '/' + result;
}
}
return result;
}
/**
* Convert a file:// URL to a system path
* @param fileUrl - A file:// URL (e.g., from import.meta.url)
* @returns System path
*/
export function fileUrlToPath(fileUrl: string): string {
if (!fileUrl.startsWith('file://')) {
return fileUrl;
}
// Remove file:// protocol
let path = fileUrl.slice(7);
// Handle Windows file URLs: file:///C:/path -> C:\path
if (/^\/[a-zA-Z]:/.test(path)) {
path = path.slice(1);
// Convert forward slashes to backslashes for Windows
path = path.replace(/\//g, '\\');
}
// Decode URL encoding
path = decodeURIComponent(path);
return path;
}
/**
* Convert a system path to a file:// URL
* @param path - System path
* @returns file:// URL
*/
export function pathToFileUrl(path: string): string {
if (path.startsWith('file://')) {
return path;
}
// Normalize slashes to forward slashes for URL
let urlPath = path.replace(/\\/g, '/');
// Encode special characters
urlPath = encodeURI(urlPath).replace(/[?#]/g, encodeURIComponent);
// Check if it's a Windows absolute path
if (/^[a-zA-Z]:/.test(urlPath)) {
return `file:///${urlPath}`;
}
// Check if it's an absolute path
if (urlPath.startsWith('/')) {
return `file://${urlPath}`;
}
// Relative path - just return as-is (can't make a file URL from relative path)
return urlPath;
}
/**
* Get the directory from a file URL or path
* @param urlOrPath - File URL (like import.meta.url) or regular path
* @returns Directory path
*/
export function dirname(urlOrPath: string): string {
// Convert file URL to path if needed
let path = fileUrlToPath(urlOrPath);
// Remove trailing slashes (but keep root slashes)
if (path.length > 1 && (path.endsWith('/') || path.endsWith('\\'))) {
// Special case: don't remove trailing slash for Windows drive root
if (!(path.length === 3 && path[1] === ':')) {
path = path.slice(0, -1);
}
}
// Special case for Windows drive root (C:\ or C:)
if (path.match(/^[a-zA-Z]:\\?$/)) {
return path.endsWith('\\') ? path : path + '\\';
}
// Find the last separator
const lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
if (lastSlash === -1) {
return '.';
}
// Special case for root
if (lastSlash === 0) {
return '/';
}
// Special case for Windows drive root (C:\)
if (lastSlash === 2 && path[1] === ':') {
return path.slice(0, 3);
}
return path.slice(0, lastSlash);
}