├── .eslintignore ├── test ├── .eslintrc.yml └── test.js ├── .eslintrc.yml ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── scorecard.yml │ └── ci.yml ├── LICENSE ├── package.json ├── index.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | mocha: true 3 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: standard 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | npm-debug.log 4 | package-lock.json 5 | .nyc_output/ -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | 8 | - package-ecosystem: npm 9 | directory: / 10 | schedule: 11 | interval: monthly 12 | time: "23:00" 13 | timezone: Europe/London 14 | open-pull-requests-limit: 10 15 | ignore: 16 | - dependency-name: "*" 17 | update-types: ["version-update:semver-major"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2016 Douglas Christopher Wilson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "encodeurl", 3 | "description": "Encode a URL to a percent-encoded form, excluding already-encoded sequences", 4 | "version": "2.0.0", 5 | "contributors": [ 6 | "Douglas Christopher Wilson " 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "encode", 11 | "encodeurl", 12 | "url" 13 | ], 14 | "repository": "pillarjs/encodeurl", 15 | "funding": { 16 | "type": "opencollective", 17 | "url": "https://opencollective.com/express" 18 | }, 19 | "devDependencies": { 20 | "eslint": "5.11.1", 21 | "eslint-config-standard": "12.0.0", 22 | "eslint-plugin-import": "2.14.0", 23 | "eslint-plugin-node": "7.0.1", 24 | "eslint-plugin-promise": "4.0.1", 25 | "eslint-plugin-standard": "4.0.0", 26 | "mocha": "~11.7.0", 27 | "nyc": "~17.1.0" 28 | }, 29 | "files": [ 30 | "LICENSE", 31 | "HISTORY.md", 32 | "README.md", 33 | "index.js" 34 | ], 35 | "engines": { 36 | "node": ">= 0.8" 37 | }, 38 | "scripts": { 39 | "lint": "eslint .", 40 | "test": "mocha --check-leaks --reporter spec", 41 | "test-ci": "nyc --reporter=lcov --reporter=text npm test", 42 | "test-cov": "nyc --reporter=html --reporter=text npm test" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * encodeurl 3 | * Copyright(c) 2016 Douglas Christopher Wilson 4 | * MIT Licensed 5 | */ 6 | 7 | 'use strict' 8 | 9 | /** 10 | * Module exports. 11 | * @public 12 | */ 13 | 14 | module.exports = encodeUrl 15 | 16 | /** 17 | * RegExp to match non-URL code points, *after* encoding (i.e. not including "%") 18 | * and including invalid escape sequences. 19 | * @private 20 | */ 21 | 22 | var ENCODE_CHARS_REGEXP = /(?:[^\x21\x23-\x3B\x3D\x3F-\x5F\x61-\x7A\x7C\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g 23 | 24 | /** 25 | * RegExp to match unmatched surrogate pair. 26 | * @private 27 | */ 28 | 29 | var UNMATCHED_SURROGATE_PAIR_REGEXP = /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g 30 | 31 | /** 32 | * String to replace unmatched surrogate pair with. 33 | * @private 34 | */ 35 | 36 | var UNMATCHED_SURROGATE_PAIR_REPLACE = '$1\uFFFD$2' 37 | 38 | /** 39 | * Encode a URL to a percent-encoded form, excluding already-encoded sequences. 40 | * 41 | * This function will take an already-encoded URL and encode all the non-URL 42 | * code points. This function will not encode the "%" character unless it is 43 | * not part of a valid sequence (`%20` will be left as-is, but `%foo` will 44 | * be encoded as `%25foo`). 45 | * 46 | * This encode is meant to be "safe" and does not throw errors. It will try as 47 | * hard as it can to properly encode the given URL, including replacing any raw, 48 | * unpaired surrogate pairs with the Unicode replacement character prior to 49 | * encoding. 50 | * 51 | * @param {string} url 52 | * @return {string} 53 | * @public 54 | */ 55 | 56 | function encodeUrl (url) { 57 | return String(url) 58 | .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) 59 | .replace(ENCODE_CHARS_REGEXP, encodeURI) 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["master"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["master"] 20 | schedule: 21 | - cron: "0 0 * * 1" 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: ["javascript"] 39 | # CodeQL supports [ $supported-codeql-languages ] 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v5 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v3.30.0 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v3.30.0 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3.30.0 72 | with: 73 | category: "/language:${{matrix.language}}" 74 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | 7 | on: 8 | # For Branch-Protection check. Only the default branch is supported. See 9 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 10 | branch_protection_rule: 11 | # To guarantee Maintained check is occasionally updated. See 12 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 13 | schedule: 14 | - cron: '16 21 * * 1' 15 | push: 16 | branches: [ "master" ] 17 | 18 | # Declare default permissions as read only. 19 | permissions: read-all 20 | 21 | jobs: 22 | analysis: 23 | name: Scorecard analysis 24 | runs-on: ubuntu-latest 25 | permissions: 26 | # Needed to upload the results to code-scanning dashboard. 27 | security-events: write 28 | # Needed to publish results and get a badge (see publish_results below). 29 | id-token: write 30 | # Uncomment the permissions below if installing in a private repository. 31 | # contents: read 32 | # actions: read 33 | 34 | steps: 35 | - name: "Checkout code" 36 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.2 37 | with: 38 | persist-credentials: false 39 | 40 | - name: "Run analysis" 41 | uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 42 | with: 43 | results_file: results.sarif 44 | results_format: sarif 45 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 46 | # - you want to enable the Branch-Protection check on a *public* repository, or 47 | # - you are installing Scorecard on a *private* repository 48 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 49 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 50 | 51 | # Public repositories: 52 | # - Publish results to OpenSSF REST API for easy access by consumers 53 | # - Allows the repository to include the Scorecard badge. 54 | # - See https://github.com/ossf/scorecard-action#publishing-results. 55 | # For private repositories: 56 | # - `publish_results` will always be set to `false`, regardless 57 | # of the value entered here. 58 | publish_results: true 59 | 60 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 61 | # format to the repository Actions tab. 62 | - name: "Upload artifact" 63 | uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 64 | with: 65 | name: SARIF file 66 | path: results.sarif 67 | retention-days: 5 68 | 69 | # Upload the results to GitHub's code scanning dashboard. 70 | - name: "Upload to code-scanning" 71 | uses: github/codeql-action/upload-sarif@f9a0f98a391397619da21c38f1ebf973bd6a55f4 # v2.23.2 72 | with: 73 | sarif_file: results.sarif 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Encode URL 2 | 3 | Encode a URL to a percent-encoded form, excluding already-encoded sequences. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | npm install encodeurl 9 | ``` 10 | 11 | ## API 12 | 13 | ```js 14 | var encodeUrl = require('encodeurl') 15 | ``` 16 | 17 | ### encodeUrl(url) 18 | 19 | Encode a URL to a percent-encoded form, excluding already-encoded sequences. 20 | 21 | This function accepts a URL and encodes all the non-URL code points (as UTF-8 byte sequences). It will not encode the "%" character unless it is not part of a valid sequence (`%20` will be left as-is, but `%foo` will be encoded as `%25foo`). 22 | 23 | This encode is meant to be "safe" and does not throw errors. It will try as hard as it can to properly encode the given URL, including replacing any raw, unpaired surrogate pairs with the Unicode replacement character prior to encoding. 24 | 25 | ## Examples 26 | 27 | ### Encode a URL containing user-controlled data 28 | 29 | ```js 30 | var encodeUrl = require('encodeurl') 31 | var escapeHtml = require('escape-html') 32 | 33 | http.createServer(function onRequest (req, res) { 34 | // get encoded form of inbound url 35 | var url = encodeUrl(req.url) 36 | 37 | // create html message 38 | var body = '

Location ' + escapeHtml(url) + ' not found

' 39 | 40 | // send a 404 41 | res.statusCode = 404 42 | res.setHeader('Content-Type', 'text/html; charset=UTF-8') 43 | res.setHeader('Content-Length', String(Buffer.byteLength(body, 'utf-8'))) 44 | res.end(body, 'utf-8') 45 | }) 46 | ``` 47 | 48 | ### Encode a URL for use in a header field 49 | 50 | ```js 51 | var encodeUrl = require('encodeurl') 52 | var escapeHtml = require('escape-html') 53 | var url = require('url') 54 | 55 | http.createServer(function onRequest (req, res) { 56 | // parse inbound url 57 | var href = url.parse(req) 58 | 59 | // set new host for redirect 60 | href.host = 'localhost' 61 | href.protocol = 'https:' 62 | href.slashes = true 63 | 64 | // create location header 65 | var location = encodeUrl(url.format(href)) 66 | 67 | // create html message 68 | var body = '

Redirecting to new site: ' + escapeHtml(location) + '

' 69 | 70 | // send a 301 71 | res.statusCode = 301 72 | res.setHeader('Content-Type', 'text/html; charset=UTF-8') 73 | res.setHeader('Content-Length', String(Buffer.byteLength(body, 'utf-8'))) 74 | res.setHeader('Location', location) 75 | res.end(body, 'utf-8') 76 | }) 77 | ``` 78 | 79 | ## Similarities 80 | 81 | This function is _similar_ to the intrinsic function `encodeURI`. However, it will not encode: 82 | 83 | * The `\`, `^`, or `|` characters 84 | * The `%` character when it's part of a valid sequence 85 | * `[` and `]` (for IPv6 hostnames) 86 | * Replaces raw, unpaired surrogate pairs with the Unicode replacement character 87 | 88 | As a result, the encoding aligns closely with the behavior in the [WHATWG URL specification][whatwg-url]. However, this package only encodes strings and does not do any URL parsing or formatting. 89 | 90 | It is expected that any output from `new URL(url)` will not change when used with this package, as the output has already been encoded. Additionally, if we were to encode before `new URL(url)`, we do not expect the before and after encoded formats to be parsed any differently. 91 | 92 | ## Testing 93 | 94 | ```sh 95 | $ npm test 96 | $ npm run lint 97 | ``` 98 | 99 | ## References 100 | 101 | - [RFC 3986: Uniform Resource Identifier (URI): Generic Syntax][rfc-3986] 102 | - [WHATWG URL Living Standard][whatwg-url] 103 | 104 | [rfc-3986]: https://tools.ietf.org/html/rfc3986 105 | [whatwg-url]: https://url.spec.whatwg.org/ 106 | 107 | ## License 108 | 109 | [MIT](LICENSE) 110 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 2 | var assert = require('assert') 3 | var encodeUrl = require('..') 4 | 5 | describe('encodeUrl(url)', function () { 6 | describe('when url contains only allowed characters', function () { 7 | it('should keep URL the same', function () { 8 | assert.strictEqual(encodeUrl('http://localhost/foo/bar.html?fizz=buzz#readme'), 'http://localhost/foo/bar.html?fizz=buzz#readme') 9 | }) 10 | 11 | it('should not touch IPv6 notation', function () { 12 | assert.strictEqual(encodeUrl('http://[::1]:8080/foo/bar'), 'http://[::1]:8080/foo/bar') 13 | }) 14 | 15 | it('should not touch backslashes', function () { 16 | assert.strictEqual(encodeUrl('http:\\\\localhost\\foo\\bar.html'), 'http:\\\\localhost\\foo\\bar.html') 17 | }) 18 | }) 19 | 20 | describe('when url contains invalid raw characters', function () { 21 | it('should encode LF', function () { 22 | assert.strictEqual(encodeUrl('http://localhost/\nsnow.html'), 'http://localhost/%0Asnow.html') 23 | }) 24 | 25 | it('should encode FF', function () { 26 | assert.strictEqual(encodeUrl('http://localhost/\fsnow.html'), 'http://localhost/%0Csnow.html') 27 | }) 28 | 29 | it('should encode CR', function () { 30 | assert.strictEqual(encodeUrl('http://localhost/\rsnow.html'), 'http://localhost/%0Dsnow.html') 31 | }) 32 | 33 | it('should encode SP', function () { 34 | assert.strictEqual(encodeUrl('http://localhost/ snow.html'), 'http://localhost/%20snow.html') 35 | }) 36 | 37 | it('should encode NULL', function () { 38 | assert.strictEqual(encodeUrl('http://localhost/\0snow.html'), 'http://localhost/%00snow.html') 39 | }) 40 | 41 | it('should encode all expected characters from ASCII set', function () { 42 | assert.strictEqual(encodeUrl('/\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f'), '/%00%01%02%03%04%05%06%07%08%09%0A%0B%0C%0D%0E%0F') 43 | assert.strictEqual(encodeUrl('/\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f'), '/%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F') 44 | assert.strictEqual(encodeUrl('/\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f'), '/%20!%22#$%25&\'()*+,-./') 45 | assert.strictEqual(encodeUrl('/\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f'), '/0123456789:;%3C=%3E?') 46 | assert.strictEqual(encodeUrl('/\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f'), '/@ABCDEFGHIJKLMNO') 47 | assert.strictEqual(encodeUrl('/\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x5b\x5c\x5d\x5e\x5f'), '/PQRSTUVWXYZ[\\]^_') 48 | assert.strictEqual(encodeUrl('/\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f'), '/%60abcdefghijklmno') 49 | assert.strictEqual(encodeUrl('/\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x7b\x7c\x7d\x7e\x7f'), '/pqrstuvwxyz%7B|%7D~%7F') 50 | }) 51 | 52 | it('should encode all characters above ASCII as UTF-8 sequences', function () { 53 | assert.strictEqual(encodeUrl('/\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f'), '/%C2%80%C2%81%C2%82%C2%83%C2%84%C2%85%C2%86%C2%87%C2%88%C2%89%C2%8A%C2%8B%C2%8C%C2%8D%C2%8E%C2%8F') 54 | assert.strictEqual(encodeUrl('/\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f'), '/%C2%90%C2%91%C2%92%C2%93%C2%94%C2%95%C2%96%C2%97%C2%98%C2%99%C2%9A%C2%9B%C2%9C%C2%9D%C2%9E%C2%9F') 55 | assert.strictEqual(encodeUrl('/\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf'), '/%C2%A0%C2%A1%C2%A2%C2%A3%C2%A4%C2%A5%C2%A6%C2%A7%C2%A8%C2%A9%C2%AA%C2%AB%C2%AC%C2%AD%C2%AE%C2%AF') 56 | assert.strictEqual(encodeUrl('/\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf'), '/%C2%B0%C2%B1%C2%B2%C2%B3%C2%B4%C2%B5%C2%B6%C2%B7%C2%B8%C2%B9%C2%BA%C2%BB%C2%BC%C2%BD%C2%BE%C2%BF') 57 | assert.strictEqual(encodeUrl('/\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf'), '/%C3%80%C3%81%C3%82%C3%83%C3%84%C3%85%C3%86%C3%87%C3%88%C3%89%C3%8A%C3%8B%C3%8C%C3%8D%C3%8E%C3%8F') 58 | assert.strictEqual(encodeUrl('/\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf'), '/%C3%90%C3%91%C3%92%C3%93%C3%94%C3%95%C3%96%C3%97%C3%98%C3%99%C3%9A%C3%9B%C3%9C%C3%9D%C3%9E%C3%9F') 59 | assert.strictEqual(encodeUrl('/\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef'), '/%C3%A0%C3%A1%C3%A2%C3%A3%C3%A4%C3%A5%C3%A6%C3%A7%C3%A8%C3%A9%C3%AA%C3%AB%C3%AC%C3%AD%C3%AE%C3%AF') 60 | assert.strictEqual(encodeUrl('/\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff'), '/%C3%B0%C3%B1%C3%B2%C3%B3%C3%B4%C3%B5%C3%B6%C3%B7%C3%B8%C3%B9%C3%BA%C3%BB%C3%BC%C3%BD%C3%BE%C3%BF') 61 | }) 62 | }) 63 | 64 | describe('when url contains percent-encoded sequences', function () { 65 | it('should not encode the "%" character', function () { 66 | assert.strictEqual(encodeUrl('http://localhost/%20snow.html'), 'http://localhost/%20snow.html') 67 | }) 68 | 69 | it('should not care if sequence is valid UTF-8', function () { 70 | assert.strictEqual(encodeUrl('http://localhost/%F0snow.html'), 'http://localhost/%F0snow.html') 71 | }) 72 | 73 | it('should encode the "%" if not a valid sequence', function () { 74 | assert.strictEqual(encodeUrl('http://localhost/%foo%bar%zap%'), 'http://localhost/%25foo%bar%25zap%25') 75 | }) 76 | }) 77 | 78 | describe('when url contains raw surrogate pairs', function () { 79 | it('should encode pair as UTF-8 byte sequences', function () { 80 | assert.strictEqual(encodeUrl('http://localhost/\uD83D\uDC7B snow.html'), 'http://localhost/%F0%9F%91%BB%20snow.html') 81 | }) 82 | 83 | describe('when unpaired', function () { 84 | it('should encode as replacement character', function () { 85 | assert.strictEqual(encodeUrl('http://localhost/\uD83Dfoo\uDC7B <\uDC7B\uD83D>.html'), 'http://localhost/%EF%BF%BDfoo%EF%BF%BD%20%3C%EF%BF%BD%EF%BF%BD%3E.html') 86 | }) 87 | 88 | it('should encode at end of string', function () { 89 | assert.strictEqual(encodeUrl('http://localhost/\uD83D'), 'http://localhost/%EF%BF%BD') 90 | }) 91 | 92 | it('should encode at start of string', function () { 93 | assert.strictEqual(encodeUrl('\uDC7Bfoo'), '%EF%BF%BDfoo') 94 | }) 95 | }) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 17 | - name: Setup Node.js 18 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 19 | with: 20 | node-version: "lts/*" 21 | 22 | - name: Install dependencies 23 | run: npm install --ignore-scripts --only=dev 24 | 25 | - name: Run lint 26 | run: npm run lint 27 | 28 | test: 29 | runs-on: ubuntu-latest 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | name: 34 | - Node.js 0.8 35 | - Node.js 0.10 36 | - Node.js 0.12 37 | - io.js 1.x 38 | - io.js 2.x 39 | - io.js 3.x 40 | - Node.js 4.x 41 | - Node.js 5.x 42 | - Node.js 6.x 43 | - Node.js 7.x 44 | - Node.js 8.x 45 | - Node.js 9.x 46 | - Node.js 10.x 47 | - Node.js 11.x 48 | - Node.js 12.x 49 | - Node.js 13.x 50 | - Node.js 14.x 51 | - Node.js 15.x 52 | - Node.js 16.x 53 | - Node.js 17.x 54 | - Node.js 18.x 55 | - Node.js 19.x 56 | - Node.js 20.x 57 | - Node.js 21.x 58 | - Node.js 22.x 59 | - Node.js 23.x 60 | - Node.js 24.x 61 | 62 | include: 63 | - name: Node.js 0.8 64 | node-version: "0.8" 65 | npm-i: mocha@2.5.3 66 | npm-rm: nyc 67 | 68 | - name: Node.js 0.10 69 | node-version: "0.10" 70 | npm-i: mocha@3.5.3 nyc@10.3.2 71 | 72 | - name: Node.js 0.12 73 | node-version: "0.12" 74 | npm-i: mocha@3.5.3 nyc@10.3.2 75 | 76 | - name: io.js 1.x 77 | node-version: "1.8" 78 | npm-i: mocha@3.5.3 nyc@10.3.2 79 | 80 | - name: io.js 2.x 81 | node-version: "2.5" 82 | npm-i: mocha@3.5.3 nyc@10.3.2 83 | 84 | - name: io.js 3.x 85 | node-version: "3.3" 86 | npm-i: mocha@3.5.3 nyc@10.3.2 87 | 88 | - name: Node.js 4.x 89 | node-version: "4" 90 | npm-i: mocha@5.2.0 nyc@11.9.0 91 | 92 | - name: Node.js 5.x 93 | node-version: "5" 94 | npm-i: mocha@5.2.0 nyc@11.9.0 95 | 96 | - name: Node.js 6.x 97 | node-version: "6" 98 | npm-i: mocha@6.2.3 nyc@14.1.1 99 | 100 | - name: Node.js 7.x 101 | node-version: "7" 102 | npm-i: mocha@6.2.3 nyc@14.1.1 103 | 104 | - name: Node.js 8.x 105 | node-version: "8" 106 | npm-i: mocha@7.2.0 nyc@14.1.1 107 | 108 | - name: Node.js 9.x 109 | node-version: "9" 110 | npm-i: mocha@7.2.0 nyc@14.1.1 111 | 112 | - name: Node.js 10.x 113 | node-version: "10" 114 | npm-i: mocha@8.4.0 nyc@15.1.0 115 | 116 | - name: Node.js 11.x 117 | node-version: "11" 118 | npm-i: mocha@8.4.0 nyc@15.1.0 119 | 120 | - name: Node.js 12.x 121 | node-version: "12" 122 | npm-i: mocha@9.2.2 nyc@15.1.0 123 | 124 | - name: Node.js 13.x 125 | node-version: "13" 126 | npm-i: mocha@9.2.2 nyc@15.1.0 127 | 128 | - name: Node.js 14.x 129 | node-version: "14" 130 | npm-i: mocha@10.8.2 nyc@15.1.0 131 | 132 | - name: Node.js 15.x 133 | node-version: "15" 134 | npm-i: mocha@9.2.2 nyc@15.1.0 135 | 136 | - name: Node.js 16.x 137 | node-version: "16" 138 | npm-i: mocha@9.2.2 nyc@15.1.0 139 | 140 | - name: Node.js 17.x 141 | node-version: "17" 142 | npm-i: mocha@9.2.2 nyc@15.1.0 143 | 144 | - name: Node.js 18.x 145 | node-version: "18" 146 | 147 | - name: Node.js 19.x 148 | node-version: "19" 149 | 150 | - name: Node.js 20.x 151 | node-version: "20" 152 | 153 | - name: Node.js 21.x 154 | node-version: "21" 155 | 156 | - name: Node.js 22.x 157 | node-version: "22" 158 | 159 | - name: Node.js 23.x 160 | node-version: "23" 161 | 162 | - name: Node.js 24.x 163 | node-version: "24" 164 | 165 | steps: 166 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 167 | 168 | - name: Install Node.js ${{ matrix.node-version }} 169 | shell: bash -eo pipefail -l {0} 170 | run: | 171 | nvm install --default ${{ matrix.node-version }} 172 | if [[ "${{ matrix.node-version }}" == 0.* && "$(cut -d. -f2 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then 173 | nvm install --alias=npm 0.10 174 | nvm use ${{ matrix.node-version }} 175 | sed -i '1s;^.*$;'"$(printf '#!%q' "$(nvm which npm)")"';' "$(readlink -f "$(which npm)")" 176 | npm config set strict-ssl false 177 | fi 178 | dirname "$(nvm which ${{ matrix.node-version }})" >> "$GITHUB_PATH" 179 | 180 | - name: Configure npm 181 | run: | 182 | if [[ "$(npm config get package-lock)" == "true" ]]; then 183 | npm config set package-lock false 184 | else 185 | npm config set shrinkwrap false 186 | fi 187 | 188 | - name: Remove npm module(s) ${{ matrix.npm-rm }} 189 | run: npm rm --silent --save-dev ${{ matrix.npm-rm }} 190 | if: matrix.npm-rm != '' 191 | 192 | - name: Install npm module(s) ${{ matrix.npm-i }} 193 | run: npm install --save-dev ${{ matrix.npm-i }} 194 | if: matrix.npm-i != '' 195 | 196 | - name: Setup Node.js version-specific dependencies 197 | shell: bash 198 | run: | 199 | # eslint for linting 200 | # - remove on Node.js < 10 201 | if [[ "$(cut -d. -f1 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then 202 | node -pe 'Object.keys(require("./package").devDependencies).join("\n")' | \ 203 | grep -E '^eslint(-|$)' | \ 204 | sort -r | \ 205 | xargs -n1 npm rm --silent --save-dev 206 | fi 207 | 208 | - name: Install Node.js dependencies 209 | run: npm install 210 | 211 | - name: List environment 212 | id: list_env 213 | shell: bash 214 | run: | 215 | echo "node@$(node -v)" 216 | echo "npm@$(npm -v)" 217 | npm -s ls ||: 218 | (npm -s ls --depth=0 ||:) | awk -F'[ @]' 'NR>1 && $2 { print $2 "=" $3 }' >> "$GITHUB_OUTPUT" 219 | 220 | - name: Run tests 221 | shell: bash 222 | run: | 223 | if npm -ps ls nyc | grep -q nyc; then 224 | npm run test-ci 225 | else 226 | npm test 227 | fi 228 | 229 | - name: Upload code coverage 230 | if: steps.list_env.outputs.nyc != '' 231 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 232 | with: 233 | name: coverage-node-${{ matrix.node-version }} 234 | path: ./coverage/lcov.info 235 | retention-days: 1 236 | 237 | coverage: 238 | needs: test 239 | runs-on: ubuntu-latest 240 | permissions: 241 | contents: read 242 | checks: write 243 | steps: 244 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 245 | 246 | - name: Install lcov 247 | shell: bash 248 | run: sudo apt-get -y install lcov 249 | 250 | - name: Collect coverage reports 251 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 252 | with: 253 | path: ./coverage 254 | pattern: coverage-node-* 255 | 256 | - name: Merge coverage reports 257 | shell: bash 258 | run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./lcov.info 259 | 260 | - name: Upload coverage report 261 | uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 262 | with: 263 | github-token: ${{ secrets.GITHUB_TOKEN }} 264 | file: ./lcov.info 265 | --------------------------------------------------------------------------------