├── .npmrc ├── .gitattributes ├── .gitignore ├── index.js ├── index.d.ts ├── .github ├── security.md └── workflows │ └── main.yml ├── .editorconfig ├── filenamify-path.js ├── filenamify-path.d.ts ├── license ├── package.json ├── filenamify.d.ts ├── readme.md ├── filenamify.js └── test.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export {default} from './filenamify.js'; 2 | export {default as filenamifyPath} from './filenamify-path.js'; 3 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export {default} from './filenamify.js'; 2 | export * from './filenamify.js'; 3 | export {default as filenamifyPath} from './filenamify-path.js'; 4 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /filenamify-path.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import filenamify from './filenamify.js'; 3 | 4 | export default function filenamifyPath(filePath, options) { 5 | filePath = path.resolve(filePath); 6 | return path.join(path.dirname(filePath), filenamify(path.basename(filePath), options)); 7 | } 8 | -------------------------------------------------------------------------------- /filenamify-path.d.ts: -------------------------------------------------------------------------------- 1 | import {type Options} from './filenamify.js'; 2 | 3 | /** 4 | Convert the filename in a path to a valid filename and return the augmented path. 5 | 6 | @example 7 | ``` 8 | import {filenamifyPath} from 'filenamify'; 9 | 10 | filenamifyPath('foo:bar'); 11 | //=> 'foo!bar' 12 | ``` 13 | */ 14 | export default function filenamifyPath(path: string, options?: Options): string; 15 | 16 | export type {Options} from './filenamify.js'; 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "filenamify", 3 | "version": "7.0.1", 4 | "description": "Convert a string to a valid safe filename", 5 | "license": "MIT", 6 | "repository": "sindresorhus/filenamify", 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 | ".": { 16 | "types": "./index.d.ts", 17 | "default": "./index.js" 18 | }, 19 | "./browser": { 20 | "types": "./filenamify.d.ts", 21 | "default": "./filenamify.js" 22 | } 23 | }, 24 | "sideEffects": false, 25 | "engines": { 26 | "node": ">=20" 27 | }, 28 | "scripts": { 29 | "test": "xo && ava" 30 | }, 31 | "files": [ 32 | "filenamify-path.d.ts", 33 | "filenamify-path.js", 34 | "filenamify.d.ts", 35 | "filenamify.js", 36 | "index.d.ts", 37 | "index.js" 38 | ], 39 | "keywords": [ 40 | "filename", 41 | "safe", 42 | "sanitize", 43 | "file", 44 | "name", 45 | "string", 46 | "path", 47 | "filepath", 48 | "convert", 49 | "valid", 50 | "dirname" 51 | ], 52 | "dependencies": { 53 | "filename-reserved-regex": "^4.0.0" 54 | }, 55 | "devDependencies": { 56 | "ava": "^6.4.1", 57 | "xo": "^1.2.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /filenamify.d.ts: -------------------------------------------------------------------------------- 1 | export type Options = { 2 | /** 3 | String to use as replacement for reserved filename characters. 4 | 5 | Cannot contain: `<` `>` `:` `"` `/` `\` `|` `?` `*` or control characters. 6 | 7 | @default '!' 8 | */ 9 | readonly replacement?: string; 10 | 11 | /** 12 | Truncate the filename to the given length. 13 | 14 | Only the base of the filename is truncated, preserving the extension. If the extension itself is longer than `maxLength`, you will get a string that is longer than `maxLength`, so you need to check for that if you allow arbitrary extensions. 15 | 16 | Truncation is grapheme-aware and will not split Unicode characters (surrogate pairs or extended grapheme clusters). If the remaining budget (after accounting for the extension) is smaller than a whole grapheme, the base filename may be truncated to an empty string to avoid splitting. 17 | 18 | Systems generally allow up to 255 characters, but we default to 100 for usability reasons. 19 | 20 | @default 100 21 | */ 22 | readonly maxLength?: number; 23 | }; 24 | 25 | /** 26 | Convert a string to a valid filename. 27 | 28 | @example 29 | ``` 30 | import filenamify from 'filenamify'; 31 | 32 | filenamify(''); 33 | //=> '!foo!bar!' 34 | 35 | filenamify('foo:"bar"', {replacement: '🐴'}); 36 | //=> 'foo🐴bar🐴' 37 | ``` 38 | */ 39 | export default function filenamify(string: string, options?: Options): string; 40 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # filenamify 2 | 3 | > Convert a string to a valid safe filename 4 | 5 | On Unix-like systems, `/` is reserved. On Windows, [`<>:"/\|?*`](http://msdn.microsoft.com/en-us/library/aa365247%28VS.85%29#naming_conventions) along with trailing periods and spaces are reserved. 6 | 7 | This module also removes non-printable control characters (including Unicode bidirectional marks) and normalizes Unicode whitespace. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install filenamify 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import filenamify from 'filenamify'; 19 | 20 | filenamify(''); 21 | //=> '!foo!bar!' 22 | 23 | filenamify('foo:"bar"', {replacement: '🐴'}); 24 | //=> 'foo🐴bar🐴' 25 | ``` 26 | 27 | ## API 28 | 29 | ### filenamify(string, options?) 30 | 31 | Convert a string to a valid filename. 32 | 33 | ### filenamifyPath(path, options?) 34 | 35 | Convert the filename in a path to a valid filename and return the augmented path. 36 | 37 | ```js 38 | import {filenamifyPath} from 'filenamify'; 39 | 40 | filenamifyPath('foo:bar'); 41 | //=> 'foo!bar' 42 | ``` 43 | 44 | #### options 45 | 46 | Type: `object` 47 | 48 | ##### replacement 49 | 50 | Type: `string`\ 51 | Default: `'!'` 52 | 53 | String to use as replacement for reserved filename characters. 54 | 55 | Cannot contain: `<` `>` `:` `"` `/` `\` `|` `?` `*` or control characters. 56 | 57 | ##### maxLength 58 | 59 | Type: `number`\ 60 | Default: `100` 61 | 62 | Truncate the filename to the given length. 63 | 64 | Only the base of the filename is truncated, preserving the extension. If the extension itself is longer than `maxLength`, you will get a string that is longer than `maxLength`, so you need to check for that if you allow arbitrary extensions. 65 | 66 | Truncation is grapheme-aware and will not split Unicode characters (surrogate pairs or extended grapheme clusters). If the remaining budget (after accounting for the extension) is smaller than a whole grapheme, the base filename may be truncated to an empty string to avoid splitting. 67 | 68 | Systems generally allow up to 255 characters, but we default to 100 for usability reasons. 69 | 70 | ## Browser-only import 71 | 72 | You can also import `filenamify/browser`, which only imports `filenamify` and not `filenamifyPath`, which relies on `path` being available or polyfilled. Importing `filenamify` this way is therefore useful when it is shipped using `webpack` or similar tools, and if `filenamifyPath` is not needed. 73 | 74 | ```js 75 | import filenamify from 'filenamify/browser'; 76 | 77 | filenamify(''); 78 | //=> '!foo!bar!' 79 | ``` 80 | 81 | ## Related 82 | 83 | - [filenamify-cli](https://github.com/sindresorhus/filenamify-cli) - CLI for this module 84 | - [filenamify-url](https://github.com/sindresorhus/filenamify-url) - Convert a URL to a valid filename 85 | - [valid-filename](https://github.com/sindresorhus/valid-filename) - Check if a string is a valid filename 86 | - [unused-filename](https://github.com/sindresorhus/unused-filename) - Get a unused filename by appending a number if it exists 87 | - [slugify](https://github.com/sindresorhus/slugify) - Slugify a string 88 | -------------------------------------------------------------------------------- /filenamify.js: -------------------------------------------------------------------------------- 1 | import filenameReservedRegex, {windowsReservedNameRegex} from 'filename-reserved-regex'; 2 | 3 | // Doesn't make sense to have longer filenames 4 | const MAX_FILENAME_LENGTH = 100; 5 | 6 | const reRelativePath = /^\.+(\\|\/)|^\.+$/; 7 | const reTrailingDotsAndSpaces = /[. ]+$/; 8 | 9 | // Remove all problematic characters except zero-width joiner (\u200D) needed for emoji 10 | const reControlChars = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/gu; 11 | const reControlCharsTest = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/u; 12 | const isZeroWidthJoiner = char => char === '\u200D'; 13 | const reRepeatedReservedCharacters = /([<>:"/\\|?*\u0000-\u001F]){2,}/g; // eslint-disable-line no-control-regex 14 | 15 | // For validating replacement string - only truly reserved characters, not trailing spaces/periods 16 | const reReplacementReservedCharacters = /[<>:"/\\|?*\u0000-\u001F]/; // eslint-disable-line no-control-regex 17 | 18 | // Normalize various Unicode whitespace characters to regular space 19 | // Using specific characters instead of \s to avoid matching regular spaces 20 | const reUnicodeWhitespace = /[\t\n\r\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+/g; 21 | 22 | let segmenter; 23 | function getSegmenter() { 24 | segmenter ??= new Intl.Segmenter(undefined, {granularity: 'grapheme'}); 25 | return segmenter; 26 | } 27 | 28 | function truncateFilename(filename, maxLength) { 29 | if (filename.length <= maxLength) { 30 | return filename; 31 | } 32 | 33 | const extensionIndex = filename.lastIndexOf('.'); 34 | 35 | // No extension - simple truncation 36 | if (extensionIndex === -1) { 37 | return truncateByGraphemeBudget(filename, maxLength); 38 | } 39 | 40 | // Has extension - preserve it and truncate base 41 | const base = filename.slice(0, extensionIndex); 42 | const extension = filename.slice(extensionIndex); 43 | const baseBudget = Math.max(0, maxLength - extension.length); 44 | const truncatedBase = truncateByGraphemeBudget(base, baseBudget); 45 | 46 | // Strip trailing spaces from base (not periods - they're not trailing in final filename) 47 | return truncatedBase.replace(/ +$/, '') + extension; 48 | } 49 | 50 | export default function filenamify(string, options = {}) { 51 | if (typeof string !== 'string') { 52 | throw new TypeError('Expected a string'); 53 | } 54 | 55 | const replacement = options.replacement ?? '!'; 56 | 57 | const hasReservedChars = reReplacementReservedCharacters.test(replacement); 58 | const hasControlChars = [...replacement].some(char => reControlCharsTest.test(char) && !isZeroWidthJoiner(char)); 59 | 60 | if (hasReservedChars || hasControlChars) { 61 | throw new Error('Replacement string cannot contain reserved filename characters'); 62 | } 63 | 64 | // Normalize to NFC first to stabilize byte representation and length calculations across platforms. 65 | string = string.normalize('NFC'); 66 | 67 | // Normalize Unicode whitespace to single spaces 68 | string = string.replaceAll(reUnicodeWhitespace, ' '); 69 | 70 | if (replacement.length > 0) { 71 | string = string.replaceAll(reRepeatedReservedCharacters, '$1'); 72 | } 73 | 74 | // Trim trailing spaces and periods (Windows rule) - do this BEFORE replacements 75 | // so they get stripped rather than replaced 76 | string = string.replace(reTrailingDotsAndSpaces, ''); 77 | 78 | string = string.replace(reRelativePath, replacement); 79 | string = string.replace(filenameReservedRegex(), replacement); 80 | string = string.replaceAll(reControlChars, char => isZeroWidthJoiner(char) ? char : replacement); 81 | 82 | // Trim trailing spaces and periods again (in case replacement created new ones) 83 | string = string.replace(reTrailingDotsAndSpaces, ''); 84 | 85 | // If the string is now empty, use replacement with trailing spaces/periods stripped 86 | if (string.length === 0) { 87 | string = replacement.replace(reTrailingDotsAndSpaces, ''); 88 | // If still empty and replacement wasn't explicitly empty, use '!' as fallback 89 | if (string.length === 0 && replacement.length > 0) { 90 | string = '!'; 91 | } 92 | } 93 | 94 | // Truncate before Windows reserved name check (truncation can create reserved names) 95 | const allowedLength = typeof options.maxLength === 'number' ? options.maxLength : MAX_FILENAME_LENGTH; 96 | string = truncateFilename(string, allowedLength); 97 | 98 | // Strip trailing spaces/periods after truncation (truncation can create them) 99 | string = string.replace(reTrailingDotsAndSpaces, ''); 100 | 101 | // Check for Windows reserved names after truncation and stripping 102 | // Windows compatibility takes precedence over maxLength, so we add suffix even if it exceeds limit 103 | if (windowsReservedNameRegex().test(string)) { 104 | string += replacement; 105 | } 106 | 107 | return string; 108 | } 109 | 110 | function truncateByGraphemeBudget(input, budget) { 111 | if (input.length <= budget) { 112 | return input; 113 | } 114 | 115 | let count = 0; 116 | let output = ''; 117 | for (const {segment} of getSegmenter().segment(input)) { 118 | const next = count + segment.length; 119 | if (next > budget) { 120 | break; 121 | } 122 | 123 | output += segment; 124 | count = next; 125 | } 126 | 127 | return output; 128 | } 129 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {fileURLToPath} from 'node:url'; 3 | import test from 'ava'; 4 | import filenamify, {filenamifyPath} from './index.js'; 5 | 6 | const directoryName = path.dirname(fileURLToPath(import.meta.url)); 7 | 8 | test('filenamify()', t => { 9 | t.is(filenamify('foo/bar'), 'foo!bar'); 10 | t.is(filenamify('foo//bar'), 'foo!bar'); 11 | t.is(filenamify('//foo//bar//'), '!foo!bar!'); 12 | t.is(filenamify(String.raw`foo\\\bar`), 'foo!bar'); 13 | t.is(filenamify('foo/bar', {replacement: '🐴🐴'}), 'foo🐴🐴bar'); 14 | t.is(filenamify('////foo////bar////', {replacement: '(('}), '((foo((bar(('); 15 | t.is(filenamify('foo\u0000bar'), 'foo!bar'); 16 | t.is(filenamify('.'), '!'); 17 | t.is(filenamify('..'), '!'); 18 | t.is(filenamify('./'), '!'); 19 | t.is(filenamify('../'), '!'); 20 | t.is(filenamify('!.foo'), '!.foo'); 21 | t.is(filenamify('foo.!'), 'foo.!'); 22 | t.is(filenamify('foo.bar.'), 'foo.bar'); 23 | t.is(filenamify('foo.bar..'), 'foo.bar'); 24 | t.is(filenamify('foo.bar...'), 'foo.bar'); 25 | t.is(filenamify('con'), 'con!'); 26 | t.is(filenamify('foo/bar/nul'), 'foo!bar!nul'); 27 | t.is(filenamify('con', {replacement: '🐴🐴'}), 'con🐴🐴'); 28 | t.is(filenamify('c/n', {replacement: 'o'}), 'cono'); 29 | t.is(filenamify('c/n', {replacement: 'con'}), 'cconn'); 30 | t.is(filenamify('.dotfile'), '.dotfile'); 31 | t.is(filenamify('my >>--', {replacement: '-'}), '---abc----'); 33 | t.is(filenamify('-<-', {replacement: '-'}), '--abc--'); 34 | t.is(filenamify('my { 39 | t.is(path.basename(filenamifyPath(path.join(directoryName, 'foo:bar'))), 'foo!bar'); 40 | t.is( 41 | path.basename(filenamifyPath(path.join(directoryName, 'This? This is very long filename that will lose its extension when passed into filenamify, which could cause issues.csv'))), 42 | 'This! This is very long filename that will lose its extension when passed into filenamify, which.csv', 43 | ); 44 | // Test trailing spaces/periods with filenamifyPath 45 | t.is(path.basename(filenamifyPath(path.join(directoryName, 'foo. '))), 'foo'); 46 | t.is(path.basename(filenamifyPath(path.join(directoryName, 'bar ...'))), 'bar'); 47 | }); 48 | 49 | test('filenamify length', t => { 50 | // Basename length: 152 51 | const filename = 'this/is/a/very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_long_filename.txt'; 52 | t.is(filenamify(path.basename(filename)), 'very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_v.txt'); 53 | t.is( 54 | filenamify(path.basename(filename), {maxLength: 180}), 55 | 'very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_long_filename.txt', 56 | ); 57 | 58 | // File extension longer than `maxLength` - base gets truncated to 0 59 | t.is(filenamify('foo.asdfghjkl', {maxLength: 5}), '.asdfghjkl'); 60 | 61 | // Basename length: 148 62 | const filenameNoExt = 'very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_long_filename'; 63 | t.is(filenamify(filenameNoExt), 'very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_'); 64 | t.is(filenamify(filenameNoExt, {maxLength: 20}), 'very_very_very_very_'); 65 | t.is(filenamify('.asdfghjkl', {maxLength: 2}), '.asdfghjkl'); 66 | }); 67 | 68 | test('grapheme-aware truncation', t => { 69 | // Test emoji sequences that should not be split 70 | t.is(filenamify('👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦', {maxLength: 20}), '👨‍👩‍👧‍👦'); 71 | t.is(filenamify('test👨‍👩‍👧‍👦.txt', {maxLength: 12}), 'test.txt'); 72 | 73 | // Test surrogate pairs (mathematical bold capital A) 74 | t.is(filenamify('𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀𝐀', {maxLength: 10}), '𝐀𝐀𝐀𝐀𝐀'); 75 | 76 | // Test combining characters 77 | t.is(filenamify('é́é́é́é́é́é́é́é́é́é́', {maxLength: 10}), 'é́é́é́é́é́'); 78 | 79 | // Test flag emojis (regional indicator symbols) 80 | t.is(filenamify('🇺🇸🇬🇧🇫🇷🇩🇪🇯🇵🇨🇳🇰🇷🇮🇹🇪🇸🇨🇦', {maxLength: 12}), '🇺🇸🇬🇧🇫🇷'); 81 | 82 | // Test with extension and grapheme clusters - emoji families are 11 chars each 83 | t.is(filenamify('👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦.txt', {maxLength: 15}), '👨‍👩‍👧‍👦.txt'); 84 | t.is(filenamify('test👨‍👩‍👧‍👦👨‍👩‍👧‍👦.txt', {maxLength: 30}), 'test👨‍👩‍👧‍👦👨‍👩‍👧‍👦.txt'); 85 | }); 86 | 87 | test('Unicode normalization', t => { 88 | // Test NFC normalization (é vs e + combining acute) 89 | const decomposed = 'café'; // E + combining acute 90 | const precomposed = 'café'; // É as single character 91 | t.is(filenamify(decomposed), filenamify(precomposed)); 92 | 93 | // Test that normalization happens before length check 94 | t.is(filenamify('caféabc', {maxLength: 6}), 'caféab'); 95 | }); 96 | 97 | test('edge cases', t => { 98 | // Empty string 99 | t.is(filenamify(''), '!'); 100 | 101 | // Only reserved characters 102 | t.is(filenamify('///'), '!'); 103 | t.is(filenamify('<<<>>>'), '!'); 104 | 105 | // Replacement validation 106 | t.throws(() => filenamify('test', {replacement: '<'}), {message: 'Replacement string cannot contain reserved filename characters'}); 107 | t.throws(() => filenamify('test', {replacement: '\u0000'}), {message: 'Replacement string cannot contain reserved filename characters'}); 108 | 109 | // Extension exactly at maxLength 110 | t.is(filenamify('test.txt', {maxLength: 8}), 'test.txt'); 111 | 112 | // Extension longer than maxLength - base truncated to 0 113 | t.is(filenamify('a.verylongextension', {maxLength: 5}), '.verylongextension'); 114 | 115 | // No extension, exact maxLength 116 | t.is(filenamify('exact', {maxLength: 5}), 'exact'); 117 | 118 | // Windows reserved names with different cases 119 | t.is(filenamify('CON'), 'CON!'); 120 | t.is(filenamify('con'), 'con!'); 121 | t.is(filenamify('CoN'), 'CoN!'); 122 | }); 123 | 124 | test('repeated reserved characters', t => { 125 | // Test that consecutive reserved characters are collapsed 126 | t.is(filenamify('foo<<< { 136 | // Test bidirectional control characters (security issue #39) 137 | t.is(filenamify('bar\u202Ecod.bat'), 'bar!cod.bat'); 138 | t.is(filenamify('hello\u202Dworld'), 'hello!world'); 139 | t.is(filenamify('test\u202A\u202B\u202C'), 'test!!!'); 140 | 141 | // Test various control characters 142 | t.is(filenamify('foo\u0000bar'), 'foo!bar'); // Null character 143 | t.is(filenamify('foo\u007Fbar'), 'foo!bar'); // Delete character 144 | t.is(filenamify('foo\u0080bar'), 'foo!bar'); // C1 control 145 | t.is(filenamify('foo\u200Bbar'), 'foo!bar'); // Zero-width space 146 | t.is(filenamify('foo\uFEFFbar'), 'foo!bar'); // BOM 147 | t.is(filenamify('foo\u2028bar'), 'foo!bar'); // Line separator 148 | t.is(filenamify('foo\u2029bar'), 'foo!bar'); // Paragraph separator 149 | 150 | // Test Unicode whitespace normalization 151 | t.is(filenamify('foo\u00A0bar'), 'foo bar'); // Non-breaking space 152 | t.is(filenamify('foo\u2000bar'), 'foo bar'); // En quad 153 | t.is(filenamify('foo\u3000bar'), 'foo bar'); // Ideographic space 154 | t.is(filenamify('foo \u00A0 \u2000 bar'), 'foo bar'); // Multiple Unicode spaces normalized but regular spaces preserved 155 | t.is(filenamify('foo\t\n\rbar'), 'foo bar'); // Tab, newline, carriage return 156 | 157 | // Combined test with control chars and reserved chars 158 | t.is(filenamify('foo\u202E/bar:baz\u200B.txt'), 'foo!!bar!baz!.txt'); 159 | }); 160 | 161 | test('replacement validation', t => { 162 | // Test that control characters in replacement throw error 163 | t.throws(() => filenamify('test', {replacement: '\u0000'}), {message: 'Replacement string cannot contain reserved filename characters'}); 164 | t.throws(() => filenamify('test', {replacement: '\u202E'}), {message: 'Replacement string cannot contain reserved filename characters'}); 165 | t.throws(() => filenamify('test', {replacement: 'a\u200Bb'}), {message: 'Replacement string cannot contain reserved filename characters'}); 166 | 167 | // Test that truly reserved characters throw error 168 | t.throws(() => filenamify('test', {replacement: '<'}), {message: 'Replacement string cannot contain reserved filename characters'}); 169 | t.throws(() => filenamify('test', {replacement: 'a/b'}), {message: 'Replacement string cannot contain reserved filename characters'}); 170 | }); 171 | 172 | test('replacement with spaces and periods (issue #45)', t => { 173 | // Test that spaces work as replacement in normal cases 174 | t.is(filenamify('foo/bar', {replacement: ' '}), 'foo bar'); 175 | t.is(filenamify('foo:bar', {replacement: ' '}), 'foo bar'); 176 | 177 | // Test that trailing spaces are handled correctly 178 | t.is(filenamify('foo/', {replacement: ' '}), 'foo'); 179 | t.is(filenamify('foo<', {replacement: ' '}), 'foo'); 180 | 181 | // Test edge case: only invalid characters with space replacement 182 | t.is(filenamify('///', {replacement: ' '}), '!'); // Fallback to '!' 183 | t.is(filenamify('<<<', {replacement: ' '}), '!'); 184 | 185 | // Test periods as replacement 186 | t.is(filenamify('foo/bar', {replacement: '.'}), 'foo.bar'); 187 | t.is(filenamify('foo/', {replacement: '.'}), 'foo'); 188 | t.is(filenamify('///', {replacement: '.'}), '!'); // Fallback to '!' 189 | 190 | // Test replacement ending with space 191 | t.is(filenamify('foo/bar', {replacement: '- '}), 'foo- bar'); 192 | t.is(filenamify('foo/', {replacement: '- '}), 'foo-'); 193 | t.is(filenamify('///', {replacement: '- '}), '-'); // Trailing space stripped 194 | 195 | // Test replacement ending with period 196 | t.is(filenamify('foo/bar', {replacement: '-.'}), 'foo-.bar'); 197 | t.is(filenamify('foo/', {replacement: '-.'}), 'foo-'); 198 | t.is(filenamify('///', {replacement: '-.'}), '-'); // Trailing period stripped 199 | 200 | // Test spaces in the middle 201 | t.is(filenamify('foo/bar', {replacement: 'a b'}), 'fooa bbar'); 202 | t.is(filenamify('///', {replacement: 'a b'}), 'a b'); 203 | }); 204 | 205 | test('combined transformations', t => { 206 | // Test NFC normalization + control chars + trailing spaces + truncation 207 | const input = 'café\u202E\u200B... '; 208 | t.is(filenamify(input), 'café!!'); 209 | 210 | // With maxLength 211 | const longInput = 'test\u202E\u200B' + 'x'.repeat(100) + '... '; 212 | t.is(filenamify(longInput, {maxLength: 10}), 'test!!' + 'x'.repeat(4)); 213 | 214 | // Everything problematic - control chars get replaced, then trailing dots/spaces removed 215 | t.is(filenamify('\u202E\u200B\u0000... '), '!!!'); 216 | t.is(filenamify(' \u200B\u202E ', {replacement: 'x'}), ' xx'); // Leading spaces preserved 217 | }); 218 | 219 | test('Windows reserved names edge cases', t => { 220 | // Reserved names without extensions get suffix 221 | t.is(filenamify('CON'), 'CON!'); 222 | t.is(filenamify('con'), 'con!'); 223 | t.is(filenamify('Com'), 'Com'); // Mixed case not reserved 224 | 225 | // With extensions they're fine 226 | t.is(filenamify('CON.txt'), 'CON.txt'); 227 | t.is(filenamify('con.txt'), 'con.txt'); 228 | 229 | // With numbers 230 | t.is(filenamify('COM1'), 'COM1!'); 231 | t.is(filenamify('LPT9'), 'LPT9!'); 232 | t.is(filenamify('COM0'), 'COM0!'); // COM0 is also reserved 233 | }); 234 | 235 | test('replacement edge cases', t => { 236 | // Empty replacement 237 | t.is(filenamify('foo/bar', {replacement: ''}), 'foobar'); 238 | t.is(filenamify('///', {replacement: ''}), ''); // Empty when replacement is empty 239 | 240 | // Zero-width joiner should be allowed in replacement 241 | t.is(filenamify('foo/bar', {replacement: '\u200D'}), 'foo‍bar'); 242 | 243 | // Multiple character replacement 244 | t.is(filenamify('a/b/c', {replacement: '--'}), 'a--b--c'); 245 | }); 246 | 247 | test('file extension edge cases', t => { 248 | // Multiple dots - last dot is extension separator 249 | t.is(filenamify('file.backup.old.txt', {maxLength: 15}), 'file.backup.txt'); 250 | t.is(filenamify('file.backup.old.txt', {maxLength: 10}), 'file.b.txt'); 251 | 252 | // Extension-only files 253 | t.is(filenamify('.gitignore'), '.gitignore'); 254 | t.is(filenamify('.gitignore...'), '.gitignore'); 255 | 256 | // File with dots - last dot is extension 257 | t.is(filenamify('file.name.here', {maxLength: 10}), 'file..here'); // "file.name" + ".here" 258 | t.is(filenamify('file.name.here', {maxLength: 8}), 'fil.here'); // "fil" + ".here" 259 | 260 | // No dots 261 | t.is(filenamify('filename', {maxLength: 8}), 'filename'); 262 | t.is(filenamify('verylongfilename', {maxLength: 8}), 'verylong'); 263 | }); 264 | 265 | test('truncation edge cases', t => { 266 | // Truncation creating Windows reserved names (must add suffix for Windows compatibility) 267 | t.is(filenamify('CONTEXT', {maxLength: 3}), 'CON!'); 268 | t.is(filenamify('CON' + 'x'.repeat(200), {maxLength: 3}), 'CON!'); 269 | t.is(filenamify('PRNTHING', {maxLength: 3}), 'PRN!'); 270 | t.is(filenamify('COM1EXTRA', {maxLength: 4}), 'COM1!'); 271 | t.is(filenamify('AUXILIARY', {maxLength: 3}), 'AUX!'); 272 | 273 | // Truncation creating trailing spaces (must be stripped for Windows compatibility) 274 | t.is(filenamify('hello world', {maxLength: 6}), 'hello'); 275 | t.is(filenamify('foo bar baz', {maxLength: 8}), 'foo bar'); 276 | t.is(filenamify('ab cd ef', {maxLength: 6}), 'ab cd'); 277 | 278 | // Truncation creating trailing periods (must be stripped for Windows compatibility) 279 | t.is(filenamify('test. . .', {maxLength: 6}), 'test'); 280 | t.is(filenamify('dots....', {maxLength: 6}), 'dots'); 281 | 282 | // Truncation with extension - trailing spaces/periods in base should be stripped 283 | t.is(filenamify('hello world test.txt', {maxLength: 16}), 'hello world.txt'); 284 | t.is(filenamify('test.. .txt', {maxLength: 8}), 'test.txt'); 285 | }); 286 | 287 | test('trailing spaces and periods', t => { 288 | // Test removal of trailing spaces and periods 289 | t.is(filenamify('x. ..'), 'x'); 290 | t.is(filenamify('foo. '), 'foo'); 291 | t.is(filenamify('foo '), 'foo'); 292 | t.is(filenamify('foo.'), 'foo'); 293 | t.is(filenamify('foo...'), 'foo'); 294 | t.is(filenamify('foo '), 'foo'); 295 | t.is(filenamify('foo. . .'), 'foo'); 296 | t.is(filenamify(' . . '), '!'); 297 | t.is(filenamify('...'), '!'); 298 | t.is(filenamify(' '), '!'); 299 | 300 | // With custom replacement 301 | t.is(filenamify(' . . ', {replacement: 'x'}), 'x'); 302 | t.is(filenamify('...', {replacement: 'y'}), 'y'); 303 | 304 | // Combined with other transformations 305 | t.is(filenamify('foo/bar. '), 'foo!bar'); 306 | t.is(filenamify('foo:bar ...'), 'foo!bar'); 307 | 308 | // Windows reserved names with trailing spaces/periods 309 | t.is(filenamify('con .'), 'con!'); 310 | t.is(filenamify('aux...'), 'aux!'); 311 | t.is(filenamify('nul '), 'nul!'); 312 | 313 | // With maxLength and trailing spaces/periods 314 | t.is(filenamify('hello world. ', {maxLength: 11}), 'hello world'); 315 | t.is(filenamify('test...', {maxLength: 4}), 'test'); 316 | 317 | // Ensure internal spaces/periods are not removed 318 | t.is(filenamify('foo. .bar'), 'foo. .bar'); 319 | t.is(filenamify('foo bar'), 'foo bar'); 320 | t.is(filenamify('foo...bar'), 'foo...bar'); 321 | }); 322 | --------------------------------------------------------------------------------