├── .prettierrc ├── src ├── index.ts ├── utils.ts ├── punycode.ts ├── nurl.ts └── index.test.ts ├── .browserslistrc ├── .markdownlintignore ├── .eslintignore ├── .gitignore ├── .eslintrc ├── .github ├── CODEOWNERS └── workflows │ ├── size-check.yaml │ ├── detect-add.yaml │ ├── ci.yaml │ └── publish.yaml ├── .markdownlint.jsonc ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── .changeset └── config.json ├── .editorconfig ├── vite.config.mts ├── tsconfig.json ├── LICENSE ├── package.json ├── CHANGELOG.md ├── README.md └── CODE_OF_CONDUCT.md /.prettierrc: -------------------------------------------------------------------------------- 1 | "@naverpay/prettier-config" 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './nurl' 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | extends @naverpay/browserslist-config 2 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | .changeset 2 | **/CHANGELOG.md 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | pnpm-lock.yaml 3 | dist 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist 4 | .idea/ 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@naverpay/eslint-config" 3 | } 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @NaverPayDev/frontend @yceffort-naver @yongholeeme -------------------------------------------------------------------------------- /.markdownlint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@naverpay/markdown-lint", 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # pnpm lock yaml 제외 2 | pnpm-lock.yaml 3 | 4 | dist 5 | 6 | # markdownlint 7 | **/*.md -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "editorconfig.editorconfig", 6 | "DavidAnson.vscode-markdownlint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit", 4 | "source.fixAll.markdownlint": "explicit" 5 | }, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "editor.formatOnSave": true, 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "typescript.preferences.importModuleSpecifier": "relative" 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*.{js,ts,tsx}] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 4 9 | insert_final_newline = true 10 | max_line_length = 120 11 | trim_trailing_whitespace = true 12 | 13 | [*.{json,yml,yaml}] 14 | charset = utf-8 15 | end_of_line = lf 16 | indent_style = space 17 | indent_size = 4 18 | insert_final_newline = true 19 | trim_trailing_whitespace = true 20 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | import {dirname} from 'path' 2 | import {fileURLToPath} from 'url' 3 | 4 | import {createViteConfig} from '@naverpay/pite' 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | 8 | export default createViteConfig({ 9 | cwd: __dirname, 10 | entry: { 11 | index: './src/index.ts', 12 | }, 13 | outputs: [ 14 | {format: 'es', dist: 'dist/esm'}, 15 | {format: 'cjs', dist: 'dist/cjs'}, 16 | ], 17 | skipRequiredPolyfillCheck: ['es.array.push'], 18 | }) 19 | -------------------------------------------------------------------------------- /.github/workflows/size-check.yaml: -------------------------------------------------------------------------------- 1 | name: Size Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | size-check: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '20.18.3' 16 | - uses: pnpm/action-setup@v4 17 | - name: Run Size Action 18 | uses: NaverPayDev/size-action@main 19 | with: 20 | cwd: './' 21 | github_token: ${{ secrets.ACTION_TOKEN }} 22 | build_script: build 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "ESNext", 5 | "lib": ["DOM", "ES2021"], 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "isolatedModules": true, 14 | "removeComments": false, 15 | "preserveConstEnums": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "declaration": true 18 | }, 19 | "include": ["./src/**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 NaverPayDev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/detect-add.yaml: -------------------------------------------------------------------------------- 1 | name: detect changed packages 2 | 3 | on: 4 | pull_request_target: 5 | branches: ['**'] 6 | types: [opened, reopened, labeled, unlabeled, synchronize] 7 | 8 | concurrency: 9 | group: detect-${{ github.event.pull_request.number }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | detect: 14 | runs-on: ubuntu-latest 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.ACTION_TOKEN }} 17 | steps: 18 | - name: Checkout Repo 19 | uses: actions/checkout@v3 20 | with: 21 | token: ${{ secrets.ACTION_TOKEN }} 22 | fetch-depth: 0 23 | 24 | - uses: actions/checkout@v4 25 | - uses: pnpm/action-setup@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: '22' 29 | cache: 'pnpm' 30 | 31 | - name: install dependencies 32 | run: pnpm install --frozen-lockfile 33 | 34 | - name: 'detect changed packages' 35 | uses: NaverPayDev/changeset-actions/detect-add@oidc 36 | with: 37 | github_token: ${{ secrets.ACTION_TOKEN }} 38 | packages_dir: . 39 | skip_label: skip-detect-change 40 | skip_branches: main 41 | formatting_script: pnpm run markdownlint:fix 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | branches: 5 | - '**' 6 | 7 | permissions: 8 | contents: write # to create release 9 | id-token: write 10 | 11 | jobs: 12 | code-style: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Use Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '22.x' 20 | - name: Enable Corepack 21 | run: corepack enable 22 | 23 | - name: Install dependencies 24 | run: pnpm install --frozen-lockfile 25 | 26 | - name: Run code style checks 27 | run: pnpm run prettier 28 | 29 | - name: Run ESlint 30 | run: pnpm run lint 31 | 32 | test: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v3 36 | - name: Use Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: '22.x' 40 | - name: Enable Corepack 41 | run: corepack enable 42 | - name: Install dependencies 43 | run: pnpm install --frozen-lockfile 44 | - run: pnpm run test 45 | 46 | build: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v3 50 | - name: Use Node.js 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: '22.x' 54 | - name: Enable Corepack 55 | run: corepack enable 56 | - name: Install dependencies 57 | run: pnpm install --frozen-lockfile 58 | - run: pnpm run build 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@naverpay/nurl", 3 | "version": "1.0.3", 4 | "description": "URL build library", 5 | "main": "./dist/cjs/index.js", 6 | "module": "./dist/esm/index.mjs", 7 | "types": "./dist/cjs/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": { 11 | "types": "./dist/esm/index.d.mts", 12 | "default": "./dist/esm/index.mjs" 13 | }, 14 | "require": { 15 | "types": "./dist/cjs/index.d.ts", 16 | "default": "./dist/cjs/index.js" 17 | } 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "sideEffects": false, 25 | "scripts": { 26 | "clean": "rm -rf dist", 27 | "build": "pnpm clean && vite build -c vite.config.mts", 28 | "test": "vitest run", 29 | "test:watch": "vitest watch", 30 | "lint": "eslint '**/*.{js,jsx,ts,tsx}'", 31 | "lint:fix": "pnpm run lint --fix", 32 | "prettier": "prettier --check '**/*.{json,yaml,md,ts,tsx,js,jsx}'", 33 | "prettier:fix": "prettier --write '**/*.{json,yaml,md,ts,tsx,js,jsx}'", 34 | "release": "changeset publish", 35 | "markdownlint": "markdownlint '**/*.md' '#.changeset' '#**/CHANGELOG.md'", 36 | "markdownlint:fix": "markdownlint --fix '**/*.md' '#.changeset' '#**/CHANGELOG.md'", 37 | "release:canary": "changeset publish --no-git-tag --directory dist" 38 | }, 39 | "keywords": [ 40 | "url", 41 | "uri" 42 | ], 43 | "author": "@NaverPayDev/frontend", 44 | "repository": { 45 | "type": "git", 46 | "url": "git+https://github.com/NaverPayDev/nurl.git" 47 | }, 48 | "license": "MIT", 49 | "devDependencies": { 50 | "@changesets/cli": "^2.27.7", 51 | "@naverpay/browserslist-config": "^1.6.1", 52 | "@naverpay/editorconfig": "^0.0.4", 53 | "@naverpay/eslint-config": "^1.0.7", 54 | "@naverpay/markdown-lint": "^0.0.3", 55 | "@naverpay/pite": "^2.1.0", 56 | "@naverpay/prettier-config": "^1.0.0", 57 | "typescript": "^5.5.4", 58 | "vite": "^6.0.7", 59 | "vitest": "^2.1.8" 60 | }, 61 | "packageManager": "pnpm@9.15.3" 62 | } 63 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | const DYNAMIC_PATH_COLON_REGEXP = /^:/ 2 | const DYNAMIC_PATH_BRACKETS_REGEXP = /^\[.*\]$/ 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export type Query = Record 6 | 7 | export function isDynamicPath(path: string) { 8 | return DYNAMIC_PATH_COLON_REGEXP.test(path) || DYNAMIC_PATH_BRACKETS_REGEXP.test(path) 9 | } 10 | 11 | export function getDynamicPaths(pathname: string): string[] { 12 | return pathname.split('/').filter(isDynamicPath) 13 | } 14 | 15 | export function extractPathKey(path: string): string { 16 | return path.slice(1, DYNAMIC_PATH_COLON_REGEXP.test(path) ? undefined : -1) 17 | } 18 | 19 | /** 20 | * Replaces dynamic paths in the pathname with values from the query 21 | * @param {string} pathname 22 | * @param {Query} query 23 | * @returns {string} refined pathname 24 | */ 25 | export function refinePathnameWithQuery(pathname: string, query: Query): string { 26 | return getDynamicPaths(pathname).reduce((acc, path) => { 27 | const pathKey = extractPathKey(path) 28 | 29 | const queryValue = query[pathKey] 30 | return queryValue && typeof queryValue === 'string' ? acc.replace(path, queryValue) : acc 31 | }, pathname) 32 | } 33 | 34 | /** 35 | * Removes queries that have already been used in the pathname. 36 | * @param {string} pathname 37 | * @param {Query} query 38 | * @returns {Query} refined query 39 | */ 40 | export function refineQueryWithPathname(pathname: string, query: Query): Query { 41 | return getDynamicPaths(pathname).reduce((acc, path) => { 42 | const pathKey = extractPathKey(path) 43 | 44 | const queryValue = acc[pathKey] 45 | if (typeof queryValue !== 'string') { 46 | return acc 47 | } 48 | 49 | const {[pathKey]: _, ...remainingQuery} = acc 50 | return remainingQuery 51 | }, query) 52 | } 53 | 54 | const MAX_ASCII_CODE = 127 55 | export function isASCIICodeChar(char: string) { 56 | return char.charCodeAt(0) > MAX_ASCII_CODE 57 | } 58 | 59 | function isValidPrimitive(value: unknown): boolean { 60 | return ['string', 'number', 'boolean'].includes(typeof value) 61 | } 62 | 63 | /** 64 | * Convert queries to array, if they are not of the defined primitive types, they will not be included in the array. 65 | * @param {string} pathname 66 | * @param {Query} query 67 | * @returns {string[][]} refined query 68 | */ 69 | export function convertQueryToArray(query: Query): string[][] { 70 | return Object.entries(query).flatMap(([key, value]) => { 71 | if (isValidPrimitive(value)) { 72 | return [[key, String(value)]] 73 | } 74 | 75 | if (Array.isArray(value) && value.every(isValidPrimitive)) { 76 | return value.map((v) => [key, String(v)]) 77 | } 78 | 79 | return [] 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | issue_comment: 8 | types: 9 | - created 10 | 11 | permissions: 12 | id-token: write 13 | contents: write 14 | 15 | concurrency: ${{ github.workflow }}-${{ github.ref }} 16 | 17 | jobs: 18 | get-branch: 19 | if: github.event_name == 'issue_comment' && github.event.issue.pull_request && (github.event.comment.body == 'canary-publish' || github.event.comment.body == '/canary-publish' || github.event.comment.body == 'rc-publish' || github.event.comment.body == '/rc-publish') 20 | runs-on: ubuntu-latest 21 | outputs: 22 | branch: ${{ steps.get_branch.outputs.branch }} 23 | steps: 24 | - name: Get PR branch name 25 | id: get_branch 26 | run: | 27 | PR=$(curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" ${{ github.event.issue.pull_request.url }}) 28 | echo "::set-output name=branch::$(echo $PR | jq -r '.head.ref')" 29 | 30 | publish: 31 | runs-on: ubuntu-latest 32 | needs: [get-branch] 33 | if: | 34 | always() && 35 | (github.event_name == 'push' || 36 | (github.event_name == 'issue_comment' && needs.get-branch.result == 'success')) 37 | steps: 38 | - name: Checkout Repo 39 | uses: actions/checkout@v4 40 | with: 41 | ref: ${{ needs.get-branch.outputs.branch || github.ref }} 42 | token: ${{ secrets.ACTION_TOKEN }} 43 | fetch-depth: 0 44 | 45 | - uses: pnpm/action-setup@v4 46 | 47 | - name: Use Node.js 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: '22' 51 | cache: 'pnpm' 52 | 53 | - name: Check and upgrade npm 54 | run: | 55 | echo "Current npm version:" 56 | npm --version 57 | npm install -g npm@latest 58 | echo "Upgraded npm version:" 59 | npm --version 60 | 61 | - name: Install Dependencies 62 | run: pnpm install --frozen-lockfile 63 | 64 | - name: Build package 65 | run: pnpm run build 66 | 67 | - name: Publish - Release 68 | if: github.event_name == 'push' 69 | uses: NaverPayDev/changeset-actions/publish@oidc 70 | with: 71 | github_token: ${{ secrets.ACTION_TOKEN }} 72 | git_username: npayfebot 73 | git_email: npay.fe.bot@navercorp.com 74 | publish_script: pnpm release 75 | pr_title: '🚀 version changed packages' 76 | commit_message: '📦 bump changed packages version' 77 | create_github_release_tag: true 78 | formatting_script: pnpm run markdownlint:fix 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.ACTION_TOKEN }} 81 | 82 | - name: Publish - Canary 83 | if: github.event_name == 'issue_comment' && (github.event.comment.body == 'canary-publish' || github.event.comment.body == '/canary-publish') 84 | uses: NaverPayDev/changeset-actions/canary-publish@oidc 85 | with: 86 | github_token: ${{ secrets.ACTION_TOKEN }} 87 | npm_tag: canary 88 | publish_script: pnpm run release:canary 89 | packages_dir: '.' 90 | excludes: '.turbo,.github' 91 | version_template: '{VERSION}-canary.{DATE}-{COMMITID7}' 92 | 93 | - name: Publish - RC 94 | if: github.event_name == 'issue_comment' && (github.event.comment.body == 'rc-publish' || github.event.comment.body == '/rc-publish') 95 | uses: NaverPayDev/changeset-actions/canary-publish@oidc 96 | with: 97 | github_token: ${{ secrets.ACTION_TOKEN }} 98 | npm_tag: rc 99 | publish_script: pnpm run release:canary 100 | packages_dir: '.' 101 | excludes: '.turbo,.github' 102 | version_template: '{VERSION}-rc.{DATE}-{COMMITID7}' 103 | create_release: true 104 | env: 105 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @naverpay/nurl 2 | 3 | ## 1.0.3 4 | 5 | ### Patch Changes 6 | 7 | - 05aa898: chore: update GitHub workflows for improved permissions 8 | 9 | PR: [chore: update GitHub workflows for improved permissions](https://github.com/NaverPayDev/nurl/pull/74) 10 | 11 | - 5393314: Revert "test" 12 | 13 | PR: [Revert "test"](https://github.com/NaverPayDev/nurl/pull/77) 14 | 15 | - 02c25f2: Revert "test" 16 | 17 | PR: [Revert "test"](https://github.com/NaverPayDev/nurl/pull/78) 18 | 19 | ## 1.0.2 20 | 21 | ### Patch Changes 22 | 23 | - 0dfac2c: remove else block that overwrites pathname in constructor 24 | 25 | PR: [remove else block that overwrites pathname in constructor](https://github.com/NaverPayDev/nurl/pull/70) 26 | 27 | ## 1.0.1 28 | 29 | ### Patch Changes 30 | 31 | - f2655b2: add missing dist folders 32 | - 28044c5: ✨ Update workflows to include package build step (please use 1.0.1) 33 | 34 | PR: [✨ Update workflows to include package build step](https://github.com/NaverPayDev/nurl/pull/67) 35 | 36 | ## 1.0.0 37 | 38 | ### Major Changes 39 | 40 | - 393bd3d: 🔧 Fix pathname handling and href generation in NURL class 41 | 42 | - Previously, when creating an NURL instance with an explicitly empty string ('') for the pathname, the resulting URL automatically defaulted to a root path ('/'). This behavior was inconsistent with the native JavaScript URL object, which retains an empty pathname as-is, resulting in a URL consisting only of query parameters. 43 | 44 | ```javascript 45 | // as-is 46 | // Input 47 | const url = new NURL({pathname: '', query: {test: '1'}}) 48 | 49 | // Output 50 | url.href === '/?test=1' 51 | 52 | // to-be 53 | // Input 54 | const url = new NURL({pathname: '', query: {test: '1'}}) 55 | 56 | // Output 57 | url.href === '?test=1' 58 | ``` 59 | 60 | - Previously, assigning an empty string ('') to the pathname property via setter retained the existing pathname. However, to align with the native JavaScript URL object’s behavior, assigning an empty string now explicitly clears the pathname. 61 | 62 | ```javascript 63 | // Before 64 | url.pathname = '' // pathname remained '/hello' 65 | 66 | // After (new default behavior): 67 | url.pathname = '' // pathname becomes '', resulting in removal of '/hello' 68 | ``` 69 | 70 | PR: [🔧 Fix pathname handling and href generation in NURL class](https://github.com/NaverPayDev/nurl/pull/65) 71 | 72 | ## 0.1.1 73 | 74 | ### Patch Changes 75 | 76 | - 21a57e7: Update Query type to use more flexible 77 | 78 | PR: [Update Query type to use more flexible](https://github.com/NaverPayDev/nurl/pull/60) 79 | 80 | ## 0.1.0 81 | 82 | ### Minor Changes 83 | 84 | - fa4eec3: string 으로 인스턴스 생성시 baseUrl 과 pathname 이 생략된 url 을 지정가능하도록 합니다. 85 | 86 | PR: [string 으로 인스턴스 생성시 baseUrl 과 pathname 이 생략된 url 을 지정가능하도록 합니다.](https://github.com/NaverPayDev/nurl/pull/55) 87 | 88 | ### Patch Changes 89 | 90 | - fce2c49: fix declaration file bug with pite 91 | 92 | PR: [fix declaration file bug with pite](https://github.com/NaverPayDev/nurl/pull/59) 93 | 94 | ## 0.0.12 95 | 96 | ### Patch Changes 97 | 98 | - 0764534: 🐛 fix wrong options 99 | 100 | PR: [🐛 fix wrong options](https://github.com/NaverPayDev/nurl/pull/52) 101 | 102 | ## 0.0.11 103 | 104 | ### Patch Changes 105 | 106 | - fa088d0: query 타입을 확장합니다. 107 | 108 | PR: [query 타입을 확장합니다.](https://github.com/NaverPayDev/nurl/pull/50) 109 | 110 | ## 0.0.10 111 | 112 | ### Patch Changes 113 | 114 | - 7d58a89: 🔧 build with pite 115 | 116 | PR: [🔧 build with pite](https://github.com/NaverPayDev/nurl/pull/48) 117 | 118 | ## 0.0.9 119 | 120 | ### Patch Changes 121 | 122 | - 0e6c484: Add basePath Support to NURL Class with Duplicate Prevention 123 | 124 | ## 0.0.8 125 | 126 | ### Patch Changes 127 | 128 | - 1047b48: 🚚 punycode 를 내재화합니다. 129 | 130 | ## 0.0.7 131 | 132 | ### Patch Changes 133 | 134 | - d2cc84e: [사용하는 값만 받도록 생성자 파라미터 타입 변경](https://github.com/NaverPayDev/nurl/pull/37) 135 | 136 | ## 0.0.6 137 | 138 | ### Patch Changes 139 | 140 | - 95cf37d: - search 파라미터 이중 인코딩 제거 https://github.com/NaverPayDev/nurl/pull/35 141 | 142 | ## 0.0.5 143 | 144 | ### Patch Changes 145 | 146 | - 9d429a1: [#31] 💩 esm 에서도 punycode cjs 동작하도록 변경 147 | 148 | ## 0.0.4 149 | 150 | ### Patch Changes 151 | 152 | - 57b65f2: 잘못 지워진 빌드 스텝 추가 153 | 154 | ## 0.0.3 155 | 156 | ### Patch Changes 157 | 158 | - 3b4ad06: esm 에서 동작하기 위한 dependency alias 적용 https://github.com/NaverPayDev/nurl/pull/26 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

