From 5c6035d851ee224becc84f2a0016c4c7d6620e59 Mon Sep 17 00:00:00 2001 From: Lexus Drumgold Date: Sun, 22 Sep 2024 00:28:01 -0400 Subject: [PATCH] feat(lib): `fileURLToPath`, `pathToFileURL` Signed-off-by: Lexus Drumgold --- .codecov.yml | 4 - .dictionary.txt | 6 + .github/infrastructure.yml | 1 - .github/workflows/ci.yml | 1 - README.md | 11 +- package.json | 15 +- src/__snapshots__/index.e2e.snap | 2 + .../__tests__/platform-options.spec-d.ts | 15 ++ src/interfaces/index.ts | 1 + src/interfaces/pathe.ts | 88 +++++++-- src/interfaces/platform-options.ts | 16 ++ src/internal/__tests__/is-url.spec.ts | 23 +++ .../__tests__/validate-object.spec.ts | 6 +- .../__tests__/validate-string.spec.ts | 6 +- src/internal/domain-to-ascii.browser.ts | 6 + src/internal/domain-to-ascii.node.ts | 6 + src/internal/domain-to-unicode.browser.ts | 6 + src/internal/domain-to-unicode.node.ts | 6 + src/internal/is-url.ts | 64 +++++++ src/lib/__tests__/file-url-to-path.spec.ts | 171 ++++++++++++++++++ src/lib/__tests__/path-to-file-url.spec.ts | 107 +++++++++++ src/lib/file-url-to-path.ts | 104 +++++++++++ src/lib/index.ts | 2 + src/lib/path-to-file-url.ts | 148 +++++++++++++++ src/pathe.ts | 4 + tsconfig.typecheck.json | 1 + yarn.lock | 8 + 27 files changed, 799 insertions(+), 29 deletions(-) create mode 100644 src/interfaces/__tests__/platform-options.spec-d.ts create mode 100644 src/interfaces/platform-options.ts create mode 100644 src/internal/__tests__/is-url.spec.ts create mode 100644 src/internal/domain-to-ascii.browser.ts create mode 100644 src/internal/domain-to-ascii.node.ts create mode 100644 src/internal/domain-to-unicode.browser.ts create mode 100644 src/internal/domain-to-unicode.node.ts create mode 100644 src/internal/is-url.ts create mode 100644 src/lib/__tests__/file-url-to-path.spec.ts create mode 100644 src/lib/__tests__/path-to-file-url.spec.ts create mode 100644 src/lib/file-url-to-path.ts create mode 100644 src/lib/path-to-file-url.ts diff --git a/.codecov.yml b/.codecov.yml index f3fd3859..b7d0f328 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -87,10 +87,6 @@ flags: carryforward: false paths: - src/ - node20: - carryforward: false - paths: - - src/ github_checks: annotations: true diff --git a/.dictionary.txt b/.dictionary.txt index 132e17ac..33feff2b 100644 --- a/.dictionary.txt +++ b/.dictionary.txt @@ -1,8 +1,11 @@ +abar attw barx +cbar cefc codecov commitlintrc +dbar dedupe deno dessant @@ -10,7 +13,9 @@ devlop docast dohm dprint +fbar fbca +fóóbàr ggshield gpgsign hmarr @@ -35,4 +40,5 @@ unstub vates vfile vitest +whatwg yarnrc diff --git a/.github/infrastructure.yml b/.github/infrastructure.yml index e40213a9..253d9ee1 100644 --- a/.github/infrastructure.yml +++ b/.github/infrastructure.yml @@ -44,7 +44,6 @@ branches: - context: gitguardian - context: lint - context: spelling - - context: test (20) - context: test (22) - context: typescript (5.0.4) - context: typescript (5.4.5) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a5311c5..fd028fc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -299,7 +299,6 @@ jobs: matrix: node-version: - 22 - - 20 steps: - id: checkout name: Checkout ${{ env.REF_NAME }} diff --git a/README.md b/README.md index 161be436..b16f5708 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ import { dirname, dot, extname, + fileURLToPath, format, formatExt, isAbsolute, @@ -102,6 +103,7 @@ import { matchesGlob, normalize, parse, + pathToFileURL, relative, removeExt, resolve, @@ -126,6 +128,7 @@ This package exports the following identifiers: - [`dot`](./src/lib/dot.ts) - [`extname`](./src/lib/extname.ts) - [`extnames`](./src/lib/extnames.ts) +- [`fileURLToPath`](./src/lib/file-url-to-path.ts) - [`formatExt`](./src/lib/format-ext.ts) - [`format`](./src/lib/format.ts) - [`isAbsolute`](./src/lib/is-absolute.ts) @@ -135,6 +138,7 @@ This package exports the following identifiers: - [`matchesGlob`](./src/lib/matches-glob.ts) - [`normalize`](./src/lib/normalize.ts) - [`parse`](./src/lib/parse.ts) +- [`pathToFileURL`](./src/lib/path-to-file-url.ts) - [`posix`](./src/pathe.ts) - [`relative`](./src/lib/relative.ts) - [`removeExt`](./src/lib/remove-ext.ts) @@ -164,13 +168,14 @@ This package is fully typed with [TypeScript][]. - [`FormatInputPathObject`](src/interfaces/format-input-path-object.ts) - [`ParsedPath`](src/interfaces/parsed-path.ts) - [`Pathe`](src/interfaces/pathe.ts) -- [`PlatformPath`](src/interfaces/platform-path-posix.ts) +- [`PlatformOptions`](src/interfaces/platform-options.ts) +- [`PlatformPath`](src/interfaces/platform-path.ts) - [`PosixDelimiter`](src/types/delimiter-posix.ts) -- [`PosixPlatformPath`](src/interfaces/platform-path-windows.ts) +- [`PosixPlatformPath`](src/interfaces/platform-path-posix.ts) - [`PosixSep`](src/types/sep-posix.ts) - [`Sep`](src/types/sep.ts) - [`WindowsDelimiter`](src/types/delimiter-windows.ts) -- [`WindowsPlatformPath`](src/interfaces/platform-path.ts) +- [`WindowsPlatformPath`](src/interfaces/platform-path-windows.ts) - [`WindowsSep`](src/types/sep-windows.ts) ## Contribute diff --git a/package.json b/package.json index 0a38b9c4..ea30682a 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,18 @@ "./win32": "./dist/win32.mjs" }, "imports": { + "#internal/domain-to-ascii": { + "pathe": "./src/internal/domain-to-ascii.node.ts", + "browser": "./dist/internal/domain-to-ascii.browser.mjs", + "node": "./dist/internal/domain-to-ascii.node.mjs", + "default": "./dist/internal/domain-to-ascii.node.mjs" + }, + "#internal/domain-to-unicode": { + "pathe": "./src/internal/domain-to-unicode.node.ts", + "browser": "./dist/internal/domain-to-unicode.browser.mjs", + "node": "./dist/internal/domain-to-unicode.node.mjs", + "default": "./dist/internal/domain-to-unicode.node.mjs" + }, "#internal/process": { "browser": "./dist/internal/process.browser.mjs", "node": "process", @@ -113,7 +125,8 @@ "dependencies": { "@flex-development/errnode": "3.1.1", "@types/micromatch": "4.0.9", - "micromatch": "4.0.8" + "micromatch": "4.0.8", + "punycode.js": "2.3.1" }, "devDependencies": { "@arethetypeswrong/cli": "0.16.4", diff --git a/src/__snapshots__/index.e2e.snap b/src/__snapshots__/index.e2e.snap index dbade033..b8d7b5df 100644 --- a/src/__snapshots__/index.e2e.snap +++ b/src/__snapshots__/index.e2e.snap @@ -11,6 +11,7 @@ exports[`e2e:pathe > should expose public api 1`] = ` "dot", "extname", "extnames", + "fileURLToPath", "format", "formatExt", "isAbsolute", @@ -20,6 +21,7 @@ exports[`e2e:pathe > should expose public api 1`] = ` "matchesGlob", "normalize", "parse", + "pathToFileURL", "relative", "removeExt", "resolve", diff --git a/src/interfaces/__tests__/platform-options.spec-d.ts b/src/interfaces/__tests__/platform-options.spec-d.ts new file mode 100644 index 00000000..0c037304 --- /dev/null +++ b/src/interfaces/__tests__/platform-options.spec-d.ts @@ -0,0 +1,15 @@ +/** + * @file Unit Tests - PlatformOptions + * @module pathe/interfaces/tests/unit-d/PlatformOptions + */ + +import type { Nilable } from '@flex-development/tutils' +import type TestSubject from '../platform-options' + +describe('unit-d:interfaces/PlatformOptions', () => { + it('should match [windows?: boolean | null | undefined]', () => { + expectTypeOf() + .toHaveProperty('windows') + .toEqualTypeOf>() + }) +}) diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 29a24fb8..d19cea6a 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -9,6 +9,7 @@ export type { } from './format-input-path-object' export type { default as ParsedPath } from './parsed-path' export type { default as Pathe } from './pathe' +export type { default as PlatformOptions } from './platform-options' export type { default as PlatformPath } from './platform-path' export type { default as PosixPlatformPath } from './platform-path-posix' export type { default as WindowsPlatformPath } from './platform-path-windows' diff --git a/src/interfaces/pathe.ts b/src/interfaces/pathe.ts index d6da15fb..c1733c54 100644 --- a/src/interfaces/pathe.ts +++ b/src/interfaces/pathe.ts @@ -4,6 +4,13 @@ */ import type extname from '#lib/extname' +import type { + ErrInvalidArgType, + ErrInvalidArgValue, + ErrInvalidFileUrlHost, + ErrInvalidFileUrlPath, + ErrInvalidUrlScheme +} from '@flex-development/errnode' import type { Cwd, DeviceRoot, @@ -12,6 +19,7 @@ import type { Ext, Sep } from '@flex-development/pathe' +import type PlatformOptions from './platform-options' import type PosixPlatformPath from './platform-path-posix' /** @@ -70,19 +78,6 @@ interface Pathe extends PosixPlatformPath { */ readonly dot: Dot - /** - * Format a file extension. - * - * @see {@linkcode EmptyString} - * @see {@linkcode Ext} - * - * @param {string | null | undefined} ext - * File extension to format - * @return {EmptyString | Ext} - * Formatted file extension or empty string - */ - formatExt(this: void, ext: string | null | undefined): EmptyString | Ext - /** * Get a list of file extensions for `path`. * @@ -96,6 +91,44 @@ interface Pathe extends PosixPlatformPath { */ extnames(path: string): Ext[] + /** + * Convert a `file:` URL to a path. + * + * @see {@linkcode ErrInvalidArgType} + * @see {@linkcode ErrInvalidFileUrlHost} + * @see {@linkcode ErrInvalidFileUrlPath} + * @see {@linkcode ErrInvalidUrlScheme} + * @see {@linkcode PlatformOptions} + * + * @param {URL | string} url + * The file URL string or URL object to convert to a path + * @param {PlatformOptions | null | undefined} [options] + * Platform options + * @return {string} + * `url` as path + * @throws {ErrInvalidArgType} + * @throws {ErrInvalidFileUrlHost} + * @throws {ErrInvalidFileUrlPath} + * @throws {ErrInvalidUrlScheme} + */ + fileURLToPath( + url: URL | string, + options?: PlatformOptions | null | undefined + ): string + + /** + * Format a file extension. + * + * @see {@linkcode EmptyString} + * @see {@linkcode Ext} + * + * @param {string | null | undefined} ext + * File extension to format + * @return {EmptyString | Ext} + * Formatted file extension or empty string + */ + formatExt(this: void, ext: string | null | undefined): EmptyString | Ext + /** * Check if `value` is a device root. * @@ -120,6 +153,35 @@ interface Pathe extends PosixPlatformPath { */ isSep(this: void, value: unknown): value is Sep + /** + * Convert a file `path` to a `file:` {@linkcode URL}. + * + * > The following characters are percent-encoded when converting from file + * > path to a `URL`: + * > + * > - %: Only character not encoded by the `pathname` setter + * > - CR: Stripped out by the `pathname` setter (see [`whatwg/url#419`][419]) + * > - LF: Stripped out by the `pathname` setter (see [`whatwg/url#419`][419]) + * > - TAB: Stripped out by the `pathname` setter + * + * [419]: https://github.com/whatwg/url/issues/419 + * + * @see {@linkcode ErrInvalidArgValue} + * @see {@linkcode PlatformOptions} + * + * @param {URL | string} path + * Path to handle + * @param {PlatformOptions | null | undefined} [options] + * Platform options + * @return {URL} + * `path` as `file:` URL + * @throws {ErrInvalidArgValue} + */ + pathToFileURL( + path: string, + options?: PlatformOptions | null | undefined + ): URL + /** * Remove the file extension of `path`. * diff --git a/src/interfaces/platform-options.ts b/src/interfaces/platform-options.ts new file mode 100644 index 00000000..2ff1d5f3 --- /dev/null +++ b/src/interfaces/platform-options.ts @@ -0,0 +1,16 @@ +/** + * @file Interfaces - PlatformOptions + * @module pathe/lib/PlatformOptions + */ + +/** + * Platform-specific options. + */ +interface PlatformOptions { + /** + * Use windows-specific logic. + */ + windows?: boolean | null | undefined +} + +export type { PlatformOptions as default } diff --git a/src/internal/__tests__/is-url.spec.ts b/src/internal/__tests__/is-url.spec.ts new file mode 100644 index 00000000..7cf961a8 --- /dev/null +++ b/src/internal/__tests__/is-url.spec.ts @@ -0,0 +1,23 @@ +/** + * @file Unit Tests - isURL + * @module pathe/internal/tests/unit/isURL + */ + +import testSubject from '../is-url' + +describe('unit:internal/isURL', () => { + it.each>([ + [null], + ['https://github.com/flex-development/errnode'], + [{ href: 'file://host/a', path: '/a', pathname: '/a', protocol: 'file:' }] + ])('should return `false` if `value` is not URL-like (%#)', value => { + expect(testSubject(value)).to.be.false + }) + + it.each>([ + [new URL('https://github.com/flex-development/pathe')], + [{ href: 'file:///', pathname: '/', protocol: 'file:' }] + ])('should return `true` if `value` is URL-like (%#)', value => { + expect(testSubject(value)).to.be.true + }) +}) diff --git a/src/internal/__tests__/validate-object.spec.ts b/src/internal/__tests__/validate-object.spec.ts index d366c9fb..72a819bf 100644 --- a/src/internal/__tests__/validate-object.spec.ts +++ b/src/internal/__tests__/validate-object.spec.ts @@ -3,7 +3,7 @@ * @module pathe/internal/tests/unit/validateObject */ -import { codes, type ErrInvalidArgType } from '@flex-development/errnode' +import { codes, isNodeError, type NodeError } from '@flex-development/errnode' import testSubject from '../validate-object' describe('unit:internal/validateObject', () => { @@ -19,7 +19,7 @@ describe('unit:internal/validateObject', () => { it('should throw if `value` is not curly-braced object', () => { // Arrange - let error!: ErrInvalidArgType + let error!: NodeError // Act try { @@ -29,7 +29,7 @@ describe('unit:internal/validateObject', () => { } // Expect - expect(error).to.be.instanceof(TypeError) + expect(error).to.satisfy(isNodeError) expect(error).to.have.property('code', codes.ERR_INVALID_ARG_TYPE) }) }) diff --git a/src/internal/__tests__/validate-string.spec.ts b/src/internal/__tests__/validate-string.spec.ts index 607bd31c..308836b3 100644 --- a/src/internal/__tests__/validate-string.spec.ts +++ b/src/internal/__tests__/validate-string.spec.ts @@ -3,7 +3,7 @@ * @module pathe/internal/tests/unit/validateString */ -import { codes, type ErrInvalidArgType } from '@flex-development/errnode' +import { codes, isNodeError, type NodeError } from '@flex-development/errnode' import testSubject from '../validate-string' describe('unit:internal/validateString', () => { @@ -19,7 +19,7 @@ describe('unit:internal/validateString', () => { it('should throw if `value` is not a string', () => { // Arrange - let error!: ErrInvalidArgType + let error!: NodeError // Act try { @@ -29,7 +29,7 @@ describe('unit:internal/validateString', () => { } // Expect - expect(error).to.be.instanceof(TypeError) + expect(error).to.satisfy(isNodeError) expect(error).to.have.property('code', codes.ERR_INVALID_ARG_TYPE) }) }) diff --git a/src/internal/domain-to-ascii.browser.ts b/src/internal/domain-to-ascii.browser.ts new file mode 100644 index 00000000..955776f5 --- /dev/null +++ b/src/internal/domain-to-ascii.browser.ts @@ -0,0 +1,6 @@ +/** + * @file Internal - domainToASCII + * @module pathe/internal/domainToASCII/browser + */ + +export { toASCII as default } from 'punycode.js' diff --git a/src/internal/domain-to-ascii.node.ts b/src/internal/domain-to-ascii.node.ts new file mode 100644 index 00000000..c2ae417f --- /dev/null +++ b/src/internal/domain-to-ascii.node.ts @@ -0,0 +1,6 @@ +/** + * @file Internal - domainToASCII + * @module pathe/internal/domainToASCII/node + */ + +export { domainToASCII as default } from 'node:url' diff --git a/src/internal/domain-to-unicode.browser.ts b/src/internal/domain-to-unicode.browser.ts new file mode 100644 index 00000000..3898be44 --- /dev/null +++ b/src/internal/domain-to-unicode.browser.ts @@ -0,0 +1,6 @@ +/** + * @file Internal - domainToUnicode + * @module pathe/internal/domainToUnicode/browser + */ + +export { toUnicode as default } from 'punycode.js' diff --git a/src/internal/domain-to-unicode.node.ts b/src/internal/domain-to-unicode.node.ts new file mode 100644 index 00000000..26352420 --- /dev/null +++ b/src/internal/domain-to-unicode.node.ts @@ -0,0 +1,6 @@ +/** + * @file Internal - domainToUnicode + * @module pathe/internal/domainToUnicode/node + */ + +export { domainToUnicode as default } from 'node:url' diff --git a/src/internal/is-url.ts b/src/internal/is-url.ts new file mode 100644 index 00000000..655cb68d --- /dev/null +++ b/src/internal/is-url.ts @@ -0,0 +1,64 @@ +/** + * @file Internal - isURL + * @module pathe/internal/isURL + * @see https://github.com/nodejs/node/blob/v22.8.0/lib/internal/url.js#L756-L773 + */ + +/** + * Object that looks like WHATWG URL object. + * + * @internal + */ +interface URLLike { + /** + * Serialized URL. + */ + href: string + + /** + * Path portion of URL. + */ + pathname: string + + /** + * Protocol portion of URL. + */ + protocol: string +} + +/** + * Check if `value` has the shape of a WHATWG URL object. + * + * Using a symbol or `instanceof` would not be able to recognize `URL` objects + * coming from other implementations (e.g. in Electron), so some well known + * properties are checked instead. + * + * The `href` and `protocol` properties are checked because they are easy to + * retrieve and calculate due to the lazy nature of the getters. + * + * The `auth` and `path` properties are checked to distinguish between legacy + * url instances and the WHATWG URL object. + * + * @param {unknown} value + * Value to check + * @return {value is URLLike} + * `true` if `value` looks like WHATWG URL object, `false` otherwise + */ +function isURL(value: unknown): value is URLLike { + return Boolean( + value !== null && + typeof value === 'object' && + 'href' in value && + 'pathname' in value && + 'protocol' in value && + typeof value.href === 'string' && + typeof value.pathname === 'string' && + typeof value.protocol === 'string' && + value.href && + value.protocol && + (>value).auth === undefined && + (>value).path === undefined + ) +} + +export default isURL diff --git a/src/lib/__tests__/file-url-to-path.spec.ts b/src/lib/__tests__/file-url-to-path.spec.ts new file mode 100644 index 00000000..0273d9ed --- /dev/null +++ b/src/lib/__tests__/file-url-to-path.spec.ts @@ -0,0 +1,171 @@ +/** + * @file Unit Tests - fileURLToPath + * @module pathe/lib/tests/unit/fileURLToPath + * @see https://github.com/nodejs/node/blob/v22.8.0/test/parallel/test-url-fileurltopath.js + */ + +import process from '#internal/process' +import { codes, isNodeError, type NodeError } from '@flex-development/errnode' +import { fileURLToPath } from 'node:url' +import testSubject from '../file-url-to-path' +import toPosix from '../to-posix' + +describe('unit:lib/fileURLToPath', () => { + it.each([ + '%2F', + '%2f', + '%5C', + '%5c' + ])('should throw if `url` contains encoded separators (%j)', separator => { + // Arrange + let error!: NodeError + + // Act + try { + testSubject('file://' + process.cwd() + separator) + } catch (e: unknown) { + error = e + } + + // Expect + expect(error).to.satisfy(isNodeError) + expect(error).to.have.property('code', codes.ERR_INVALID_FILE_URL_PATH) + }) + + it.each<[unknown]>([ + [3], + [null], + [true], + [undefined], + [{}] + ])('should throw if `url` is not an `URL` or string (%#)', url => { + // Arrange + let error!: NodeError + + // Act + try { + testSubject(url) + } catch (e: unknown) { + error = e + } + + // Expect + expect(error).to.satisfy(isNodeError) + expect(error).to.have.property('code', codes.ERR_INVALID_ARG_TYPE) + }) + + it('should throw if `url` protocol is not `file:`', () => { + // Arrange + let error!: NodeError + + // Act + try { + testSubject('https://a/b/c') + } catch (e: unknown) { + error = e + } + + // Expect + expect(error).to.satisfy(isNodeError) + expect(error).to.have.property('code', codes.ERR_INVALID_URL_SCHEME) + }) + + describe('posix', () => { + it.each>([ + ['file:///foo=bar'], + ['file:///foo;bar'], + ['file:///foo:bar'], + ['file:///foo.mjs'], + ['file:///foo'], + ['file:///foo&bar'], + ['file:///foo%3Fbar'], + ['file:///foo%25bar'], + ['file:///foo%23bar'], + ['file:///foo%20bar'], + ['file:///foo%0Dbar'], + ['file:///foo%0Abar'], + ['file:///foo%09bar'], + ['file:///foo%08bar'], + ['file:///f%C3%B3%C3%B3b%C3%A0r'], + ['file:///dir/foo'], + ['file:///dir/'], + ['file:///FOO'], + ['file:///%F0%9F%9A%80'], + ['file:///%E2%82%AC'] + ])('should return `url` as path (%#)', url => { + // Act + const result = testSubject(url) + + // Expect + expect(result).to.eq(toPosix(fileURLToPath(url, { windows: false }))) + }) + + it('should throw if `url` hostname is invalid', () => { + // Arrange + let error!: NodeError + + // Act + try { + testSubject(new URL('file://host/a')) + } catch (e: unknown) { + error = e + } + + // Expect + expect(error).to.satisfy(isNodeError) + expect(error).to.have.property('code', codes.ERR_INVALID_FILE_URL_HOST) + }) + }) + + describe('windows', () => { + it.each>([ + ['file:///C:/%E2%82%AC'], + ['file:///C:/%F0%9F%9A%80'], + ['file:///C:/FOO'], + ['file:///C:/dir/'], + ['file:///C:/dir/foo'], + ['file:///C:/f%C3%B3%C3%B3b%C3%A0r'], + ['file:///C:/foo%08bar'], + ['file:///C:/foo%09bar'], + ['file:///C:/foo%0Abar'], + ['file:///C:/foo%0Dbar'], + ['file:///C:/foo%20bar'], + ['file:///C:/foo%23bar'], + ['file:///C:/foo%25bar'], + ['file:///C:/foo%3Fbar'], + ['file:///C:/foo&bar'], + ['file:///C:/foo'], + ['file:///C:/foo.mjs'], + ['file:///C:/foo/bar'], + ['file:///C:/foo:bar'], + ['file:///C:/foo;bar'], + ['file:///C:/foo=bar'], + ['file://nas/My%20Docs/File.doc'] + ])('should return `url` as path (%#)', url => { + // Arrange + const windows: boolean = true + + // Act + const result = testSubject(url, { windows }) + + // Expect + expect(result).to.eq(toPosix(fileURLToPath(url, { windows }))) + }) + + it('should throw if path is not absolute', () => { + // Arrange + let error!: NodeError + + // Act + try { + testSubject(new URL('file:///?:/'), { windows: true }) + } catch (e: unknown) { + error = e + } + + // Expect + expect(error).to.satisfy(isNodeError) + expect(error).to.have.property('code', codes.ERR_INVALID_FILE_URL_PATH) + }) + }) +}) diff --git a/src/lib/__tests__/path-to-file-url.spec.ts b/src/lib/__tests__/path-to-file-url.spec.ts new file mode 100644 index 00000000..1ac693ed --- /dev/null +++ b/src/lib/__tests__/path-to-file-url.spec.ts @@ -0,0 +1,107 @@ +/** + * @file Unit Tests - pathToFileURL + * @module pathe/lib/tests/unit/pathToFileURL + * @see https://github.com/nodejs/node/blob/v22.8.0/test/parallel/test-url-pathtofileurl.js + */ + +import { codes, isNodeError, type NodeError } from '@flex-development/errnode' +import { pathToFileURL } from 'node:url' +import testSubject from '../path-to-file-url' +import toPosix from '../to-posix' + +describe('unit:lib/pathToFileURL', () => { + describe('posix', () => { + it.each>([ + ['/FOO'], + ['/dir/'], + ['/dir/foo'], + ['/foo bar'], + ['/foo#bar'], + ['/foo%bar'], + ['/foo&bar'], + ['/foo'], + ['/foo.mjs'], + ['/foo:bar'], + ['/foo;bar'], + ['/foo=bar'], + ['/foo?bar'], + ['/foo\bbar'], + ['/foo\nbar'], + ['/foo\rbar'], + ['/foo\tbar'], + ['/fóóbàr'], + ['/€'], + ['/🚀'] + ])('should return `path` as `file:` URL (%#)', path => { + // Arrange + const url: URL = pathToFileURL(path, { windows: false }) + + // Act + const result = testSubject(path) + + // Expect + expect(result.href).to.eq(toPosix(url.href)) + expect(result.pathname).to.eq(toPosix(url.pathname)) + }) + }) + + describe('windows', () => { + it.each>([ + ['C:\\FOO'], + ['C:\\dir\\'], + ['C:\\dir\\foo'], + ['C:\\foo bar'], + ['C:\\foo#bar'], + ['C:\\foo%bar'], + ['C:\\foo&bar'], + ['C:\\foo'], + ['C:\\foo.mjs'], + ['C:\\foo:bar'], + ['C:\\foo;bar'], + ['C:\\foo=bar'], + ['C:\\foo?bar'], + ['C:\\foo\\bar'], + ['C:\\foo\bbar'], + ['C:\\foo\nbar'], + ['C:\\foo\rbar'], + ['C:\\foo\tbar'], + ['C:\\fóóbàr'], + ['C:\\€'], + ['C:\\🚀'], + ['\\\\?\\C:\\path\\to\\file.txt'], + ['\\\\?\\UNC\\server\\share\\folder\\file.txt'], + ['\\\\nas\\My Docs\\File.doc'], + ['\\\\nas\\share\\path.txt'] + ])('should return `path` as `file:` URL (%#)', path => { + // Arrange + const windows: boolean = true + const url: URL = pathToFileURL(path, { windows }) + + // Act + const result = testSubject(path, { windows }) + + // Expect + expect(result.href).to.eq(toPosix(url.href)) + expect(result.pathname).to.eq(toPosix(url.pathname)) + }) + + it.each>([ + ['\\\\host'], + ['\\\\\\no-server'] + ])('should throw if UNC path hostname is invalid (%#)', path => { + // Arrange + let error!: NodeError + + // Act + try { + testSubject(path, { windows: true }) + } catch (e: unknown) { + error = e + } + + // Expect + expect(error).to.satisfy(isNodeError) + expect(error).to.have.property('code', codes.ERR_INVALID_ARG_VALUE) + }) + }) +}) diff --git a/src/lib/file-url-to-path.ts b/src/lib/file-url-to-path.ts new file mode 100644 index 00000000..d95434fc --- /dev/null +++ b/src/lib/file-url-to-path.ts @@ -0,0 +1,104 @@ +/** + * @file fileURLToPath + * @module pathe/lib/fileURLToPath + */ + +import domainToUnicode from '#internal/domain-to-unicode' +import isURL from '#internal/is-url' +import process from '#internal/process' +import { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_FILE_URL_HOST, + ERR_INVALID_FILE_URL_PATH, + ERR_INVALID_URL_SCHEME, + type ErrInvalidArgType, + type ErrInvalidFileUrlHost, + type ErrInvalidFileUrlPath, + type ErrInvalidUrlScheme +} from '@flex-development/errnode' +import type { PlatformOptions } from '@flex-development/pathe' +import isDeviceRoot from './is-device-root' +import isSep from './is-sep' +import sep from './sep' +import toPosix from './to-posix' + +/** + * Convert a `file:` URL to a path. + * + * @see {@linkcode ErrInvalidArgType} + * @see {@linkcode ErrInvalidFileUrlHost} + * @see {@linkcode ErrInvalidFileUrlPath} + * @see {@linkcode ErrInvalidUrlScheme} + * @see {@linkcode PlatformOptions} + * + * @category + * utils + * + * @param {URL | string} url + * The file URL string or URL object to convert to a path + * @param {PlatformOptions | null | undefined} [options] + * Platform options + * @return {string} + * `url` as path + * @throws {ErrInvalidArgType} + * @throws {ErrInvalidFileUrlHost} + * @throws {ErrInvalidFileUrlPath} + * @throws {ErrInvalidUrlScheme} + */ +function fileURLToPath( + url: URL | string, + options?: PlatformOptions | null | undefined +): string { + if (typeof url === 'string') url = new URL(url) + + if (!isURL(url)) { + throw new ERR_INVALID_ARG_TYPE('url', ['string', 'URL'], url) + } + + if (url.protocol !== 'file:') throw new ERR_INVALID_URL_SCHEME('file') + + /** + * URL pathname. + * + * @var {string} pathname + */ + let pathname: string = toPosix(url.pathname) + + // check for encoded separators + if (/(?:%2f)/i.test(pathname.replace(/(?:%5c)/gi, '%2f'))) { + /** + * Error message. + * + * @const {string} message + */ + const message: string = 'must not include encoded "/" or "\\" characters' + + throw new ERR_INVALID_FILE_URL_PATH(message) + } + + // decode pathname + pathname = decodeURIComponent(pathname) + + // hostname -> UNC path + if (url.hostname) { + if (options?.windows) { + // pass the hostname through domainToUnicode just in case it is an IDN + // using punycode encoding. + // note: this only causes IDNs with an `xn--` prefix to be decoded. + pathname = `${sep}${sep}${domainToUnicode(url.hostname)}${pathname}` + } else { + throw new ERR_INVALID_FILE_URL_HOST(process.platform) + } + } + + // drive path + if (isSep(pathname[0]) && isDeviceRoot(pathname.slice(1, 4))) { + if (!url.hostname) pathname = pathname.slice(1) + } else if (options?.windows && !url.hostname) { + throw new ERR_INVALID_FILE_URL_PATH('must be absolute') + } + + return pathname +} + +export default fileURLToPath diff --git a/src/lib/index.ts b/src/lib/index.ts index 8cbfd413..ec254d75 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -12,6 +12,7 @@ export { default as dirname } from './dirname' export { default as dot } from './dot' export { default as extname } from './extname' export { default as extnames } from './extnames' +export { default as fileURLToPath } from './file-url-to-path' export { default as format } from './format' export { default as formatExt } from './format-ext' export { default as isAbsolute } from './is-absolute' @@ -21,6 +22,7 @@ export { default as join } from './join' export { default as matchesGlob } from './matches-glob' export { default as normalize } from './normalize' export { default as parse } from './parse' +export { default as pathToFileURL } from './path-to-file-url' export { default as relative } from './relative' export { default as removeExt } from './remove-ext' export { default as resolve } from './resolve' diff --git a/src/lib/path-to-file-url.ts b/src/lib/path-to-file-url.ts new file mode 100644 index 00000000..9bcff43c --- /dev/null +++ b/src/lib/path-to-file-url.ts @@ -0,0 +1,148 @@ +/** + * @file pathToFileURL + * @module pathe/lib/pathToFileURL + */ + +import domainToASCII from '#internal/domain-to-ascii' +import validateString from '#internal/validate-string' +import { + ERR_INVALID_ARG_VALUE, + type ErrInvalidArgValue +} from '@flex-development/errnode' +import type { PlatformOptions } from '@flex-development/pathe' +import isSep from './is-sep' +import resolve from './resolve' +import sep from './sep' +import toPosix from './to-posix' + +export default pathToFileURL + +/** + * Convert a file `path` to a `file:` {@linkcode URL}. + * + * > The following characters are percent-encoded when converting from file path + * > to a `URL`: + * > + * > - %: Only character not encoded by the `pathname` setter + * > - CR: Stripped out by the `pathname` setter (see [`whatwg/url#419`][419]) + * > - LF: Stripped out by the `pathname` setter (see [`whatwg/url#419`][419]) + * > - TAB: Stripped out by the `pathname` setter + * + * [419]: https://github.com/whatwg/url/issues/419 + * + * @see {@linkcode ErrInvalidArgValue} + * @see {@linkcode PlatformOptions} + * + * @category + * utils + * + * @param {URL | string} path + * Path to handle + * @param {PlatformOptions | null | undefined} [options] + * Platform options + * @return {URL} + * `path` as `file:` URL + * @throws {ErrInvalidArgValue} + */ +function pathToFileURL( + path: string, + options?: PlatformOptions | null | undefined +): URL { + validateString(path, 'path') + path = toPosix(path) + + // UNC path format: \\server\share\resource + // handle extended and standard UNC path + // "\\?\UNC\" path prefix should be ignored. + // ref: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation + if (options?.windows && path.startsWith(sep.repeat(2))) { + /** + * Length of UNC path prefix. + * + * @const {number} prefixLength + */ + const prefixLength: number = path.startsWith(`${sep}${sep}?${sep}UNC${sep}`) + ? 8 + : 2 + + /** + * End index of hostname. + * + * @const {number} hostnameEndIndex + */ + const hostnameEndIndex: number = path.indexOf(sep, prefixLength) + + // throw if hostname is invalid + if (hostnameEndIndex === -1 || hostnameEndIndex === 2) { + /** + * Error message. + * + * @const {string} reason + */ + const reason: string = hostnameEndIndex < 0 + ? 'Missing UNC resource path' + : 'Empty UNC servername' + + throw new ERR_INVALID_ARG_VALUE('path', path, reason) + } + + /** + * File URL. + * + * @const {URL} outURL + */ + const outURL: URL = new URL('file://') + + outURL.hostname = domainToASCII(path.slice(prefixLength, hostnameEndIndex)) + outURL.pathname = encodePathChars(path.slice(hostnameEndIndex)) + + return outURL + } + + /** + * Last character in path. + * + * @const {string} lastChar + */ + const lastChar: string = path[path.length - 1]! + + /** + * Resolved path. + * + * @var {string} resolved + */ + let resolved: string = resolve(path) + + // resolve strips trailing slash -> add it back + if (isSep(lastChar) && !isSep(resolved[resolved.length - 1])) resolved += sep + + // call `encodePathChars` first to avoid encoding % again for ? and # + resolved = encodePathChars(resolved) + + // question and hash characters should be included in pathname. + // encoding is required to eliminate parsing them in different states. + // this is done as an optimization so that a URL instance is not created and + // the pathname setter is not triggered, which impacts performance + if (resolved.includes('?')) resolved = resolved.replace(/\?/g, '%3F') + if (resolved.includes('#')) resolved = resolved.replace(/#/g, '%23') + + return new URL(`file://${resolved}`) +} + +/** + * Encode special characters in `path`. + * + * @internal + * + * @param {string} path + * Path to handle + * @return {string} + * `path` with special characters encoded + */ +function encodePathChars(path: string): string { + if (path.includes('%')) path = path.replace(/%/g, '%25') + if (path.includes('\n')) path = path.replace(/\n/g, '%0A') + if (path.includes('\r')) path = path.replace(/\r/g, '%0D') + if (path.includes('\t')) path = path.replace(/\t/g, '%09') + return path +} diff --git a/src/pathe.ts b/src/pathe.ts index 7409117d..0e2966c7 100644 --- a/src/pathe.ts +++ b/src/pathe.ts @@ -19,6 +19,7 @@ import { dot, extname, extnames, + fileURLToPath, format, formatExt, isAbsolute, @@ -28,6 +29,7 @@ import { matchesGlob, normalize, parse, + pathToFileURL, relative, removeExt, resolve, @@ -111,6 +113,7 @@ const pathe: Pathe = { dot, extname, extnames, + fileURLToPath, format, formatExt, isAbsolute, @@ -120,6 +123,7 @@ const pathe: Pathe = { matchesGlob, normalize, parse, + pathToFileURL, posix, relative, removeExt, diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 3ebf9596..e18b4f67 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -5,6 +5,7 @@ "src/internal/process.d.mts", "typings/@faker-js/faker/global.d.ts", "typings/@types/node/process.d.ts", + "typings/punycode.js/index.d.ts", "typings/typescript/lib.es5.d.ts", "vitest-env.d.ts" ], diff --git a/yarn.lock b/yarn.lock index d0f8f9ae..c170a94f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1791,6 +1791,7 @@ __metadata: node-notifier: "npm:10.0.1" prettier: "npm:3.3.3" pretty-bytes: "npm:6.1.1" + punycode.js: "npm:2.3.1" remark: "npm:15.0.1" remark-cli: "npm:12.0.1" remark-directive: "npm:3.0.0" @@ -9017,6 +9018,13 @@ __metadata: languageName: node linkType: hard +"punycode.js@npm:2.3.1": + version: 2.3.1 + resolution: "punycode.js@npm:2.3.1" + checksum: 10/f0e946d1edf063f9e3d30a32ca86d8ff90ed13ca40dad9c75d37510a04473340cfc98db23a905cc1e517b1e9deb0f6021dce6f422ace235c60d3c9ac47c5a16a + languageName: node + linkType: hard + "punycode@npm:^2.1.0": version: 2.1.1 resolution: "punycode@npm:2.1.1"