From 58d70c14cbee37f60bc61c47696d84f0d80a1adb Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Fri, 13 Feb 2026 00:21:14 +0100 Subject: [PATCH 1/8] Typescript Rewrite --- .editorconfig | 10 + .eslintignore | 2 - .eslintrc.yml | 9 - .github/workflows/ci.yml | 89 ++- .github/workflows/codeql.yml | 10 +- .github/workflows/scorecard.yml | 12 +- .gitignore | 1 + .husky/pre-commit | 1 + .prettierrc.json | 3 + HISTORY.md | 81 +-- README.md | 52 +- eslint.config.mjs | 15 + index.js | 477 --------------- package.json | 60 +- src/index.ts | 428 +++++++++++++ test/create.spec.ts | 299 +++++++++ test/parse.spec.ts | 1005 ++++++++++++++++++++++++++++++ test/test.js | 1017 ------------------------------- tsconfig.json | 21 + tsdown.config.ts | 15 + 20 files changed, 1948 insertions(+), 1659 deletions(-) create mode 100644 .editorconfig delete mode 100644 .eslintignore delete mode 100644 .eslintrc.yml create mode 100644 .husky/pre-commit create mode 100644 .prettierrc.json create mode 100644 eslint.config.mjs delete mode 100644 index.js create mode 100644 src/index.ts create mode 100644 test/create.spec.ts create mode 100644 test/parse.spec.ts delete mode 100644 test/test.js create mode 100644 tsconfig.json create mode 100644 tsdown.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..773ca2d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +quote_type = single diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 62562b7..0000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -coverage -node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index cf3015f..0000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,9 +0,0 @@ -root: true -extends: - - standard - - plugin:markdown/recommended -plugins: - - markdown -overrides: - - files: '**/*.md' - processor: 'markdown/markdown' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5517d49..9cccdf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,69 +1,62 @@ -name: ci +name: CI on: push: - branches: - - master - paths-ignore: - - '*.md' pull_request: - paths-ignore: - - '*.md' permissions: contents: read jobs: - lint: - name: Lint + checks: + name: Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - - name: Install Node.js - uses: actions/setup-node@v6 + - uses: actions/setup-node@v6 with: node-version: 'lts/*' + - run: npm install + - run: npm run lint + - run: npm run format:check + - run: npm run typecheck - - name: Install Node.js dependencies - run: npm install --ignore-scripts --include=dev - - - name: Lint code - run: npm run lint + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 'lts/*' + - run: npm install + - run: npm run build - test: + test: name: Test - Node.js ${{ matrix.node-version }} runs-on: ubuntu-latest strategy: fail-fast: false - matrix: + matrix: # Node.js release schedule: https://nodejs.org/en/about/releases/ node-version: [18, 19, 20, 21, 22, 23, 24, 25] steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - - - name: Install Node.js dependencies - run: npm install - - - name: Output Node and NPM versions - run: | - echo "Node.js version: $(node -v)" - echo "NPM version: $(npm -v)" - - - name: Run tests - run: npm run test-ci - - - name: Upload code coverage - uses: actions/upload-artifact@v5 - with: - name: coverage-node-${{ matrix.node-version }} - path: ./coverage/lcov.info - retention-days: 1 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - name: Output Node and NPM versions + run: | + echo "Node.js version: $(node -v)" + echo "NPM version: $(npm -v)" + - run: npm run test:ci + - name: Upload code coverage + uses: actions/upload-artifact@v5 + with: + name: coverage-node-${{ matrix.node-version }} + path: ./coverage/lcov.info + retention-days: 1 coverage: name: Coverage @@ -74,20 +67,14 @@ jobs: checks: write steps: - uses: actions/checkout@v6 - - - name: Install lcov - run: sudo apt-get -y install lcov - + - run: sudo apt-get -y install lcov - name: Collect coverage reports uses: actions/download-artifact@v6 with: path: ./coverage pattern: coverage-node-* - - name: Merge coverage reports run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./lcov.info - - - name: Upload coverage report - uses: coverallsapp/github-action@v2 + - uses: coverallsapp/github-action@v2 with: file: ./lcov.info diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8ca67e1..8414722 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,16 +9,16 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: 'CodeQL' on: push: - branches: ["master"] + branches: ['master'] pull_request: # The branches below must be a subset of the branches above - branches: ["master"] + branches: ['master'] schedule: - - cron: "0 0 * * 1" + - cron: '0 0 * * 1' permissions: contents: read @@ -63,4 +63,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 with: - category: "/language:javascript" + category: '/language:javascript' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f30d982..05988c7 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -12,7 +12,7 @@ on: schedule: - cron: '16 21 * * 1' push: - branches: [ "master" ] + branches: ['master'] # Declare default permissions as read only. permissions: read-all @@ -31,12 +31,12 @@ jobs: # actions: read steps: - - name: "Checkout code" + - name: 'Checkout code' uses: actions/checkout@v6 with: persist-credentials: false - - name: "Run analysis" + - name: 'Run analysis' uses: ossf/scorecard-action@v2.4.3 with: results_file: results.sarif @@ -58,7 +58,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - - name: "Upload artifact" + - name: 'Upload artifact' uses: actions/upload-artifact@v6 with: name: SARIF file @@ -66,7 +66,7 @@ jobs: retention-days: 5 # Upload the results to GitHub's code scanning dashboard. - - name: "Upload to code-scanning" + - name: 'Upload to code-scanning' uses: github/codeql-action/upload-sarif@v4 with: - sarif_file: results.sarif \ No newline at end of file + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 0fa6951..e9b4bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ coverage/ node_modules/ npm-debug.log package-lock.json +dist/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..052eed2 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx pretty-quick --staged \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..535a205 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-packagejson"] +} diff --git a/HISTORY.md b/HISTORY.md index 0a4c996..a54b599 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,77 +1,62 @@ -unreleased -================= +# unreleased - * refactor: use simplified `basename` function and remove dependency on `node:path` +- refactor: use simplified `basename` function and remove dependency on `node:path` -1.0.1 / 2025-11-18 -================= +# 1.0.1 / 2025-11-18 - * Updated `engines` field to Node@18 or higher (fixed reference, see 1.0.0) - * Remove dependency `safe-buffer` +- Updated `engines` field to Node@18 or higher (fixed reference, see 1.0.0) +- Remove dependency `safe-buffer` -1.0.0 / 2024-08-31 -================== +# 1.0.0 / 2024-08-31 - * drop node <18 - * allow utf8 as alias for utf-8 +- drop node <18 +- allow utf8 as alias for utf-8 -0.5.4 / 2021-12-10 -================== +# 0.5.4 / 2021-12-10 - * deps: safe-buffer@5.2.1 +- deps: safe-buffer@5.2.1 -0.5.3 / 2018-12-17 -================== +# 0.5.3 / 2018-12-17 - * Use `safe-buffer` for improved Buffer API +- Use `safe-buffer` for improved Buffer API -0.5.2 / 2016-12-08 -================== +# 0.5.2 / 2016-12-08 - * Fix `parse` to accept any linear whitespace character +- Fix `parse` to accept any linear whitespace character -0.5.1 / 2016-01-17 -================== +# 0.5.1 / 2016-01-17 - * perf: enable strict mode +- perf: enable strict mode -0.5.0 / 2014-10-11 -================== +# 0.5.0 / 2014-10-11 - * Add `parse` function +- Add `parse` function -0.4.0 / 2014-09-21 -================== +# 0.4.0 / 2014-09-21 - * Expand non-Unicode `filename` to the full ISO-8859-1 charset +- Expand non-Unicode `filename` to the full ISO-8859-1 charset -0.3.0 / 2014-09-20 -================== +# 0.3.0 / 2014-09-20 - * Add `fallback` option - * Add `type` option +- Add `fallback` option +- Add `type` option -0.2.0 / 2014-09-19 -================== +# 0.2.0 / 2014-09-19 - * Reduce ambiguity of file names with hex escape in buggy browsers +- Reduce ambiguity of file names with hex escape in buggy browsers -0.1.2 / 2014-09-19 -================== +# 0.1.2 / 2014-09-19 - * Fix periodic invalid Unicode filename header +- Fix periodic invalid Unicode filename header -0.1.1 / 2014-09-19 -================== +# 0.1.1 / 2014-09-19 - * Fix invalid characters appearing in `filename*` parameter +- Fix invalid characters appearing in `filename*` parameter -0.1.0 / 2014-09-18 -================== +# 0.1.0 / 2014-09-18 - * Make the `filename` argument optional +- Make the `filename` argument optional -0.0.0 / 2014-09-18 -================== +# 0.0.0 / 2014-09-18 - * Initial release +- Initial release diff --git a/README.md b/README.md index 75c91a9..b407bdc 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ $ npm install content-disposition ## API ```js -const contentDisposition = require('content-disposition') +const contentDisposition = require('content-disposition'); ``` ### contentDisposition(filename, options) @@ -27,7 +27,7 @@ if supplied. The `filename` is optional and if no file name is desired, but you want to specify `options`, set `filename` to `undefined`. ```js -res.setHeader('Content-Disposition', contentDisposition('∫ maths.pdf')) +res.setHeader('Content-Disposition', contentDisposition('∫ maths.pdf')); ``` **note** HTTP headers are of the ISO-8859-1 character set. If you are writing this @@ -47,11 +47,11 @@ a ISO-8859-1 version of the file name is automatically generated. This specifies the ISO-8859-1 file name to override the automatic generation or disables the generation all together, defaults to `true`. - - A string will specify the ISO-8859-1 file name to use in place of automatic - generation. - - `false` will disable including a ISO-8859-1 file name and only include the - Unicode version (unless the file name is already ISO-8859-1). - - `true` will enable automatic generation if the file name is outside ISO-8859-1. +- A string will specify the ISO-8859-1 file name to use in place of automatic + generation. +- `false` will disable including a ISO-8859-1 file name and only include the + Unicode version (unless the file name is already ISO-8859-1). +- `true` will enable automatic generation if the file name is outside ISO-8859-1. If the `filename` option is ISO-8859-1 and this option is specified and has a different value, then the `filename` option is encoded in the extended field @@ -67,7 +67,9 @@ it). The type is normalized to lower-case. ### contentDisposition.parse(string) ```js -const disposition = contentDisposition.parse('attachment; filename="EURO rates.txt"; filename*=UTF-8\'\'%e2%82%ac%20rates.txt') +const disposition = contentDisposition.parse( + 'attachment; filename="EURO rates.txt"; filename*=UTF-8\'\'%e2%82%ac%20rates.txt', +); ``` Parse a `Content-Disposition` header string. This automatically handles extended @@ -75,36 +77,36 @@ Parse a `Content-Disposition` header string. This automatically handles extended parameter name. This will return an object with the following properties (examples are shown for the string `'attachment; filename="EURO rates.txt"; filename*=UTF-8\'\'%e2%82%ac%20rates.txt'`): - - `type`: The disposition type (always lower case). Example: `'attachment'` +- `type`: The disposition type (always lower case). Example: `'attachment'` - - `parameters`: An object of the parameters in the disposition (name of parameter - always lower case and extended versions replace non-extended versions). Example: - `{filename: "€ rates.txt"}` +- `parameters`: An object of the parameters in the disposition (name of parameter + always lower case and extended versions replace non-extended versions). Example: + `{filename: "€ rates.txt"}` ## Examples ### Send a file for download ```js -const contentDisposition = require('content-disposition') -const fs = require('fs') -const http = require('http') -const onFinished = require('on-finished') +const contentDisposition = require('content-disposition'); +const fs = require('fs'); +const http = require('http'); +const onFinished = require('on-finished'); -const filePath = '/path/to/public/plans.pdf' +const filePath = '/path/to/public/plans.pdf'; -http.createServer(function onRequest (req, res) { +http.createServer(function onRequest(req, res) { // set headers - res.setHeader('Content-Type', 'application/pdf') - res.setHeader('Content-Disposition', contentDisposition(filePath)) + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', contentDisposition(filePath)); // send file - const stream = fs.createReadStream(filePath) - stream.pipe(res) + const stream = fs.createReadStream(filePath); + stream.pipe(res); onFinished(res, function () { - stream.destroy() - }) -}) + stream.destroy(); + }); +}); ``` ## Testing diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b6588af --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,15 @@ +import eslint from '@eslint/js'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +export default defineConfig( + globalIgnores(['coverage', 'dist']), + eslint.configs.recommended, + tseslint.configs.strict, + { + files: ['**/*.spec.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +); diff --git a/index.js b/index.js deleted file mode 100644 index 08458b3..0000000 --- a/index.js +++ /dev/null @@ -1,477 +0,0 @@ -/*! - * content-disposition - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -/** - * Module exports. - * @public - */ - -module.exports = contentDisposition -module.exports.parse = parse - -/** - * RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%") - * @private - */ - -var ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g // eslint-disable-line no-control-regex - -/** - * RegExp to match percent encoding escape. - * @private - */ - -var HEX_ESCAPE_REGEXP = /%[0-9A-Fa-f]{2}/ -var HEX_ESCAPE_REPLACE_REGEXP = /%([0-9A-Fa-f]{2})/g - -/** - * RegExp to match non-latin1 characters. - * @private - */ - -var NON_LATIN1_REGEXP = /[^\x20-\x7e\xa0-\xff]/g - -/** - * RegExp to match quoted-pair in RFC 2616 - * - * quoted-pair = "\" CHAR - * CHAR = - * @private - */ - -var QESC_REGEXP = /\\([\u0000-\u007f])/g // eslint-disable-line no-control-regex - -/** - * RegExp to match chars that must be quoted-pair in RFC 2616 - * @private - */ - -var QUOTE_REGEXP = /([\\"])/g - -/** - * RegExp for various RFC 2616 grammar - * - * parameter = token "=" ( token | quoted-string ) - * token = 1* - * separators = "(" | ")" | "<" | ">" | "@" - * | "," | ";" | ":" | "\" | <"> - * | "/" | "[" | "]" | "?" | "=" - * | "{" | "}" | SP | HT - * quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) - * qdtext = > - * quoted-pair = "\" CHAR - * CHAR = - * TEXT = - * LWS = [CRLF] 1*( SP | HT ) - * CRLF = CR LF - * CR = - * LF = - * SP = - * HT = - * CTL = - * OCTET = - * @private - */ - -var PARAM_REGEXP = /;[\x09\x20]*([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*=[\x09\x20]*("(?:[\x20!\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\x20-\x7e])*"|[!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*/g // eslint-disable-line no-control-regex -var TEXT_REGEXP = /^[\x20-\x7e\x80-\xff]+$/ -var TOKEN_REGEXP = /^[!#$%&'*+.0-9A-Z^_`a-z|~-]+$/ - -/** - * RegExp for various RFC 5987 grammar - * - * ext-value = charset "'" [ language ] "'" value-chars - * charset = "UTF-8" / "ISO-8859-1" / mime-charset - * mime-charset = 1*mime-charsetc - * mime-charsetc = ALPHA / DIGIT - * / "!" / "#" / "$" / "%" / "&" - * / "+" / "-" / "^" / "_" / "`" - * / "{" / "}" / "~" - * language = ( 2*3ALPHA [ extlang ] ) - * / 4ALPHA - * / 5*8ALPHA - * extlang = *3( "-" 3ALPHA ) - * value-chars = *( pct-encoded / attr-char ) - * pct-encoded = "%" HEXDIG HEXDIG - * attr-char = ALPHA / DIGIT - * / "!" / "#" / "$" / "&" / "+" / "-" / "." - * / "^" / "_" / "`" / "|" / "~" - * @private - */ - -var EXT_VALUE_REGEXP = /^([A-Za-z0-9!#$%&+\-^_`{}~]+)'(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8}|)'((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+.^_`|~-])+)$/ - -/** - * RegExp for various RFC 6266 grammar - * - * disposition-type = "inline" | "attachment" | disp-ext-type - * disp-ext-type = token - * disposition-parm = filename-parm | disp-ext-parm - * filename-parm = "filename" "=" value - * | "filename*" "=" ext-value - * disp-ext-parm = token "=" value - * | ext-token "=" ext-value - * ext-token = - * @private - */ - -var DISPOSITION_TYPE_REGEXP = /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/ // eslint-disable-line no-control-regex - -/** - * Create an attachment Content-Disposition header. - * - * @param {string} [filename] - * @param {object} [options] - * @param {string} [options.type=attachment] - * @param {string|boolean} [options.fallback=true] - * @return {string} - * @public - */ - -function contentDisposition (filename, options) { - var opts = options || {} - - // get type - var type = opts.type || 'attachment' - - // get parameters - var params = createparams(filename, opts.fallback) - - // format into string - return format(new ContentDisposition(type, params)) -} - -/** - * Create parameters object from filename and fallback. - * - * @param {string} [filename] - * @param {string|boolean} [fallback=true] - * @return {object} - * @private - */ - -function createparams (filename, fallback) { - if (filename === undefined) { - return - } - - var params = {} - - if (typeof filename !== 'string') { - throw new TypeError('filename must be a string') - } - - // fallback defaults to true - if (fallback === undefined) { - fallback = true - } - - if (typeof fallback !== 'string' && typeof fallback !== 'boolean') { - throw new TypeError('fallback must be a string or boolean') - } - - if (typeof fallback === 'string' && NON_LATIN1_REGEXP.test(fallback)) { - throw new TypeError('fallback must be ISO-8859-1 string') - } - - // restrict to file base name - var name = basename(filename) - - // determine if name is suitable for quoted string - var isQuotedString = TEXT_REGEXP.test(name) - - // generate fallback name - var fallbackName = typeof fallback !== 'string' - ? fallback && getlatin1(name) - : basename(fallback) - var hasFallback = typeof fallbackName === 'string' && fallbackName !== name - - // set extended filename parameter - if (hasFallback || !isQuotedString || HEX_ESCAPE_REGEXP.test(name)) { - params['filename*'] = name - } - - // set filename parameter - if (isQuotedString || hasFallback) { - params.filename = hasFallback - ? fallbackName - : name - } - - return params -} - -/** - * Format object to Content-Disposition header. - * - * @param {object} obj - * @param {string} obj.type - * @param {object} [obj.parameters] - * @return {string} - * @private - */ - -function format (obj) { - var parameters = obj.parameters - var type = obj.type - - if (!type || typeof type !== 'string' || !TOKEN_REGEXP.test(type)) { - throw new TypeError('invalid type') - } - - // start with normalized type - var string = String(type).toLowerCase() - - // append parameters - if (parameters && typeof parameters === 'object') { - var param - var params = Object.keys(parameters).sort() - - for (var i = 0; i < params.length; i++) { - param = params[i] - - var val = param.slice(-1) === '*' - ? ustring(parameters[param]) - : qstring(parameters[param]) - - string += '; ' + param + '=' + val - } - } - - return string -} - -/** - * Decode a RFC 5987 field value (gracefully). - * - * @param {string} str - * @return {string} - * @private - */ - -function decodefield (str) { - var match = EXT_VALUE_REGEXP.exec(str) - - if (!match) { - throw new TypeError('invalid extended field value') - } - - var charset = match[1].toLowerCase() - var encoded = match[2] - var value - - // to binary string - var binary = encoded.replace(HEX_ESCAPE_REPLACE_REGEXP, pdecode) - - switch (charset) { - case 'iso-8859-1': - value = getlatin1(binary) - break - case 'utf-8': - case 'utf8': - value = Buffer.from(binary, 'binary').toString('utf8') - break - default: - throw new TypeError('unsupported charset in extended field') - } - - return value -} - -/** - * Get ISO-8859-1 version of string. - * - * @param {string} val - * @return {string} - * @private - */ - -function getlatin1 (val) { - // simple Unicode -> ISO-8859-1 transformation - return String(val).replace(NON_LATIN1_REGEXP, '?') -} - -/** - * Parse Content-Disposition header string. - * - * @param {string} string - * @return {object} - * @public - */ - -function parse (string) { - if (!string || typeof string !== 'string') { - throw new TypeError('argument string is required') - } - - var match = DISPOSITION_TYPE_REGEXP.exec(string) - - if (!match) { - throw new TypeError('invalid type format') - } - - // normalize type - var index = match[0].length - var type = match[1].toLowerCase() - - var key - var names = [] - var params = {} - var value - - // calculate index to start at - index = PARAM_REGEXP.lastIndex = match[0].slice(-1) === ';' - ? index - 1 - : index - - // match parameters - while ((match = PARAM_REGEXP.exec(string))) { - if (match.index !== index) { - throw new TypeError('invalid parameter format') - } - - index += match[0].length - key = match[1].toLowerCase() - value = match[2] - - if (names.indexOf(key) !== -1) { - throw new TypeError('invalid duplicate parameter') - } - - names.push(key) - - if (key.indexOf('*') + 1 === key.length) { - // decode extended value - key = key.slice(0, -1) - value = decodefield(value) - - // overwrite existing value - params[key] = value - continue - } - - if (typeof params[key] === 'string') { - continue - } - - if (value[0] === '"') { - // remove quotes and escapes - value = value - .slice(1, -1) - .replace(QESC_REGEXP, '$1') - } - - params[key] = value - } - - if (index !== -1 && index !== string.length) { - throw new TypeError('invalid parameter format') - } - - return new ContentDisposition(type, params) -} - -/** - * Percent decode a single character. - * - * @param {string} str - * @param {string} hex - * @return {string} - * @private - */ - -function pdecode (str, hex) { - return String.fromCharCode(parseInt(hex, 16)) -} - -/** - * Percent encode a single character. - * - * @param {string} char - * @return {string} - * @private - */ - -function pencode (char) { - return '%' + String(char) - .charCodeAt(0) - .toString(16) - .toUpperCase() -} - -/** - * Quote a string for HTTP. - * - * @param {string} val - * @return {string} - * @private - */ - -function qstring (val) { - var str = String(val) - - return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"' -} - -/** - * Encode a Unicode string for HTTP (RFC 5987). - * - * @param {string} val - * @return {string} - * @private - */ - -function ustring (val) { - var str = String(val) - - // percent encode as UTF-8 - var encoded = encodeURIComponent(str) - .replace(ENCODE_URL_ATTR_CHAR_REGEXP, pencode) - - return 'UTF-8\'\'' + encoded -} - -/** - * Class for parsed Content-Disposition header for v8 optimization - * - * @public - * @param {string} type - * @param {object} parameters - * @constructor - */ - -function ContentDisposition (type, parameters) { - this.type = type - this.parameters = parameters -} - -/** - * Return the last portion of a path - * - * @param {string} path - * @returns {string} - */ -function basename (path) { - const normalized = path.replaceAll('\\', '/') - - let end = normalized.length - while (end > 0 && normalized[end - 1] === '/') { - end-- - } - - if (end === 0) { - return '' - } - - let start = end - 1 - while (start >= 0 && normalized[start] !== '/') { - start-- - } - - return normalized.slice(start + 1, end) -} diff --git a/package.json b/package.json index 4f1d8a3..5961b09 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,7 @@ { "name": "content-disposition", - "description": "Create and parse Content-Disposition header", "version": "1.0.1", - "author": "Douglas Christopher Wilson ", - "license": "MIT", + "description": "Create and parse Content-Disposition header", "keywords": [ "content-disposition", "http", @@ -15,26 +13,50 @@ "type": "opencollective", "url": "https://opencollective.com/express" }, - "devDependencies": { - "c8": "^10.1.2", - "eslint": "^8.57.1", - "eslint-config-standard": "^14.1.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-markdown": "^3.0.1", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^6.6.0", - "eslint-plugin-standard": "^4.1.0" + "license": "MIT", + "author": "Douglas Christopher Wilson ", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.cts", "files": [ - "index.js" + "dist" ], - "engines": { - "node": ">=18" - }, "scripts": { + "build": "tsdown", + "format": "prettier --write .", + "format:check": "prettier --check .", "lint": "eslint .", - "test": "node --test --test-reporter spec", - "test-ci": "c8 --reporter=lcovonly --reporter=text npm test", - "test-cov": "c8 --reporter=html --reporter=text npm test" + "prepare": "husky", + "test": "vitest", + "test:ci": "vitest --coverage --coverage.include=src/** --coverage.reporter text --coverage.reporter=lcovonly", + "test:cov": "vitest --coverage --coverage.include=src/**", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@arethetypeswrong/core": "^0.18.2", + "@eslint/js": "^9.39.2", + "@types/node": "^20.19.33", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.39.2", + "husky": "^9.1.7", + "prettier": "^3.8.1", + "prettier-plugin-packagejson": "^3.0.0", + "pretty-quick": "^4.2.2", + "publint": "^0.3.17", + "tsdown": "^0.20.3", + "typescript": "^5.9.3", + "typescript-eslint": "^8.55.0", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=18" } } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9dc4711 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,428 @@ +/*! + * content-disposition + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%") + */ +const ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g; // eslint-disable-line no-control-regex + +/** + * RegExp to match percent encoding escape. + */ +const HEX_ESCAPE_REGEXP = /%[0-9A-Fa-f]{2}/; +const HEX_ESCAPE_REPLACE_REGEXP = /%([0-9A-Fa-f]{2})/g; + +/** + * RegExp to match non-latin1 characters. + */ +const NON_LATIN1_REGEXP = /[^\x20-\x7e\xa0-\xff]/g; + +/** + * RegExp to match quoted-pair in RFC 2616 + * + * quoted-pair = "\" CHAR + * CHAR = + */ +const QESC_REGEXP = /\\([\u0000-\u007f])/g; // eslint-disable-line no-control-regex + +/** + * RegExp to match chars that must be quoted-pair in RFC 2616 + */ +const QUOTE_REGEXP = /([\\"])/g; + +/** + * RegExp for various RFC 2616 grammar + * + * parameter = token "=" ( token | quoted-string ) + * token = 1* + * separators = "(" | ")" | "<" | ">" | "@" + * | "," | ";" | ":" | "\" | <"> + * | "/" | "[" | "]" | "?" | "=" + * | "{" | "}" | SP | HT + * quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) + * qdtext = > + * quoted-pair = "\" CHAR + * CHAR = + * TEXT = + * LWS = [CRLF] 1*( SP | HT ) + * CRLF = CR LF + * CR = + * LF = + * SP = + * HT = + * CTL = + * OCTET = + */ +const PARAM_REGEXP = + /;[\x09\x20]*([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*=[\x09\x20]*("(?:[\x20!\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\x20-\x7e])*"|[!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*/g; // eslint-disable-line no-control-regex +const TEXT_REGEXP = /^[\x20-\x7e\x80-\xff]+$/; +const TOKEN_REGEXP = /^[!#$%&'*+.0-9A-Z^_`a-z|~-]+$/; + +/** + * RegExp for various RFC 5987 grammar + * + * ext-value = charset "'" [ language ] "'" value-chars + * charset = "UTF-8" / "ISO-8859-1" / mime-charset + * mime-charset = 1*mime-charsetc + * mime-charsetc = ALPHA / DIGIT + * / "!" / "#" / "$" / "%" / "&" + * / "+" / "-" / "^" / "_" / "`" + * / "{" / "}" / "~" + * language = ( 2*3ALPHA [ extlang ] ) + * / 4ALPHA + * / 5*8ALPHA + * extlang = *3( "-" 3ALPHA ) + * value-chars = *( pct-encoded / attr-char ) + * pct-encoded = "%" HEXDIG HEXDIG + * attr-char = ALPHA / DIGIT + * / "!" / "#" / "$" / "&" / "+" / "-" / "." + * / "^" / "_" / "`" / "|" / "~" + */ +const EXT_VALUE_REGEXP = + /^([A-Za-z0-9!#$%&+\-^_`{}~]+)'(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8}|)'((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+.^_`|~-])+)$/; + +/** + * RegExp for various RFC 6266 grammar + * + * disposition-type = "inline" | "attachment" | disp-ext-type + * disp-ext-type = token + * disposition-parm = filename-parm | disp-ext-parm + * filename-parm = "filename" "=" value + * | "filename*" "=" ext-value + * disp-ext-parm = token "=" value + * | ext-token "=" ext-value + * ext-token = + */ +const DISPOSITION_TYPE_REGEXP = + /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/; // eslint-disable-line no-control-regex + +export interface ContentDisposition { + /** + * Content-Disposition type, such as "attachment" or "inline" + */ + type: string; + /** + * Content-Disposition parameters, such as "filename" + */ + parameters: Record; +} + +export interface ContentDispositionCreateOptions { + /** + * Content-Disposition type, defaults to "attachment" + * @default "attachment" + */ + type?: string; + /** + * Fallback filename for non-ISO-8859-1 strings. If true, a fallback will be generated by replacing non-latin1 characters with "?". If false, no fallback will be generated. + * @default true + */ + fallback?: string | boolean; +} + +export interface ContentDispositionFormat { + type: string; + parameters?: Record; +} + +/** + * Create an attachment Content-Disposition header. + */ +export function create( + filename?: string, + options?: ContentDispositionCreateOptions, +): string { + // get type + const type = options?.type || 'attachment'; + + // get parameters + const params = createparams(filename, options?.fallback); + + // format into string + return format(new ContentDispositionImpl(type, params)); +} + +/** + * Create parameters object from filename and fallback. + */ +function createparams( + filename?: string, + fallback: string | boolean = true, +): Record | undefined { + if (filename === undefined) { + return; + } + + if (typeof filename !== 'string') { + throw new TypeError('filename must be a string'); + } + + if (typeof fallback !== 'string' && typeof fallback !== 'boolean') { + throw new TypeError('fallback must be a string or boolean'); + } + + if (typeof fallback === 'string' && NON_LATIN1_REGEXP.test(fallback)) { + throw new TypeError('fallback must be ISO-8859-1 string'); + } + + const params: Record = {}; + + // restrict to file base name + const name = basename(filename); + + // determine if name is suitable for quoted string + const isQuotedString = TEXT_REGEXP.test(name); + + // generate fallback name + const fallbackName = + typeof fallback !== 'string' + ? fallback && getlatin1(name) + : basename(fallback); + const hasFallback = typeof fallbackName === 'string' && fallbackName !== name; + + // set extended filename parameter + if (hasFallback || !isQuotedString || HEX_ESCAPE_REGEXP.test(name)) { + params['filename*'] = name; + } + + // set filename parameter + if (isQuotedString || hasFallback) { + params.filename = hasFallback ? fallbackName : name; + } + + return params; +} + +/** + * Format object to Content-Disposition header. + */ +export function format(obj: ContentDispositionFormat): string { + if (!obj || typeof obj !== 'object') { + throw new TypeError('argument obj is required'); + } + + if ( + !obj.type || + typeof obj.type !== 'string' || + !TOKEN_REGEXP.test(obj.type) + ) { + throw new TypeError('invalid type'); + } + + // start with normalized type + let string = obj.type.toLowerCase(); + + // append parameters + if (obj.parameters && typeof obj.parameters === 'object') { + const params = Object.keys(obj.parameters).sort(); + + for (let i = 0; i < params.length; i++) { + const param = params[i]; + + const val = + param.slice(-1) === '*' + ? ustring(obj.parameters[param]) + : qstring(obj.parameters[param]); + + string += `; ${param}=${val}`; + } + } + + return string; +} + +/** + * Decode a RFC 5987 field value (gracefully). + */ +function decodefield(str: string): string { + const match = EXT_VALUE_REGEXP.exec(str); + + if (!match) { + throw new TypeError('invalid extended field value'); + } + + const charset = match[1].toLowerCase(); + const encoded = match[2]; + let value; + + // to binary string + const binary = encoded.replace(HEX_ESCAPE_REPLACE_REGEXP, pdecode); + + switch (charset) { + case 'iso-8859-1': + value = getlatin1(binary); + break; + case 'utf-8': + case 'utf8': + value = Buffer.from(binary, 'binary').toString('utf8'); + break; + default: + throw new TypeError('unsupported charset in extended field'); + } + + return value; +} + +/** + * Get ISO-8859-1 version of string. + */ +function getlatin1(val: string): string { + // simple Unicode -> ISO-8859-1 transformation + return val.replace(NON_LATIN1_REGEXP, '?'); +} + +/** + * Parse Content-Disposition header string. + */ +export function parse(string: string): ContentDisposition { + if (!string || typeof string !== 'string') { + throw new TypeError('argument string is required'); + } + + let match = DISPOSITION_TYPE_REGEXP.exec(string); + + if (!match) { + throw new TypeError('invalid type format'); + } + + // normalize type + let index = match[0].length; + const type = match[1].toLowerCase(); + + const obj = new ContentDispositionImpl(type); + + let key; + const names = []; + let value; + + // calculate index to start at + index = PARAM_REGEXP.lastIndex = + match[0].slice(-1) === ';' ? index - 1 : index; + + // match parameters + while ((match = PARAM_REGEXP.exec(string))) { + if (match.index !== index) { + throw new TypeError('invalid parameter format'); + } + + index += match[0].length; + key = match[1].toLowerCase(); + value = match[2]; + + if (names.indexOf(key) !== -1) { + throw new TypeError('invalid duplicate parameter'); + } + + names.push(key); + + if (key.indexOf('*') + 1 === key.length) { + // decode extended value + key = key.slice(0, -1); + value = decodefield(value); + + // overwrite existing value + obj.parameters[key] = value; + continue; + } + + if (typeof obj.parameters[key] === 'string') { + continue; + } + + if (value[0] === '"') { + // remove quotes and escapes + value = value.slice(1, -1).replace(QESC_REGEXP, '$1'); + } + + obj.parameters[key] = value; + } + + if (index !== -1 && index !== string.length) { + throw new TypeError('invalid parameter format'); + } + + return obj; +} + +/** + * Percent decode a single character. + */ +function pdecode(str: string, hex: string): string { + return String.fromCharCode(parseInt(hex, 16)); +} + +/** + * Percent encode a single character. + */ +function pencode(char: string): string { + return '%' + char.charCodeAt(0).toString(16).toUpperCase(); +} + +/** + * Quote a string for HTTP. + */ +function qstring(val: unknown): string { + const str = String(val); + + return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"'; +} + +/** + * Encode a Unicode string for HTTP (RFC 5987). + */ +function ustring(val: unknown): string { + const str = String(val); + + // percent encode as UTF-8 + const encoded = encodeURIComponent(str).replace( + ENCODE_URL_ATTR_CHAR_REGEXP, + pencode, + ); + + return "UTF-8''" + encoded; +} + +/** + * Class for parsed Content-Disposition header for v8 optimization + */ +class ContentDispositionImpl implements ContentDisposition { + type: string; + parameters: Record; + + constructor(type: string, parameters?: Record) { + this.type = type; + this.parameters = Object.create(null); + + if (parameters) { + for (const [key, value] of Object.entries(parameters)) { + this.parameters[key] = value; + } + } + } +} + +/** + * Return the last portion of a path + */ +function basename(path: string): string { + const normalized = path.replaceAll('\\', '/'); + + let end = normalized.length; + while (end > 0 && normalized[end - 1] === '/') { + end--; + } + + if (end === 0) { + return ''; + } + + let start = end - 1; + while (start >= 0 && normalized[start] !== '/') { + start--; + } + + return normalized.slice(start + 1, end); +} diff --git a/test/create.spec.ts b/test/create.spec.ts new file mode 100644 index 0000000..f4f588c --- /dev/null +++ b/test/create.spec.ts @@ -0,0 +1,299 @@ +import { describe, it, assert } from 'vitest'; +import { create } from '../src'; + +describe('create()', function () { + it('should create an attachment header', function () { + assert.strictEqual(create(), 'attachment'); + }); +}); + +describe('create(filename)', function () { + it('should require a string', function () { + assert.throws(create.bind(null, 42 as any), /filename.*string/); + }); + + it('should create a header with file name', function () { + assert.strictEqual(create('plans.pdf'), 'attachment; filename="plans.pdf"'); + }); + + it('should use the basename of a posix path', function () { + assert.strictEqual( + create('/path/to/plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path', function () { + assert.strictEqual( + create('\\path\\to\\plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with drive letter', function () { + assert.strictEqual( + create('C:\\path\\to\\plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a posix path with trailing slash', function () { + assert.strictEqual( + create('/path/to/plans.pdf/'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with trailing slash', function () { + assert.strictEqual( + create('\\path\\to\\plans.pdf\\'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with drive letter and trailing slash', function () { + assert.strictEqual( + create('C:\\path\\to\\plans.pdf\\'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a posix path with trailing slashes', function () { + assert.strictEqual( + create('/path/to/plans.pdf///'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with trailing slashes', function () { + assert.strictEqual( + create('\\path\\to\\plans.pdf\\\\\\'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with drive letter and trailing slashes', function () { + assert.strictEqual( + create('C:\\path\\to\\plans.pdf\\\\\\'), + 'attachment; filename="plans.pdf"', + ); + }); + + describe('when "filename" is US-ASCII', function () { + it('should only include filename parameter', function () { + assert.strictEqual( + create('plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should escape quotes', function () { + assert.strictEqual( + create('the "plans".pdf'), + 'attachment; filename="the \\"plans\\".pdf"', + ); + }); + }); + + describe('when "filename" is ISO-8859-1', function () { + it('should only include filename parameter', function () { + assert.strictEqual( + create('«plans».pdf'), + 'attachment; filename="«plans».pdf"', + ); + }); + + it('should escape quotes', function () { + assert.strictEqual( + create('the "plans" (1µ).pdf'), + 'attachment; filename="the \\"plans\\" (1µ).pdf"', + ); + }); + }); + + describe('when "filename" is Unicode', function () { + it('should include filename* parameter', function () { + assert.strictEqual( + create('планы.pdf'), + 'attachment; filename="?????.pdf"; filename*=UTF-8\'\'%D0%BF%D0%BB%D0%B0%D0%BD%D1%8B.pdf', + ); + }); + + it('should include filename fallback', function () { + assert.strictEqual( + create('£ and € rates.pdf'), + 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', + ); + assert.strictEqual( + create('€ rates.pdf'), + 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + it('should encode special characters', function () { + assert.strictEqual( + create("€'*%().pdf"), + "attachment; filename=\"?'*%().pdf\"; filename*=UTF-8''%E2%82%AC%27%2A%25%28%29.pdf", + ); + }); + }); + + describe('when "filename" contains hex escape', function () { + it('should include filename* parameter', function () { + assert.strictEqual( + create('the%20plans.pdf'), + 'attachment; filename="the%20plans.pdf"; filename*=UTF-8\'\'the%2520plans.pdf', + ); + }); + + it('should handle Unicode', function () { + assert.strictEqual( + create('€%20£.pdf'), + 'attachment; filename="?%20£.pdf"; filename*=UTF-8\'\'%E2%82%AC%2520%C2%A3.pdf', + ); + }); + }); +}); + +describe('create(filename, options)', function () { + describe('with "fallback" option', function () { + it('should require a string or Boolean', function () { + assert.throws( + create.bind(null, 'plans.pdf', { fallback: 42 } as any), + /fallback.*string/, + ); + }); + + it('should default to true', function () { + assert.strictEqual( + create('€ rates.pdf'), + 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + describe('when "false"', function () { + it('should not generate ISO-8859-1 fallback', function () { + assert.strictEqual( + create('£ and € rates.pdf', { fallback: false }), + "attachment; filename*=UTF-8''%C2%A3%20and%20%E2%82%AC%20rates.pdf", + ); + }); + + it('should keep ISO-8859-1 filename', function () { + assert.strictEqual( + create('£ rates.pdf', { fallback: false }), + 'attachment; filename="£ rates.pdf"', + ); + }); + }); + + describe('when "true"', function () { + it('should generate ISO-8859-1 fallback', function () { + assert.strictEqual( + create('£ and € rates.pdf', { fallback: true }), + 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', + ); + }); + + it('should pass through ISO-8859-1 filename', function () { + assert.strictEqual( + create('£ rates.pdf', { fallback: true }), + 'attachment; filename="£ rates.pdf"', + ); + }); + }); + + describe('when a string', function () { + it('should require an ISO-8859-1 string', function () { + assert.throws( + create.bind(null, '€ rates.pdf', { fallback: '€ rates.pdf' }), + /fallback.*iso-8859-1/i, + ); + }); + + it('should use as ISO-8859-1 fallback', function () { + assert.strictEqual( + create('£ and € rates.pdf', { fallback: '£ and EURO rates.pdf' }), + 'attachment; filename="£ and EURO rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', + ); + }); + + it('should use as fallback even when filename is ISO-8859-1', function () { + assert.strictEqual( + create('"£ rates".pdf', { fallback: '£ rates.pdf' }), + 'attachment; filename="£ rates.pdf"; filename*=UTF-8\'\'%22%C2%A3%20rates%22.pdf', + ); + }); + + it('should do nothing if equal to filename', function () { + assert.strictEqual( + create('plans.pdf', { fallback: 'plans.pdf' }), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a posix path', function () { + assert.strictEqual( + create('€ rates.pdf', { fallback: '/path/to/EURO rates.pdf' }), + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + it('should use the basename of a windows path', function () { + assert.strictEqual( + create('€ rates.pdf', { fallback: '\\path\\to\\EURO rates.pdf' }), + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + it('should use the basename of a windows path with drive letter', function () { + assert.strictEqual( + create('€ rates.pdf', { fallback: 'C:\\path\\to\\EURO rates.pdf' }), + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + it('should do nothing without filename option', function () { + assert.strictEqual( + create(undefined, { fallback: 'plans.pdf' }), + 'attachment', + ); + }); + }); + }); + + describe('with "type" option', function () { + it('should default to attachment', function () { + assert.strictEqual(create(), 'attachment'); + }); + + it('should require a string', function () { + assert.throws( + create.bind(null, undefined, { type: 42 } as any), + /invalid type/, + ); + }); + + it('should require a valid type', function () { + assert.throws( + create.bind(null, undefined, { type: 'invalid;type' }), + /invalid type/, + ); + }); + + it('should create a header with inline type', function () { + assert.strictEqual(create(undefined, { type: 'inline' }), 'inline'); + }); + + it('should create a header with inline type & filename', function () { + assert.strictEqual( + create('plans.pdf', { type: 'inline' }), + 'inline; filename="plans.pdf"', + ); + }); + + it('should normalize type', function () { + assert.strictEqual(create(undefined, { type: 'INLINE' }), 'inline'); + }); + }); +}); diff --git a/test/parse.spec.ts b/test/parse.spec.ts new file mode 100644 index 0000000..9851617 --- /dev/null +++ b/test/parse.spec.ts @@ -0,0 +1,1005 @@ +import { describe, it, assert } from 'vitest'; +import { parse } from '../src'; + +describe('parse(string)', function () { + it('should require string', function () { + assert.throws((parse as any).bind(null), /argument string.*required/); + }); + + it('should reject non-strings', function () { + assert.throws((parse as any).bind(null, 42), /argument string.*required/); + }); + + describe('with only type', function () { + it('should reject quoted value', function () { + assert.throws(parse.bind(null, '"attachment"'), /invalid type format/); + }); + + it('should reject trailing semicolon', function () { + assert.throws(parse.bind(null, 'attachment;'), /invalid.*format/); + }); + + it('should parse "attachment"', function () { + assert.deepEqual(parse('attachment'), { + type: 'attachment', + parameters: {}, + }); + }); + + it('should parse "inline"', function () { + assert.deepEqual(parse('inline'), { + type: 'inline', + parameters: {}, + }); + }); + + it('should parse "form-data"', function () { + assert.deepEqual(parse('form-data'), { + type: 'form-data', + parameters: {}, + }); + }); + + it('should parse with trailing LWS', function () { + assert.deepEqual(parse('attachment \t '), { + type: 'attachment', + parameters: {}, + }); + }); + + it('should normalize to lower-case', function () { + assert.deepEqual(parse('ATTACHMENT'), { + type: 'attachment', + parameters: {}, + }); + }); + }); + + describe('with parameters', function () { + it('should reject trailing semicolon', function () { + assert.throws( + parse.bind(null, 'attachment; filename="rates.pdf";'), + /invalid parameter format/, + ); + }); + + it('should reject invalid parameter name', function () { + assert.throws( + parse.bind(null, 'attachment; filename@="rates.pdf"'), + /invalid parameter format/, + ); + }); + + it('should reject missing parameter value', function () { + assert.throws( + parse.bind(null, 'attachment; filename='), + /invalid parameter format/, + ); + }); + + it('should reject invalid parameter value', function () { + assert.throws( + parse.bind(null, 'attachment; filename=trolly,trains'), + /invalid parameter format/, + ); + }); + + it('should reject invalid parameters', function () { + assert.throws( + parse.bind(null, 'attachment; filename=total/; foo=bar'), + /invalid parameter format/, + ); + }); + + it('should reject duplicate parameters', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo; filename=bar'), + /invalid duplicate parameter/, + ); + }); + + it('should reject missing type', function () { + assert.throws( + parse.bind(null, 'filename="plans.pdf"'), + /invalid type format/, + ); + assert.throws( + parse.bind(null, '; filename="plans.pdf"'), + /invalid type format/, + ); + }); + + it('should lower-case parameter name', function () { + assert.deepEqual(parse('attachment; FILENAME="plans.pdf"'), { + type: 'attachment', + parameters: { filename: 'plans.pdf' }, + }); + }); + + it('should parse quoted parameter value', function () { + assert.deepEqual(parse('attachment; filename="plans.pdf"'), { + type: 'attachment', + parameters: { filename: 'plans.pdf' }, + }); + }); + + it('should parse & unescape quoted value', function () { + assert.deepEqual(parse('attachment; filename="the \\"plans\\".pdf"'), { + type: 'attachment', + parameters: { filename: 'the "plans".pdf' }, + }); + }); + + it('should include all parameters', function () { + assert.deepEqual(parse('attachment; filename="plans.pdf"; foo=bar'), { + type: 'attachment', + parameters: { filename: 'plans.pdf', foo: 'bar' }, + }); + }); + + it('should parse parameters separated with any LWS', function () { + assert.deepEqual( + parse('attachment;filename="plans.pdf" \t; \t\t foo=bar'), + { + type: 'attachment', + parameters: { filename: 'plans.pdf', foo: 'bar' }, + }, + ); + }); + + it('should parse token filename', function () { + assert.deepEqual(parse('attachment; filename=plans.pdf'), { + type: 'attachment', + parameters: { filename: 'plans.pdf' }, + }); + }); + + it('should parse ISO-8859-1 filename', function () { + assert.deepEqual(parse('attachment; filename="£ rates.pdf"'), { + type: 'attachment', + parameters: { filename: '£ rates.pdf' }, + }); + }); + }); + + describe('with extended parameters', function () { + it('should reject quoted extended parameter value', function () { + assert.throws( + parse.bind( + null, + 'attachment; filename*="UTF-8\'\'%E2%82%AC%20rates.pdf"', + ), + /invalid extended.*value/, + ); + }); + + it('should parse UTF-8 extended parameter value', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8''%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + + it('should parse UTF8 extended parameter value', function () { + assert.deepEqual( + parse("attachment; filename*=utf8''%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + + it('should parse UTF-8 extended parameter value', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8''%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + assert.deepEqual(parse("attachment; filename*=UTF-8''%E4%20rates.pdf"), { + type: 'attachment', + parameters: { filename: '\ufffd rates.pdf' }, + }); + }); + + it('should parse ISO-8859-1 extended parameter value', function () { + assert.deepEqual( + parse("attachment; filename*=ISO-8859-1''%A3%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '£ rates.pdf' }, + }, + ); + assert.deepEqual( + parse("attachment; filename*=ISO-8859-1''%82%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '? rates.pdf' }, + }, + ); + }); + + it('should not be case-sensitive for charset', function () { + assert.deepEqual( + parse("attachment; filename*=utf-8''%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + + it('should reject unsupported charset', function () { + assert.throws( + parse.bind(null, "attachment; filename*=ISO-8859-2''%A4%20rates.pdf"), + /unsupported charset/, + ); + }); + + it('should parse with embedded language', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8'en'%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + + it('should prefer extended parameter value', function () { + assert.deepEqual( + parse( + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + assert.deepEqual( + parse( + 'attachment; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf; filename="EURO rates.pdf"', + ), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + }); + + describe('from TC 2231', function () { + describe('Disposition-Type Inline', function () { + it('should parse "inline"', function () { + assert.deepEqual(parse('inline'), { + type: 'inline', + parameters: {}, + }); + }); + + it('should reject ""inline""', function () { + assert.throws(parse.bind(null, '"inline"'), /invalid type format/); + }); + + it('should parse "inline; filename="foo.html""', function () { + assert.deepEqual(parse('inline; filename="foo.html"'), { + type: 'inline', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should parse "inline; filename="Not an attachment!""', function () { + assert.deepEqual(parse('inline; filename="Not an attachment!"'), { + type: 'inline', + parameters: { filename: 'Not an attachment!' }, + }); + }); + + it('should parse "inline; filename="foo.pdf""', function () { + assert.deepEqual(parse('inline; filename="foo.pdf"'), { + type: 'inline', + parameters: { filename: 'foo.pdf' }, + }); + }); + }); + + describe('Disposition-Type Attachment', function () { + it('should parse "attachment"', function () { + assert.deepEqual(parse('attachment'), { + type: 'attachment', + parameters: {}, + }); + }); + + it('should reject ""attachment""', function () { + assert.throws(parse.bind(null, '"attachment"'), /invalid type format/); + }); + + it('should parse "ATTACHMENT"', function () { + assert.deepEqual(parse('ATTACHMENT'), { + type: 'attachment', + parameters: {}, + }); + }); + + it('should parse "attachment; filename="foo.html""', function () { + assert.deepEqual(parse('attachment; filename="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should parse "attachment; filename="0000000000111111111122222""', function () { + assert.deepEqual( + parse('attachment; filename="0000000000111111111122222"'), + { + type: 'attachment', + parameters: { filename: '0000000000111111111122222' }, + }, + ); + }); + + it('should parse "attachment; filename="00000000001111111111222222222233333""', function () { + assert.deepEqual( + parse('attachment; filename="00000000001111111111222222222233333"'), + { + type: 'attachment', + parameters: { filename: '00000000001111111111222222222233333' }, + }, + ); + }); + + it('should parse "attachment; filename="f\\oo.html""', function () { + assert.deepEqual(parse('attachment; filename="f\\oo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should parse "attachment; filename="\\"quoting\\" tested.html""', function () { + assert.deepEqual( + parse('attachment; filename="\\"quoting\\" tested.html"'), + { + type: 'attachment', + parameters: { filename: '"quoting" tested.html' }, + }, + ); + }); + + it('should parse "attachment; filename="Here\'s a semicolon;.html""', function () { + assert.deepEqual( + parse('attachment; filename="Here\'s a semicolon;.html"'), + { + type: 'attachment', + parameters: { filename: "Here's a semicolon;.html" }, + }, + ); + }); + + it('should parse "attachment; foo="bar"; filename="foo.html""', function () { + assert.deepEqual(parse('attachment; foo="bar"; filename="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html', foo: 'bar' }, + }); + }); + + it('should parse "attachment; foo="\\"\\\\";filename="foo.html""', function () { + assert.deepEqual( + parse('attachment; foo="\\"\\\\";filename="foo.html"'), + { + type: 'attachment', + parameters: { filename: 'foo.html', foo: '"\\' }, + }, + ); + }); + + it('should parse "attachment; FILENAME="foo.html""', function () { + assert.deepEqual(parse('attachment; FILENAME="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should parse "attachment; filename=foo.html"', function () { + assert.deepEqual(parse('attachment; filename=foo.html'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should reject "attachment; filename=foo,bar.html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo,bar.html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo.html ;"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo.html ;'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; ;filename=foo"', function () { + assert.throws( + parse.bind(null, 'attachment; ;filename=foo'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo bar.html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo bar.html'), + /invalid parameter format/, + ); + }); + + it("should parse \"attachment; filename='foo.bar'", function () { + assert.deepEqual(parse("attachment; filename='foo.bar'"), { + type: 'attachment', + parameters: { filename: "'foo.bar'" }, + }); + }); + + it('should parse "attachment; filename="foo-ä.html""', function () { + assert.deepEqual(parse('attachment; filename="foo-ä.html"'), { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }); + }); + + it('should parse "attachment; filename="foo-ä.html""', function () { + assert.deepEqual(parse('attachment; filename="foo-ä.html"'), { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }); + }); + + it('should parse "attachment; filename="foo-%41.html""', function () { + assert.deepEqual(parse('attachment; filename="foo-%41.html"'), { + type: 'attachment', + parameters: { filename: 'foo-%41.html' }, + }); + }); + + it('should parse "attachment; filename="50%.html""', function () { + assert.deepEqual(parse('attachment; filename="50%.html"'), { + type: 'attachment', + parameters: { filename: '50%.html' }, + }); + }); + + it('should parse "attachment; filename="foo-%\\41.html""', function () { + assert.deepEqual(parse('attachment; filename="foo-%\\41.html"'), { + type: 'attachment', + parameters: { filename: 'foo-%41.html' }, + }); + }); + + it('should parse "attachment; name="foo-%41.html""', function () { + assert.deepEqual(parse('attachment; name="foo-%41.html"'), { + type: 'attachment', + parameters: { name: 'foo-%41.html' }, + }); + }); + + it('should parse "attachment; filename="ä-%41.html""', function () { + assert.deepEqual(parse('attachment; filename="ä-%41.html"'), { + type: 'attachment', + parameters: { filename: 'ä-%41.html' }, + }); + }); + + it('should parse "attachment; filename="foo-%c3%a4-%e2%82%ac.html""', function () { + assert.deepEqual( + parse('attachment; filename="foo-%c3%a4-%e2%82%ac.html"'), + { + type: 'attachment', + parameters: { filename: 'foo-%c3%a4-%e2%82%ac.html' }, + }, + ); + }); + + it('should parse "attachment; filename ="foo.html""', function () { + assert.deepEqual(parse('attachment; filename ="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should reject "attachment; filename="foo.html"; filename="bar.html"', function () { + assert.throws( + parse.bind( + null, + 'attachment; filename="foo.html"; filename="bar.html"', + ), + /invalid duplicate parameter/, + ); + }); + + it('should reject "attachment; filename=foo[1](2).html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo[1](2).html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo-ä.html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo-ä.html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo-ä.html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo-ä.html'), + /invalid parameter format/, + ); + }); + + it('should reject "filename=foo.html"', function () { + assert.throws( + parse.bind(null, 'filename=foo.html'), + /invalid type format/, + ); + }); + + it('should reject "x=y; filename=foo.html"', function () { + assert.throws( + parse.bind(null, 'x=y; filename=foo.html'), + /invalid type format/, + ); + }); + + it('should reject ""foo; filename=bar;baz"; filename=qux"', function () { + assert.throws( + parse.bind(null, '"foo; filename=bar;baz"; filename=qux'), + /invalid type format/, + ); + }); + + it('should reject "filename=foo.html, filename=bar.html"', function () { + assert.throws( + parse.bind(null, 'filename=foo.html, filename=bar.html'), + /invalid type format/, + ); + }); + + it('should reject "; filename=foo.html"', function () { + assert.throws( + parse.bind(null, '; filename=foo.html'), + /invalid type format/, + ); + }); + + it('should reject ": inline; attachment; filename=foo.html', function () { + assert.throws( + parse.bind(null, ': inline; attachment; filename=foo.html'), + /invalid type format/, + ); + }); + + it('should reject "inline; attachment; filename=foo.html', function () { + assert.throws( + parse.bind(null, 'inline; attachment; filename=foo.html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; inline; filename=foo.html', function () { + assert.throws( + parse.bind(null, 'attachment; inline; filename=foo.html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename="foo.html".txt', function () { + assert.throws( + parse.bind(null, 'attachment; filename="foo.html".txt'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename="bar', function () { + assert.throws( + parse.bind(null, 'attachment; filename="bar'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo"bar;baz"qux', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo"bar;baz"qux'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo.html, attachment; filename=bar.html', function () { + assert.throws( + parse.bind( + null, + 'attachment; filename=foo.html, attachment; filename=bar.html', + ), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; foo=foo filename=bar', function () { + assert.throws( + parse.bind(null, 'attachment; foo=foo filename=bar'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=bar foo=foo', function () { + assert.throws( + parse.bind(null, 'attachment; filename=bar foo=foo'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment filename=bar', function () { + assert.throws( + parse.bind(null, 'attachment filename=bar'), + /invalid type format/, + ); + }); + + it('should reject "filename=foo.html; attachment', function () { + assert.throws( + parse.bind(null, 'filename=foo.html; attachment'), + /invalid type format/, + ); + }); + + it('should parse "attachment; xfilename=foo.html"', function () { + assert.deepEqual(parse('attachment; xfilename=foo.html'), { + type: 'attachment', + parameters: { xfilename: 'foo.html' }, + }); + }); + + it('should parse "attachment; filename="/foo.html""', function () { + assert.deepEqual(parse('attachment; filename="/foo.html"'), { + type: 'attachment', + parameters: { filename: '/foo.html' }, + }); + }); + + it('should parse "attachment; filename="\\\\foo.html""', function () { + assert.deepEqual(parse('attachment; filename="\\\\foo.html"'), { + type: 'attachment', + parameters: { filename: '\\foo.html' }, + }); + }); + }); + + describe('Additional Parameters', function () { + it('should parse "attachment; creation-date="Wed, 12 Feb 1997 16:29:51 -0500""', function () { + assert.deepEqual( + parse('attachment; creation-date="Wed, 12 Feb 1997 16:29:51 -0500"'), + { + type: 'attachment', + parameters: { 'creation-date': 'Wed, 12 Feb 1997 16:29:51 -0500' }, + }, + ); + }); + + it('should parse "attachment; modification-date="Wed, 12 Feb 1997 16:29:51 -0500""', function () { + assert.deepEqual( + parse( + 'attachment; modification-date="Wed, 12 Feb 1997 16:29:51 -0500"', + ), + { + type: 'attachment', + parameters: { + 'modification-date': 'Wed, 12 Feb 1997 16:29:51 -0500', + }, + }, + ); + }); + }); + + describe('Disposition-Type Extension', function () { + it('should parse "foobar"', function () { + assert.deepEqual(parse('foobar'), { + type: 'foobar', + parameters: {}, + }); + }); + + it('should parse "attachment; example="filename=example.txt""', function () { + assert.deepEqual(parse('attachment; example="filename=example.txt"'), { + type: 'attachment', + parameters: { example: 'filename=example.txt' }, + }); + }); + }); + + describe('RFC 2231/5987 Encoding: Character Sets', function () { + it('should parse "attachment; filename*=iso-8859-1\'\'foo-%E4.html"', function () { + assert.deepEqual( + parse("attachment; filename*=iso-8859-1''foo-%E4.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should parse "attachment; filename*=UTF-8\'\'foo-%c3%a4-%e2%82%ac.html"', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8''foo-%c3%a4-%e2%82%ac.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä-€.html' }, + }, + ); + }); + + it('should reject "attachment; filename*=\'\'foo-%c3%a4-%e2%82%ac.html"', function () { + assert.throws( + parse.bind(null, "attachment; filename*=''foo-%c3%a4-%e2%82%ac.html"), + /invalid extended.*value/, + ); + }); + + it('should parse "attachment; filename*=UTF-8\'\'foo-a%cc%88.html"', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8''foo-a%cc%88.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should parse "attachment; filename*=iso-8859-1\'\'foo-%c3%a4-%e2%82%ac.html"', function () { + assert.deepEqual( + parse("attachment; filename*=iso-8859-1''foo-%c3%a4-%e2%82%ac.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä-â?¬.html' }, + }, + ); + }); + + it('should parse "attachment; filename*=utf-8\'\'foo-%E4.html"', function () { + assert.deepEqual(parse("attachment; filename*=utf-8''foo-%E4.html"), { + type: 'attachment', + parameters: { filename: 'foo-\ufffd.html' }, + }); + }); + + it('should reject "attachment; filename *=UTF-8\'\'foo-%c3%a4.html"', function () { + assert.throws( + parse.bind(null, "attachment; filename *=UTF-8''foo-%c3%a4.html"), + /invalid parameter format/, + ); + }); + + it('should parse "attachment; filename*= UTF-8\'\'foo-%c3%a4.html"', function () { + assert.deepEqual( + parse("attachment; filename*= UTF-8''foo-%c3%a4.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should parse "attachment; filename* =UTF-8\'\'foo-%c3%a4.html"', function () { + assert.deepEqual( + parse("attachment; filename* =UTF-8''foo-%c3%a4.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should reject "attachment; filename*="UTF-8\'\'foo-%c3%a4.html""', function () { + assert.throws( + parse.bind(null, 'attachment; filename*="UTF-8\'\'foo-%c3%a4.html"'), + /invalid extended field value/, + ); + }); + + it('should reject "attachment; filename*="foo%20bar.html""', function () { + assert.throws( + parse.bind(null, 'attachment; filename*="foo%20bar.html"'), + /invalid extended field value/, + ); + }); + + it('should reject "attachment; filename*=UTF-8\'foo-%c3%a4.html"', function () { + assert.throws( + parse.bind(null, "attachment; filename*=UTF-8'foo-%c3%a4.html"), + /invalid extended field value/, + ); + }); + + it('should reject "attachment; filename*=UTF-8\'\'foo%"', function () { + assert.throws( + parse.bind(null, "attachment; filename*=UTF-8''foo%"), + /invalid extended field value/, + ); + }); + + it('should reject "attachment; filename*=UTF-8\'\'f%oo.html"', function () { + assert.throws( + parse.bind(null, "attachment; filename*=UTF-8''f%oo.html"), + /invalid extended field value/, + ); + }); + + it('should parse "attachment; filename*=UTF-8\'\'A-%2541.html"', function () { + assert.deepEqual(parse("attachment; filename*=UTF-8''A-%2541.html"), { + type: 'attachment', + parameters: { filename: 'A-%41.html' }, + }); + }); + + it('should parse "attachment; filename*=UTF-8\'\'%5cfoo.html"', function () { + assert.deepEqual(parse("attachment; filename*=UTF-8''%5cfoo.html"), { + type: 'attachment', + parameters: { filename: '\\foo.html' }, + }); + }); + }); + + describe('RFC2231 Encoding: Continuations', function () { + it('should parse "attachment; filename*0="foo."; filename*1="html""', function () { + assert.deepEqual( + parse('attachment; filename*0="foo."; filename*1="html"'), + { + type: 'attachment', + parameters: { 'filename*0': 'foo.', 'filename*1': 'html' }, + }, + ); + }); + + it('should parse "attachment; filename*0="foo"; filename*1="\\b\\a\\r.html""', function () { + assert.deepEqual( + parse('attachment; filename*0="foo"; filename*1="\\b\\a\\r.html"'), + { + type: 'attachment', + parameters: { 'filename*0': 'foo', 'filename*1': 'bar.html' }, + }, + ); + }); + + it('should parse "attachment; filename*0*=UTF-8\'\'foo-%c3%a4; filename*1=".html""', function () { + assert.deepEqual( + parse( + 'attachment; filename*0*=UTF-8\'\'foo-%c3%a4; filename*1=".html"', + ), + { + type: 'attachment', + parameters: { + 'filename*0*': "UTF-8''foo-%c3%a4", + 'filename*1': '.html', + }, + }, + ); + }); + + it('should parse "attachment; filename*0="foo"; filename*01="bar""', function () { + assert.deepEqual( + parse('attachment; filename*0="foo"; filename*01="bar"'), + { + type: 'attachment', + parameters: { 'filename*0': 'foo', 'filename*01': 'bar' }, + }, + ); + }); + + it('should parse "attachment; filename*0="foo"; filename*2="bar""', function () { + assert.deepEqual( + parse('attachment; filename*0="foo"; filename*2="bar"'), + { + type: 'attachment', + parameters: { 'filename*0': 'foo', 'filename*2': 'bar' }, + }, + ); + }); + + it('should parse "attachment; filename*1="foo."; filename*2="html""', function () { + assert.deepEqual( + parse('attachment; filename*1="foo."; filename*2="html"'), + { + type: 'attachment', + parameters: { 'filename*1': 'foo.', 'filename*2': 'html' }, + }, + ); + }); + + it('should parse "attachment; filename*1="bar"; filename*0="foo""', function () { + assert.deepEqual( + parse('attachment; filename*1="bar"; filename*0="foo"'), + { + type: 'attachment', + parameters: { 'filename*1': 'bar', 'filename*0': 'foo' }, + }, + ); + }); + }); + + describe('RFC2231 Encoding: Fallback Behaviour', function () { + it('should parse "attachment; filename="foo-ae.html"; filename*=UTF-8\'\'foo-%c3%a4.html"', function () { + assert.deepEqual( + parse( + 'attachment; filename="foo-ae.html"; filename*=UTF-8\'\'foo-%c3%a4.html', + ), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should parse "attachment; filename*=UTF-8\'\'foo-%c3%a4.html; filename="foo-ae.html"', function () { + assert.deepEqual( + parse( + 'attachment; filename*=UTF-8\'\'foo-%c3%a4.html; filename="foo-ae.html"', + ), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it("should parse \"attachment; filename*0*=ISO-8859-15''euro-sign%3d%a4; filename*=ISO-8859-1''currency-sign%3d%a4", function () { + assert.deepEqual( + parse( + "attachment; filename*0*=ISO-8859-15''euro-sign%3d%a4; filename*=ISO-8859-1''currency-sign%3d%a4", + ), + { + type: 'attachment', + parameters: { + filename: 'currency-sign=¤', + 'filename*0*': "ISO-8859-15''euro-sign%3d%a4", + }, + }, + ); + }); + + it('should parse "attachment; foobar=x; filename="foo.html"', function () { + assert.deepEqual(parse('attachment; foobar=x; filename="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html', foobar: 'x' }, + }); + }); + }); + + describe('RFC2047 Encoding', function () { + it('should reject "attachment; filename==?ISO-8859-1?Q?foo-=E4.html?="', function () { + assert.throws( + parse.bind( + null, + 'attachment; filename==?ISO-8859-1?Q?foo-=E4.html?=', + ), + /invalid parameter format/, + ); + }); + + it('should parse "attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?=""', function () { + assert.deepEqual( + parse('attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?="'), + { + type: 'attachment', + parameters: { filename: '=?ISO-8859-1?Q?foo-=E4.html?=' }, + }, + ); + }); + }); + }); +}); diff --git a/test/test.js b/test/test.js deleted file mode 100644 index ff5dd26..0000000 --- a/test/test.js +++ /dev/null @@ -1,1017 +0,0 @@ -var assert = require('assert') -var contentDisposition = require('..') -var { describe, it } = require('node:test') - -describe('contentDisposition()', function () { - it('should create an attachment header', function () { - assert.strictEqual(contentDisposition(), 'attachment') - }) -}) - -describe('contentDisposition(filename)', function () { - it('should require a string', function () { - assert.throws(contentDisposition.bind(null, 42), - /filename.*string/) - }) - - it('should create a header with file name', function () { - assert.strictEqual(contentDisposition('plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a posix path', function () { - assert.strictEqual(contentDisposition('/path/to/plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path', function () { - assert.strictEqual(contentDisposition('\\path\\to\\plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with drive letter', function () { - assert.strictEqual(contentDisposition('C:\\path\\to\\plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a posix path with trailing slash', function () { - assert.strictEqual(contentDisposition('/path/to/plans.pdf/'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with trailing slash', function () { - assert.strictEqual(contentDisposition('\\path\\to\\plans.pdf\\'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with drive letter and trailing slash', function () { - assert.strictEqual(contentDisposition('C:\\path\\to\\plans.pdf\\'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a posix path with trailing slashes', function () { - assert.strictEqual(contentDisposition('/path/to/plans.pdf///'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with trailing slashes', function () { - assert.strictEqual(contentDisposition('\\path\\to\\plans.pdf\\\\\\'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with drive letter and trailing slashes', function () { - assert.strictEqual(contentDisposition('C:\\path\\to\\plans.pdf\\\\\\'), - 'attachment; filename="plans.pdf"') - }) - - describe('when "filename" is US-ASCII', function () { - it('should only include filename parameter', function () { - assert.strictEqual(contentDisposition('plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should escape quotes', function () { - assert.strictEqual(contentDisposition('the "plans".pdf'), - 'attachment; filename="the \\"plans\\".pdf"') - }) - }) - - describe('when "filename" is ISO-8859-1', function () { - it('should only include filename parameter', function () { - assert.strictEqual(contentDisposition('«plans».pdf'), - 'attachment; filename="«plans».pdf"') - }) - - it('should escape quotes', function () { - assert.strictEqual(contentDisposition('the "plans" (1µ).pdf'), - 'attachment; filename="the \\"plans\\" (1µ).pdf"') - }) - }) - - describe('when "filename" is Unicode', function () { - it('should include filename* parameter', function () { - assert.strictEqual(contentDisposition('планы.pdf'), - 'attachment; filename="?????.pdf"; filename*=UTF-8\'\'%D0%BF%D0%BB%D0%B0%D0%BD%D1%8B.pdf') - }) - - it('should include filename fallback', function () { - assert.strictEqual(contentDisposition('£ and € rates.pdf'), - 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') - assert.strictEqual(contentDisposition('€ rates.pdf'), - 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - it('should encode special characters', function () { - assert.strictEqual(contentDisposition('€\'*%().pdf'), - 'attachment; filename="?\'*%().pdf"; filename*=UTF-8\'\'%E2%82%AC%27%2A%25%28%29.pdf') - }) - }) - - describe('when "filename" contains hex escape', function () { - it('should include filename* parameter', function () { - assert.strictEqual(contentDisposition('the%20plans.pdf'), - 'attachment; filename="the%20plans.pdf"; filename*=UTF-8\'\'the%2520plans.pdf') - }) - - it('should handle Unicode', function () { - assert.strictEqual(contentDisposition('€%20£.pdf'), - 'attachment; filename="?%20£.pdf"; filename*=UTF-8\'\'%E2%82%AC%2520%C2%A3.pdf') - }) - }) -}) - -describe('contentDisposition(filename, options)', function () { - describe('with "fallback" option', function () { - it('should require a string or Boolean', function () { - assert.throws(contentDisposition.bind(null, 'plans.pdf', { fallback: 42 }), - /fallback.*string/) - }) - - it('should default to true', function () { - assert.strictEqual(contentDisposition('€ rates.pdf'), - 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - describe('when "false"', function () { - it('should not generate ISO-8859-1 fallback', function () { - assert.strictEqual(contentDisposition('£ and € rates.pdf', { fallback: false }), - 'attachment; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') - }) - - it('should keep ISO-8859-1 filename', function () { - assert.strictEqual(contentDisposition('£ rates.pdf', { fallback: false }), - 'attachment; filename="£ rates.pdf"') - }) - }) - - describe('when "true"', function () { - it('should generate ISO-8859-1 fallback', function () { - assert.strictEqual(contentDisposition('£ and € rates.pdf', { fallback: true }), - 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') - }) - - it('should pass through ISO-8859-1 filename', function () { - assert.strictEqual(contentDisposition('£ rates.pdf', { fallback: true }), - 'attachment; filename="£ rates.pdf"') - }) - }) - - describe('when a string', function () { - it('should require an ISO-8859-1 string', function () { - assert.throws(contentDisposition.bind(null, '€ rates.pdf', { fallback: '€ rates.pdf' }), - /fallback.*iso-8859-1/i) - }) - - it('should use as ISO-8859-1 fallback', function () { - assert.strictEqual(contentDisposition('£ and € rates.pdf', { fallback: '£ and EURO rates.pdf' }), - 'attachment; filename="£ and EURO rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') - }) - - it('should use as fallback even when filename is ISO-8859-1', function () { - assert.strictEqual(contentDisposition('"£ rates".pdf', { fallback: '£ rates.pdf' }), - 'attachment; filename="£ rates.pdf"; filename*=UTF-8\'\'%22%C2%A3%20rates%22.pdf') - }) - - it('should do nothing if equal to filename', function () { - assert.strictEqual(contentDisposition('plans.pdf', { fallback: 'plans.pdf' }), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a posix path', function () { - assert.strictEqual(contentDisposition('€ rates.pdf', { fallback: '/path/to/EURO rates.pdf' }), - 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - it('should use the basename of a windows path', function () { - assert.strictEqual(contentDisposition('€ rates.pdf', { fallback: '\\path\\to\\EURO rates.pdf' }), - 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - it('should use the basename of a windows path with drive letter', function () { - assert.strictEqual(contentDisposition('€ rates.pdf', { fallback: 'C:\\path\\to\\EURO rates.pdf' }), - 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - it('should do nothing without filename option', function () { - assert.strictEqual(contentDisposition(undefined, { fallback: 'plans.pdf' }), - 'attachment') - }) - }) - }) - - describe('with "type" option', function () { - it('should default to attachment', function () { - assert.strictEqual(contentDisposition(), - 'attachment') - }) - - it('should require a string', function () { - assert.throws(contentDisposition.bind(null, undefined, { type: 42 }), - /invalid type/) - }) - - it('should require a valid type', function () { - assert.throws(contentDisposition.bind(null, undefined, { type: 'invalid;type' }), - /invalid type/) - }) - - it('should create a header with inline type', function () { - assert.strictEqual(contentDisposition(undefined, { type: 'inline' }), - 'inline') - }) - - it('should create a header with inline type & filename', function () { - assert.strictEqual(contentDisposition('plans.pdf', { type: 'inline' }), - 'inline; filename="plans.pdf"') - }) - - it('should normalize type', function () { - assert.strictEqual(contentDisposition(undefined, { type: 'INLINE' }), - 'inline') - }) - }) -}) - -describe('contentDisposition.parse(string)', function () { - it('should require string', function () { - assert.throws(contentDisposition.parse.bind(null), /argument string.*required/) - }) - - it('should reject non-strings', function () { - assert.throws(contentDisposition.parse.bind(null, 42), /argument string.*required/) - }) - - describe('with only type', function () { - it('should reject quoted value', function () { - assert.throws(contentDisposition.parse.bind(null, '"attachment"'), - /invalid type format/) - }) - - it('should reject trailing semicolon', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment;'), - /invalid.*format/) - }) - - it('should parse "attachment"', function () { - assert.deepEqual(contentDisposition.parse('attachment'), { - type: 'attachment', - parameters: {} - }) - }) - - it('should parse "inline"', function () { - assert.deepEqual(contentDisposition.parse('inline'), { - type: 'inline', - parameters: {} - }) - }) - - it('should parse "form-data"', function () { - assert.deepEqual(contentDisposition.parse('form-data'), { - type: 'form-data', - parameters: {} - }) - }) - - it('should parse with trailing LWS', function () { - assert.deepEqual(contentDisposition.parse('attachment \t '), { - type: 'attachment', - parameters: {} - }) - }) - - it('should normalize to lower-case', function () { - assert.deepEqual(contentDisposition.parse('ATTACHMENT'), { - type: 'attachment', - parameters: {} - }) - }) - }) - - describe('with parameters', function () { - it('should reject trailing semicolon', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename="rates.pdf";'), - /invalid parameter format/) - }) - - it('should reject invalid parameter name', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename@="rates.pdf"'), - /invalid parameter format/) - }) - - it('should reject missing parameter value', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename='), - /invalid parameter format/) - }) - - it('should reject invalid parameter value', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=trolly,trains'), - /invalid parameter format/) - }) - - it('should reject invalid parameters', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=total/; foo=bar'), - /invalid parameter format/) - }) - - it('should reject duplicate parameters', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo; filename=bar'), - /invalid duplicate parameter/) - }) - - it('should reject missing type', function () { - assert.throws(contentDisposition.parse.bind(null, 'filename="plans.pdf"'), - /invalid type format/) - assert.throws(contentDisposition.parse.bind(null, '; filename="plans.pdf"'), - /invalid type format/) - }) - - it('should lower-case parameter name', function () { - assert.deepEqual(contentDisposition.parse('attachment; FILENAME="plans.pdf"'), { - type: 'attachment', - parameters: { filename: 'plans.pdf' } - }) - }) - - it('should parse quoted parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="plans.pdf"'), { - type: 'attachment', - parameters: { filename: 'plans.pdf' } - }) - }) - - it('should parse & unescape quoted value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="the \\"plans\\".pdf"'), { - type: 'attachment', - parameters: { filename: 'the "plans".pdf' } - }) - }) - - it('should include all parameters', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="plans.pdf"; foo=bar'), { - type: 'attachment', - parameters: { filename: 'plans.pdf', foo: 'bar' } - }) - }) - - it('should parse parameters separated with any LWS', function () { - assert.deepEqual(contentDisposition.parse('attachment;filename="plans.pdf" \t; \t\t foo=bar'), { - type: 'attachment', - parameters: { filename: 'plans.pdf', foo: 'bar' } - }) - }) - - it('should parse token filename', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename=plans.pdf'), { - type: 'attachment', - parameters: { filename: 'plans.pdf' } - }) - }) - - it('should parse ISO-8859-1 filename', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="£ rates.pdf"'), { - type: 'attachment', - parameters: { filename: '£ rates.pdf' } - }) - }) - }) - - describe('with extended parameters', function () { - it('should reject quoted extended parameter value', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*="UTF-8\'\'%E2%82%AC%20rates.pdf"'), - /invalid extended.*value/) - }) - - it('should parse UTF-8 extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - - it('should parse UTF8 extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=utf8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - - it('should parse UTF-8 extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%E4%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '\ufffd rates.pdf' } - }) - }) - - it('should parse ISO-8859-1 extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=ISO-8859-1\'\'%A3%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '£ rates.pdf' } - }) - assert.deepEqual(contentDisposition.parse('attachment; filename*=ISO-8859-1\'\'%82%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '? rates.pdf' } - }) - }) - - it('should not be case-sensitive for charset', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=utf-8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - - it('should reject unsupported charset', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=ISO-8859-2\'\'%A4%20rates.pdf'), - /unsupported charset/) - }) - - it('should parse with embedded language', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'en\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - - it('should prefer extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf; filename="EURO rates.pdf"'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - }) - - describe('from TC 2231', function () { - describe('Disposition-Type Inline', function () { - it('should parse "inline"', function () { - assert.deepEqual(contentDisposition.parse('inline'), { - type: 'inline', - parameters: {} - }) - }) - - it('should reject ""inline""', function () { - assert.throws(contentDisposition.parse.bind(null, '"inline"'), - /invalid type format/) - }) - - it('should parse "inline; filename="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('inline; filename="foo.html"'), { - type: 'inline', - parameters: { filename: 'foo.html' } - }) - }) - - it('should parse "inline; filename="Not an attachment!""', function () { - assert.deepEqual(contentDisposition.parse('inline; filename="Not an attachment!"'), { - type: 'inline', - parameters: { filename: 'Not an attachment!' } - }) - }) - - it('should parse "inline; filename="foo.pdf""', function () { - assert.deepEqual(contentDisposition.parse('inline; filename="foo.pdf"'), { - type: 'inline', - parameters: { filename: 'foo.pdf' } - }) - }) - }) - - describe('Disposition-Type Attachment', function () { - it('should parse "attachment"', function () { - assert.deepEqual(contentDisposition.parse('attachment'), { - type: 'attachment', - parameters: {} - }) - }) - - it('should reject ""attachment""', function () { - assert.throws(contentDisposition.parse.bind(null, '"attachment"'), - /invalid type format/) - }) - - it('should parse "ATTACHMENT"', function () { - assert.deepEqual(contentDisposition.parse('ATTACHMENT'), { - type: 'attachment', - parameters: {} - }) - }) - - it('should parse "attachment; filename="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should parse "attachment; filename="0000000000111111111122222""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="0000000000111111111122222"'), { - type: 'attachment', - parameters: { filename: '0000000000111111111122222' } - }) - }) - - it('should parse "attachment; filename="00000000001111111111222222222233333""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="00000000001111111111222222222233333"'), { - type: 'attachment', - parameters: { filename: '00000000001111111111222222222233333' } - }) - }) - - it('should parse "attachment; filename="f\\oo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="f\\oo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should parse "attachment; filename="\\"quoting\\" tested.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="\\"quoting\\" tested.html"'), { - type: 'attachment', - parameters: { filename: '"quoting" tested.html' } - }) - }) - - it('should parse "attachment; filename="Here\'s a semicolon;.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="Here\'s a semicolon;.html"'), { - type: 'attachment', - parameters: { filename: 'Here\'s a semicolon;.html' } - }) - }) - - it('should parse "attachment; foo="bar"; filename="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; foo="bar"; filename="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html', foo: 'bar' } - }) - }) - - it('should parse "attachment; foo="\\"\\\\";filename="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; foo="\\"\\\\";filename="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html', foo: '"\\' } - }) - }) - - it('should parse "attachment; FILENAME="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; FILENAME="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should parse "attachment; filename=foo.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename=foo.html'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should reject "attachment; filename=foo,bar.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo,bar.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo.html ;"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo.html ;'), - /invalid parameter format/) - }) - - it('should reject "attachment; ;filename=foo"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; ;filename=foo'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo bar.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo bar.html'), - /invalid parameter format/) - }) - - it('should parse "attachment; filename=\'foo.bar\'', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename=\'foo.bar\''), { - type: 'attachment', - parameters: { filename: '\'foo.bar\'' } - }) - }) - - it('should parse "attachment; filename="foo-ä.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-ä.html"'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename="foo-ä.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-ä.html"'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename="foo-%41.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-%41.html"'), { - type: 'attachment', - parameters: { filename: 'foo-%41.html' } - }) - }) - - it('should parse "attachment; filename="50%.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="50%.html"'), { - type: 'attachment', - parameters: { filename: '50%.html' } - }) - }) - - it('should parse "attachment; filename="foo-%\\41.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-%\\41.html"'), { - type: 'attachment', - parameters: { filename: 'foo-%41.html' } - }) - }) - - it('should parse "attachment; name="foo-%41.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; name="foo-%41.html"'), { - type: 'attachment', - parameters: { name: 'foo-%41.html' } - }) - }) - - it('should parse "attachment; filename="ä-%41.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="ä-%41.html"'), { - type: 'attachment', - parameters: { filename: 'ä-%41.html' } - }) - }) - - it('should parse "attachment; filename="foo-%c3%a4-%e2%82%ac.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-%c3%a4-%e2%82%ac.html"'), { - type: 'attachment', - parameters: { filename: 'foo-%c3%a4-%e2%82%ac.html' } - }) - }) - - it('should parse "attachment; filename ="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename ="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should reject "attachment; filename="foo.html"; filename="bar.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename="foo.html"; filename="bar.html"'), - /invalid duplicate parameter/) - }) - - it('should reject "attachment; filename=foo[1](2).html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo[1](2).html'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo-ä.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo-ä.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo-ä.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo-ä.html'), - /invalid parameter format/) - }) - - it('should reject "filename=foo.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'filename=foo.html'), - /invalid type format/) - }) - - it('should reject "x=y; filename=foo.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'x=y; filename=foo.html'), - /invalid type format/) - }) - - it('should reject ""foo; filename=bar;baz"; filename=qux"', function () { - assert.throws(contentDisposition.parse.bind(null, '"foo; filename=bar;baz"; filename=qux'), - /invalid type format/) - }) - - it('should reject "filename=foo.html, filename=bar.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'filename=foo.html, filename=bar.html'), - /invalid type format/) - }) - - it('should reject "; filename=foo.html"', function () { - assert.throws(contentDisposition.parse.bind(null, '; filename=foo.html'), - /invalid type format/) - }) - - it('should reject ": inline; attachment; filename=foo.html', function () { - assert.throws(contentDisposition.parse.bind(null, ': inline; attachment; filename=foo.html'), - /invalid type format/) - }) - - it('should reject "inline; attachment; filename=foo.html', function () { - assert.throws(contentDisposition.parse.bind(null, 'inline; attachment; filename=foo.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; inline; filename=foo.html', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; inline; filename=foo.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename="foo.html".txt', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename="foo.html".txt'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename="bar', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename="bar'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo"bar;baz"qux', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo"bar;baz"qux'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo.html, attachment; filename=bar.html', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo.html, attachment; filename=bar.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; foo=foo filename=bar', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; foo=foo filename=bar'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=bar foo=foo', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=bar foo=foo'), - /invalid parameter format/) - }) - - it('should reject "attachment filename=bar', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment filename=bar'), - /invalid type format/) - }) - - it('should reject "filename=foo.html; attachment', function () { - assert.throws(contentDisposition.parse.bind(null, 'filename=foo.html; attachment'), - /invalid type format/) - }) - - it('should parse "attachment; xfilename=foo.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; xfilename=foo.html'), { - type: 'attachment', - parameters: { xfilename: 'foo.html' } - }) - }) - - it('should parse "attachment; filename="/foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="/foo.html"'), { - type: 'attachment', - parameters: { filename: '/foo.html' } - }) - }) - - it('should parse "attachment; filename="\\\\foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="\\\\foo.html"'), { - type: 'attachment', - parameters: { filename: '\\foo.html' } - }) - }) - }) - - describe('Additional Parameters', function () { - it('should parse "attachment; creation-date="Wed, 12 Feb 1997 16:29:51 -0500""', function () { - assert.deepEqual(contentDisposition.parse('attachment; creation-date="Wed, 12 Feb 1997 16:29:51 -0500"'), { - type: 'attachment', - parameters: { 'creation-date': 'Wed, 12 Feb 1997 16:29:51 -0500' } - }) - }) - - it('should parse "attachment; modification-date="Wed, 12 Feb 1997 16:29:51 -0500""', function () { - assert.deepEqual(contentDisposition.parse('attachment; modification-date="Wed, 12 Feb 1997 16:29:51 -0500"'), { - type: 'attachment', - parameters: { 'modification-date': 'Wed, 12 Feb 1997 16:29:51 -0500' } - }) - }) - }) - - describe('Disposition-Type Extension', function () { - it('should parse "foobar"', function () { - assert.deepEqual(contentDisposition.parse('foobar'), { - type: 'foobar', - parameters: {} - }) - }) - - it('should parse "attachment; example="filename=example.txt""', function () { - assert.deepEqual(contentDisposition.parse('attachment; example="filename=example.txt"'), { - type: 'attachment', - parameters: { example: 'filename=example.txt' } - }) - }) - }) - - describe('RFC 2231/5987 Encoding: Character Sets', function () { - it('should parse "attachment; filename*=iso-8859-1\'\'foo-%E4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=iso-8859-1\'\'foo-%E4.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename*=UTF-8\'\'foo-%c3%a4-%e2%82%ac.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'foo-%c3%a4-%e2%82%ac.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä-€.html' } - }) - }) - - it('should reject "attachment; filename*=\'\'foo-%c3%a4-%e2%82%ac.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=\'\'foo-%c3%a4-%e2%82%ac.html'), - /invalid extended.*value/) - }) - - it('should parse "attachment; filename*=UTF-8\'\'foo-a%cc%88.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'foo-a%cc%88.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename*=iso-8859-1\'\'foo-%c3%a4-%e2%82%ac.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=iso-8859-1\'\'foo-%c3%a4-%e2%82%ac.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä-â?¬.html' } - }) - }) - - it('should parse "attachment; filename*=utf-8\'\'foo-%E4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=utf-8\'\'foo-%E4.html'), { - type: 'attachment', - parameters: { filename: 'foo-\ufffd.html' } - }) - }) - - it('should reject "attachment; filename *=UTF-8\'\'foo-%c3%a4.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename *=UTF-8\'\'foo-%c3%a4.html'), - /invalid parameter format/) - }) - - it('should parse "attachment; filename*= UTF-8\'\'foo-%c3%a4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*= UTF-8\'\'foo-%c3%a4.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename* =UTF-8\'\'foo-%c3%a4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename* =UTF-8\'\'foo-%c3%a4.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should reject "attachment; filename*="UTF-8\'\'foo-%c3%a4.html""', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*="UTF-8\'\'foo-%c3%a4.html"'), - /invalid extended field value/) - }) - - it('should reject "attachment; filename*="foo%20bar.html""', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*="foo%20bar.html"'), - /invalid extended field value/) - }) - - it('should reject "attachment; filename*=UTF-8\'foo-%c3%a4.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=UTF-8\'foo-%c3%a4.html'), - /invalid extended field value/) - }) - - it('should reject "attachment; filename*=UTF-8\'\'foo%"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=UTF-8\'\'foo%'), - /invalid extended field value/) - }) - - it('should reject "attachment; filename*=UTF-8\'\'f%oo.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=UTF-8\'\'f%oo.html'), - /invalid extended field value/) - }) - - it('should parse "attachment; filename*=UTF-8\'\'A-%2541.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'A-%2541.html'), { - type: 'attachment', - parameters: { filename: 'A-%41.html' } - }) - }) - - it('should parse "attachment; filename*=UTF-8\'\'%5cfoo.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%5cfoo.html'), { - type: 'attachment', - parameters: { filename: '\\foo.html' } - }) - }) - }) - - describe('RFC2231 Encoding: Continuations', function () { - it('should parse "attachment; filename*0="foo."; filename*1="html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0="foo."; filename*1="html"'), { - type: 'attachment', - parameters: { 'filename*0': 'foo.', 'filename*1': 'html' } - }) - }) - - it('should parse "attachment; filename*0="foo"; filename*1="\\b\\a\\r.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0="foo"; filename*1="\\b\\a\\r.html"'), { - type: 'attachment', - parameters: { 'filename*0': 'foo', 'filename*1': 'bar.html' } - }) - }) - - it('should parse "attachment; filename*0*=UTF-8\'\'foo-%c3%a4; filename*1=".html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0*=UTF-8\'\'foo-%c3%a4; filename*1=".html"'), { - type: 'attachment', - parameters: { 'filename*0*': 'UTF-8\'\'foo-%c3%a4', 'filename*1': '.html' } - }) - }) - - it('should parse "attachment; filename*0="foo"; filename*01="bar""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0="foo"; filename*01="bar"'), { - type: 'attachment', - parameters: { 'filename*0': 'foo', 'filename*01': 'bar' } - }) - }) - - it('should parse "attachment; filename*0="foo"; filename*2="bar""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0="foo"; filename*2="bar"'), { - type: 'attachment', - parameters: { 'filename*0': 'foo', 'filename*2': 'bar' } - }) - }) - - it('should parse "attachment; filename*1="foo."; filename*2="html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*1="foo."; filename*2="html"'), { - type: 'attachment', - parameters: { 'filename*1': 'foo.', 'filename*2': 'html' } - }) - }) - - it('should parse "attachment; filename*1="bar"; filename*0="foo""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*1="bar"; filename*0="foo"'), { - type: 'attachment', - parameters: { 'filename*1': 'bar', 'filename*0': 'foo' } - }) - }) - }) - - describe('RFC2231 Encoding: Fallback Behaviour', function () { - it('should parse "attachment; filename="foo-ae.html"; filename*=UTF-8\'\'foo-%c3%a4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-ae.html"; filename*=UTF-8\'\'foo-%c3%a4.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename*=UTF-8\'\'foo-%c3%a4.html; filename="foo-ae.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'foo-%c3%a4.html; filename="foo-ae.html"'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename*0*=ISO-8859-15\'\'euro-sign%3d%a4; filename*=ISO-8859-1\'\'currency-sign%3d%a4', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0*=ISO-8859-15\'\'euro-sign%3d%a4; filename*=ISO-8859-1\'\'currency-sign%3d%a4'), { - type: 'attachment', - parameters: { filename: 'currency-sign=¤', 'filename*0*': 'ISO-8859-15\'\'euro-sign%3d%a4' } - }) - }) - - it('should parse "attachment; foobar=x; filename="foo.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; foobar=x; filename="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html', foobar: 'x' } - }) - }) - }) - - describe('RFC2047 Encoding', function () { - it('should reject "attachment; filename==?ISO-8859-1?Q?foo-=E4.html?="', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename==?ISO-8859-1?Q?foo-=E4.html?='), - /invalid parameter format/) - }) - - it('should parse "attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?=""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?="'), { - type: 'attachment', - parameters: { filename: '=?ISO-8859-1?Q?foo-=E4.html?=' } - }) - }) - }) - }) -}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a33747f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "noEmit": true, + "target": "esnext", + "lib": ["es2024"], + "moduleDetection": "force", + "module": "preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "isolatedDeclarations": true + }, + "include": ["src"] +} diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..0edcc76 --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + platform: 'neutral', + format: { + esm: { target: ['es2022'] }, + cjs: {}, // No target needed -> tsdown will infer from package.json "engines" field + }, + dts: true, + exports: true, + + /* Package Validation: https://tsdown.dev/options/lint */ + attw: true, + publint: true, +}); From aebe4d86186bec21ccece3d54152f675891667b0 Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Thu, 19 Feb 2026 23:36:39 +0100 Subject: [PATCH 2/8] Address feedback from blake --- .github/workflows/ci.yml | 74 ++++++---------------------------------- package.json | 10 +++--- 2 files changed, 15 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cccdf0..e55340c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,80 +1,26 @@ name: CI - on: - push: - pull_request: - + - push + - pull_request permissions: contents: read - jobs: - checks: - name: Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 'lts/*' - - run: npm install - - run: npm run lint - - run: npm run format:check - - run: npm run typecheck - - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 'lts/*' - - run: npm install - - run: npm run build - test: - name: Test - Node.js ${{ matrix.node-version }} + name: Node.js ${{ matrix.node-version }} runs-on: ubuntu-latest strategy: - fail-fast: false matrix: - # Node.js release schedule: https://nodejs.org/en/about/releases/ - node-version: [18, 19, 20, 21, 22, 23, 24, 25] + node-version: + - '18' + - '*' steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - run: npm install - - name: Output Node and NPM versions - run: | - echo "Node.js version: $(node -v)" - echo "NPM version: $(npm -v)" - - run: npm run test:ci - - name: Upload code coverage - uses: actions/upload-artifact@v5 - with: - name: coverage-node-${{ matrix.node-version }} - path: ./coverage/lcov.info - retention-days: 1 - - coverage: - name: Coverage - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - checks: write - steps: - - uses: actions/checkout@v6 - - run: sudo apt-get -y install lcov - - name: Collect coverage reports - uses: actions/download-artifact@v6 - with: - path: ./coverage - pattern: coverage-node-* - - name: Merge coverage reports - run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./lcov.info - - uses: coverallsapp/github-action@v2 + - run: npm test + - uses: codecov/codecov-action@v5 with: - file: ./lcov.info + name: Node.js ${{ matrix.node-version }} + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/package.json b/package.json index 5961b09..e2d83bd 100644 --- a/package.json +++ b/package.json @@ -31,14 +31,14 @@ ], "scripts": { "build": "tsdown", + "check": "prettier --check . && tsc && npm run lint", "format": "prettier --write .", - "format:check": "prettier --check .", "lint": "eslint .", "prepare": "husky", - "test": "vitest", - "test:ci": "vitest --coverage --coverage.include=src/** --coverage.reporter text --coverage.reporter=lcovonly", - "test:cov": "vitest --coverage --coverage.include=src/**", - "typecheck": "tsc --noEmit" + "specs": "vitest run", + "specs:ci": "vitest run --coverage --coverage.include=src/** --coverage.reporter text --coverage.reporter=lcovonly", + "specs:cov": "vitest run --coverage --coverage.include=src/**", + "test": "npm run build && npm run check && npm run specs:ci" }, "devDependencies": { "@arethetypeswrong/core": "^0.18.2", From 3d52d964e549747df0b6255424b09ecd6d22d37d Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Thu, 19 Feb 2026 23:40:52 +0100 Subject: [PATCH 3/8] Rasing nodejs version in CI as rolldown only support ^20.19.0 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e55340c..8d00376 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: node-version: - - '18' + - '^20.19.0' - '*' steps: - uses: actions/checkout@v6 From d87f3c76bae16b24cf7846d9741e19c2c822f6e5 Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Mon, 23 Feb 2026 20:48:20 +0100 Subject: [PATCH 4/8] include changes from master --- src/index.ts | 109 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 26 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9dc4711..21cc261 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,15 +5,14 @@ */ /** - * RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%") + * TextDecoder instance for UTF-8 decoding when decodeURIComponent fails due to invalid byte sequences. */ -const ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g; // eslint-disable-line no-control-regex +const utf8Decoder = new TextDecoder('utf-8'); /** - * RegExp to match percent encoding escape. + * RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%") */ -const HEX_ESCAPE_REGEXP = /%[0-9A-Fa-f]{2}/; -const HEX_ESCAPE_REPLACE_REGEXP = /%([0-9A-Fa-f]{2})/g; +const ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g; // eslint-disable-line no-control-regex /** * RegExp to match non-latin1 characters. @@ -184,7 +183,7 @@ function createparams( const hasFallback = typeof fallbackName === 'string' && fallbackName !== name; // set extended filename parameter - if (hasFallback || !isQuotedString || HEX_ESCAPE_REGEXP.test(name)) { + if (hasFallback || !isQuotedString || hasHexEscape(name)) { params['filename*'] = name; } @@ -246,24 +245,32 @@ function decodefield(str: string): string { const charset = match[1].toLowerCase(); const encoded = match[2]; - let value; - - // to binary string - const binary = encoded.replace(HEX_ESCAPE_REPLACE_REGEXP, pdecode); switch (charset) { - case 'iso-8859-1': - value = getlatin1(binary); - break; + case 'iso-8859-1': { + const binary = decodeHexEscapes(encoded); + return getlatin1(binary); + } case 'utf-8': - case 'utf8': - value = Buffer.from(binary, 'binary').toString('utf8'); - break; - default: - throw new TypeError('unsupported charset in extended field'); + case 'utf8': { + try { + return decodeURIComponent(encoded); + } catch { + // Failed to decode with decodeURIComponent, fallback to lenient decoding which replaces invalid UTF-8 byte sequences with the Unicode replacement character + // TODO: Consider removing in the next major version to be more strict about invalid percent-encodings + const binary = decodeHexEscapes(encoded); + + const bytes = new Uint8Array(binary.length); + for (let idx = 0; idx < binary.length; idx++) { + bytes[idx] = binary.charCodeAt(idx); + } + + return utf8Decoder.decode(bytes); + } + } } - return value; + throw new TypeError('unsupported charset in extended field'); } /** @@ -347,13 +354,6 @@ export function parse(string: string): ContentDisposition { return obj; } -/** - * Percent decode a single character. - */ -function pdecode(str: string, hex: string): string { - return String.fromCharCode(parseInt(hex, 16)); -} - /** * Percent encode a single character. */ @@ -426,3 +426,60 @@ function basename(path: string): string { return normalized.slice(start + 1, end); } + +/** + * Check if a character is a hex digit [0-9A-Fa-f] + */ +function isHexDigit(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 70) || // A-F + (code >= 97 && code <= 102) // a-f + ); +} + +/** + * Check if a string contains percent encoding escapes. + */ +function hasHexEscape(str: string): boolean { + const maxIndex = str.length - 3; + let lastIndex = -1; + + while ( + (lastIndex = str.indexOf('%', lastIndex + 1)) !== -1 && + lastIndex <= maxIndex + ) { + if (isHexDigit(str[lastIndex + 1]) && isHexDigit(str[lastIndex + 2])) { + return true; + } + } + + return false; +} + +/** + * Decode hex escapes in a string (e.g., %20 -> space) + */ +function decodeHexEscapes(str: string): string { + const firstEscape = str.indexOf('%'); + if (firstEscape === -1) return str; + + let result = str.slice(0, firstEscape); + for (let idx = firstEscape; idx < str.length; idx++) { + if ( + str[idx] === '%' && + idx + 2 < str.length && + isHexDigit(str[idx + 1]) && + isHexDigit(str[idx + 2]) + ) { + result += String.fromCharCode( + Number.parseInt(str[idx + 1] + str[idx + 2], 16), + ); + idx += 2; + } else { + result += str[idx]; + } + } + return result; +} From 5a8af192f8c4ed08cb90af09a56d45ef3cee6806 Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Mon, 23 Feb 2026 20:48:37 +0100 Subject: [PATCH 5/8] Remove dependency on @types/node --- package.json | 1 - textdecoder.d.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 textdecoder.d.ts diff --git a/package.json b/package.json index e2d83bd..8bc1344 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "devDependencies": { "@arethetypeswrong/core": "^0.18.2", "@eslint/js": "^9.39.2", - "@types/node": "^20.19.33", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.39.2", "husky": "^9.1.7", diff --git a/textdecoder.d.ts b/textdecoder.d.ts new file mode 100644 index 0000000..35da2e4 --- /dev/null +++ b/textdecoder.d.ts @@ -0,0 +1,56 @@ +// Adapted from file://./node_modules/typescript/lib/lib.dom.d.ts so we don't have to include the entire DOM lib +// Ref: https://github.com/microsoft/TypeScript/issues/31535, https://github.com/microsoft/TypeScript/issues/41727, https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1685 + +type AllowSharedBufferSource = + | ArrayBufferLike + | ArrayBufferView; + +interface TextDecodeOptions { + stream?: boolean; +} + +interface TextDecoderOptions { + fatal?: boolean; + ignoreBOM?: boolean; +} + +/** + * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) + */ +interface TextDecoder extends TextDecoderCommon { + /** + * The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode) + */ + decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string; +} + +// eslint-disable-next-line no-var +declare var TextDecoder: { + prototype: TextDecoder; + new (label?: string, options?: TextDecoderOptions): TextDecoder; +}; + +interface TextDecoderCommon { + /** + * Returns encoding's name, lowercased. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/encoding) + */ + readonly encoding: string; + /** + * Returns true if error mode is "fatal", otherwise false. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/fatal) + */ + readonly fatal: boolean; + /** + * Returns the value of ignore BOM. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/ignoreBOM) + */ + readonly ignoreBOM: boolean; +} diff --git a/tsconfig.json b/tsconfig.json index a33747f..e7aed96 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "module": "preserve", "moduleResolution": "bundler", "resolveJsonModule": true, - "types": ["node"], + "types": ["./textdecoder.d.ts"], "strict": true, "noUnusedLocals": true, "declaration": true, From 1f5ccc5fed6c23e7c379388046b48ea5f626e21f Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Mon, 23 Feb 2026 21:32:40 +0100 Subject: [PATCH 6/8] refactor to @borderless/ts-scripts --- .github/workflows/ci.yml | 2 +- .gitignore | 3 +- .husky/pre-commit | 1 - .prettierrc.json | 3 - eslint.config.mjs | 15 - package.json | 51 ++- .../contentDisposition.spec.ts | 105 +++--- src/index.ts | 320 +++++++++--------- {test => src}/parse.spec.ts | 2 +- textdecoder.d.ts | 5 + tsconfig.build.json | 5 + tsconfig.json | 26 +- tsdown.config.ts | 15 - 13 files changed, 267 insertions(+), 286 deletions(-) delete mode 100644 .husky/pre-commit delete mode 100644 .prettierrc.json delete mode 100644 eslint.config.mjs rename test/create.spec.ts => src/contentDisposition.spec.ts (71%) rename {test => src}/parse.spec.ts (99%) create mode 100644 tsconfig.build.json delete mode 100644 tsdown.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d00376..8fff328 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: node-version: - - '^20.19.0' + - 18 - '*' steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index e9b4bb6..7c269f2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ coverage/ node_modules/ npm-debug.log package-lock.json -dist/ \ No newline at end of file +dist/ +*.tsbuildinfo \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 052eed2..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -npx pretty-quick --staged \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 535a205..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "plugins": ["prettier-plugin-packagejson"] -} diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index b6588af..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import eslint from '@eslint/js'; -import { defineConfig, globalIgnores } from 'eslint/config'; -import tseslint from 'typescript-eslint'; - -export default defineConfig( - globalIgnores(['coverage', 'dist']), - eslint.configs.recommended, - tseslint.configs.strict, - { - files: ['**/*.spec.ts'], - rules: { - '@typescript-eslint/no-explicit-any': 'off', - }, - }, -); diff --git a/package.json b/package.json index 8bc1344..809b7ff 100644 --- a/package.json +++ b/package.json @@ -15,47 +15,36 @@ }, "license": "MIT", "author": "Douglas Christopher Wilson ", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "./package.json": "./package.json" - }, - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.cts", + "type": "commonjs", + "exports": "./dist/index.js", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "files": [ - "dist" + "dist/" ], "scripts": { - "build": "tsdown", - "check": "prettier --check . && tsc && npm run lint", - "format": "prettier --write .", - "lint": "eslint .", - "prepare": "husky", - "specs": "vitest run", - "specs:ci": "vitest run --coverage --coverage.include=src/** --coverage.reporter text --coverage.reporter=lcovonly", - "specs:cov": "vitest run --coverage --coverage.include=src/**", - "test": "npm run build && npm run check && npm run specs:ci" + "build": "ts-scripts build", + "format": "ts-scripts format", + "lint": "ts-scripts lint", + "prepare": "ts-scripts install && npm run build", + "specs": "ts-scripts specs", + "test": "ts-scripts test" }, "devDependencies": { - "@arethetypeswrong/core": "^0.18.2", - "@eslint/js": "^9.39.2", + "@borderless/ts-scripts": "^0.15.0", "@vitest/coverage-v8": "^3.2.4", - "eslint": "^9.39.2", - "husky": "^9.1.7", - "prettier": "^3.8.1", - "prettier-plugin-packagejson": "^3.0.0", - "pretty-quick": "^4.2.2", - "publint": "^0.3.17", - "tsdown": "^0.20.3", "typescript": "^5.9.3", - "typescript-eslint": "^8.55.0", "vitest": "^3.2.4" }, "engines": { "node": ">=18" + }, + "ts-scripts": { + "dist": [ + "dist" + ], + "project": [ + "tsconfig.build.json" + ] } } diff --git a/test/create.spec.ts b/src/contentDisposition.spec.ts similarity index 71% rename from test/create.spec.ts rename to src/contentDisposition.spec.ts index f4f588c..71544b9 100644 --- a/test/create.spec.ts +++ b/src/contentDisposition.spec.ts @@ -1,80 +1,83 @@ import { describe, it, assert } from 'vitest'; -import { create } from '../src'; +import contentDisposition from './index'; describe('create()', function () { it('should create an attachment header', function () { - assert.strictEqual(create(), 'attachment'); + assert.strictEqual(contentDisposition(), 'attachment'); }); }); describe('create(filename)', function () { it('should require a string', function () { - assert.throws(create.bind(null, 42 as any), /filename.*string/); + assert.throws(contentDisposition.bind(null, 42 as any), /filename.*string/); }); it('should create a header with file name', function () { - assert.strictEqual(create('plans.pdf'), 'attachment; filename="plans.pdf"'); + assert.strictEqual( + contentDisposition('plans.pdf'), + 'attachment; filename="plans.pdf"', + ); }); it('should use the basename of a posix path', function () { assert.strictEqual( - create('/path/to/plans.pdf'), + contentDisposition('/path/to/plans.pdf'), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a windows path', function () { assert.strictEqual( - create('\\path\\to\\plans.pdf'), + contentDisposition('\\path\\to\\plans.pdf'), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a windows path with drive letter', function () { assert.strictEqual( - create('C:\\path\\to\\plans.pdf'), + contentDisposition('C:\\path\\to\\plans.pdf'), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a posix path with trailing slash', function () { assert.strictEqual( - create('/path/to/plans.pdf/'), + contentDisposition('/path/to/plans.pdf/'), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a windows path with trailing slash', function () { assert.strictEqual( - create('\\path\\to\\plans.pdf\\'), + contentDisposition('\\path\\to\\plans.pdf\\'), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a windows path with drive letter and trailing slash', function () { assert.strictEqual( - create('C:\\path\\to\\plans.pdf\\'), + contentDisposition('C:\\path\\to\\plans.pdf\\'), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a posix path with trailing slashes', function () { assert.strictEqual( - create('/path/to/plans.pdf///'), + contentDisposition('/path/to/plans.pdf///'), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a windows path with trailing slashes', function () { assert.strictEqual( - create('\\path\\to\\plans.pdf\\\\\\'), + contentDisposition('\\path\\to\\plans.pdf\\\\\\'), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a windows path with drive letter and trailing slashes', function () { assert.strictEqual( - create('C:\\path\\to\\plans.pdf\\\\\\'), + contentDisposition('C:\\path\\to\\plans.pdf\\\\\\'), 'attachment; filename="plans.pdf"', ); }); @@ -82,14 +85,14 @@ describe('create(filename)', function () { describe('when "filename" is US-ASCII', function () { it('should only include filename parameter', function () { assert.strictEqual( - create('plans.pdf'), + contentDisposition('plans.pdf'), 'attachment; filename="plans.pdf"', ); }); it('should escape quotes', function () { assert.strictEqual( - create('the "plans".pdf'), + contentDisposition('the "plans".pdf'), 'attachment; filename="the \\"plans\\".pdf"', ); }); @@ -98,14 +101,14 @@ describe('create(filename)', function () { describe('when "filename" is ISO-8859-1', function () { it('should only include filename parameter', function () { assert.strictEqual( - create('«plans».pdf'), + contentDisposition('«plans».pdf'), 'attachment; filename="«plans».pdf"', ); }); it('should escape quotes', function () { assert.strictEqual( - create('the "plans" (1µ).pdf'), + contentDisposition('the "plans" (1µ).pdf'), 'attachment; filename="the \\"plans\\" (1µ).pdf"', ); }); @@ -114,25 +117,25 @@ describe('create(filename)', function () { describe('when "filename" is Unicode', function () { it('should include filename* parameter', function () { assert.strictEqual( - create('планы.pdf'), + contentDisposition('планы.pdf'), 'attachment; filename="?????.pdf"; filename*=UTF-8\'\'%D0%BF%D0%BB%D0%B0%D0%BD%D1%8B.pdf', ); }); it('should include filename fallback', function () { assert.strictEqual( - create('£ and € rates.pdf'), + contentDisposition('£ and € rates.pdf'), 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', ); assert.strictEqual( - create('€ rates.pdf'), + contentDisposition('€ rates.pdf'), 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', ); }); it('should encode special characters', function () { assert.strictEqual( - create("€'*%().pdf"), + contentDisposition("€'*%().pdf"), "attachment; filename=\"?'*%().pdf\"; filename*=UTF-8''%E2%82%AC%27%2A%25%28%29.pdf", ); }); @@ -141,14 +144,14 @@ describe('create(filename)', function () { describe('when "filename" contains hex escape', function () { it('should include filename* parameter', function () { assert.strictEqual( - create('the%20plans.pdf'), + contentDisposition('the%20plans.pdf'), 'attachment; filename="the%20plans.pdf"; filename*=UTF-8\'\'the%2520plans.pdf', ); }); it('should handle Unicode', function () { assert.strictEqual( - create('€%20£.pdf'), + contentDisposition('€%20£.pdf'), 'attachment; filename="?%20£.pdf"; filename*=UTF-8\'\'%E2%82%AC%2520%C2%A3.pdf', ); }); @@ -159,14 +162,14 @@ describe('create(filename, options)', function () { describe('with "fallback" option', function () { it('should require a string or Boolean', function () { assert.throws( - create.bind(null, 'plans.pdf', { fallback: 42 } as any), + contentDisposition.bind(null, 'plans.pdf', { fallback: 42 } as any), /fallback.*string/, ); }); it('should default to true', function () { assert.strictEqual( - create('€ rates.pdf'), + contentDisposition('€ rates.pdf'), 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', ); }); @@ -174,14 +177,14 @@ describe('create(filename, options)', function () { describe('when "false"', function () { it('should not generate ISO-8859-1 fallback', function () { assert.strictEqual( - create('£ and € rates.pdf', { fallback: false }), + contentDisposition('£ and € rates.pdf', { fallback: false }), "attachment; filename*=UTF-8''%C2%A3%20and%20%E2%82%AC%20rates.pdf", ); }); it('should keep ISO-8859-1 filename', function () { assert.strictEqual( - create('£ rates.pdf', { fallback: false }), + contentDisposition('£ rates.pdf', { fallback: false }), 'attachment; filename="£ rates.pdf"', ); }); @@ -190,14 +193,14 @@ describe('create(filename, options)', function () { describe('when "true"', function () { it('should generate ISO-8859-1 fallback', function () { assert.strictEqual( - create('£ and € rates.pdf', { fallback: true }), + contentDisposition('£ and € rates.pdf', { fallback: true }), 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', ); }); it('should pass through ISO-8859-1 filename', function () { assert.strictEqual( - create('£ rates.pdf', { fallback: true }), + contentDisposition('£ rates.pdf', { fallback: true }), 'attachment; filename="£ rates.pdf"', ); }); @@ -206,56 +209,66 @@ describe('create(filename, options)', function () { describe('when a string', function () { it('should require an ISO-8859-1 string', function () { assert.throws( - create.bind(null, '€ rates.pdf', { fallback: '€ rates.pdf' }), + contentDisposition.bind(null, '€ rates.pdf', { + fallback: '€ rates.pdf', + }), /fallback.*iso-8859-1/i, ); }); it('should use as ISO-8859-1 fallback', function () { assert.strictEqual( - create('£ and € rates.pdf', { fallback: '£ and EURO rates.pdf' }), + contentDisposition('£ and € rates.pdf', { + fallback: '£ and EURO rates.pdf', + }), 'attachment; filename="£ and EURO rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', ); }); it('should use as fallback even when filename is ISO-8859-1', function () { assert.strictEqual( - create('"£ rates".pdf', { fallback: '£ rates.pdf' }), + contentDisposition('"£ rates".pdf', { fallback: '£ rates.pdf' }), 'attachment; filename="£ rates.pdf"; filename*=UTF-8\'\'%22%C2%A3%20rates%22.pdf', ); }); it('should do nothing if equal to filename', function () { assert.strictEqual( - create('plans.pdf', { fallback: 'plans.pdf' }), + contentDisposition('plans.pdf', { fallback: 'plans.pdf' }), 'attachment; filename="plans.pdf"', ); }); it('should use the basename of a posix path', function () { assert.strictEqual( - create('€ rates.pdf', { fallback: '/path/to/EURO rates.pdf' }), + contentDisposition('€ rates.pdf', { + fallback: '/path/to/EURO rates.pdf', + }), 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', ); }); it('should use the basename of a windows path', function () { assert.strictEqual( - create('€ rates.pdf', { fallback: '\\path\\to\\EURO rates.pdf' }), + contentDisposition('€ rates.pdf', { + fallback: '\\path\\to\\EURO rates.pdf', + }), 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', ); }); it('should use the basename of a windows path with drive letter', function () { assert.strictEqual( - create('€ rates.pdf', { fallback: 'C:\\path\\to\\EURO rates.pdf' }), + contentDisposition('€ rates.pdf', { + fallback: 'C:\\path\\to\\EURO rates.pdf', + }), 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', ); }); it('should do nothing without filename option', function () { assert.strictEqual( - create(undefined, { fallback: 'plans.pdf' }), + contentDisposition(undefined, { fallback: 'plans.pdf' }), 'attachment', ); }); @@ -264,36 +277,42 @@ describe('create(filename, options)', function () { describe('with "type" option', function () { it('should default to attachment', function () { - assert.strictEqual(create(), 'attachment'); + assert.strictEqual(contentDisposition(), 'attachment'); }); it('should require a string', function () { assert.throws( - create.bind(null, undefined, { type: 42 } as any), + contentDisposition.bind(null, undefined, { type: 42 } as any), /invalid type/, ); }); it('should require a valid type', function () { assert.throws( - create.bind(null, undefined, { type: 'invalid;type' }), + contentDisposition.bind(null, undefined, { type: 'invalid;type' }), /invalid type/, ); }); it('should create a header with inline type', function () { - assert.strictEqual(create(undefined, { type: 'inline' }), 'inline'); + assert.strictEqual( + contentDisposition(undefined, { type: 'inline' }), + 'inline', + ); }); it('should create a header with inline type & filename', function () { assert.strictEqual( - create('plans.pdf', { type: 'inline' }), + contentDisposition('plans.pdf', { type: 'inline' }), 'inline; filename="plans.pdf"', ); }); it('should normalize type', function () { - assert.strictEqual(create(undefined, { type: 'INLINE' }), 'inline'); + assert.strictEqual( + contentDisposition(undefined, { type: 'INLINE' }), + 'inline', + ); }); }); }); diff --git a/src/index.ts b/src/index.ts index 21cc261..2e6e24f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,167 @@ * MIT Licensed */ +export = contentDisposition; + +/** + * Create an attachment Content-Disposition header. + */ +function contentDisposition( + filename?: string, + options?: contentDisposition.Options, +): string { + // get type + const type = options?.type || 'attachment'; + + // get parameters + const params = createparams(filename, options?.fallback); + + // format into string + return contentDisposition.format(new ContentDispositionImpl(type, params)); +} + +namespace contentDisposition { + export interface ContentDisposition { + /** + * Content-Disposition type, such as "attachment" or "inline" + */ + type: string; + /** + * Content-Disposition parameters, such as "filename" + */ + parameters: Record; + } + + export interface Options { + /** + * Content-Disposition type, defaults to "attachment" + * @default "attachment" + */ + type?: string; + /** + * Fallback filename for non-ISO-8859-1 strings. If true, a fallback will be generated by replacing non-latin1 characters with "?". If false, no fallback will be generated. + * @default true + */ + fallback?: string | boolean; + } + + export interface FormatOptions { + type: string; + parameters?: Record; + } + + /** + * Parse Content-Disposition header string. + */ + export function parse(string: string): ContentDisposition { + if (!string || typeof string !== 'string') { + throw new TypeError('argument string is required'); + } + + let match = DISPOSITION_TYPE_REGEXP.exec(string); + + if (!match) { + throw new TypeError('invalid type format'); + } + + // normalize type + let index = match[0].length; + const type = match[1].toLowerCase(); + + const obj = new ContentDispositionImpl(type); + + let key; + const names = []; + let value; + + // calculate index to start at + index = PARAM_REGEXP.lastIndex = + match[0].slice(-1) === ';' ? index - 1 : index; + + // match parameters + while ((match = PARAM_REGEXP.exec(string))) { + if (match.index !== index) { + throw new TypeError('invalid parameter format'); + } + + index += match[0].length; + key = match[1].toLowerCase(); + value = match[2]; + + if (names.indexOf(key) !== -1) { + throw new TypeError('invalid duplicate parameter'); + } + + names.push(key); + + if (key.indexOf('*') + 1 === key.length) { + // decode extended value + key = key.slice(0, -1); + value = decodefield(value); + + // overwrite existing value + obj.parameters[key] = value; + continue; + } + + if (typeof obj.parameters[key] === 'string') { + continue; + } + + if (value[0] === '"') { + // remove quotes and escapes + value = value.slice(1, -1).replace(QESC_REGEXP, '$1'); + } + + obj.parameters[key] = value; + } + + if (index !== -1 && index !== string.length) { + throw new TypeError('invalid parameter format'); + } + + return obj; + } + + /** + * Format object to Content-Disposition header. + */ + export function format(obj: FormatOptions): string { + if (!obj || typeof obj !== 'object') { + throw new TypeError('argument obj is required'); + } + + if ( + !obj.type || + typeof obj.type !== 'string' || + !TOKEN_REGEXP.test(obj.type) + ) { + throw new TypeError('invalid type'); + } + + // start with normalized type + let string = obj.type.toLowerCase(); + + // append parameters + if (obj.parameters && typeof obj.parameters === 'object') { + const params = Object.keys(obj.parameters).sort(); + + for (let i = 0; i < params.length; i++) { + const param = params[i]; + + const val = + param.slice(-1) === '*' + ? ustring(obj.parameters[param]) + : qstring(obj.parameters[param]); + + string += `; ${param}=${val}`; + } + } + + return string; + } +} + /** * TextDecoder instance for UTF-8 decoding when decodeURIComponent fails due to invalid byte sequences. */ @@ -98,52 +259,6 @@ const EXT_VALUE_REGEXP = const DISPOSITION_TYPE_REGEXP = /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/; // eslint-disable-line no-control-regex -export interface ContentDisposition { - /** - * Content-Disposition type, such as "attachment" or "inline" - */ - type: string; - /** - * Content-Disposition parameters, such as "filename" - */ - parameters: Record; -} - -export interface ContentDispositionCreateOptions { - /** - * Content-Disposition type, defaults to "attachment" - * @default "attachment" - */ - type?: string; - /** - * Fallback filename for non-ISO-8859-1 strings. If true, a fallback will be generated by replacing non-latin1 characters with "?". If false, no fallback will be generated. - * @default true - */ - fallback?: string | boolean; -} - -export interface ContentDispositionFormat { - type: string; - parameters?: Record; -} - -/** - * Create an attachment Content-Disposition header. - */ -export function create( - filename?: string, - options?: ContentDispositionCreateOptions, -): string { - // get type - const type = options?.type || 'attachment'; - - // get parameters - const params = createparams(filename, options?.fallback); - - // format into string - return format(new ContentDispositionImpl(type, params)); -} - /** * Create parameters object from filename and fallback. */ @@ -195,44 +310,6 @@ function createparams( return params; } -/** - * Format object to Content-Disposition header. - */ -export function format(obj: ContentDispositionFormat): string { - if (!obj || typeof obj !== 'object') { - throw new TypeError('argument obj is required'); - } - - if ( - !obj.type || - typeof obj.type !== 'string' || - !TOKEN_REGEXP.test(obj.type) - ) { - throw new TypeError('invalid type'); - } - - // start with normalized type - let string = obj.type.toLowerCase(); - - // append parameters - if (obj.parameters && typeof obj.parameters === 'object') { - const params = Object.keys(obj.parameters).sort(); - - for (let i = 0; i < params.length; i++) { - const param = params[i]; - - const val = - param.slice(-1) === '*' - ? ustring(obj.parameters[param]) - : qstring(obj.parameters[param]); - - string += `; ${param}=${val}`; - } - } - - return string; -} - /** * Decode a RFC 5987 field value (gracefully). */ @@ -281,79 +358,6 @@ function getlatin1(val: string): string { return val.replace(NON_LATIN1_REGEXP, '?'); } -/** - * Parse Content-Disposition header string. - */ -export function parse(string: string): ContentDisposition { - if (!string || typeof string !== 'string') { - throw new TypeError('argument string is required'); - } - - let match = DISPOSITION_TYPE_REGEXP.exec(string); - - if (!match) { - throw new TypeError('invalid type format'); - } - - // normalize type - let index = match[0].length; - const type = match[1].toLowerCase(); - - const obj = new ContentDispositionImpl(type); - - let key; - const names = []; - let value; - - // calculate index to start at - index = PARAM_REGEXP.lastIndex = - match[0].slice(-1) === ';' ? index - 1 : index; - - // match parameters - while ((match = PARAM_REGEXP.exec(string))) { - if (match.index !== index) { - throw new TypeError('invalid parameter format'); - } - - index += match[0].length; - key = match[1].toLowerCase(); - value = match[2]; - - if (names.indexOf(key) !== -1) { - throw new TypeError('invalid duplicate parameter'); - } - - names.push(key); - - if (key.indexOf('*') + 1 === key.length) { - // decode extended value - key = key.slice(0, -1); - value = decodefield(value); - - // overwrite existing value - obj.parameters[key] = value; - continue; - } - - if (typeof obj.parameters[key] === 'string') { - continue; - } - - if (value[0] === '"') { - // remove quotes and escapes - value = value.slice(1, -1).replace(QESC_REGEXP, '$1'); - } - - obj.parameters[key] = value; - } - - if (index !== -1 && index !== string.length) { - throw new TypeError('invalid parameter format'); - } - - return obj; -} - /** * Percent encode a single character. */ @@ -388,7 +392,7 @@ function ustring(val: unknown): string { /** * Class for parsed Content-Disposition header for v8 optimization */ -class ContentDispositionImpl implements ContentDisposition { +class ContentDispositionImpl implements contentDisposition.ContentDisposition { type: string; parameters: Record; diff --git a/test/parse.spec.ts b/src/parse.spec.ts similarity index 99% rename from test/parse.spec.ts rename to src/parse.spec.ts index 9851617..ccc8197 100644 --- a/test/parse.spec.ts +++ b/src/parse.spec.ts @@ -1,5 +1,5 @@ import { describe, it, assert } from 'vitest'; -import { parse } from '../src'; +import { parse } from './index'; describe('parse(string)', function () { it('should require string', function () { diff --git a/textdecoder.d.ts b/textdecoder.d.ts index 35da2e4..bd04605 100644 --- a/textdecoder.d.ts +++ b/textdecoder.d.ts @@ -54,3 +54,8 @@ interface TextDecoderCommon { */ readonly ignoreBOM: boolean; } + +// eslint-disable-next-line no-var +declare var module: { + exports: unknown; +}; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..fb2211f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": ["src/**/*.spec.ts", "src/**/*.bench.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index e7aed96..d3699eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,13 @@ { + "extends": "@borderless/ts-scripts/configs/tsconfig.json", "compilerOptions": { - "noEmit": true, - "target": "esnext", - "lib": ["es2024"], - "moduleDetection": "force", - "module": "preserve", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "types": ["./textdecoder.d.ts"], - "strict": true, - "noUnusedLocals": true, - "declaration": true, - "esModuleInterop": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true, - "isolatedDeclarations": true + "target": "ES2022", + "lib": ["ES2023"], + "rootDir": "src", + "outDir": "dist", + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["./textdecoder.d.ts"] }, - "include": ["src"] + "include": ["src/**/*"] } diff --git a/tsdown.config.ts b/tsdown.config.ts deleted file mode 100644 index 0edcc76..0000000 --- a/tsdown.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - platform: 'neutral', - format: { - esm: { target: ['es2022'] }, - cjs: {}, // No target needed -> tsdown will infer from package.json "engines" field - }, - dts: true, - exports: true, - - /* Package Validation: https://tsdown.dev/options/lint */ - attw: true, - publint: true, -}); From e82170acdde18eb2bac5e89281b92f04c47bd745 Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Mon, 23 Feb 2026 22:14:07 +0100 Subject: [PATCH 7/8] include format method in docs --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index b407bdc..9afffb8 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,27 @@ are shown for the string `'attachment; filename="EURO rates.txt"; filename*=UTF- always lower case and extended versions replace non-extended versions). Example: `{filename: "€ rates.txt"}` +### contentDisposition.format(obj) + +```js +const disposition = contentDisposition.format({ + type: 'attachment', + parameters: { filename: 'EURO rates.txt' }, +}); +``` + +Format an object into a `Content-Disposition` header string. This is the inverse of +the `parse` method. It takes an object with the following properties and returns a +properly formatted header string: + +- `type` (required): The disposition type (string). Will be normalized to lower-case. + Example: `'attachment'` or `'inline'` + +- `parameters` (optional): An object of parameters to include in the header. Example: `{ filename: "plans.pdf" }` + +The method handles encoding of parameter values appropriately, including proper +formatting of special characters and Unicode handling. + ## Examples ### Send a file for download From 188ca6385313499fed6c6a8d337a297a0f1e8e7f Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Mon, 23 Feb 2026 22:36:08 +0100 Subject: [PATCH 8/8] remove format export and docs --- README.md | 21 ------------- src/index.ts | 86 ++++++++++++++++++++++++++-------------------------- 2 files changed, 43 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 9afffb8..b407bdc 100644 --- a/README.md +++ b/README.md @@ -83,27 +83,6 @@ are shown for the string `'attachment; filename="EURO rates.txt"; filename*=UTF- always lower case and extended versions replace non-extended versions). Example: `{filename: "€ rates.txt"}` -### contentDisposition.format(obj) - -```js -const disposition = contentDisposition.format({ - type: 'attachment', - parameters: { filename: 'EURO rates.txt' }, -}); -``` - -Format an object into a `Content-Disposition` header string. This is the inverse of -the `parse` method. It takes an object with the following properties and returns a -properly formatted header string: - -- `type` (required): The disposition type (string). Will be normalized to lower-case. - Example: `'attachment'` or `'inline'` - -- `parameters` (optional): An object of parameters to include in the header. Example: `{ filename: "plans.pdf" }` - -The method handles encoding of parameter values appropriately, including proper -formatting of special characters and Unicode handling. - ## Examples ### Send a file for download diff --git a/src/index.ts b/src/index.ts index 2e6e24f..d533661 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ function contentDisposition( const params = createparams(filename, options?.fallback); // format into string - return contentDisposition.format(new ContentDispositionImpl(type, params)); + return format(new ContentDispositionImpl(type, params)); } namespace contentDisposition { @@ -48,11 +48,6 @@ namespace contentDisposition { fallback?: string | boolean; } - export interface FormatOptions { - type: string; - parameters?: Record; - } - /** * Parse Content-Disposition header string. */ @@ -125,44 +120,11 @@ namespace contentDisposition { return obj; } +} - /** - * Format object to Content-Disposition header. - */ - export function format(obj: FormatOptions): string { - if (!obj || typeof obj !== 'object') { - throw new TypeError('argument obj is required'); - } - - if ( - !obj.type || - typeof obj.type !== 'string' || - !TOKEN_REGEXP.test(obj.type) - ) { - throw new TypeError('invalid type'); - } - - // start with normalized type - let string = obj.type.toLowerCase(); - - // append parameters - if (obj.parameters && typeof obj.parameters === 'object') { - const params = Object.keys(obj.parameters).sort(); - - for (let i = 0; i < params.length; i++) { - const param = params[i]; - - const val = - param.slice(-1) === '*' - ? ustring(obj.parameters[param]) - : qstring(obj.parameters[param]); - - string += `; ${param}=${val}`; - } - } - - return string; - } +interface FormatOptions { + type: string; + parameters?: Record; } /** @@ -350,6 +312,44 @@ function decodefield(str: string): string { throw new TypeError('unsupported charset in extended field'); } +/** + * Format object to Content-Disposition header. + */ +function format(obj: FormatOptions): string { + if (!obj || typeof obj !== 'object') { + throw new TypeError('argument obj is required'); + } + + if ( + !obj.type || + typeof obj.type !== 'string' || + !TOKEN_REGEXP.test(obj.type) + ) { + throw new TypeError('invalid type'); + } + + // start with normalized type + let string = obj.type.toLowerCase(); + + // append parameters + if (obj.parameters && typeof obj.parameters === 'object') { + const params = Object.keys(obj.parameters).sort(); + + for (let i = 0; i < params.length; i++) { + const param = params[i]; + + const val = + param.slice(-1) === '*' + ? ustring(obj.parameters[param]) + : qstring(obj.parameters[param]); + + string += `; ${param}=${val}`; + } + } + + return string; +} + /** * Get ISO-8859-1 version of string. */