@naverpay/nurl

2 | 3 |

4 | 5 | npm version 6 | 7 | 8 | minzipped size 9 | 10 |

11 | 12 | NURL is a powerful URL manipulation library that extends the standard URL class. It provides dynamic segment processing and flexible URL creation capabilities. 13 | 14 | ## Features 15 | 16 | - Extends and implements the URL class 17 | - Supports various URL creation methods (string, URL object, custom options object) 18 | - Provides a factory function NURL.create() for creating instances without the new keyword 19 | - Dynamic segment processing functionality 20 | - Setters behave differently from the standard URL 21 | - Provides decoded hostname for IDN (Internationalized Domain Names) support 22 | 23 | ## Usage 24 | 25 | ### Basic Usage 26 | 27 | ```javascript 28 | import { NURL } from 'nurl' 29 | 30 | // Create URL from string 31 | const url1 = new NURL('https://example.com/users/123?name=John') 32 | 33 | // Create URL from existing URL object 34 | const standardUrl = new URL('https://example.com') 35 | const url2 = new NURL(standardUrl) 36 | 37 | // Create URL from custom options object 38 | const url3 = new NURL({ 39 | baseUrl: 'https://example.com', 40 | pathname: '/users/:id', 41 | query: { id: '123', name: 'John' } 42 | }) 43 | 44 | // Create empty URL 45 | const url4 = new NURL() 46 | 47 | // Using the factory function 48 | const url5 = NURL.create('https://example.com') 49 | 50 | // The factory function also works with options object 51 | const url6 = NURL.create({ 52 | baseUrl: 'https://example.com', 53 | pathname: '/users/:id', 54 | query: { id: '123', name: 'John' } 55 | }) 56 | ``` 57 | 58 | ### Dynamic Segment Processing 59 | 60 | NURL processes dynamic segments in the pathname and replaces them with values from the query object. If a dynamic segment doesn't have a corresponding query value, it remains unchanged in the pathname without any encoding: 61 | 62 | ```javascript 63 | const url = new NURL({ 64 | baseUrl: 'https://api.example.com', 65 | pathname: '/users/:a/posts/[b]/[c]', 66 | query: { 67 | a: '123', 68 | b: '456', 69 | format: 'json' 70 | } 71 | }) 72 | 73 | console.log(url.href) 74 | // Output: https://api.example.com/users/123/posts/456/[c]?format=json 75 | ``` 76 | 77 | ### IDN Support 78 | 79 | NURL automatically handles Internationalized Domain Names: 80 | 81 | ```javascript 82 | const url = new NURL('https://한글.도메인') 83 | console.log(url.hostname) // xn--bj0bj06e.xn--hq1bm8jm9l 84 | console.log(url.decodedHostname) // 한글.도메인 (in human-readable format) 85 | ``` 86 | 87 | ## API 88 | 89 | ### `constructor(input?: string | URL | URLOptions)` 90 | 91 | - `input`: Can be one of the following: 92 | - `string`: Standard URL string 93 | - `URL`: Standard URL object 94 | - `URLOptions`: Custom options object that extends `Partial` and includes: 95 | - `baseUrl?: string`: Optional base URL string 96 | - `query?: Record`: Optional object for query parameters 97 | - Can include any property from the standard URL object (e.g., `pathname`, `protocol`, etc.) 98 | 99 | ### Dynamic Segments 100 | 101 | - Supports `:paramName` or `[paramName]` format in the pathname. 102 | - If a corresponding key exists in the query object or URLOptions, the dynamic segment is replaced with its value. 103 | - If a corresponding key does not exist, the dynamic segment remains unchanged in the pathname without throwing an error. 104 | 105 | ### Properties 106 | 107 | NURL inherits all properties from the standard URL class: 108 | 109 | - `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, `searchParams`, `hash` 110 | 111 | ### Methods 112 | 113 | - `toString()`: Returns the URL as a string 114 | - `toJSON()`: Returns the URL as a JSON representation 115 | 116 | ## Important Notes 117 | 118 | 1. NURL's setter methods behave differently from the standard URL. They are designed to consider dynamic segment and query parameter replacement functionality. 119 | 2. When created with no arguments, all properties are initialized as empty strings. 120 | 3. When using `URLOptions`, if a query value corresponding to a dynamic segment is missing, the dynamic segment remains unchanged in the pathname. 121 | 4. Dynamic segments only support the `:paramName` or `[paramName]` format. 122 | 123 | ## Differences from Standard URL 124 | 125 | 1. **Constructor Flexibility**: NURL can create a URL from a string, URL object, or custom options object. 126 | 2. **Empty URL Creation**: NURL can create an empty URL when called with no arguments. 127 | 3. **Dynamic Segments**: NURL supports dynamic segments in the pathname. 128 | 4. **Setter Behavior**: NURL's setter methods behave differently from the standard URL, considering dynamic segment processing and query parameter replacement. 129 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | ### Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ### Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward other people 14 | - Being respectful of differing opinions, viewpoints, and experiences 15 | - Giving and gracefully accepting constructive feedback 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | - Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | - Trolling, insulting or derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others’ private information, such as a physical or email address, without their explicit permission 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 32 | 33 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 34 | 35 | ### Scope 36 | 37 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 38 | 39 | ### Enforcement 40 | 41 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [nfn0000220@navercorp.com](mailto:nfn0000220@navercorp.com). All complaints will be reviewed and investigated promptly and fairly. 42 | 43 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 44 | 45 | ### Enforcement Guidelines 46 | 47 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 48 | 49 | #### 1. Correction 50 | 51 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 52 | 53 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 54 | 55 | #### 2. Warning 56 | 57 | **Community Impact**: A violation through a single incident or series of actions. 58 | 59 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 60 | 61 | #### 3. Temporary Ban 62 | 63 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 64 | 65 | Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 66 | 67 | #### 4. Permanent Ban 68 | 69 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 70 | 71 | **Consequence**: A permanent ban from any sort of public interaction within the community. 72 | 73 | ### Attribution 74 | 75 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, 76 | available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct/][v2.1] 77 | 78 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][mozilla coc]. 79 | 80 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][faq]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 81 | 82 | [homepage]: https://www.contributor-covenant.org 83 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 84 | [mozilla coc]: https://github.com/mozilla/diversity 85 | [faq]: https://www.contributor-covenant.org/faq 86 | [translations]: https://www.contributor-covenant.org/translations 87 | -------------------------------------------------------------------------------- /src/punycode.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eqeqeq */ 2 | /* eslint-disable no-param-reassign */ 3 | 4 | interface PunycodeStatic { 5 | version: string 6 | ucs2: { 7 | decode(string: string): number[] 8 | encode(codePoints: number[]): string 9 | } 10 | decode(input: string): string 11 | encode(input: string): string 12 | toASCII(input: string): string 13 | toUnicode(input: string): string 14 | } 15 | 16 | type ErrorMessages = Record 17 | 18 | /** Highest positive signed 32-bit float value */ 19 | const maxInt: number = 2147483647 // aka. 0x7FFFFFFF or 2^31-1 20 | 21 | /** Bootstring parameters */ 22 | const base: number = 36 23 | const tMin: number = 1 24 | const tMax: number = 26 25 | const skew: number = 38 26 | const damp: number = 700 27 | const initialBias: number = 72 28 | const initialN: number = 128 // 0x80 29 | const delimiter: string = '-' // '\x2D' 30 | 31 | /** Regular expressions */ 32 | const regexPunycode: RegExp = /^xn--/ 33 | const regexNonASCII: RegExp = /[^\0-\x7F]/ // Note: U+007F DEL is excluded too. 34 | const regexSeparators: RegExp = /[\x2E\u3002\uFF0E\uFF61]/g // RFC 3490 separators 35 | 36 | /** Error messages */ 37 | const errors: ErrorMessages = { 38 | overflow: 'Overflow: input needs wider integers to process', 39 | 'not-basic': 'Illegal input >= 0x80 (not a basic code point)', 40 | 'invalid-input': 'Invalid input', 41 | } 42 | 43 | /** Convenience shortcuts */ 44 | const baseMinusTMin: number = base - tMin 45 | const floor: (x: number) => number = Math.floor 46 | const stringFromCharCode: (...codes: number[]) => string = String.fromCharCode 47 | 48 | /* -------------------------------------------------------------------------- */ 49 | 50 | /** 51 | * A generic error utility function. 52 | * @private 53 | * @param {string} type The error type. 54 | * @throws {RangeError} with the applicable error message. 55 | */ 56 | function error(type: string): never { 57 | throw new RangeError(errors[type]) 58 | } 59 | 60 | /** 61 | * A generic `Array#map` utility function. 62 | * @private 63 | * @param {T[]} array The array to iterate over. 64 | * @param {(value: T) => U} callback The function that gets called for every array item. 65 | * @returns {U[]} A new array of values returned by the callback function. 66 | */ 67 | function map(array: T[], callback: (value: T) => U): U[] { 68 | const result: U[] = new Array(array.length) 69 | let length: number = array.length 70 | while (length--) { 71 | result[length] = callback(array[length]) 72 | } 73 | return result 74 | } 75 | 76 | /** 77 | * A simple `Array#map`-like wrapper to work with domain name strings or email addresses. 78 | * @private 79 | * @param {string} domain The domain name or email address. 80 | * @param {(string: string) => string} callback The function that gets called for every character. 81 | * @returns {string} A new string of characters returned by the callback function. 82 | */ 83 | function mapDomain(domain: string, callback: (string: string) => string): string { 84 | const parts: string[] = domain.split('@') 85 | let result: string = '' 86 | if (parts.length > 1) { 87 | // In email addresses, only the domain name should be punycoded. Leave 88 | // the local part (i.e. everything up to `@`) intact. 89 | result = parts[0] + '@' 90 | domain = parts[1] 91 | } 92 | // Avoid `split(regex)` for IE8 compatibility. See #17. 93 | domain = domain.replace(regexSeparators, '\x2E') 94 | const labels: string[] = domain.split('.') 95 | const encoded: string = map(labels, callback).join('.') 96 | return result + encoded 97 | } 98 | 99 | /** 100 | * Creates an array containing the numeric code points of each Unicode 101 | * character in the string. 102 | * @param {string} string The Unicode input string (UCS-2). 103 | * @returns {number[]} The new array of code points. 104 | */ 105 | function ucs2decode(string: string): number[] { 106 | const output: number[] = [] 107 | let counter: number = 0 108 | const length: number = string.length 109 | 110 | while (counter < length) { 111 | const value: number = string.charCodeAt(counter++) 112 | if (value >= 0xd800 && value <= 0xdbff && counter < length) { 113 | // It's a high surrogate, and there is a next character. 114 | const extra: number = string.charCodeAt(counter++) 115 | if ((extra & 0xfc00) == 0xdc00) { 116 | // Low surrogate. 117 | output.push(((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000) 118 | } else { 119 | // It's an unmatched surrogate; only append this code unit, in case the 120 | // next code unit is the high surrogate of a surrogate pair. 121 | output.push(value) 122 | counter-- 123 | } 124 | } else { 125 | output.push(value) 126 | } 127 | } 128 | return output 129 | } 130 | 131 | /** 132 | * Creates a string based on an array of numeric code points. 133 | * @param {number[]} codePoints The array of numeric code points. 134 | * @returns {string} The new Unicode string (UCS-2). 135 | */ 136 | const ucs2encode = (codePoints: number[]): string => String.fromCodePoint(...codePoints) 137 | 138 | /** 139 | * Converts a basic code point into a digit/integer. 140 | * @private 141 | * @param {number} codePoint The basic numeric code point value. 142 | * @returns {number} The numeric value of a basic code point. 143 | */ 144 | const basicToDigit = (codePoint: number): number => { 145 | if (codePoint >= 0x30 && codePoint < 0x3a) { 146 | return 26 + (codePoint - 0x30) 147 | } 148 | if (codePoint >= 0x41 && codePoint < 0x5b) { 149 | return codePoint - 0x41 150 | } 151 | if (codePoint >= 0x61 && codePoint < 0x7b) { 152 | return codePoint - 0x61 153 | } 154 | return base 155 | } 156 | 157 | /** 158 | * Converts a digit/integer into a basic code point. 159 | * @private 160 | * @param {number} digit The numeric value of a basic code point. 161 | * @param {number} flag The flag value. 162 | * @returns {number} The basic code point. 163 | */ 164 | const digitToBasic = (digit: number, flag: number): number => { 165 | // 0..25 map to ASCII a..z or A..Z 166 | // 26..35 map to ASCII 0..9 167 | // @descriptoin 원래 코드는 아래와 같았지만, 타입스크립트에서는 불리언 연산이 안되서 임의로 수정 168 | // return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5) 169 | return digit + 22 + 75 * (digit < 26 ? 1 : 0) - ((flag != 0 ? 1 : 0) << 5) 170 | } 171 | 172 | /** 173 | * Bias adaptation function as per section 3.4 of RFC 3492. 174 | * @private 175 | * @param {number} delta The delta value. 176 | * @param {number} numPoints The number of points. 177 | * @param {boolean} firstTime Whether this is the first time. 178 | * @returns {number} The adapted bias. 179 | */ 180 | const adapt = (delta: number, numPoints: number, firstTime: boolean): number => { 181 | let k: number = 0 182 | delta = firstTime ? floor(delta / damp) : delta >> 1 183 | delta += floor(delta / numPoints) 184 | for (; /* no initialization */ delta > (baseMinusTMin * tMax) >> 1; k += base) { 185 | delta = floor(delta / baseMinusTMin) 186 | } 187 | return floor(k + ((baseMinusTMin + 1) * delta) / (delta + skew)) 188 | } 189 | 190 | /** 191 | * Converts a Punycode string of ASCII-only symbols to a string of Unicode symbols. 192 | * @param {string} input The Punycode string of ASCII-only symbols. 193 | * @returns {string} The resulting string of Unicode symbols. 194 | */ 195 | const decode = (input: string): string => { 196 | const output: number[] = [] 197 | const inputLength: number = input.length 198 | let i: number = 0 199 | let n: number = initialN 200 | let bias: number = initialBias 201 | 202 | let basic: number = input.lastIndexOf(delimiter) 203 | if (basic < 0) { 204 | basic = 0 205 | } 206 | 207 | for (let j: number = 0; j < basic; ++j) { 208 | if (input.charCodeAt(j) >= 0x80) { 209 | error('not-basic') 210 | } 211 | output.push(input.charCodeAt(j)) 212 | } 213 | 214 | for (let index: number = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */; ) { 215 | const oldi: number = i 216 | for (let w: number = 1, k: number = base /* no condition */; ; k += base) { 217 | if (index >= inputLength) { 218 | error('invalid-input') 219 | } 220 | 221 | const digit: number = basicToDigit(input.charCodeAt(index++)) 222 | 223 | if (digit >= base) { 224 | error('invalid-input') 225 | } 226 | if (digit > floor((maxInt - i) / w)) { 227 | error('overflow') 228 | } 229 | 230 | i += digit * w 231 | const t: number = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias 232 | 233 | if (digit < t) { 234 | break 235 | } 236 | 237 | const baseMinusT: number = base - t 238 | if (w > floor(maxInt / baseMinusT)) { 239 | error('overflow') 240 | } 241 | 242 | w *= baseMinusT 243 | } 244 | 245 | const out: number = output.length + 1 246 | bias = adapt(i - oldi, out, oldi == 0) 247 | 248 | if (floor(i / out) > maxInt - n) { 249 | error('overflow') 250 | } 251 | 252 | n += floor(i / out) 253 | i %= out 254 | 255 | output.splice(i++, 0, n) 256 | } 257 | 258 | return String.fromCodePoint(...output) 259 | } 260 | 261 | /** 262 | * Converts a string of Unicode symbols to a Punycode string of ASCII-only symbols. 263 | * @param {string} input The string of Unicode symbols. 264 | * @returns {string} The resulting Punycode string of ASCII-only symbols. 265 | */ 266 | const encode = (input: string): string => { 267 | const output: string[] = [] 268 | 269 | // Convert the input in UCS-2 to an array of Unicode code points. 270 | const inputArray: number[] = ucs2decode(input) 271 | 272 | // Cache the length. 273 | const inputLength: number = inputArray.length 274 | 275 | // Initialize the state. 276 | let n: number = initialN 277 | let delta: number = 0 278 | let bias: number = initialBias 279 | 280 | // Handle the basic code points. 281 | for (const currentValue of inputArray) { 282 | if (currentValue < 0x80) { 283 | output.push(stringFromCharCode(currentValue)) 284 | } 285 | } 286 | 287 | const basicLength: number = output.length 288 | let handledCPCount: number = basicLength 289 | 290 | // Finish the basic string with a delimiter unless it's empty. 291 | if (basicLength) { 292 | output.push(delimiter) 293 | } 294 | 295 | // Main encoding loop: 296 | while (handledCPCount < inputLength) { 297 | let m: number = maxInt 298 | for (const currentValue of inputArray) { 299 | if (currentValue >= n && currentValue < m) { 300 | m = currentValue 301 | } 302 | } 303 | 304 | const handledCPCountPlusOne: number = handledCPCount + 1 305 | if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) { 306 | error('overflow') 307 | } 308 | 309 | delta += (m - n) * handledCPCountPlusOne 310 | n = m 311 | 312 | for (const currentValue of inputArray) { 313 | if (currentValue < n && ++delta > maxInt) { 314 | error('overflow') 315 | } 316 | if (currentValue === n) { 317 | let q: number = delta 318 | for (let k: number = base /* no condition */; ; k += base) { 319 | const t: number = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias 320 | if (q < t) { 321 | break 322 | } 323 | const qMinusT: number = q - t 324 | const baseMinusT: number = base - t 325 | output.push(stringFromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0))) 326 | q = floor(qMinusT / baseMinusT) 327 | } 328 | 329 | output.push(stringFromCharCode(digitToBasic(q, 0))) 330 | bias = adapt(delta, handledCPCountPlusOne, handledCPCount === basicLength) 331 | delta = 0 332 | ++handledCPCount 333 | } 334 | } 335 | 336 | ++delta 337 | ++n 338 | } 339 | return output.join('') 340 | } 341 | 342 | /** 343 | * Converts a Punycode string representing a domain name or an email address to Unicode. 344 | * @param {string} input The Punycoded domain name or email address. 345 | * @returns {string} The Unicode representation of the given Punycode string. 346 | */ 347 | const toUnicode = (input: string): string => { 348 | return mapDomain(input, (string: string): string => { 349 | return regexPunycode.test(string) ? decode(string.slice(4).toLowerCase()) : string 350 | }) 351 | } 352 | 353 | /** 354 | * Converts a Unicode string representing a domain name or an email address to Punycode. 355 | * @param {string} input The domain name or email address to convert. 356 | * @returns {string} The Punycode representation of the given domain name or email address. 357 | */ 358 | const toASCII = (input: string): string => { 359 | return mapDomain(input, (string: string): string => { 360 | return regexNonASCII.test(string) ? 'xn--' + encode(string) : string 361 | }) 362 | } 363 | 364 | /** Define the public API */ 365 | /** 366 | * @description originated from https://unpkg.com/browse/punycode@2.3.1/ 367 | */ 368 | const punycode: PunycodeStatic = { 369 | version: '2.3.1', 370 | ucs2: { 371 | decode: ucs2decode, 372 | encode: ucs2encode, 373 | }, 374 | decode, 375 | encode, 376 | toASCII, 377 | toUnicode, 378 | } 379 | 380 | export {ucs2decode, ucs2encode, decode, encode, toASCII, toUnicode} 381 | export default punycode 382 | -------------------------------------------------------------------------------- /src/nurl.ts: -------------------------------------------------------------------------------- 1 | import {decode, encode} from './punycode' 2 | import { 3 | extractPathKey, 4 | getDynamicPaths, 5 | isASCIICodeChar, 6 | isDynamicPath, 7 | refinePathnameWithQuery, 8 | refineQueryWithPathname, 9 | convertQueryToArray, 10 | Query, 11 | } from './utils' 12 | 13 | interface URLOptions 14 | extends Partial< 15 | Pick< 16 | URL, 17 | | 'href' 18 | | 'protocol' 19 | | 'host' 20 | | 'hostname' 21 | | 'port' 22 | | 'pathname' 23 | | 'search' 24 | | 'hash' 25 | | 'username' 26 | | 'password' 27 | > 28 | > { 29 | baseUrl?: string 30 | query?: Query 31 | basePath?: string 32 | } 33 | 34 | export default class NURL implements URL { 35 | private _href: string = '' 36 | private _protocol: string = '' 37 | private _host: string = '' 38 | private _hostname: string = '' 39 | private _port: string = '' 40 | private _pathname: string = '' 41 | private _search: string = '' 42 | private _hash: string = '' 43 | private _origin: string = '' 44 | private _username: string = '' 45 | private _password: string = '' 46 | private _baseUrl: string = '' 47 | private _searchParams: URLSearchParams = new URLSearchParams() 48 | private _basePath: string = '' 49 | 50 | constructor(input?: string | URL | URLOptions) { 51 | this._searchParams = new URLSearchParams() 52 | if (typeof input === 'string' || input instanceof URL) { 53 | this.href = input.toString() 54 | } else if (input) { 55 | if (input.basePath) { 56 | this._basePath = input.basePath.startsWith('/') ? input.basePath : `/${input.basePath}` 57 | } 58 | 59 | if (input.baseUrl) { 60 | this.baseUrl = input.baseUrl 61 | } 62 | if (input.href) { 63 | this.href = input.href 64 | } 65 | if (input.protocol) { 66 | this.protocol = input.protocol 67 | } 68 | if (input.host) { 69 | this.host = input.host 70 | } 71 | if (input.hostname) { 72 | this.hostname = input.hostname 73 | } 74 | if (input.port) { 75 | this.port = input.port 76 | } 77 | if (input.pathname !== undefined) { 78 | if (input.pathname === '') { 79 | this._pathname = '' 80 | } else { 81 | this.pathname = refinePathnameWithQuery(input.pathname, input.query ?? {}) 82 | } 83 | } 84 | if (input.search) { 85 | this.search = input.search 86 | } 87 | if (input.hash) { 88 | this.hash = input.hash 89 | } 90 | if (input.username) { 91 | this.username = input.username 92 | } 93 | if (input.password) { 94 | this.password = input.password 95 | } 96 | if (input.query) { 97 | const refinedQuery = refineQueryWithPathname(input.pathname ?? '', input.query) 98 | if (Object.keys(refinedQuery).length > 0) { 99 | this.search = new URLSearchParams(convertQueryToArray(refinedQuery)).toString() 100 | } 101 | } 102 | this.updateHref() 103 | } 104 | } 105 | 106 | static withBasePath(basePath: string) { 107 | return (urlOptions?: string | URL | URLOptions) => { 108 | if (typeof urlOptions === 'string' || urlOptions instanceof URL) { 109 | return new NURL({href: urlOptions.toString(), basePath}) 110 | } 111 | return new NURL({...urlOptions, basePath}) 112 | } 113 | } 114 | 115 | static create(input?: string | URL | URLOptions) { 116 | return new NURL(input) 117 | } 118 | 119 | static canParse(input: string): boolean { 120 | if (input.startsWith('/')) { 121 | return /^\/[^?#]*(\?[^#]*)?(#.*)?$/.test(input) 122 | } 123 | 124 | try { 125 | // eslint-disable-next-line no-new 126 | new URL(input) 127 | return true 128 | } catch { 129 | // URL 생성자로 파싱할 수 없는 경우, 추가적인 검사를 수행 130 | // 예: 'example.com' 또는 'example.com/path'와 같은 형식 허용 131 | return /^[^:/?#]+(\.[^:/?#]+)+(\/[^?#]*(\?[^#]*)?(#.*)?)?$/.test(input) 132 | } 133 | } 134 | 135 | get baseUrl(): string { 136 | return this._baseUrl 137 | } 138 | 139 | set baseUrl(value: string) { 140 | this._baseUrl = value 141 | try { 142 | const url = new URL(value) 143 | this._protocol = url.protocol 144 | this._host = url.host 145 | this._hostname = url.hostname 146 | this._port = url.port 147 | this._origin = url.origin 148 | this._username = url.username 149 | this._password = url.password 150 | if (url.pathname !== '/') { 151 | this._pathname = url.pathname 152 | } 153 | if (url.search) { 154 | this._search = url.search 155 | this._searchParams = new URLSearchParams(url.search) 156 | } 157 | if (url.hash) { 158 | this._hash = url.hash 159 | } 160 | this.updateHref() 161 | } catch (error) { 162 | // eslint-disable-next-line no-console 163 | console.warn(`Invalid baseUrl: ${value}`, error) 164 | } 165 | } 166 | 167 | get href(): string { 168 | return this._href 169 | } 170 | 171 | set href(value: string) { 172 | try { 173 | const url = new URL(value) 174 | this._href = url.href 175 | this._protocol = url.protocol 176 | this._host = url.host 177 | this._hostname = url.hostname 178 | this._port = url.port 179 | this._pathname = url.pathname 180 | this._search = url.search 181 | this._hash = url.hash 182 | this._origin = url.origin 183 | this._username = url.username 184 | this._password = url.password 185 | this._searchParams = url.searchParams 186 | } catch (error) { 187 | const urlLike = NURL.parseStringToURLLike(value) 188 | 189 | if (!urlLike) { 190 | // eslint-disable-next-line no-console 191 | console.warn(`Can not parse ${value}`) 192 | throw error 193 | } 194 | 195 | this._href = urlLike.href 196 | this._protocol = urlLike.protocol 197 | this._host = urlLike.hostname 198 | this._hostname = urlLike.hostname 199 | this._port = urlLike.port 200 | this._pathname = urlLike.pathname 201 | this._search = urlLike.search 202 | this._hash = urlLike.hash 203 | this._origin = urlLike.origin 204 | this._username = urlLike.username 205 | this._password = urlLike.password 206 | this._searchParams = urlLike.searchParams 207 | } 208 | } 209 | 210 | static parseStringToURLLike(value: string) { 211 | const pattern = 212 | /^(?:(https?:\/\/)(?:([^:@]+)(?::([^@]+))?@)?((?:[^/:?#]+)(?::(\d+))?)?)?([/][^?#]*)?(\?[^#]*)?(#.*)?$/ 213 | const match = value.match(pattern) 214 | const [ 215 | href = value, 216 | protocol = '', 217 | username = '', 218 | password = '', 219 | hostname = '', 220 | port = '', 221 | pathname = '', 222 | search = '', 223 | hash = '', 224 | ] = match || [] 225 | 226 | if (!match || (protocol && !hostname && !pathname && !search && !hash)) { 227 | return null 228 | } 229 | 230 | const origin = protocol && hostname ? `${protocol}//${hostname}${port ? `:${port}` : ''}` : '' 231 | 232 | return { 233 | href, 234 | protocol, 235 | host: hostname, 236 | hostname, 237 | port, 238 | pathname: value ? pathname || '/' : '', 239 | search, 240 | hash, 241 | origin, 242 | username, 243 | password, 244 | searchParams: new URLSearchParams(search), 245 | } 246 | } 247 | 248 | get protocol(): string { 249 | return this._protocol 250 | } 251 | 252 | set protocol(value: string) { 253 | this._protocol = value 254 | this.updateHref() 255 | } 256 | 257 | get host(): string { 258 | return this._host 259 | } 260 | 261 | set host(value: string) { 262 | const [hostname, port] = value.split(':') 263 | 264 | const encodedHostname = this.encodeHostname(hostname) 265 | 266 | this._host = port ? `${encodedHostname}:${port}` : encodedHostname 267 | this._hostname = encodedHostname 268 | this._port = port || '' 269 | this.updateHref() 270 | } 271 | 272 | get hostname(): string { 273 | return this._hostname 274 | } 275 | 276 | set hostname(value: string) { 277 | const encodedHostname = this.encodeHostname(value) 278 | 279 | this._hostname = encodedHostname 280 | this._host = this._port ? `${encodedHostname}:${this._port}` : encodedHostname 281 | this.updateHref() 282 | } 283 | 284 | get port(): string { 285 | return this._port 286 | } 287 | 288 | set port(value: string) { 289 | this._port = value 290 | this._host = `${this._hostname}${value ? ':' + value : ''}` 291 | this.updateHref() 292 | } 293 | 294 | get pathname(): string { 295 | return this._pathname 296 | } 297 | 298 | set pathname(inputPathname: string) { 299 | let pathname = inputPathname 300 | 301 | if (inputPathname === '') { 302 | pathname = '/' 303 | this._pathname = pathname 304 | this.updateHref() 305 | return 306 | } 307 | 308 | if (this._basePath && !pathname.startsWith(this._basePath)) { 309 | pathname = `${this._basePath}${pathname.startsWith('/') ? '' : '/'}${pathname}` 310 | } 311 | 312 | const encodedPathname = pathname 313 | .split('/') 314 | .map((segment) => (isDynamicPath(segment) ? segment : encodeURI(segment))) 315 | .join('/') 316 | 317 | this._pathname = encodedPathname.startsWith('/') ? encodedPathname : `/${encodedPathname}` 318 | this.updateHref() 319 | } 320 | 321 | get search(): string { 322 | return this._search 323 | } 324 | 325 | set search(search: string) { 326 | this._search = search.startsWith('?') ? search : `?${search}` 327 | this._searchParams = new URLSearchParams(search) 328 | this.updateHref() 329 | } 330 | 331 | setSearchParams(_params: Record): void { 332 | const searchParams = new URLSearchParams(_params) 333 | 334 | this._search = searchParams.toString() ? `?${searchParams.toString()}` : '' 335 | this._searchParams = searchParams 336 | this.updateHref() 337 | } 338 | 339 | appendSearchParams(_params: Record): void { 340 | const searchParams = new URLSearchParams(this._searchParams) 341 | const dynamicRoutes = getDynamicPaths(this._pathname).map(extractPathKey) 342 | 343 | Object.keys(_params).forEach((key) => { 344 | if (dynamicRoutes.includes(key)) { 345 | this._pathname = refinePathnameWithQuery(this._pathname, {[key]: _params[key]}) 346 | } else { 347 | searchParams.append(key, _params[key]) 348 | } 349 | }) 350 | 351 | this._search = searchParams.toString() ? `?${searchParams.toString()}` : '' 352 | this._searchParams = searchParams 353 | this.updateHref() 354 | } 355 | 356 | removeSearchParams(..._keys: string[]): void { 357 | const searchParams = new URLSearchParams(this._searchParams) 358 | 359 | _keys.forEach((key) => { 360 | searchParams.delete(key) 361 | }) 362 | 363 | this._search = searchParams.toString() ? `?${searchParams.toString()}` : '' 364 | this._searchParams = searchParams 365 | this.updateHref() 366 | } 367 | 368 | get searchParams(): URLSearchParams { 369 | return new Proxy(this._searchParams, { 370 | get: (target, prop, receiver) => { 371 | const value = Reflect.get(target, prop, receiver) 372 | if (typeof value === 'function') { 373 | return (...args: unknown[]) => { 374 | const result = value.apply(target, args) 375 | this._search = this._searchParams.toString() ? `?${this._searchParams.toString()}` : '' 376 | this.updateHref() 377 | return result 378 | } 379 | } 380 | return value 381 | }, 382 | }) 383 | } 384 | 385 | get hash(): string { 386 | return this._hash 387 | } 388 | 389 | set hash(value: string) { 390 | this._hash = value.startsWith('#') ? value : `#${value}` 391 | this.updateHref() 392 | } 393 | 394 | get origin(): string { 395 | return this._origin 396 | } 397 | 398 | get username(): string { 399 | return this._username 400 | } 401 | 402 | set username(value: string) { 403 | this._username = value 404 | this.updateHref() 405 | } 406 | 407 | get password(): string { 408 | return this._password 409 | } 410 | 411 | set password(value: string) { 412 | this._password = value 413 | this.updateHref() 414 | } 415 | 416 | private updateHref() { 417 | const pathname = this._pathname 418 | 419 | if (this._baseUrl) { 420 | const baseUrl = new URL(this._baseUrl) 421 | baseUrl.pathname = pathname 422 | baseUrl.search = this._search 423 | baseUrl.hash = this._hash 424 | this._href = baseUrl.href 425 | this._origin = baseUrl.origin 426 | } else { 427 | this._href = `${this._protocol}${this._protocol && '//'}${this._username}${this._password ? ':' + this._password : ''}${ 428 | this._username || this._password ? '@' : '' 429 | }${this._hostname}${this._port ? ':' + this._port : ''}${pathname}${this._search}${this._hash}` 430 | 431 | this._origin = `${this._protocol}//${this._hostname}${this._port ? ':' + this._port : ''}` 432 | } 433 | } 434 | 435 | toString(): string { 436 | return this.href 437 | } 438 | 439 | toJSON(): string { 440 | return this.href 441 | } 442 | 443 | private punycodePrefix = 'xn--' 444 | 445 | private encodeHostname(hostname: string): string { 446 | return hostname 447 | .split('.') 448 | .map((segment) => { 449 | for (const char of segment) { 450 | if (isASCIICodeChar(char)) { 451 | return `${this.punycodePrefix}${encode(segment)}` 452 | } 453 | } 454 | return segment 455 | }) 456 | .join('.') 457 | } 458 | 459 | get decodedIDN(): string { 460 | let href = this._href 461 | 462 | this._hostname.split('.').forEach((segment) => { 463 | href = href.replace(segment, decode(segment.replace(this.punycodePrefix, ''))) 464 | }) 465 | 466 | return href 467 | } 468 | 469 | get decodedHostname(): string { 470 | return this._hostname 471 | .split('.') 472 | .map((segment) => decode(segment.replace(this.punycodePrefix, ''))) 473 | .join('.') 474 | } 475 | } 476 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | 3 | import NURL from './nurl' 4 | 5 | const compareNurlWithUrl = ({url, nurl}: {url: URL; nurl: NURL}) => { 6 | expect(nurl.toString()).toBe(url.toString()) 7 | expect(nurl.href).toBe(url.href) 8 | expect(nurl.origin).toBe(url.origin) 9 | expect(nurl.protocol).toBe(url.protocol) 10 | expect(nurl.username).toBe(url.username) 11 | expect(nurl.password).toBe(url.password) 12 | expect(nurl.host).toBe(url.host) 13 | expect(nurl.hostname).toBe(url.hostname) 14 | expect(nurl.port).toBe(url.port) 15 | expect(nurl.pathname).toBe(url.pathname) 16 | expect(nurl.search).toBe(url.search) 17 | expect(nurl.searchParams.toString()).toBe(url.searchParams.toString()) 18 | expect(nurl.hash).toBe(url.hash) 19 | } 20 | 21 | describe('NURL', () => { 22 | describe('Constructor', () => { 23 | describe('new NURL("")', () => { 24 | test('should initialize with empty string', () => { 25 | const nurl = new NURL('') 26 | 27 | expect(nurl.href).toBe('') 28 | expect(nurl.protocol).toBe('') 29 | expect(nurl.host).toBe('') 30 | expect(nurl.hostname).toBe('') 31 | expect(nurl.port).toBe('') 32 | expect(nurl.pathname).toBe('') 33 | expect(nurl.search).toBe('') 34 | expect(nurl.hash).toBe('') 35 | expect(nurl.origin).toBe('') 36 | expect(nurl.username).toBe('') 37 | expect(nurl.password).toBe('') 38 | expect(nurl.searchParams.toString()).toBe('') 39 | }) 40 | 41 | test('should initialize with undefined', () => { 42 | const nurl = new NURL() 43 | 44 | expect(nurl.href).toBe('') 45 | expect(nurl.protocol).toBe('') 46 | expect(nurl.host).toBe('') 47 | expect(nurl.hostname).toBe('') 48 | expect(nurl.port).toBe('') 49 | expect(nurl.pathname).toBe('') 50 | expect(nurl.search).toBe('') 51 | expect(nurl.hash).toBe('') 52 | expect(nurl.origin).toBe('') 53 | expect(nurl.username).toBe('') 54 | expect(nurl.password).toBe('') 55 | expect(nurl.searchParams.toString()).toBe('') 56 | }) 57 | 58 | test('should allow setting properties after empty initialization', () => { 59 | const nurl = new NURL('') 60 | 61 | nurl.href = 'https://example.com/path?query=value#hash' 62 | 63 | expect(nurl.href).toBe('https://example.com/path?query=value#hash') 64 | expect(nurl.protocol).toBe('https:') 65 | expect(nurl.host).toBe('example.com') 66 | expect(nurl.hostname).toBe('example.com') 67 | expect(nurl.pathname).toBe('/path') 68 | expect(nurl.search).toBe('?query=value') 69 | expect(nurl.hash).toBe('#hash') 70 | }) 71 | 72 | test('should handle invalid URLs gracefully', () => { 73 | expect(() => new NURL('invalid-url')).toThrowError() 74 | }) 75 | }) 76 | 77 | describe('new NURL(stringOrURL)', () => { 78 | test('should create an instance with the same behavior as URL', () => { 79 | const urlString = 'https://example.com:8080/path/to/page?query=value#hash' 80 | const url = new URL(urlString) 81 | const nurl = new NURL(urlString) 82 | 83 | expect(nurl.toString()).toBe(url.toString()) 84 | expect(nurl.href).toBe(url.href) 85 | expect(nurl.origin).toBe(url.origin) 86 | expect(nurl.protocol).toBe(url.protocol) 87 | expect(nurl.username).toBe(url.username) 88 | expect(nurl.password).toBe(url.password) 89 | expect(nurl.host).toBe(url.host) 90 | expect(nurl.hostname).toBe(url.hostname) 91 | expect(nurl.port).toBe(url.port) 92 | expect(nurl.pathname).toBe(url.pathname) 93 | expect(nurl.search).toBe(url.search) 94 | expect(nurl.searchParams.toString()).toBe(url.searchParams.toString()) 95 | expect(nurl.hash).toBe(url.hash) 96 | }) 97 | 98 | test('should update properties correctly', () => { 99 | const urlString = 'https://example.com/path' 100 | const nurl = new NURL(urlString) 101 | const url = new URL(urlString) 102 | 103 | nurl.protocol = 'http:' 104 | url.protocol = 'http:' 105 | expect(nurl.protocol).toBe(url.protocol) 106 | 107 | nurl.hostname = 'newexample.com' 108 | url.hostname = 'newexample.com' 109 | expect(nurl.hostname).toBe(url.hostname) 110 | 111 | nurl.port = '8080' 112 | url.port = '8080' 113 | expect(nurl.port).toBe(url.port) 114 | 115 | nurl.pathname = '/newpath' 116 | url.pathname = '/newpath' 117 | expect(nurl.pathname).toBe(url.pathname) 118 | 119 | nurl.search = '?newquery=newvalue' 120 | url.search = '?newquery=newvalue' 121 | expect(nurl.search).toBe(url.search) 122 | 123 | nurl.hash = '#newhash' 124 | url.hash = '#newhash' 125 | expect(nurl.hash).toBe(url.hash) 126 | }) 127 | 128 | test('should handle special cases', () => { 129 | const urlString = 'https://user:pass@example.com:8080/path?query=value#hash' 130 | const nurl = new NURL(urlString) 131 | const url = new URL(urlString) 132 | 133 | expect(nurl.username).toBe(url.username) 134 | expect(nurl.password).toBe(url.password) 135 | 136 | nurl.username = 'newuser' 137 | url.username = 'newuser' 138 | expect(nurl.username).toBe(url.username) 139 | 140 | nurl.password = 'newpass' 141 | url.password = 'newpass' 142 | expect(nurl.password).toBe(url.password) 143 | }) 144 | 145 | test('should handle searchParams operations', () => { 146 | const urlString = 'https://example.com/path?query=value' 147 | const nurl = new NURL(urlString) 148 | const url = new URL(urlString) 149 | 150 | nurl.searchParams.append('newParam', 'newValue') 151 | url.searchParams.append('newParam', 'newValue') 152 | expect(nurl.search).toBe(url.search) 153 | 154 | nurl.searchParams.delete('query') 155 | url.searchParams.delete('query') 156 | expect(nurl.search).toBe(url.search) 157 | 158 | nurl.searchParams.set('updatedParam', 'updatedValue') 159 | url.searchParams.set('updatedParam', 'updatedValue') 160 | expect(nurl.search).toBe(url.search) 161 | }) 162 | 163 | test('should create an instance even with a string missing baseUrl or pathname', () => { 164 | // Given 165 | const withoutBaseUrl = '/path?query=value' 166 | 167 | // When 168 | const nurl1 = new NURL(withoutBaseUrl) 169 | 170 | // Then 171 | expect(nurl1.baseUrl).toBe('') 172 | expect(nurl1.pathname).toBe('/path') 173 | expect(nurl1.searchParams.get('query')).toBe('value') 174 | 175 | // Given 176 | const withoutPathname = '?query=value' 177 | // When 178 | const nurl2 = new NURL(withoutPathname) 179 | 180 | // Then 181 | expect(nurl2.baseUrl).toBe('') 182 | expect(nurl2.pathname).toBe('/') 183 | expect(nurl2.searchParams.get('query')).toBe('value') 184 | }) 185 | }) 186 | 187 | describe('new NURL({ baseUrl, pathname, query })', () => { 188 | test('should create a URL with baseUrl and pathname', () => { 189 | const nurl = new NURL({ 190 | baseUrl: 'https://example.com', 191 | pathname: '/path', 192 | }) 193 | 194 | expect(nurl.href).toBe('https://example.com/path') 195 | expect(nurl.origin).toBe('https://example.com') 196 | expect(nurl.pathname).toBe('/path') 197 | }) 198 | 199 | test('should add query parameters', () => { 200 | const nurl = new NURL({ 201 | baseUrl: 'https://example.com', 202 | pathname: '/path', 203 | query: {key: 'value', another: 'param', rurl: 'https://example.com'}, 204 | }) 205 | 206 | expect(nurl.href).toBe( 207 | 'https://example.com/path?key=value&another=param&rurl=https%3A%2F%2Fexample.com', 208 | ) 209 | expect(nurl.search).toBe('?key=value&another=param&rurl=https%3A%2F%2Fexample.com') 210 | expect(nurl.searchParams.get('rurl')).toBe('https://example.com') 211 | }) 212 | 213 | test('should support number, boolean, and arrays in query', () => { 214 | const nurl = new NURL({ 215 | baseUrl: 'https://example.com', 216 | pathname: '/path', 217 | query: {page: 1, active: true, tags: ['news', 'tech']}, 218 | }) 219 | 220 | expect(nurl.href).toBe('https://example.com/path?page=1&active=true&tags=news&tags=tech') 221 | expect(nurl.searchParams.get('page')).toBe('1') 222 | expect(nurl.searchParams.get('active')).toBe('true') 223 | expect(nurl.searchParams.getAll('tags')).toEqual(['news', 'tech']) 224 | }) 225 | 226 | test('should exclude non-defined types from query', () => { 227 | const nurl = new NURL({ 228 | baseUrl: 'https://example.com', 229 | pathname: '/path', 230 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 231 | query: {valid: 'yes', invalid: {}, anotherInvalid: []} as any, 232 | }) 233 | 234 | expect(nurl.href).toBe('https://example.com/path?valid=yes') 235 | expect(nurl.searchParams.get('valid')).toBe('yes') 236 | expect(nurl.searchParams.get('invalid')).toBeNull() 237 | expect(nurl.searchParams.get('anotherInvalid')).toBeNull() 238 | }) 239 | 240 | test('should replace /:id with query parameter', () => { 241 | const nurl = new NURL({ 242 | baseUrl: 'https://example.com', 243 | pathname: '/users/:id', 244 | query: {id: '123'}, 245 | }) 246 | 247 | expect(nurl.href).toBe('https://example.com/users/123') 248 | expect(nurl.pathname).toBe('/users/123') 249 | }) 250 | 251 | test('should not replace /:id when value is not a string', () => { 252 | const nurl = new NURL({ 253 | baseUrl: 'https://example.com', 254 | pathname: '/users/:id', 255 | query: {id: 456}, 256 | }) 257 | 258 | expect(nurl.href).toBe('https://example.com/users/:id?id=456') 259 | expect(nurl.searchParams.get('id')).toBe('456') 260 | expect(nurl.pathname).toBe('/users/:id') 261 | }) 262 | 263 | test('should replace /[id] with query parameter', () => { 264 | const nurl = new NURL({ 265 | baseUrl: 'https://example.com', 266 | pathname: '/posts/[id]/comments', 267 | query: {id: '456'}, 268 | }) 269 | 270 | expect(nurl.href).toBe('https://example.com/posts/456/comments') 271 | expect(nurl.pathname).toBe('/posts/456/comments') 272 | }) 273 | 274 | test('should not replace /:id when value is not a string', () => { 275 | const nurl = new NURL({ 276 | baseUrl: 'https://example.com', 277 | pathname: '/users/[id]', 278 | query: {id: 456}, 279 | }) 280 | 281 | expect(nurl.href).toBe('https://example.com/users/[id]?id=456') 282 | expect(nurl.searchParams.get('id')).toBe('456') 283 | expect(nurl.pathname).toBe('/users/[id]') 284 | }) 285 | 286 | test('should handle multiple dynamic segments', () => { 287 | const nurl = new NURL({ 288 | baseUrl: 'https://example.com', 289 | pathname: '/users/:userId/posts/:postId', 290 | query: {userId: '789', postId: '101112'}, 291 | }) 292 | 293 | expect(nurl.href).toBe('https://example.com/users/789/posts/101112') 294 | expect(nurl.pathname).toBe('/users/789/posts/101112') 295 | }) 296 | 297 | test('should replace only string values in pathname and keep other query parameters', () => { 298 | const nurl = new NURL({ 299 | baseUrl: 'https://example.com', 300 | pathname: '/users/:userId/posts/:postId', 301 | query: {userId: '789', postId: 101112, sort: 'asc'}, 302 | }) 303 | 304 | expect(nurl.href).toBe('https://example.com/users/789/posts/:postId?postId=101112&sort=asc') 305 | expect(nurl.pathname).toBe('/users/789/posts/:postId') 306 | expect(nurl.search).toBe('?postId=101112&sort=asc') 307 | }) 308 | 309 | test('should keep query parameters not used in pathname', () => { 310 | const nurl = new NURL({ 311 | pathname: '/users/:id', 312 | query: {id: '123', sort: 'asc', filter: 'active'}, 313 | }) 314 | 315 | expect(nurl.href).toBe('/users/123?sort=asc&filter=active') 316 | expect(nurl.pathname).toBe('/users/123') 317 | expect(nurl.search).toBe('?sort=asc&filter=active') 318 | }) 319 | 320 | test('should replace only string values in pathname with [bracket] notation and keep other query parameters', () => { 321 | const nurl = new NURL({ 322 | baseUrl: 'https://example.com', 323 | pathname: '/users/[userId]/posts/[postId]', 324 | query: {userId: '789', postId: 101112, sort: 'asc'}, 325 | }) 326 | 327 | expect(nurl.href).toBe('https://example.com/users/789/posts/[postId]?postId=101112&sort=asc') 328 | expect(nurl.pathname).toBe('/users/789/posts/[postId]') 329 | expect(nurl.search).toBe('?postId=101112&sort=asc') 330 | }) 331 | 332 | test('should handle empty query object', () => { 333 | const nurl = new NURL({ 334 | pathname: '/path', 335 | query: {}, 336 | }) 337 | 338 | expect(nurl.href).toBe('/path') 339 | expect(nurl.search).toBe('') 340 | }) 341 | 342 | test(':id should not be replaced when the query parameter is missing', () => { 343 | const nurl = new NURL({baseUrl: 'https://example.com', pathname: '/users/:id', query: {}}) 344 | expect(nurl.href).toBe('https://example.com/users/:id') 345 | }) 346 | 347 | test('[id] should not be replaced when the query parameter is missing', () => { 348 | const nurl = new NURL({baseUrl: 'https://example.com', pathname: '/users/[id]', query: {}}) 349 | expect(nurl.href).toBe('https://example.com/users/[id]') 350 | }) 351 | 352 | test('[id] should not be replaced when same parameter is in search', () => { 353 | const nurl = new NURL({baseUrl: 'https://example.com', pathname: '/users/[id]', search: '?id=3'}) 354 | expect(nurl.href).toBe('https://example.com/users/[id]?id=3') 355 | }) 356 | 357 | test('should preserve root pathname when combining with query parameters', () => { 358 | const nurl = new NURL({ 359 | pathname: '/', 360 | query: { 361 | test: '1', 362 | key: 'value', 363 | }, 364 | }) 365 | 366 | expect(nurl.pathname).toBe('/') 367 | expect(nurl.search).toBe('?test=1&key=value') 368 | expect(nurl.href).toBe('/?test=1&key=value') 369 | }) 370 | 371 | test('should preserve root pathname when using baseUrl with query parameters', () => { 372 | const nurl = new NURL({ 373 | baseUrl: 'https://example.com', 374 | pathname: '/', 375 | query: { 376 | test: '1', 377 | key: 'value', 378 | }, 379 | }) 380 | 381 | expect(nurl.pathname).toBe('/') 382 | expect(nurl.search).toBe('?test=1&key=value') 383 | expect(nurl.href).toBe('https://example.com/?test=1&key=value') 384 | }) 385 | }) 386 | 387 | describe('new NURL({ href, query })', () => { 388 | test('should handle root domain without trailing slash', () => { 389 | const nurl = new NURL({ 390 | href: 'https://example.com', 391 | }) 392 | 393 | expect(nurl.pathname).toBe('/') 394 | expect(nurl.href).toBe('https://example.com/') 395 | }) 396 | 397 | test('should handle root domain with trailing slash', () => { 398 | const nurl = new NURL({ 399 | href: 'https://example.com/', 400 | }) 401 | 402 | expect(nurl.pathname).toBe('/') 403 | expect(nurl.href).toBe('https://example.com/') 404 | }) 405 | 406 | test('should preserve pathname from href with path', () => { 407 | const nurl = new NURL({ 408 | href: 'https://example.com/important/path', 409 | }) 410 | 411 | expect(nurl.pathname).toBe('/important/path') 412 | expect(nurl.href).toBe('https://example.com/important/path') 413 | }) 414 | 415 | test('should preserve pathname from href with path and trailing slash', () => { 416 | const nurl = new NURL({ 417 | href: 'https://example.com/important/path/', 418 | }) 419 | 420 | expect(nurl.pathname).toBe('/important/path/') 421 | expect(nurl.href).toBe('https://example.com/important/path/') 422 | }) 423 | 424 | test('should handle root domain with query parameters', () => { 425 | const nurl = new NURL({ 426 | href: 'https://example.com', 427 | query: {test: 'value'}, 428 | }) 429 | 430 | expect(nurl.pathname).toBe('/') 431 | expect(nurl.href).toBe('https://example.com/?test=value') 432 | }) 433 | 434 | test('should handle root domain with trailing slash and query parameters', () => { 435 | const nurl = new NURL({ 436 | href: 'https://example.com/', 437 | query: {test: 'value'}, 438 | }) 439 | 440 | expect(nurl.pathname).toBe('/') 441 | expect(nurl.href).toBe('https://example.com/?test=value') 442 | }) 443 | 444 | test('should preserve pathname when href has path and query parameters', () => { 445 | const nurl = new NURL({ 446 | href: 'https://api.example.com/v1/users/123/settings', 447 | query: {tab: 'profile', from: 'dashboard'}, 448 | }) 449 | 450 | expect(nurl.pathname).toBe('/v1/users/123/settings') 451 | expect(nurl.href).toBe('https://api.example.com/v1/users/123/settings?tab=profile&from=dashboard') 452 | }) 453 | 454 | test('should preserve pathname when href has path with trailing slash and query parameters', () => { 455 | const nurl = new NURL({ 456 | href: 'https://api.example.com/v1/users/123/settings/', 457 | query: {tab: 'profile', from: 'dashboard'}, 458 | }) 459 | 460 | expect(nurl.pathname).toBe('/v1/users/123/settings/') 461 | expect(nurl.href).toBe('https://api.example.com/v1/users/123/settings/?tab=profile&from=dashboard') 462 | }) 463 | }) 464 | }) 465 | 466 | describe('Static methods', () => { 467 | describe('NURL.canParse', () => { 468 | test.each([ 469 | ['https://example.com', true], 470 | ['https://example.com/path?query=value#hash', true], 471 | ['http://localhost:3000', true], 472 | ['ftp://ftp.example.com', true], 473 | ['mailto:user@example.com', true], 474 | ['/path/to/resource', true], 475 | ['/path/to/resource?query=value', true], 476 | ['/path/to/resource#hash', true], 477 | ['example.com', true], 478 | ['example.com/path', true], 479 | ['subdomain.example.com/path?query=value#hash', true], 480 | ['invalid-url', false], 481 | ['http://', false], 482 | ['', false], 483 | ])('should return %p for %s', (input, expected) => { 484 | expect(NURL.canParse(input)).toBe(expected) 485 | }) 486 | }) 487 | }) 488 | 489 | describe('Extended functionality', () => { 490 | describe('Constructor and basic operations', () => { 491 | test('should create NURL from string', () => { 492 | const url = new NURL('https://example.com/path?query=value#hash') 493 | expect(url.href).toBe('https://example.com/path?query=value#hash') 494 | }) 495 | 496 | test('should create NURL from URLOptions', () => { 497 | const url = new NURL({ 498 | baseUrl: 'https://example.com', 499 | pathname: '/path', 500 | query: {key: 'value'}, 501 | }) 502 | expect(url.href).toBe('https://example.com/path?key=value') 503 | }) 504 | 505 | test('should handle dynamic segments in pathname', () => { 506 | const url = new NURL({ 507 | baseUrl: 'https://api.example.com', 508 | pathname: '/users/:id/posts/:postId', 509 | query: {id: '123', postId: '456', filter: 'recent'}, 510 | }) 511 | expect(url.pathname).toBe('/users/123/posts/456') 512 | expect(url.search).toBe('?filter=recent') 513 | }) 514 | }) 515 | 516 | describe('Property getters and setters', () => { 517 | test('should update href when changing properties', () => { 518 | const url = new NURL('https://example.com') 519 | url.pathname = '/newpath' 520 | url.search = 'key=value' 521 | url.hash = 'newhash' 522 | expect(url.href).toBe('https://example.com/newpath?key=value#newhash') 523 | }) 524 | 525 | test('should preserve raw search parameter when set via search property', () => { 526 | const url = new NURL('https://example.com') 527 | url.pathname = '/newpath' 528 | url.search = '?a=/b' 529 | url.hash = 'newhash' 530 | expect(url.href).toBe('https://example.com/newpath?a=/b#newhash') 531 | }) 532 | 533 | test('should update searchParams when changing search', () => { 534 | const url = new NURL('https://example.com') 535 | url.search = 'key1=value1&key2=value2' 536 | expect(url.searchParams.get('key1')).toBe('value1') 537 | expect(url.searchParams.get('key2')).toBe('value2') 538 | }) 539 | 540 | test('should accept pathname with dynamic segments', () => { 541 | const url = new NURL('https://example.com') 542 | url.pathname = '/user/[id]' 543 | expect(url.href).toBe('https://example.com/user/[id]') 544 | }) 545 | 546 | test('[id] should not be replaced when same parameter is in search', () => { 547 | const url = new NURL({baseUrl: 'https://example.com', pathname: '/users/[id]'}) 548 | url.search = '?id=3' 549 | expect(url.href).toBe('https://example.com/users/[id]?id=3') 550 | }) 551 | 552 | describe('setter should handle korean characters', () => { 553 | test('should handle korean hostname', () => { 554 | const nurl = new NURL('https://example.com') 555 | const url = new URL('https://example.com') 556 | const koreanHostname = '한글.도메인' 557 | nurl.hostname = koreanHostname 558 | url.hostname = koreanHostname 559 | compareNurlWithUrl({url, nurl}) 560 | }) 561 | 562 | test('should handle korean pathname', () => { 563 | const nurl = new NURL('https://example.com') 564 | const url = new URL('https://example.com') 565 | const koreanPathname = '/한글/경로' 566 | nurl.pathname = koreanPathname 567 | url.pathname = koreanPathname 568 | compareNurlWithUrl({url, nurl}) 569 | }) 570 | 571 | test('should handle korean search', () => { 572 | const nurl = new NURL('https://example.com') 573 | const url = new NURL('https://example.com') 574 | const koreanSearch = '?검색어=값' 575 | nurl.search = koreanSearch 576 | url.search = koreanSearch 577 | compareNurlWithUrl({url, nurl}) 578 | }) 579 | 580 | test('search parameters should be updated correctly when the search changes', () => { 581 | const nurl = new NURL('https://example.com') 582 | const url = new NURL('https://example.com') 583 | const koreanSearchParams = {key: '검색어', value: '값'} 584 | nurl.search = `?${koreanSearchParams.key}=${koreanSearchParams.value}` 585 | url.search = `?${koreanSearchParams.key}=${koreanSearchParams.value}` 586 | compareNurlWithUrl({url, nurl}) 587 | }) 588 | 589 | test('host should be updated correctly when hostname changes', () => { 590 | const nurl = new NURL('https://example.com') 591 | const url = new NURL('https://example.com') 592 | const koreanHostname = '한글.도메인' 593 | url.hostname = koreanHostname 594 | nurl.hostname = koreanHostname 595 | compareNurlWithUrl({url, nurl}) 596 | }) 597 | }) 598 | }) 599 | 600 | describe('Search Parameters Operations', () => { 601 | describe('setSearchParams', () => { 602 | test('should set multiple search parameters', () => { 603 | const url = new NURL('https://example.com') 604 | url.setSearchParams({key1: 'value1', key2: 'value2'}) 605 | expect(url.search).toBe('?key1=value1&key2=value2') 606 | }) 607 | 608 | test('should override existing search parameters', () => { 609 | const url = new NURL('https://example.com?old=param') 610 | url.setSearchParams({new: 'value', another: 'param', rurl: 'https://example.com'}) 611 | expect(url.search).toBe('?new=value&another=param&rurl=https%3A%2F%2Fexample.com') 612 | expect(url.searchParams.get('rurl')).toBe('https://example.com') 613 | }) 614 | 615 | test('should handle empty object', () => { 616 | const url = new NURL('https://example.com?existing=value') 617 | url.setSearchParams({}) 618 | expect(url.search).toBe('') 619 | }) 620 | 621 | test('should handle special characters', () => { 622 | const url = new NURL('https://example.com') 623 | url.setSearchParams({'special=char': 'value&with=symbols'}) 624 | expect(url.search).toBe('?special%3Dchar=value%26with%3Dsymbols') 625 | }) 626 | 627 | test('should handle Korean characters', () => { 628 | const url = new NURL('https://example.com') 629 | url.setSearchParams({한글키: '한글값'}) 630 | expect(url.search).toBe('?%ED%95%9C%EA%B8%80%ED%82%A4=%ED%95%9C%EA%B8%80%EA%B0%92') 631 | }) 632 | }) 633 | 634 | describe('appendSearchParams', () => { 635 | test('should add new search parameters', () => { 636 | const url = new NURL('https://example.com?existing=value') 637 | url.appendSearchParams({new: 'param'}) 638 | expect(url.search).toBe('?existing=value&new=param') 639 | }) 640 | 641 | test('should not override existing parameters', () => { 642 | const url = new NURL('https://example.com?key=value1') 643 | url.appendSearchParams({key: 'value2'}) 644 | expect(url.searchParams.getAll('key')).toEqual(['value1', 'value2']) 645 | }) 646 | 647 | test('should handle multiple parameters', () => { 648 | const url = new NURL('https://example.com') 649 | url.appendSearchParams({key1: 'value1', key2: 'value2'}) 650 | expect(url.search).toBe('?key1=value1&key2=value2') 651 | }) 652 | 653 | test('should handle empty object', () => { 654 | const url = new NURL('https://example.com?existing=value') 655 | url.appendSearchParams({}) 656 | expect(url.search).toBe('?existing=value') 657 | }) 658 | 659 | test('should handle special characters', () => { 660 | const url = new NURL('https://example.com') 661 | url.appendSearchParams({'special=char': 'value&with=symbols'}) 662 | expect(url.search).toBe('?special%3Dchar=value%26with%3Dsymbols') 663 | }) 664 | 665 | test('should handle Korean characters', () => { 666 | const url = new NURL('https://example.com?existing=value') 667 | url.appendSearchParams({한글키: '한글값'}) 668 | expect(url.search).toBe('?existing=value&%ED%95%9C%EA%B8%80%ED%82%A4=%ED%95%9C%EA%B8%80%EA%B0%92') 669 | }) 670 | 671 | test('should replace dynamic segment with appendSearchParams', () => { 672 | const url = new NURL({baseUrl: 'https://example.com', pathname: '/users/[id]'}) 673 | url.appendSearchParams({id: '3'}) 674 | expect(url.pathname).toBe('/users/3') 675 | }) 676 | }) 677 | 678 | describe('removeSearchParams', () => { 679 | test('should remove specified search parameter', () => { 680 | const url = new NURL('https://example.com?key1=value1&key2=value2') 681 | url.removeSearchParams('key1') 682 | expect(url.search).toBe('?key2=value2') 683 | }) 684 | 685 | test('should handle non-existent parameter', () => { 686 | const url = new NURL('https://example.com?existing=value') 687 | url.removeSearchParams('nonexistent') 688 | expect(url.search).toBe('?existing=value') 689 | }) 690 | 691 | test('should handle multiple parameters', () => { 692 | const url = new NURL('https://example.com?key1=value1&key2=value2&key3=value3') 693 | url.removeSearchParams('key1', 'key3') 694 | expect(url.search).toBe('?key2=value2') 695 | }) 696 | 697 | test('should handle all parameters removal', () => { 698 | const url = new NURL('https://example.com?key1=value1&key2=value2') 699 | url.removeSearchParams('key1', 'key2') 700 | expect(url.search).toBe('') 701 | }) 702 | 703 | test('should handle Korean characters', () => { 704 | const url = new NURL( 705 | 'https://example.com?existing=value&%ED%95%9C%EA%B8%80%ED%82%A4=%ED%95%9C%EA%B8%80%EA%B0%92', 706 | ) 707 | url.removeSearchParams('한글키') 708 | expect(url.search).toBe('?existing=value') 709 | }) 710 | }) 711 | 712 | describe('Complex scenarios', () => { 713 | test('should handle a series of operations', () => { 714 | const url = new NURL('https://example.com') 715 | url.setSearchParams({initial: 'value'}) 716 | url.appendSearchParams({added: 'later'}) 717 | url.removeSearchParams('initial') 718 | url.appendSearchParams({'new=param': 'complex&value'}) 719 | expect(url.search).toBe('?added=later&new%3Dparam=complex%26value') 720 | }) 721 | 722 | test('should handle setting, appending, and removing Korean parameters', () => { 723 | const url = new NURL('https://example.com') 724 | url.setSearchParams({키1: '값1', 키2: '값2'}) 725 | url.appendSearchParams({키3: '값3'}) 726 | url.removeSearchParams('키2') 727 | expect(url.searchParams.get('키1')).toBe('값1') 728 | expect(url.searchParams.has('키2')).toBe(false) 729 | expect(url.searchParams.get('키3')).toBe('값3') 730 | }) 731 | }) 732 | }) 733 | 734 | describe('Edge cases and error handling', () => { 735 | test('should handle URLs with auth info', () => { 736 | const url = new NURL('https://user:pass@example.com') 737 | expect(url.username).toBe('user') 738 | expect(url.password).toBe('pass') 739 | }) 740 | 741 | test('should handle IPv6 addresses', () => { 742 | const url = new NURL('http://[2001:db8::1]:8080/path') 743 | expect(url.hostname).toBe('[2001:db8::1]') 744 | expect(url.port).toBe('8080') 745 | }) 746 | 747 | test('should handle very long URLs', () => { 748 | const longPath = 'a'.repeat(2000) 749 | const url = new NURL(`https://example.com/${longPath}`) 750 | expect(url.pathname.length).toBe(2001) // Including leading slash 751 | }) 752 | 753 | test('should handle invalid input gracefully', () => { 754 | expect(() => new NURL('invalid-url')).toThrowError() 755 | }) 756 | 757 | test('protocol can be changed to a non-special protocol', () => { 758 | const url = new NURL('https://example.com') 759 | url.protocol = 'bokdol' 760 | expect(url.protocol).not.toBe('https') 761 | expect(url.protocol).toBe('bokdol') 762 | }) 763 | }) 764 | 765 | describe('Internationalization support', () => { 766 | test('should handle Korean IDN hostnames', () => { 767 | const originalDomain = '한글도메인.테스트' 768 | const url = new NURL(`https://${originalDomain}`) 769 | expect(url.href.includes('xn--')).toBe(true) 770 | expect(url.hostname).not.toBe(originalDomain) 771 | expect(url.hostname.startsWith('xn--')).toBe(true) 772 | expect(url.decodedHostname).toBe(originalDomain) 773 | }) 774 | 775 | test('should handle Korean characters in pathname and search', () => { 776 | const originalPath = '/한글경로' 777 | const originalSearch = '검색어=값' 778 | const url = new NURL(`https://example.com${originalPath}?${originalSearch}`) 779 | expect(url.pathname).not.toBe(originalPath) 780 | expect(url.search).not.toBe(`?${originalSearch}`) 781 | expect(decodeURIComponent(url.pathname)).toBe(originalPath) 782 | expect(decodeURIComponent(url.search)).toBe(`?${originalSearch}`) 783 | }) 784 | 785 | test('should properly encode and decode Korean characters', () => { 786 | const originalPath = '/한글/경로' 787 | const url = new NURL(`https://example.com${originalPath}`) 788 | expect(url.pathname).not.toBe(originalPath) 789 | expect(decodeURIComponent(url.pathname)).toBe(originalPath) 790 | }) 791 | 792 | test('should handle complex Korean URLs', () => { 793 | const originalURL = 'https://사용자:비밀번호@한글주소.한국/경로/테스트?키=값#부분' 794 | const url = new NURL(originalURL) 795 | expect(url.href).not.toBe(originalURL) 796 | expect(decodeURIComponent(url.decodedIDN)).toBe(originalURL) 797 | expect(decodeURIComponent(url.username)).toBe('사용자') 798 | expect(decodeURIComponent(url.password)).toBe('비밀번호') 799 | expect(url.hostname).not.toBe('한글주소.한국') 800 | expect(url.hostname.startsWith('xn--')).toBe(true) 801 | expect(url.decodedHostname).toBe('한글주소.한국') 802 | expect(decodeURIComponent(url.pathname)).toBe('/경로/테스트') 803 | expect(decodeURIComponent(url.search)).toBe('?키=값') 804 | expect(decodeURIComponent(url.hash)).toBe('#부분') 805 | }) 806 | 807 | test('should correctly handle Korean characters in searchParams', () => { 808 | const url = new NURL('https://example.com') 809 | const key = '한글키' 810 | const value = '한글값' 811 | url.searchParams.append(key, value) 812 | expect(url.search).not.toBe(`?${key}=${value}`) 813 | expect(url.search.includes('%')).toBe(true) 814 | expect(decodeURIComponent(url.search)).toBe(`?${key}=${value}`) 815 | expect(url.searchParams.get(key)).toBe(value) 816 | }) 817 | 818 | test('should support encoding', () => { 819 | const originalUrl = 'https://한글도메인.테스트/한글/경로?검색어=값' 820 | const url = new NURL(originalUrl) 821 | expect(url.href).not.toBe(originalUrl) 822 | }) 823 | }) 824 | }) 825 | 826 | describe('Base Path Support', () => { 827 | test('should prepend basePath when provided in constructor', () => { 828 | const nurl = new NURL({ 829 | baseUrl: 'https://example.com', 830 | basePath: '/app', 831 | pathname: '/about', 832 | }) 833 | expect(nurl.href).toBe('https://example.com/app/about') 834 | }) 835 | 836 | test('should not double prepend basePath if pathname already includes it', () => { 837 | const nurl = new NURL({ 838 | baseUrl: 'https://example.com', 839 | basePath: '/app', 840 | pathname: '/app/about', 841 | }) 842 | expect(nurl.href).toBe('https://example.com/app/about') 843 | }) 844 | 845 | test('should work with relative URLs', () => { 846 | const nurl = new NURL({ 847 | basePath: '/app', 848 | pathname: '/contact', 849 | }) 850 | expect(nurl.href).toBe('/app/contact') 851 | }) 852 | 853 | test('should update pathname with basePath after instantiation', () => { 854 | const nurl = new NURL({baseUrl: 'https://example.com', basePath: '/app'}) 855 | nurl.pathname = '/features' 856 | expect(nurl.href).toBe('https://example.com/app/features') 857 | }) 858 | 859 | test('should allow using the withBasePath static method', () => { 860 | const createNURL = NURL.withBasePath('/app') 861 | const url = createNURL('https://example.com') 862 | url.pathname = '/docs' 863 | expect(url.href).toBe('https://example.com/app/docs') 864 | }) 865 | 866 | test('withBasePath should handle URLOptions properly', () => { 867 | const createNURL = NURL.withBasePath('/app') 868 | const url = createNURL({baseUrl: 'https://example.com', pathname: '/team'}) 869 | expect(url.href).toBe('https://example.com/app/team') 870 | }) 871 | 872 | test('withBasePath should not double prepend', () => { 873 | const createNURL = NURL.withBasePath('/app') 874 | const url = createNURL({baseUrl: 'https://example.com', pathname: '/app/help'}) 875 | expect(url.href).toBe('https://example.com/app/help') 876 | }) 877 | }) 878 | 879 | describe('href handling edge cases', () => { 880 | test('should correctly handle root path with query params', () => { 881 | const nurl = new NURL({pathname: '/', query: {test: '1'}}) 882 | expect(nurl.href).toBe('/?test=1') 883 | expect(nurl.pathname).toBe('/') 884 | expect(nurl.search).toBe('?test=1') 885 | }) 886 | 887 | test('should correctly handle empty pathname with query params', () => { 888 | const nurl = new NURL({pathname: '', query: {test: '1'}}) 889 | expect(nurl.href).toBe('?test=1') 890 | expect(nurl.pathname).toBe('') 891 | expect(nurl.search).toBe('?test=1') 892 | }) 893 | 894 | test('should correctly handle pathname="/" explicitly set after instantiation', () => { 895 | const nurl = new NURL() 896 | nurl.pathname = '/' 897 | nurl.setSearchParams({test: '1'}) 898 | 899 | expect(nurl.href).toBe('/?test=1') 900 | expect(nurl.pathname).toBe('/') 901 | expect(nurl.search).toBe('?test=1') 902 | }) 903 | 904 | test('should correctly handle setting hostname with IDN and ensure single trailing slash', () => { 905 | const nurl = new NURL('https://example.com') 906 | nurl.hostname = '한글.도메인' 907 | 908 | expect(nurl.href).toBe('https://xn--bj0bj06e.xn--hq1bm8jm9l/') 909 | expect(nurl.hostname).toBe('xn--bj0bj06e.xn--hq1bm8jm9l') 910 | expect(nurl.pathname).toBe('/') 911 | }) 912 | 913 | test('should handle setting pathname to "/" explicitly and ensure no duplicated slashes', () => { 914 | const nurl = new NURL('https://example.com/somepath') 915 | nurl.pathname = '/' 916 | 917 | expect(nurl.href).toBe('https://example.com/') 918 | expect(nurl.pathname).toBe('/') 919 | }) 920 | 921 | test('should not add unnecessary trailing slash', () => { 922 | const nurl = new NURL('https://example.com') 923 | expect(nurl.href).toBe('https://example.com/') 924 | expect(nurl.pathname).toBe('/') 925 | }) 926 | 927 | test('should maintain correct pathname and search when using appendSearchParams on root', () => { 928 | const nurl = new NURL('/') 929 | nurl.appendSearchParams({added: 'param'}) 930 | 931 | expect(nurl.href).toBe('/?added=param') 932 | expect(nurl.pathname).toBe('/') 933 | }) 934 | 935 | test('should correctly handle baseUrl with pathname="/"', () => { 936 | const nurl = new NURL({baseUrl: 'https://example.com', pathname: '/'}) 937 | expect(nurl.href).toBe('https://example.com/') 938 | expect(nurl.pathname).toBe('/') 939 | }) 940 | 941 | test('should correctly handle pathname without leading slash', () => { 942 | const nurl = new NURL({pathname: 'path'}) 943 | expect(nurl.href).toBe('/path') 944 | expect(nurl.pathname).toBe('/path') 945 | }) 946 | }) 947 | }) 948 | --------------------------------------------------------------------------------