diff --git a/README.md b/README.md index 6914d789..1dc5c581 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,6 @@ ![👽 ufo](.github/banner.svg) - -UFO exports URL utilities based on a URL-like interface with some improvements: - -- Supporting schemeless and hostless URLs -- Supporting relative URLs -- Preserving trailing-slash status -- Decoded and mutable classs properties -- Consistent URL parser independent of environment -- Consistent encoding independent of environment - ## Install Install using npm or yarn: @@ -69,20 +59,32 @@ joinURL('a', '/b', '/c') joinURL('http://foo.com/foo?test=123#token', 'bar', 'baz') ``` -### `withparams` +### `withQuery` ```ts // Result: /foo?page=a&token=secret -withParams('/foo?page=a', { token: 'secret' }) +withQuery('/foo?page=a', { token: 'secret' }) ``` -### `getParams` +### `getQuery` ```ts // Result: { test: '123', unicode: '好' } -getParams('http://foo.com/foo?test=123&unicode=%E5%A5%BD') +getQuery('http://foo.com/foo?test=123&unicode=%E5%A5%BD') ``` +### `$URL` + +Implementing URL interface with some improvements: + +- Supporting schemeless and hostless URLs +- Supporting relative URLs +- Preserving trailing-slash status +- Decoded and mutable classs properties (`protocol`, `host`, `auth`, `pathname`, `query`, `hash`) +- Consistent URL parser independent of environment +- Consistent encoding independent of environment +- Punycode support for host encoding + ### `withTrailingSlash` Ensures url ends with a trailing slash diff --git a/src/encoding.ts b/src/encoding.ts index 4335b4a6..3bc62b3b 100644 --- a/src/encoding.ts +++ b/src/encoding.ts @@ -115,18 +115,6 @@ export function decode (text: string | number = ''): string { } } -export function encodeSearchParam (key: string, val: string | string[]) { - if (!val) { - return key - } - - if (Array.isArray(val)) { - return val.map(_val => `${encodeQueryKey(key)}=${encodeParam(_val)}`).join('&') - } - - return `${encodeQueryKey(key)}=${encodeParam(val)}` -} - export function encodeHost (name: string = '') { return toASCII(name) } diff --git a/src/index.ts b/src/index.ts index 6f14b92c..00a17c36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -export * from './utils' -export * from './parse' -export * from './ufo' export * from './encoding' +export * from './parse' +export * from './query' +export * from './url' +export * from './utils' diff --git a/src/parse.ts b/src/parse.ts index 5966e82d..5565622e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,32 +1,39 @@ import { decode } from './encoding' -export type ParamsObject = Record - +import { hasProtocol } from './utils' export interface ParsedURL { protocol?: string - hostname?: string - port?: string - username?: string - password?: string + host?: string + auth?: string pathname: string - hash?: string - params?: ParamsObject + hash: string + search: string +} + +export interface ParsedAuth { + username: string + password: string +} + +export interface ParsedHost { + hostname: string + port: string } export function parseURL (input: string = ''): ParsedURL { + if (!hasProtocol(input)) { + return parsePath(input) + } + const [protocol, auth, hostAndPath] = (input.match(/([^:/]+:)\/\/([^/@]+@)?(.*)/) || []).splice(1) const [host = '', path = ''] = (hostAndPath.match(/([^/]*)(.*)?/) || []).splice(1) - const [hostname = '', port = ''] = (host.match(/([^/]*)(:0-9+)?/) || []).splice(1) - const { pathname, params, hash } = parsePath(path) - const [username, password] = auth ? auth.substr(0, auth.length - 1).split(':') : [] + const { pathname, search, hash } = parsePath(path) return { protocol, - username, - password, - hostname, - port, + auth: auth ? auth.substr(0, auth.length - 1) : '', + host, pathname, - params, + search, hash } } @@ -36,34 +43,31 @@ export function parsePath (input: string = ''): ParsedURL { return { pathname, - params: search ? parsedParamsToObject(parseParams(search.substr(1))) : {}, + search, hash } } -export function parseParams (paramsStr: string = ''): [string, string][] { - return paramsStr.split('&').map((param) => { - const [key, value] = param.split('=') - return [decode(key), decode(value)] - }) +export function parseAuth (input: string = ''): ParsedAuth { + const [username, password] = input.split(':') + return { + username: decode(username), + password: decode(password) + } } -export function hasProtocol (inputStr: string): boolean { - return /^\w+:\/\//.test(inputStr) +export function parseHost (input: string = ''): ParsedHost { + const [hostname, port] = (input.match(/([^/]*)(:0-9+)?/) || []).splice(1) + return { + hostname: decode(hostname), + port + } } -export function parsedParamsToObject (entries: [string, string][]): Record { - const obj: Record = {} - for (const [key, value] of entries) { - if (obj[key]) { - if (Array.isArray(obj[key])) { - (obj[key] as string[]).push(value) - } else { - obj[key] = [obj[key] as string, value] - } - } else { - obj[key] = value - } +export function stringifyParsedURL (parsed: ParsedURL) { + const fullpath = parsed.pathname + (parsed.search ? '?' + parsed.search : '') + parsed.hash + if (!parsed.protocol) { + return fullpath } - return obj + return parsed.protocol + '//' + (parsed.auth ? parsed.auth + '@' : '') + parsed.host + fullpath } diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 00000000..e59fa062 --- /dev/null +++ b/src/query.ts @@ -0,0 +1,43 @@ +import { decode, encodeQueryKey, encodeQueryValue } from './encoding' + +export type QueryValue = string | string[] | undefined +export type QueryObject = Record + +export function parseQuery (paramsStr: string = ''): QueryObject { + const obj: QueryObject = {} + if (paramsStr[0] === '?') { + paramsStr = paramsStr.substr(1) + } + for (const param of paramsStr.split('&')) { + const s = param.split('=') + if (!s[0]) { continue } + const key = decode(s[0]) + const value = decode(s[1]) + if (obj[key]) { + if (Array.isArray(obj[key])) { + (obj[key] as string[]).push(value) + } else { + obj[key] = [obj[key] as string, value] + } + } else { + obj[key] = value + } + } + return obj +} + +export function encodeQueryItem (key: string, val: QueryValue): string { + if (!val) { + return encodeQueryKey(key) + } + + if (Array.isArray(val)) { + return val.map(_val => `${encodeQueryKey(key)}=${encodeQueryValue(_val)}`).join('&') + } + + return `${encodeQueryKey(key)}=${encodeQueryValue(val)}` +} + +export function stringifyQuery (query: QueryObject) { + return Object.keys(query).map(k => encodeQueryItem(k, query[k])).join('&') +} diff --git a/src/ufo.ts b/src/ufo.ts deleted file mode 100644 index 91acfb0a..00000000 --- a/src/ufo.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { hasProtocol, parsePath, ParamsObject, parseURL } from './parse' -import { withoutLeadingSlash, withTrailingSlash } from './utils' -import { encodeSearchParam, encodeHash, encodePath, decode, encodeHost } from './encoding' - -export class UFO implements URL { - params: ParamsObject = {} - hash: string; - hostname: string; - password: string; - pathname: string; - port: string; - protocol: string; - username: string; - - constructor (input: string = '') { - if (typeof input !== 'string') { - throw new TypeError(`URL input should be string received ${typeof input} (${input})`) - } - const _hasProtocol = hasProtocol(input) - - // Use native URL for parsing (replacable) - const parsed = _hasProtocol ? parseURL(input) : parsePath(input) - this.hash = decode(parsed.hash || '') - this.hostname = decode(parsed.hostname || '') - this.pathname = decode(parsed.pathname) - this.username = decode(parsed.username || '') - this.password = decode(parsed.password || '') - this.port = parsed.port || '' - this.protocol = (_hasProtocol && parsed.protocol) || '' - this.params = parsed.params || {} - } - - get hasProtocol () { - return this.protocol.length - } - - get isAbsolute () { - return this.hasProtocol || this.pathname[0] === '/' - } - - get search (): string { - const components = Object.keys(this.params).map(k => encodeSearchParam(k, this.params[k])) - return components.length ? ('?' + components.join('&')) : '' - } - - get searchParams (): URLSearchParams { - const p = new URLSearchParams() - for (const name in this.params) { - const value = this.params[name] - if (Array.isArray(value)) { - value.forEach(v => p.append(name, v)) - } else { - p.append(name, value) - } - } - return p - } - - get host () { - return encodeHost(this.hostname) + (this.port ? `:${this.port}` : '') - } - - get origin (): string { - return (this.protocol ? this.protocol + '//' : '') + this.host - } - - get originWithAuth (): string { - return (this.protocol ? this.protocol + '//' : '') + this.auth + this.host - } - - get auth () { - if (this.username || this.password) { - return encodeURIComponent(this.username) + (this.password ? (':' + encodeURIComponent(this.password)) : '') + '@' - } - return '' - } - - get fullpath (): string { - return encodePath(this.pathname) + this.search + encodeHash(this.hash) - } - - get href (): string { - return (this.hasProtocol && this.isAbsolute) ? (this.originWithAuth + this.fullpath) : this.fullpath - } - - append (url: UFO) { - if (url.hasProtocol) { - throw new Error('Cannot append a URL with protocol') - } - - Object.assign(this.params, url.params) - - if (url.pathname) { - this.pathname = withTrailingSlash(this.pathname) + withoutLeadingSlash(url.pathname) - } - - if (url.hash) { - this.hash = url.hash - } - } - - toJSON (): string { - return this.href - } - - toString (): string { - return this.href - } -} diff --git a/src/url.ts b/src/url.ts new file mode 100644 index 00000000..38fc2ee9 --- /dev/null +++ b/src/url.ts @@ -0,0 +1,114 @@ +import { parseURL, parseAuth, parseHost } from './parse' +import { QueryObject, parseQuery, stringifyQuery } from './query' +import { withoutLeadingSlash, withTrailingSlash } from './utils' +import { encodeHash, encodePath, decode, encodeHost, encode } from './encoding' + +export class $URL implements URL { + protocol: string + host: string + auth: string + pathname: string + query: QueryObject = {} + hash: string + + constructor (input: string = '') { + if (typeof input !== 'string') { + throw new TypeError(`URL input should be string received ${typeof input} (${input})`) + } + + const parsed = parseURL(input) + + this.protocol = decode(parsed.protocol) + this.host = decode(parsed.host) + this.auth = decode(parsed.auth) + this.pathname = decode(parsed.pathname) + this.query = parseQuery(parsed.search) + this.hash = decode(parsed.hash) + } + + get hostname (): string { + return parseHost(this.host).hostname + } + + get port (): string { + return parseHost(this.host).port || '' + } + + get username (): string { + return parseAuth(this.auth).username + } + + get password (): string { + return parseAuth(this.auth).password || '' + } + + get hasProtocol () { + return this.protocol.length + } + + get isAbsolute () { + return this.hasProtocol || this.pathname[0] === '/' + } + + get search (): string { + const q = stringifyQuery(this.query) + return q.length ? '?' + q : '' + } + + get searchParams (): URLSearchParams { + const p = new URLSearchParams() + for (const name in this.query) { + const value = this.query[name] + if (Array.isArray(value)) { + value.forEach(v => p.append(name, v)) + } else { + p.append(name, value || '') + } + } + return p + } + + get origin (): string { + return (this.protocol ? this.protocol + '//' : '') + encodeHost(this.host) + } + + get fullpath (): string { + return encodePath(this.pathname) + this.search + encodeHash(this.hash) + } + + get encodedAuth (): string { + if (!this.auth) { return '' } + const { username, password } = parseAuth(this.auth) + return encodeURIComponent(username) + (password ? ':' + encodeURIComponent(password) : '') + } + + get href (): string { + const auth = this.encodedAuth + const originWithAuth = (this.protocol ? this.protocol + '//' : '') + (auth ? auth + '@' : '') + encodeHost(this.host) + return (this.hasProtocol && this.isAbsolute) ? (originWithAuth + this.fullpath) : this.fullpath + } + + append (url: $URL) { + if (url.hasProtocol) { + throw new Error('Cannot append a URL with protocol') + } + + Object.assign(this.query, url.query) + + if (url.pathname) { + this.pathname = withTrailingSlash(this.pathname) + withoutLeadingSlash(url.pathname) + } + + if (url.hash) { + this.hash = url.hash + } + } + + toJSON (): string { + return this.href + } + + toString (): string { + return this.href + } +} diff --git a/src/utils.ts b/src/utils.ts index c39b3ac4..400a0631 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,10 @@ -import { UFO } from './ufo' -import { ParamsObject } from './parse' +import { $URL } from './url' +import { parseURL, stringifyParsedURL } from './parse' +import { QueryObject, parseQuery, stringifyQuery } from './query' + +export function hasProtocol (inputStr: string): boolean { + return /^\w+:\/\//.test(inputStr) +} export function withoutTrailingSlash (input: string = ''): string { return input.endsWith('/') ? input.slice(0, -1) : input @@ -21,23 +26,15 @@ export function cleanDoubleSlashes (input: string = ''): string { return input.split('://').map(str => str.replace(/\/{2,}/g, '/')).join('://') } -export function createURL (input: string): UFO { - return new UFO(input) -} - -export function normalizeURL (input: string): string { - return createURL(input).toString() -} - -export function withParams (input: string, params: ParamsObject): string { - const parsed = createURL(input) - const mergedParams = { ...getParams(input), ...params } - parsed.params = mergedParams - return parsed.toString() +export function withQuery (input: string, query: QueryObject): string { + const parsed = parseURL(input) + const mergedQuery = { ...parseQuery(parsed.search), ...query } + parsed.search = stringifyQuery(mergedQuery) + return stringifyParsedURL(parsed) } -export function getParams (input: string): ParamsObject { - return createURL(input).params +export function getQuery (input: string): QueryObject { + return parseQuery(parseURL(input).search) } export function joinURL (base: string, ...input: string[]): string { @@ -50,6 +47,16 @@ export function joinURL (base: string, ...input: string[]): string { return url } +// $URL based utils + +export function createURL (input: string): $URL { + return new $URL(input) +} + +export function normalizeURL (input: string): string { + return createURL(input).toString() +} + export function resolveURL (base: string, ...input: string[]): string { const url = createURL(base) diff --git a/test/normalize.test.ts b/test/normalize.test.ts index 3be54df0..622d7bdb 100644 --- a/test/normalize.test.ts +++ b/test/normalize.test.ts @@ -20,7 +20,7 @@ describe('normalizeURL', () => { 'http://foo.com/test?query=123#hash': 'http://foo.com/test?query=123#hash', 'http://localhost:3000': 'http://localhost:3000', 'http://my_email%40gmail.com:password@www.my_site.com': 'http://my_email%40gmail.com:password@www.my_site.com', - '/test?query=123 123#hash, test': '/test?query=123%20123#hash,%20test', + '/test?query=123+123#hash, test': '/test?query=123%2B123#hash,%20test', 'http://test.com/%C3%B6?foo=تست': 'http://test.com/%C3%B6?foo=%D8%AA%D8%B3%D8%AA', '/http:/': '/http:/', 'http://[2001:db8:85a3:8d3:1319:8a2e:370:7348]/': 'http://[2001:db8:85a3:8d3:1319:8a2e:370:7348]/' @@ -73,7 +73,7 @@ describe('normalizeURL', () => { for (const input of validURLS) { test(input, () => { - expect(withoutTrailingSlash(normalizeURL(input))).toBe(withoutTrailingSlash(new URL(input).href)) + expect(withoutTrailingSlash(normalizeURL(input).replace(/\+/g, '%20'))).toBe(withoutTrailingSlash(new URL(input).href)) }) } }) diff --git a/test/params.test.ts b/test/params.test.ts deleted file mode 100644 index 81fa229b..00000000 --- a/test/params.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-nocheck -import { getParams, withParams } from '../src' - -describe('withParams', () => { - const tests = [ - { input: '', params: {}, out: '' }, - { input: '/', params: {}, out: '/' }, - { input: '?test', params: {}, out: '?test' }, - { input: '/?test', params: {}, out: '/?test' }, - { input: '/?test', params: { foo: 1 }, out: '/?test&foo=1' }, - { input: '/?foo=1', params: { foo: 2 }, out: '/?foo=2' }, - { input: '/?x=1,2,3', params: { y: '1,2,3' }, out: '/?x=1,2,3&y=1,2,3' } - ] - - for (const t of tests) { - test(t.input.toString() + ' with ' + JSON.stringify(t.params), () => { - expect(withParams(t.input, t.params)).toBe(t.out) - }) - } -}) - -describe('getParams', () => { - const tests = { - 'http://foo.com/foo?test=123&unicode=%E5%A5%BD': { test: '123', unicode: '好' } - } - - for (const t in tests) { - test(t, () => { - expect(getParams(t)).toMatchObject(tests[t]) - }) - } -}) diff --git a/test/query.test.ts b/test/query.test.ts new file mode 100644 index 00000000..c43a308f --- /dev/null +++ b/test/query.test.ts @@ -0,0 +1,32 @@ +// @ts-nocheck +import { getQuery, withQuery } from '../src' + +describe('withQuery', () => { + const tests = [ + { input: '', query: {}, out: '' }, + { input: '/', query: {}, out: '/' }, + { input: '?test', query: {}, out: '?test' }, + { input: '/?test', query: {}, out: '/?test' }, + { input: '/?test', query: { foo: 1 }, out: '/?test&foo=1' }, + { input: '/?foo=1', query: { foo: 2 }, out: '/?foo=2' }, + { input: '/?x=1,2,3', query: { y: '1,2,3' }, out: '/?x=1,2,3&y=1,2,3' } + ] + + for (const t of tests) { + test(t.input.toString() + ' with ' + JSON.stringify(t.query), () => { + expect(withQuery(t.input, t.query)).toBe(t.out) + }) + } +}) + +describe('getQuery', () => { + const tests = { + 'http://foo.com/foo?test=123&unicode=%E5%A5%BD': { test: '123', unicode: '好' } + } + + for (const t in tests) { + test(t, () => { + expect(getQuery(t)).toMatchObject(tests[t]) + }) + } +})