├── .npmrc ├── .gitattributes ├── .gitignore ├── .editorconfig ├── readme.md ├── index.d.ts ├── .github └── workflows │ └── main.yml ├── package.json ├── license ├── index.js └── test.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # truncate-url 2 | 3 | > Truncate a URL to a specific length 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install truncate-url 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import truncateUrl from 'truncate-url'; 15 | 16 | truncateUrl('https://sindresorhus.com/foo/bar/baz/faz', 30); 17 | //=> 'https://sindresorhus.com/…/faz' 18 | ``` 19 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Truncate a URL to a specific length. 3 | 4 | @param url - The URL to be truncated. 5 | @param maxLength - The maximum length of the truncated URL. 6 | @returns The truncated URL. 7 | 8 | @example 9 | ``` 10 | import truncateUrl from 'truncate-url'; 11 | 12 | truncateUrl('https://sindresorhus.com/foo/bar/baz/faz', 30); 13 | //=> 'https://sindresorhus.com/…/faz' 14 | ``` 15 | */ 16 | export default function truncateUrl(url: string, maxLength: number): string; 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 20 15 | steps: 16 | - uses: actions/checkout@v5 17 | - uses: actions/setup-node@v5 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "truncate-url", 3 | "version": "3.0.0", 4 | "description": "Truncate a URL to a specific length", 5 | "license": "MIT", 6 | "repository": "sindresorhus/truncate-url", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=20" 21 | }, 22 | "scripts": { 23 | "test": "xo && ava" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "url", 31 | "truncate", 32 | "shorten", 33 | "crop", 34 | "cut", 35 | "length", 36 | "limit", 37 | "string" 38 | ], 39 | "devDependencies": { 40 | "ava": "^6.4.1", 41 | "xo": "^1.2.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export default function truncateUrl(urlString, maxLength) { 2 | if (typeof urlString !== 'string') { 3 | throw new TypeError('Expected input to be a string'); 4 | } 5 | 6 | if (!(typeof maxLength === 'number' && Number.isFinite(maxLength) && maxLength >= 0)) { 7 | throw new TypeError('Expected length to be a finite non-negative number'); 8 | } 9 | 10 | // Parse URL first to validate it 11 | const parsed = new URL(urlString); 12 | 13 | if (urlString.length <= maxLength) { 14 | return urlString; 15 | } 16 | 17 | const ellipsis = '…'; 18 | 19 | // Get the base URL without pathname, including auth if present 20 | let base = parsed.origin; 21 | if (parsed.username || parsed.password) { 22 | const auth = parsed.username + (parsed.password ? ':' + parsed.password : ''); 23 | base = `${parsed.protocol}//${auth}@${parsed.host}`; 24 | } 25 | 26 | const queryAndHash = parsed.search + parsed.hash; 27 | 28 | // Split pathname into segments (excluding empty first element from leading /) 29 | const pathSegments = parsed.pathname.split('/').slice(1); 30 | 31 | // If no path segments beyond root, return base + query/hash 32 | if (pathSegments.length === 0) { 33 | const minimalUrl = base + queryAndHash; 34 | if (minimalUrl.length <= maxLength) { 35 | return minimalUrl; 36 | } 37 | 38 | // Base + query/hash too long, truncate aggressively 39 | if (maxLength <= ellipsis.length) { 40 | return ellipsis.slice(0, maxLength); 41 | } 42 | 43 | return urlString.slice(0, maxLength - ellipsis.length) + ellipsis; 44 | } 45 | 46 | // Try to build URL with as many trailing segments as possible 47 | // Start with the full path and work backwards 48 | for (let numberSegments = pathSegments.length; numberSegments >= 1; numberSegments--) { 49 | const segments = pathSegments.slice(-numberSegments); 50 | const testUrl = numberSegments === pathSegments.length 51 | ? base + '/' + segments.join('/') + queryAndHash 52 | : base + '/' + ellipsis + '/' + segments.join('/') + queryAndHash; 53 | 54 | if (testUrl.length <= maxLength) { 55 | return testUrl; 56 | } 57 | } 58 | 59 | // No segments fit with query/hash, try just ellipsis + query/hash 60 | const minimalWithQueryHash = base + '/' + ellipsis + queryAndHash; 61 | if (minimalWithQueryHash.length <= maxLength) { 62 | return minimalWithQueryHash; 63 | } 64 | 65 | // Can't fit query/hash, try just ellipsis 66 | const justBaseAndEllipsis = base + '/' + ellipsis; 67 | if (justBaseAndEllipsis.length <= maxLength) { 68 | return justBaseAndEllipsis; 69 | } 70 | 71 | // Last resort: truncate everything 72 | if (maxLength <= ellipsis.length) { 73 | return ellipsis.slice(0, maxLength); 74 | } 75 | 76 | return urlString.slice(0, maxLength - ellipsis.length) + ellipsis; 77 | } 78 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import truncateUrl from './index.js'; 3 | 4 | test('truncate url', t => { 5 | t.is(truncateUrl('http://sindresorhus.com/foo/bar/baz/faz', 30), 'http://sindresorhus.com/…/faz'); 6 | t.is(truncateUrl('http://example.com/a/cool/page/that-is-really-deeply/nested/', 40), 'http://example.com/…/nested/'); 7 | t.is(truncateUrl('http://example.com/a/b/c/d', 24).length, 24); 8 | t.is(truncateUrl('http://example.com/a/b/c', 24), 'http://example.com/a/b/c'); 9 | t.is(truncateUrl('http://example.com/a/b/cd', 24), 'http://example.com/…/cd'); 10 | }); 11 | 12 | test('passes corner case', t => { 13 | const result = truncateUrl('http://example.com/a/b/cd', 24); 14 | t.true(result.length <= 24, `${result} is > 24`); 15 | }); 16 | 17 | test('uses WHATWG URL API internally', t => { 18 | // Test that the function works with various URL formats 19 | const result1 = truncateUrl('https://example.com/very/long/path/here', 30); 20 | t.is(result1, 'https://example.com/…/here'); 21 | 22 | // Test with query parameters 23 | const result2 = truncateUrl('https://example.com/very/long/path?query=value', 35); 24 | t.true(result2.includes('…')); 25 | t.true(result2.includes('?query=value')); 26 | 27 | // Test with hash 28 | const result3 = truncateUrl('https://example.com/very/long/path#section', 35); 29 | t.true(result3.includes('…')); 30 | t.true(result3.includes('#section')); 31 | }); 32 | 33 | test('handles URLs with special characters', t => { 34 | // Test with spaces (already encoded) 35 | const urlWithSpaces = 'https://example.com/path%20with%20spaces/file.html'; 36 | const truncatedSpaces = truncateUrl(urlWithSpaces, 35); 37 | t.true(truncatedSpaces.includes('…')); 38 | 39 | // Test with Unicode characters 40 | const urlWithUnicode = 'https://example.com/path/文件.html'; 41 | const truncatedUnicode = truncateUrl(urlWithUnicode, 50); 42 | // WHATWG URL properly encodes Unicode, so result may be longer than input 43 | // but should still include ellipsis for truncation or be unchanged if short enough 44 | t.true(truncatedUnicode.includes('…') || truncatedUnicode === urlWithUnicode); 45 | }); 46 | 47 | test('handles URLs with authentication', t => { 48 | const urlWithAuth = 'https://user:pass@example.com/very/long/path/here'; 49 | const truncated = truncateUrl(urlWithAuth, 40); 50 | t.true(truncated.includes('user:pass@')); 51 | t.true(truncated.includes('…')); 52 | t.true(truncated.length <= 40); 53 | }); 54 | 55 | test('handles URLs with ports', t => { 56 | const urlWithPort = 'https://example.com:8080/very/long/path/here'; 57 | const truncated = truncateUrl(urlWithPort, 35); 58 | t.true(truncated.includes(':8080')); 59 | t.true(truncated.includes('…')); 60 | t.true(truncated.length <= 35); 61 | }); 62 | 63 | test('throws on invalid input', t => { 64 | t.throws(() => truncateUrl(null, 10), {instanceOf: TypeError}); 65 | t.throws(() => truncateUrl(123, 10), {instanceOf: TypeError}); 66 | t.throws(() => truncateUrl('http://example.com', '10'), {instanceOf: TypeError}); 67 | t.throws(() => truncateUrl('http://example.com', null), {instanceOf: TypeError}); 68 | 69 | // Test non-finite numbers 70 | t.throws(() => truncateUrl('http://example.com', Number.NaN), {instanceOf: TypeError}); 71 | t.throws(() => truncateUrl('http://example.com', Infinity), {instanceOf: TypeError}); 72 | t.throws(() => truncateUrl('http://example.com', -Infinity), {instanceOf: TypeError}); 73 | }); 74 | 75 | test('throws on invalid URLs', t => { 76 | t.throws(() => truncateUrl('not-a-url', 10), {instanceOf: TypeError}); 77 | t.throws(() => truncateUrl('://missing-protocol', 10), {instanceOf: TypeError}); 78 | t.throws(() => truncateUrl('http://', 10), {instanceOf: TypeError}); 79 | }); 80 | 81 | test('handles edge cases correctly', t => { 82 | // URL exactly at max length 83 | t.is(truncateUrl('http://example.com/path', 24), 'http://example.com/path'); 84 | 85 | // URL with trailing slash 86 | t.is(truncateUrl('http://example.com/a/b/c/', 24), 'http://example.com/…/c/'); 87 | 88 | // Very short max length - URL must have a path separator, so minimum is base + "/…" 89 | const veryShort = truncateUrl('https://example.com/very/long/path', 21); 90 | t.true(veryShort.length <= 21); 91 | t.true(veryShort.includes('…')); 92 | 93 | // Root path only 94 | t.is(truncateUrl('https://example.com/', 20), 'https://example.com/'); 95 | }); 96 | 97 | test('handles critical edge cases with length constraints', t => { 98 | // URLs with query parameters exceeding maxLength 99 | const result1 = truncateUrl('https://example.com/path?query=value', 25); 100 | t.true(result1.length <= 25); 101 | t.true(result1.includes('…')); 102 | 103 | // URLs with hash fragments exceeding maxLength 104 | const result2 = truncateUrl('https://example.com/path#section', 25); 105 | t.true(result2.length <= 25); 106 | t.true(result2.includes('…')); 107 | 108 | // URLs with both query and hash exceeding maxLength 109 | const result3 = truncateUrl('https://example.com/very/long/path?query=value#section', 30); 110 | t.true(result3.length <= 30); 111 | t.true(result3.includes('…')); 112 | 113 | // Base domain exceeds maxLength 114 | const result4 = truncateUrl('https://very-long-domain-name.example.com/path', 15); 115 | t.true(result4.length <= 15); 116 | t.true(result4.includes('…')); 117 | }); 118 | 119 | test('handles extreme maxLength values', t => { 120 | const url = 'https://example.com/path'; 121 | 122 | // MaxLength = 0 123 | const result0 = truncateUrl(url, 0); 124 | t.is(result0.length, 0); 125 | t.is(result0, ''); 126 | 127 | // MaxLength = 1 (exactly ellipsis length) 128 | const result1 = truncateUrl(url, 1); 129 | t.is(result1.length, 1); 130 | t.is(result1, '…'); 131 | 132 | // MaxLength = 2 133 | const result2 = truncateUrl(url, 2); 134 | t.true(result2.length <= 2); 135 | 136 | // Negative maxLength should throw 137 | t.throws(() => truncateUrl(url, -1), {instanceOf: TypeError}); 138 | }); 139 | 140 | test('preserves length contract in all scenarios', t => { 141 | const testCases = [ 142 | {url: 'https://example.com/path?query=value', maxLength: 30}, 143 | {url: 'https://example.com/very/long/path?query=value#section', maxLength: 40}, 144 | {url: 'https://user:pass@example.com/path', maxLength: 25}, 145 | {url: 'https://example.com:8080/path', maxLength: 20}, 146 | {url: 'https://example.com/path%20with%20spaces', maxLength: 35}, 147 | ]; 148 | 149 | for (const testCase of testCases) { 150 | const result = truncateUrl(testCase.url, testCase.maxLength); 151 | t.true( 152 | result.length <= testCase.maxLength, 153 | `Result "${result}" (${result.length} chars) exceeds maxLength ${testCase.maxLength} for URL "${testCase.url}"`, 154 | ); 155 | } 156 | }); 157 | --------------------------------------------------------------------------------