├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── node.js.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── SECURITY.md ├── commitlint.config.mjs ├── cspell.json ├── examples ├── asset-integrity.ts ├── asset-size.ts ├── aws-s3-data-integrity.ts ├── custom-cdn.ts ├── customized.ts ├── merged.ts ├── sorted.ts └── transformed.ts ├── lint-staged.config.mjs ├── package.json ├── pnpm-lock.yaml ├── readme.md ├── release.config.mjs ├── src ├── helpers.ts ├── index.ts ├── options-schema.ts ├── plugin.ts ├── type-predicate.ts └── types.ts ├── test ├── fixtures │ ├── bad-import.js │ ├── client.js │ ├── complex.mjs │ ├── hello.js │ ├── images │ │ ├── Ginger.asset.jpg │ │ ├── Ginger.jpg │ │ └── Ginger.loader.jpg │ ├── json │ │ ├── images.json │ │ ├── invalid-json.txt │ │ └── sample-manifest.json │ ├── load-styles.mjs │ ├── main.js │ ├── prefetch.js │ ├── preload.js │ ├── readme.md │ ├── remote.js │ ├── server.js │ └── styles │ │ ├── bad-import.css │ │ ├── ginger.css │ │ └── main.css ├── helpers.test.ts ├── plugin.test.ts ├── type-predicate.test.ts ├── utils.ts └── webpack-configs.ts ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.configs.json ├── tsconfig.examples.json ├── tsconfig.json ├── tsconfig.mjs.json ├── tsconfig.test.json └── vitest.config.mts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | private/ 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@webdeveric/eslint-config-ts", "plugin:import/recommended", "plugin:import/typescript", "prettier"], 4 | "env": { 5 | "es6": true, 6 | "node": true 7 | }, 8 | "parserOptions": { 9 | "project": ["./tsconfig.json"], 10 | "EXPERIMENTAL_useProjectService": { 11 | "maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING": 1000 12 | } 13 | }, 14 | "settings": { 15 | "import/extensions": [".ts", ".mts", ".cts", ".js", ".json"], 16 | "import/resolver": { 17 | "typescript": { 18 | "project": "./tsconfig.json" 19 | }, 20 | "node": { 21 | "extensions": [".js", ".ts", ".mts", ".cts"] 22 | } 23 | }, 24 | "import/parsers": { 25 | "@typescript-eslint/parser": [".ts", ".mts", ".cts"] 26 | } 27 | }, 28 | "rules": { 29 | "@typescript-eslint/no-shadow": "off", 30 | "import/first": "error", 31 | "import/no-absolute-path": "error", 32 | "import/no-cycle": "error", 33 | "import/no-deprecated": "error", 34 | "import/no-extraneous-dependencies": [ 35 | "error", 36 | { 37 | "devDependencies": ["./vitest.config.mts", "./lint-staged.config.mjs", "./test/**/*"] 38 | } 39 | ], 40 | "import/no-relative-packages": "error", 41 | "import/no-self-import": "error", 42 | "import/no-unresolved": "error", 43 | "import/no-useless-path-segments": [ 44 | "error", 45 | { 46 | "noUselessIndex": false 47 | } 48 | ], 49 | "import/order": [ 50 | "error", 51 | { 52 | "alphabetize": { 53 | "order": "asc", 54 | "caseInsensitive": true 55 | }, 56 | "groups": ["builtin", "external", "internal", "parent", ["sibling", "index"], "type"], 57 | "newlines-between": "always" 58 | } 59 | ], 60 | "sort-imports": "off" 61 | }, 62 | "overrides": [ 63 | { 64 | "files": ["**/*.test.ts"], 65 | "rules": { 66 | "@typescript-eslint/no-explicit-any": "off" 67 | } 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @webdeveric 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [webdeveric] 2 | tidelift: npm/webpack-assets-manifest 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## The problem 2 | 3 | ## Technical details 4 | 5 | ### Webpack version 6 | 7 | ### Webpack config 8 | 9 | ### Operating system 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | labels: 13 | - "dependencies" 14 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | ci: 11 | name: Continuous Integration 12 | runs-on: ubuntu-22.04 13 | strategy: 14 | matrix: 15 | node-version: [20.x, 22.x] 16 | webpack-version: ['5.61.0', latest] 17 | dev-server-version: [4, latest] 18 | css-loader-version: ['3.5.0', latest] 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'pnpm' 31 | 32 | - name: Install dependencies 33 | run: pnpm install --frozen-lockfile 34 | 35 | - name: Install specific dependency versions 36 | run: pnpm install webpack@${{ matrix.webpack-version }} webpack-dev-server@${{ matrix.dev-server-version }} css-loader@${{ matrix.css-loader-version }} --no-lockfile 37 | 38 | - name: Linting 39 | run: pnpm lint 40 | 41 | - name: Build 42 | run: pnpm build 43 | 44 | - name: Test 45 | run: pnpm coverage 46 | 47 | - name: Upload code coverage 48 | uses: codecov/codecov-action@v4 49 | with: 50 | fail_ci_if_error: true 51 | token: ${{ secrets.CODECOV_TOKEN }} 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: read # for checkout 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write # to be able to publish a GitHub release 17 | issues: write # to be able to comment on released issues 18 | pull-requests: write # to be able to comment on released pull requests 19 | id-token: write # to enable use of OIDC for npm provenance 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Setup pnpm 27 | uses: pnpm/action-setup@v4 28 | 29 | - name: Use Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version-file: '.nvmrc' 33 | cache: 'pnpm' 34 | 35 | - name: Installing dependencies 36 | run: pnpm install --frozen-lockfile 37 | 38 | # https://github.com/pnpm/pnpm/issues/7909 39 | # - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies 40 | # run: npm audit signatures 41 | 42 | - name: Release 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | # Set this value in your repository secrets after you generate it at NPM. 46 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | NPM_CONFIG_PROVENANCE: true 48 | run: pnpm exec semantic-release 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea/ 3 | cache/ 4 | coverage/ 5 | dist/ 6 | private/ 7 | *.tsbuildinfo 8 | npm-debug.log 9 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm exec commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm exec lint-staged --relative 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # https://pnpm.io/npmrc 2 | auto-install-peers=true 3 | enable-pre-post-scripts=true 4 | engine-strict=true 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .husky/ 3 | coverage/ 4 | build/ 5 | dist/ 6 | private/ 7 | package-lock.json 8 | pnpm-lock.yaml 9 | .prettierignore 10 | .editorconfig 11 | .npmrc 12 | .nvmrc 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "streetsidesoftware.code-spell-checker", 4 | "editorconfig.editorconfig", 5 | "dbaeumer.vscode-eslint", 6 | "eamodio.gitlens" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "eslint.validate": ["javascript", "typescript"], 4 | "eslint.format.enable": true, 5 | "eslint.lintTask.enable": true, 6 | "editor.bracketPairColorization.enabled": true, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll": "explicit" 9 | }, 10 | "editor.defaultFormatter": "esbenp.prettier-vscode", 11 | "editor.formatOnSave": true, 12 | "javascript.format.semicolons": "insert", 13 | "javascript.preferences.quoteStyle": "single", 14 | "javascript.updateImportsOnFileMove.enabled": "always", 15 | "typescript.format.semicolons": "insert", 16 | "typescript.preferences.quoteStyle": "single", 17 | "typescript.updateImportsOnFileMove.enabled": "always", 18 | "[json]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | }, 21 | "[jsonc]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "[javascript]": { 25 | "editor.defaultFormatter": "esbenp.prettier-vscode" 26 | }, 27 | "[typescript]": { 28 | "editor.defaultFormatter": "esbenp.prettier-vscode" 29 | }, 30 | "[css]": { 31 | "editor.defaultFormatter": "esbenp.prettier-vscode" 32 | }, 33 | "[xml]": { 34 | "editor.defaultFormatter": "esbenp.prettier-vscode" 35 | }, 36 | "[yaml]": { 37 | "editor.defaultFormatter": "esbenp.prettier-vscode" 38 | }, 39 | "files.associations": { 40 | "**/*.min.{cjs,mjs,js}": "plaintext", 41 | ".env.*": "dotenv" 42 | }, 43 | "search.exclude": { 44 | "**/dist": true, 45 | "**/private": true 46 | }, 47 | "editor.formatOnSaveMode": "file", 48 | "workbench.settings.openDefaultSettings": true, 49 | "git.inputValidationSubjectLength": 60, 50 | "javascript.preferences.importModuleSpecifier": "non-relative", 51 | "javascript.preferences.importModuleSpecifierEnding": "js", 52 | "typescript.preferences.importModuleSpecifier": "non-relative", 53 | "typescript.preferences.importModuleSpecifierEnding": "js", 54 | "typescript.preferences.preferTypeOnlyAutoImports": true, 55 | "typescript.enablePromptUseWorkspaceTsdk": true, 56 | "typescript.referencesCodeLens.enabled": true, 57 | "typescript.surveys.enabled": false, 58 | "typescript.implementationsCodeLens.enabled": true 59 | } 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Setup 4 | 5 | ```bash 6 | git clone https://github.com/webdeveric/webpack-assets-manifest.git 7 | cd webpack-assets-manifest 8 | corepack enable 9 | pnpm install --frozen-lockfile 10 | ``` 11 | 12 | ## Testing 13 | 14 | Run tests 15 | 16 | ```bash 17 | pnpm test 18 | ``` 19 | 20 | Run tests and generate a coverage report. Please keep the code coverage at 100%. 21 | 22 | ```bash 23 | pnpm test:report 24 | ``` 25 | 26 | ## Pull requests 27 | 28 | Pull requests are welcome. If you want to add a large feature or breaking change, please open an issue first so it can be discussed. 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Eric King 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The last two major versions will be supported. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 5.x.x | :white_check_mark: | 10 | | 4.x.x | :white_check_mark: | 11 | | < 4.0 | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Report any issues to me at `eric@webdeveric.com` 16 | -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@commitlint/types').UserConfig} 3 | */ 4 | export default { 5 | extends: ['@commitlint/config-conventional'], 6 | plugins: ['commitlint-plugin-cspell'], 7 | rules: { 8 | 'cspell/type': [2, 'always'], 9 | 'cspell/scope': [2, 'always'], 10 | 'cspell/subject': [2, 'always'], 11 | 'cspell/body': [2, 'always'], 12 | 'cspell/footer': [2, 'always'], 13 | 'scope-case': [2, 'always', ['lower-case', 'upper-case']], 14 | 'subject-empty': [2, 'never'], 15 | 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], 16 | 'type-case': [2, 'always', 'lower-case'], 17 | 'type-empty': [2, 'never'], 18 | 'type-enum': [ 19 | 2, 20 | 'always', 21 | ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'], 22 | ], 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "words": ["Analyse", "chunkhash", "conventionalcommits", "subresource", "unrs-resolver", "webdeveric"], 5 | "flagWords": [], 6 | "ignorePaths": ["pnpm-lock.yaml", "./.vscode/", "node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /examples/asset-integrity.ts: -------------------------------------------------------------------------------- 1 | // This is imported this way for typechecking purposes. 2 | // Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` in your code. 3 | import { WebpackAssetsManifest } from '../src/plugin.js'; 4 | 5 | new WebpackAssetsManifest({ 6 | output: 'asset-integrity-manifest.json', 7 | integrity: true, 8 | publicPath: true, 9 | customize(entry, _original, manifest, asset) { 10 | return manifest.utils.isKeyValuePair(entry) 11 | ? { 12 | key: entry.value, 13 | value: asset && asset.info['integrity'], 14 | } 15 | : entry; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /examples/asset-size.ts: -------------------------------------------------------------------------------- 1 | // This is imported this way for typechecking purposes. 2 | // Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` in your code. 3 | import { WebpackAssetsManifest } from '../src/plugin.js'; 4 | 5 | new WebpackAssetsManifest({ 6 | output: 'asset-size-manifest.json', 7 | customize(entry, _original, manifest, asset) { 8 | return manifest.utils.isKeyValuePair(entry) 9 | ? { 10 | value: { 11 | value: entry.value, 12 | // `asset` could be `undefined` when `manifest.set()` is manually called. 13 | // `size()` returns number of bytes 14 | size: asset?.source.size(), 15 | }, 16 | } 17 | : entry; 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /examples/aws-s3-data-integrity.ts: -------------------------------------------------------------------------------- 1 | // This is imported this way for typechecking purposes. 2 | // Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` in your code. 3 | import { WebpackAssetsManifest } from '../src/plugin.js'; 4 | 5 | new WebpackAssetsManifest({ 6 | output: 'aws-s3-data-integrity-manifest.json', 7 | integrity: true, 8 | integrityHashes: ['md5'], 9 | integrityPropertyName: 'md5', 10 | publicPath: 's3://some-bucket/some-folder/', 11 | customize(entry, _original, manifest, asset) { 12 | return manifest.utils.isKeyValuePair(entry) 13 | ? { 14 | key: entry.value, 15 | value: asset && asset.info['md5'].substr(4), 16 | } 17 | : entry; 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /examples/custom-cdn.ts: -------------------------------------------------------------------------------- 1 | // This is imported this way for typechecking purposes. 2 | // Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` in your code. 3 | import { WebpackAssetsManifest } from '../src/plugin.js'; 4 | 5 | new WebpackAssetsManifest({ 6 | output: 'custom-cdn-manifest.json', 7 | publicPath(filename, manifest) { 8 | switch (manifest.getExtension(filename).substring(1).toLowerCase()) { 9 | case 'jpg': 10 | case 'jpeg': 11 | case 'gif': 12 | case 'png': 13 | case 'svg': 14 | return `https://img.cdn.example.com/${filename}`; 15 | case 'css': 16 | return `https://css.cdn.example.com/${filename}`; 17 | case 'js': 18 | return `https://js.cdn.example.com/${filename}`; 19 | default: 20 | return `https://cdn.example.com/${filename}`; 21 | } 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /examples/customized.ts: -------------------------------------------------------------------------------- 1 | // This is imported this way for typechecking purposes. 2 | // Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` in your code. 3 | import { WebpackAssetsManifest } from '../src/plugin.js'; 4 | 5 | new WebpackAssetsManifest({ 6 | output: 'customized-manifest.json', 7 | // This will allow you to customize each individual entry in the manifest. 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | customize(entry, _original, manifest, _asset) { 10 | if (manifest.isMerging) { 11 | // Do something 12 | } 13 | 14 | if (manifest.utils.isKeyValuePair(entry)) { 15 | // You can prevent adding items to the manifest by returning false. 16 | if (typeof entry.key === 'string' && entry.key.toLowerCase().endsWith('.map')) { 17 | return false; 18 | } 19 | 20 | // You can directly modify key/value on the `entry` argument 21 | // or you can return a new object that has key and/or value properties. 22 | // If either the key or value is missing, the defaults will be used. 23 | // 24 | // The key should be a string and the value can be anything that can be JSON stringified. 25 | // If something else (or nothing) is returned, the manifest will add the entry normally. 26 | return { 27 | key: `src/${entry.key}`, 28 | value: `dist/${entry.value}`, 29 | }; 30 | } 31 | 32 | return entry; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /examples/merged.ts: -------------------------------------------------------------------------------- 1 | // This is imported this way for typechecking purposes. 2 | // Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` in your code. 3 | import { WebpackAssetsManifest } from '../src/plugin.js'; 4 | 5 | new WebpackAssetsManifest({ 6 | output: 'merged-manifest.json', 7 | merge: true, 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | customize(entry, _original, manifest, _asset) { 10 | if (manifest.isMerging) { 11 | // Do something 12 | } 13 | 14 | return entry; 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /examples/sorted.ts: -------------------------------------------------------------------------------- 1 | // This is imported this way for typechecking purposes. 2 | // Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` in your code. 3 | import { WebpackAssetsManifest } from '../src/plugin.js'; 4 | 5 | new WebpackAssetsManifest({ 6 | output: 'sorted-manifest.json', 7 | sortManifest(left, right) { 8 | // `this` is the manifest instance. 9 | 10 | return this.getExtension(left).localeCompare(this.getExtension(right)) || left.localeCompare(right); 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /examples/transformed.ts: -------------------------------------------------------------------------------- 1 | import pkg from 'webpack-assets-manifest/package.json' with { type: 'json' }; 2 | 3 | // This is imported this way for typechecking purposes. 4 | // Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` in your code. 5 | import { WebpackAssetsManifest } from '../src/plugin.js'; 6 | 7 | new WebpackAssetsManifest({ 8 | output: 'transformed-manifest.json', 9 | transform(assets, manifest) { 10 | assets['package'] = { 11 | name: pkg.name, 12 | version: pkg.version, 13 | }; 14 | 15 | // You can call the customize hook if you need to. 16 | const customized = manifest.hooks.customize.call( 17 | { 18 | key: 'YourKey', 19 | value: 'YourValue', 20 | }, 21 | { 22 | key: 'YourKey', 23 | value: 'YourValue', 24 | }, 25 | manifest, 26 | undefined, 27 | ); 28 | 29 | if (manifest.utils.isKeyValuePair(customized) && typeof customized.key === 'string') { 30 | const { key, value } = customized; 31 | 32 | assets[key] = value; 33 | } 34 | 35 | return assets; 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {Record string | string[] | Promise)>} 3 | */ 4 | export default { 5 | '*.{js,cjs,mjs,ts,cts,mts}': ['eslint --fix', 'prettier --write'], 6 | '*.{json,md}': 'prettier --write', 7 | '*': (files) => { 8 | return [ 9 | `cspell lint --no-progress --no-summary --no-must-find-files ${files.join(' ')}`, 10 | `sh -c 'echo "${files.join('\n')}" | cspell --show-context stdin'`, // Spell check file names. 11 | ]; 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-assets-manifest", 3 | "version": "0.0.0-development", 4 | "description": "This Webpack plugin will generate a JSON file that matches the original filename with the hashed version.", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+ssh://git@github.com/webdeveric/webpack-assets-manifest.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/webdeveric/webpack-assets-manifest/issues" 12 | }, 13 | "author": { 14 | "email": "eric@webdeveric.com", 15 | "name": "Eric King", 16 | "url": "http://webdeveric.com/" 17 | }, 18 | "homepage": "https://github.com/webdeveric/webpack-assets-manifest", 19 | "keywords": [ 20 | "webpack-assets-manifest", 21 | "webpack-plugin", 22 | "webpack", 23 | "plugin", 24 | "assets", 25 | "manifest", 26 | "json", 27 | "subresource", 28 | "integrity", 29 | "sri" 30 | ], 31 | "engines": { 32 | "node": ">=20.10.0" 33 | }, 34 | "type": "module", 35 | "types": "./dist/types/index.d.ts", 36 | "exports": { 37 | ".": { 38 | "types": "./dist/types/index.d.ts", 39 | "require": "./dist/cjs/index.js", 40 | "import": "./dist/mjs/index.js" 41 | }, 42 | "./*": { 43 | "types": "./dist/types/*.d.ts", 44 | "require": "./dist/cjs/*.js", 45 | "import": "./dist/mjs/*.js" 46 | }, 47 | "./package.json": "./package.json" 48 | }, 49 | "typesVersions": { 50 | "*": { 51 | "helpers": [ 52 | "./dist/types/helpers.d.ts" 53 | ], 54 | "options-schema": [ 55 | "./dist/types/options-schema.d.ts" 56 | ], 57 | "type-predicate": [ 58 | "./dist/types/type-predicate.d.ts" 59 | ], 60 | "types": [ 61 | "./dist/types/types.d.ts" 62 | ] 63 | } 64 | }, 65 | "files": [ 66 | "dist" 67 | ], 68 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977", 69 | "scripts": { 70 | "clean": "rimraf ./coverage/ ./dist/ ./cache/", 71 | "prebuild": "pnpm clean", 72 | "validate": "validate-package-exports --check --verify --info", 73 | "build": "tsc --build tsconfig.cjs.json tsconfig.mjs.json --force", 74 | "postbuild": "echo '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json && echo '{\"type\":\"module\"}' > ./dist/mjs/package.json && pnpm validate", 75 | "lint": "eslint ./*{js,cjs,mjs,mts} ./src ./test ./examples", 76 | "typecheck": "tsc --build --verbose", 77 | "spellcheck": "cspell --no-progress './{.github,src,examples,test}/**/*.{ts,js,json}' './*.{md,js,mjs,mts}' './package.json'", 78 | "format": "prettier --write ./*.{mts,mjs,json,md} ./src/ ./test/ --no-error-on-unmatched-pattern", 79 | "test": "vitest", 80 | "coverage": "vitest run --coverage", 81 | "prepare": "husky", 82 | "prepack": "pnpm build", 83 | "prepublishOnly": "pnpm spellcheck && pnpm lint && pnpm coverage" 84 | }, 85 | "prettier": "@webdeveric/prettier-config", 86 | "dependencies": { 87 | "deepmerge": "^4.3.1", 88 | "lockfile": "^1.0.4", 89 | "schema-utils": "^4.3.2", 90 | "tapable": "^2.2.1" 91 | }, 92 | "peerDependencies": { 93 | "webpack": "^5.61.0" 94 | }, 95 | "devDependencies": { 96 | "@commitlint/config-conventional": "^19.8.1", 97 | "@commitlint/types": "^19.8.1", 98 | "@types/lockfile": "^1.0.4", 99 | "@types/node": "^20.17.51", 100 | "@types/tapable": "^2.2.7", 101 | "@types/webpack-sources": "^3.2.3", 102 | "@vitest/coverage-v8": "^3.1.4", 103 | "@webdeveric/eslint-config-ts": "^0.11.0", 104 | "@webdeveric/prettier-config": "^0.3.0", 105 | "commitlint": "^19.8.1", 106 | "commitlint-plugin-cspell": "^0.2.0", 107 | "compression-webpack-plugin": "^11.1.0", 108 | "conventional-changelog-conventionalcommits": "^9.0.0", 109 | "copy-webpack-plugin": "^13.0.0", 110 | "cspell": "^9.0.2", 111 | "css-loader": "^7.1.2", 112 | "eslint": "^8.57.1", 113 | "eslint-config-prettier": "^10.1.5", 114 | "eslint-import-resolver-typescript": "^4.4.1", 115 | "eslint-plugin-import": "^2.31.0", 116 | "file-loader": "^6.2.0", 117 | "fs-extra": "^11.3.0", 118 | "husky": "^9.1.7", 119 | "lint-staged": "^16.1.0", 120 | "memfs": "^4.17.2", 121 | "mini-css-extract-plugin": "^2.9.2", 122 | "prettier": "^3.5.3", 123 | "rimraf": "^6.0.1", 124 | "sass-loader": "^16.0.5", 125 | "semantic-release": "^24.2.5", 126 | "typescript": "^5.8.3", 127 | "validate-package-exports": "^0.9.0", 128 | "vitest": "^3.1.4", 129 | "webpack": "^5.99.9", 130 | "webpack-dev-server": "^5.2.1", 131 | "webpack-subresource-integrity": "^5.1.0" 132 | }, 133 | "pnpm": { 134 | "onlyBuiltDependencies": [ 135 | "esbuild", 136 | "unrs-resolver" 137 | ] 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Webpack Assets Manifest 2 | 3 | [![Node.js CI](https://github.com/webdeveric/webpack-assets-manifest/actions/workflows/node.js.yml/badge.svg)](https://github.com/webdeveric/webpack-assets-manifest/actions/workflows/node.js.yml) [![codecov](https://codecov.io/gh/webdeveric/webpack-assets-manifest/branch/master/graph/badge.svg)](https://codecov.io/gh/webdeveric/webpack-assets-manifest) 4 | 5 | This webpack plugin will generate a JSON file that matches the original filename with the hashed version. 6 | 7 | ## Installation 8 | 9 | ```shell 10 | pnpm add -D webpack-assets-manifest 11 | ``` 12 | 13 | ```shell 14 | npm install webpack-assets-manifest -D 15 | ``` 16 | 17 | ```shell 18 | yarn add webpack-assets-manifest -D 19 | ``` 20 | 21 | ## New in version 6 22 | 23 | - This plugin is now written with TypeScript. 24 | - TypeScript types are provided by this package. 25 | - The package exports `esm` and `cjs`. 26 | - Added `manifest.utils` for use in the `customize` hook. 27 | - See [examples/customized.js](https://github.com/webdeveric/webpack-assets-manifest/blob/master/examples/customized.ts) 28 | - https://github.com/webdeveric/webpack-assets-manifest/blob/b6ed8c09394bf33c8fadd5adff8bad7694f295ba/src/plugin.ts#L185-L195 29 | 30 | ### Breaking changes 31 | 32 | - Bumped minimum webpack version to `5.61` 33 | - Bumped minimum Node version to `20.10` 34 | - The plugin is exported using a named export instead of default. 35 | - Use `import { WebpackAssetsManifest } from 'webpack-assets-manifest';` 36 | 37 | ## New in version 5 38 | 39 | - Compatible with webpack 5 only (5.1+ required). 40 | - Supports finding [asset modules](https://webpack.js.org/guides/asset-modules/). 41 | - Updated options schema to prevent additional properties. This helps with catching typos in option names. 42 | - :warning: Updated default value of the `output` option to be `assets-manifest.json`. This is to prevent confusion when working with [Web app manifests](https://developer.mozilla.org/en-US/docs/Web/Manifest) or [WebExtension manifests](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json). 43 | 44 | ## New in version 4 45 | 46 | - Requires Node 10+. 47 | - Compatible with webpack 4 only (4.40+ required). 48 | - Added options: [`enabled`](#enabled), [`entrypointsUseAssets`](#entrypointsUseAssets), [`contextRelativeKeys`](#contextRelativeKeys). 49 | - Updated [`writeToDisk`](#writeToDisk) option to default to `auto`. 50 | - Use lock files for various operations. 51 | - `done` hook is now an `AsyncSeriesHook`. 52 | - :warning: The structure of the `entrypoints` data has been updated to include `preload` and `prefetch` assets. Assets for an entrypoint are now included in an `assets` property under the entrypoint. 53 | 54 | Example: 55 | 56 | ```json 57 | { 58 | "entrypoints": { 59 | "main": { 60 | "assets": { 61 | "css": ["main.css"], 62 | "js": ["main.js"] 63 | }, 64 | "prefetch": { 65 | "js": ["prefetch.js"] 66 | }, 67 | "preload": { 68 | "js": ["preload.js"] 69 | } 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | ## Usage 76 | 77 | In your webpack config, require the plugin then add an instance to the `plugins` array. 78 | 79 | ```js 80 | import { WebpackAssetsManifest } from 'webpack-assets-manifest'; 81 | 82 | export default { 83 | entry: { 84 | // Your entry points 85 | }, 86 | output: { 87 | // Your output details 88 | }, 89 | module: { 90 | // Your loader rules go here. 91 | }, 92 | plugins: [ 93 | new WebpackAssetsManifest({ 94 | // Options go here 95 | }), 96 | ], 97 | }; 98 | ``` 99 | 100 | ## Sample output 101 | 102 | ```json 103 | { 104 | "main.js": "main-9c68d5e8de1b810a80e4.js", 105 | "main.css": "main-9c68d5e8de1b810a80e4.css", 106 | "images/logo.svg": "images/logo-b111da4f34cefce092b965ebc1078ee3.svg" 107 | } 108 | ``` 109 | 110 | --- 111 | 112 | ## Options ([read the schema](src/options-schema.ts)) 113 | 114 | ### `enabled` 115 | 116 | Type: `boolean` 117 | 118 | Default: `true` 119 | 120 | Is the plugin enabled? 121 | 122 | ### `output` 123 | 124 | Type: `string` 125 | 126 | Default: `assets-manifest.json` 127 | 128 | This is where to save the manifest file relative to your webpack `output.path`. 129 | 130 | ### `assets` 131 | 132 | Type: `object` 133 | 134 | Default: `{}` 135 | 136 | Data is stored in this object. 137 | 138 | #### Sharing data 139 | 140 | You can share data between instances by passing in your own object in the `assets` option. 141 | 142 | This is useful in [multi-compiler mode](https://github.com/webpack/webpack/tree/master/examples/multi-compiler). 143 | 144 | ```js 145 | const data = Object.create(null); 146 | 147 | const manifest1 = new WebpackAssetsManifest({ 148 | assets: data, 149 | }); 150 | 151 | const manifest2 = new WebpackAssetsManifest({ 152 | assets: data, 153 | }); 154 | ``` 155 | 156 | ### `contextRelativeKeys` 157 | 158 | Type: `boolean` 159 | 160 | Default: `false` 161 | 162 | Keys are relative to the compiler context. 163 | 164 | ### `space` 165 | 166 | Type: `int` 167 | 168 | Default: `2` 169 | 170 | Number of spaces to use for pretty printing. 171 | 172 | ### `replacer` 173 | 174 | Type: `null`, `function`, or `array` 175 | 176 | Default: `null` 177 | 178 | [Replacer reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter) 179 | 180 | You'll probably want to use the `transform` hook instead. 181 | 182 | ### `fileExtRegex` 183 | 184 | Type: `regex` 185 | 186 | Default: `/\.\w{2,4}\.(?:map|gz)$|\.\w+$/i` 187 | 188 | This is the regular expression used to find file extensions. You'll probably never need to change this. 189 | 190 | ### `writeToDisk` 191 | 192 | Type: `boolean`, `string` 193 | 194 | Default: `'auto'` 195 | 196 | Write the manifest to disk using `fs`. 197 | 198 | :warning: If you're using another language for your site and you're using `webpack-dev-server` to process your assets during development, you should set `writeToDisk: true` and provide an absolute path in `output` so the manifest file is actually written to disk and not kept only in memory. 199 | 200 | ### `sortManifest` 201 | 202 | Type: `boolean`, `function` 203 | 204 | Default: `true` 205 | 206 | The manifest is sorted alphabetically by default. You can turn off sorting by setting `sortManifest: false`. 207 | 208 | If you want more control over how the manifest is sorted, you can provide your own [comparison function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Parameters). See the [sorted](examples/sorted.js) example. 209 | 210 | ```js 211 | new WebpackAssetsManifest({ 212 | sortManifest(left, right) { 213 | // Return -1, 0, or 1 214 | }, 215 | }); 216 | ``` 217 | 218 | ### `merge` 219 | 220 | Type: `boolean`, `string` 221 | 222 | Default: `false` 223 | 224 | If the `output` file already exists and you'd like to add to it, use `merge: true`. The default behavior is to use the existing keys/values without modification. 225 | 226 | ```js 227 | new WebpackAssetsManifest({ 228 | output: '/path/to/manifest.json', 229 | merge: true, 230 | }); 231 | ``` 232 | 233 | If you need to customize during merge, use `merge: 'customize'`. 234 | 235 | If you want to know if `customize` was called when merging with an existing manifest, you can check `manifest.isMerging`. 236 | 237 | ```js 238 | new WebpackAssetsManifest({ 239 | merge: 'customize', 240 | customize(entry, original, manifest, asset) { 241 | if ( manifest.isMerging ) { 242 | // Do something 243 | } 244 | }, 245 | }), 246 | ``` 247 | 248 | ### `publicPath` 249 | 250 | Type: `string`, `function`, `boolean`, 251 | 252 | Default: `null` 253 | 254 | When using `publicPath: true`, your webpack config `output.publicPath` will be used as the value prefix. 255 | 256 | ```js 257 | const manifest = new WebpackAssetsManifest({ 258 | publicPath: true, 259 | }); 260 | ``` 261 | 262 | When using a string, it will be the value prefix. One common use is to prefix your CDN URL. 263 | 264 | ```js 265 | const manifest = new WebpackAssetsManifest({ 266 | publicPath: 'https://cdn.example.com', 267 | }); 268 | ``` 269 | 270 | If you'd like to have more control, use a function. See the [custom CDN](examples/custom-cdn.js) example. 271 | 272 | ```js 273 | const manifest = new WebpackAssetsManifest({ 274 | publicPath(filename, manifest) { 275 | // customize filename here 276 | return filename; 277 | }, 278 | }); 279 | ``` 280 | 281 | ### `entrypoints` 282 | 283 | Type: `boolean` 284 | 285 | Default: `false` 286 | 287 | Include `compilation.entrypoints` in the manifest file. 288 | 289 | ### `entrypointsKey` 290 | 291 | Type: `string`, `boolean` 292 | 293 | Default: `entrypoints` 294 | 295 | If this is set to `false`, the `entrypoints` will be added to the root of the manifest. 296 | 297 | ### `entrypointsUseAssets` 298 | 299 | Type: `boolean` 300 | 301 | Default: `false` 302 | 303 | Entrypoint data should use the value from `assets`, which means the values could be customized and not just a `string` file path. This new option defaults to `false` so the new behavior is opt-in. 304 | 305 | ### `integrity` 306 | 307 | Type: `boolean` 308 | 309 | Default: `false` 310 | 311 | Include the [subresource integrity hash](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity). 312 | 313 | ### `integrityHashes` 314 | 315 | Type: `array` 316 | 317 | Default: `[ 'sha256', 'sha384', 'sha512' ]` 318 | 319 | Hash algorithms to use when generating SRI. For browsers, the currently the allowed integrity hashes are `sha256`, `sha384`, and `sha512`. 320 | 321 | Other hash algorithms can be used if your target environment is not a browser. If you were to create a tool to audit your S3 buckets for [data integrity](https://aws.amazon.com/premiumsupport/knowledge-center/data-integrity-s3/), you could use something like this [example](examples/aws-s3-data-integrity.js) to record the `md5` hashes. 322 | 323 | ### `integrityPropertyName` 324 | 325 | Type: `string` 326 | 327 | Default: `integrity` 328 | 329 | This is the property that will be set on each entry in `compilation.assets`, which will then be available during `customize`. It is customizable so that you can have multiple instances of this plugin and not have them overwrite the `currentAsset.integrity` property. 330 | 331 | You'll probably only need to change this if you're using multiple instances of this plugin to create different manifests. 332 | 333 | ### `apply` 334 | 335 | Type: `function` 336 | 337 | Default: `null` 338 | 339 | Callback to run after setup is complete. 340 | 341 | ### `customize` 342 | 343 | Type: `function` 344 | 345 | Default: `null` 346 | 347 | Callback to customize each entry in the manifest. 348 | 349 | You can use this to customize entry names for example. In the sample below, we adjust `img` keys so that it's easier to use them with a template engine: 350 | 351 | ```javascript 352 | new WebpackAssetsManifest({ 353 | customize(entry, original, manifest, asset) { 354 | if (manifest.utils.isKeyValuePair(entry) && entry.key.startsWith('img/')) { 355 | return { key: entry.key.split('img/')[1], value: entry.value }; 356 | } 357 | 358 | return entry; 359 | } 360 | } 361 | ``` 362 | 363 | The function is called per each entry and provides you a way to intercept and rewrite each object. The result is then merged into a whole manifest. 364 | 365 | [View the example](examples/customized.js) to see what else you can do with this function. 366 | 367 | ### `transform` 368 | 369 | Type: `function` 370 | 371 | Default: `null` 372 | 373 | Callback to transform the entire manifest. 374 | 375 | ### `done` 376 | 377 | Type: `function` 378 | 379 | Default: `null` 380 | 381 | Callback to run after the compilation is done and the manifest has been written. 382 | 383 | --- 384 | 385 | ### Hooks 386 | 387 | This plugin is using hooks from [Tapable](https://github.com/webpack/tapable/). 388 | 389 | The `apply`, `customize`, `transform`, and `done` options are automatically tapped into the appropriate hook. 390 | 391 | | Name | Type | Callback signature | 392 | | -------------- | ------------------- | ---------------------------------------------- | 393 | | `apply` | `SyncHook` | `function(manifest){}` | 394 | | `customize` | `SyncWaterfallHook` | `function(entry, original, manifest, asset){}` | 395 | | `transform` | `SyncWaterfallHook` | `function(assets, manifest){}` | 396 | | `done` | `AsyncSeriesHook` | `async function(manifest, stats){}` | 397 | | `options` | `SyncWaterfallHook` | `function(options){}` | 398 | | `afterOptions` | `SyncHook` | `function(options){}` | 399 | 400 | #### Tapping into hooks 401 | 402 | Tap into a hook by calling the `tap` method on the hook as shown below. 403 | 404 | If you want more control over exactly what gets added to your manifest, then use the `customize` and `transform` hooks. See the [customized](examples/customized.js) and [transformed](examples/transformed.js) examples. 405 | 406 | ```js 407 | const manifest = new WebpackAssetsManifest(); 408 | 409 | manifest.hooks.apply.tap('YourPluginName', function (manifest) { 410 | // Do something here 411 | manifest.set('some-key', 'some-value'); 412 | }); 413 | 414 | manifest.hooks.customize.tap('YourPluginName', function (entry, original, manifest, asset) { 415 | // customize entry here 416 | return entry; 417 | }); 418 | 419 | manifest.hooks.transform.tap('YourPluginName', function (assets, manifest) { 420 | // customize assets here 421 | return assets; 422 | }); 423 | 424 | manifest.hooks.options.tap('YourPluginName', function (options) { 425 | // customize options here 426 | return options; 427 | }); 428 | 429 | manifest.hooks.done.tap('YourPluginName', function (manifest, stats) { 430 | console.log(`The manifest has been written to ${manifest.getOutputPath()}`); 431 | console.log(`${manifest}`); 432 | }); 433 | 434 | manifest.hooks.done.tapPromise('YourPluginName', async (manifest, stats) => { 435 | await yourAsyncOperation(); 436 | }); 437 | ``` 438 | 439 | These hooks can also be set by passing them in the constructor options. 440 | 441 | ```js 442 | new WebpackAssetsManifest({ 443 | done(manifest, stats) { 444 | console.log(`The manifest has been written to ${manifest.getOutputPath()}`); 445 | console.log(`${manifest}`); 446 | }, 447 | }); 448 | ``` 449 | 450 | ## Manifest methods 451 | 452 | If the manifest instance is passed to a hook, you can use the following methods to manage what goes into the manifest. 453 | 454 | - `has(key)` 455 | - `get(key)` 456 | - `set(key, value)` 457 | - `setRaw(key, value)` 458 | - `delete(key)` 459 | 460 | If you want to write the manifest to another location, you can use `writeTo(destination)`. 461 | 462 | ```js 463 | new WebpackAssetsManifest({ 464 | async done(manifest) { 465 | await manifest.writeTo('/some/other/path/assets-manifest.json'); 466 | }, 467 | }); 468 | ``` 469 | -------------------------------------------------------------------------------- /release.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {Partial} 3 | */ 4 | export default { 5 | branches: ['master'], 6 | preset: 'conventionalcommits', 7 | plugins: [ 8 | [ 9 | '@semantic-release/commit-analyzer', 10 | { 11 | releaseRules: [ 12 | { 13 | type: 'chore', 14 | scope: 'deps', 15 | release: 'minor', 16 | }, 17 | { 18 | type: 'chore', 19 | scope: 'deps-dev', 20 | release: false, 21 | }, 22 | { 23 | type: 'docs', 24 | release: 'patch', 25 | }, 26 | { 27 | type: 'refactor', 28 | release: 'patch', 29 | }, 30 | { 31 | type: 'chore', 32 | scope: 'spelling', 33 | release: 'patch', 34 | }, 35 | ], 36 | }, 37 | ], 38 | '@semantic-release/release-notes-generator', 39 | '@semantic-release/npm', 40 | '@semantic-release/github', 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { createHash, type BinaryLike } from 'node:crypto'; 2 | import { tmpdir } from 'node:os'; 3 | import { join } from 'node:path'; 4 | 5 | import { lock as lockfileLock, unlock as lockfileUnlock } from 'lockfile'; 6 | 7 | import { isPropertyKey } from './type-predicate.js'; 8 | 9 | export function asArray(data: T | T[]): T[] { 10 | return Array.isArray(data) ? data : [data]; 11 | } 12 | 13 | /** 14 | * See {@link https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity | Subresource Integrity} at MDN 15 | * 16 | * @public 17 | */ 18 | export function getSRIHash(algorithm: string, content: string | BinaryLike): string { 19 | return `${algorithm}-${createHash(algorithm).update(content).digest('base64')}`; 20 | } 21 | 22 | /** 23 | * Get an object sorted by keys. 24 | * 25 | * @internal 26 | */ 27 | export function getSortedObject( 28 | object: Record, 29 | compareFunction?: (left: string, right: string) => number, 30 | ): typeof object { 31 | return Object.fromEntries( 32 | Object.entries(object).sort(compareFunction ? (left, right) => compareFunction(left[0], right[0]) : undefined), 33 | ); 34 | } 35 | 36 | /** 37 | * Find a Map entry key by the value 38 | * 39 | * @internal 40 | */ 41 | export function findMapKeysByValue(map: Map): (searchValue: V) => K[] { 42 | const entries = [...map.entries()]; 43 | 44 | return (searchValue: V): K[] => entries.filter(([, value]) => value === searchValue).map(([name]) => name); 45 | } 46 | 47 | /** 48 | * Group items from an array based on a callback return value. 49 | * 50 | * @internal 51 | */ 52 | export function group( 53 | data: readonly T[], 54 | getGroup: (item: T) => PropertyKey | undefined, 55 | mapper?: (item: T, group: PropertyKey) => T, 56 | ): Record { 57 | return data.reduce((obj, item) => { 58 | const group = getGroup(item); 59 | 60 | if (isPropertyKey(group)) { 61 | (obj[group] ??= []).push(mapper ? mapper(item, group) : item); 62 | } 63 | 64 | return obj; 65 | }, Object.create(null)); 66 | } 67 | 68 | /** 69 | * Build a file path to a lock file in the tmp directory 70 | * 71 | * @internal 72 | */ 73 | export function getLockFilename(filename: string): string { 74 | const name = filename.replace(/[^\w]+/g, '-'); 75 | 76 | return join(tmpdir(), `${name}.lock`); 77 | } 78 | 79 | /** 80 | * Create a lockfile (async) 81 | * 82 | * @internal 83 | */ 84 | export function lock(filename: string): Promise { 85 | return new Promise((resolve, reject) => { 86 | lockfileLock( 87 | getLockFilename(filename), 88 | { 89 | wait: 10000, 90 | stale: 20000, 91 | retries: 100, 92 | retryWait: 100, 93 | }, 94 | (error) => { 95 | if (error) { 96 | reject(error); 97 | 98 | return; 99 | } 100 | 101 | resolve(); 102 | }, 103 | ); 104 | }); 105 | } 106 | 107 | /** 108 | * Remove a lockfile (async) 109 | * 110 | * @internal 111 | */ 112 | export function unlock(filename: string): Promise { 113 | return new Promise((resolve, reject) => { 114 | lockfileUnlock(getLockFilename(filename), (error) => { 115 | if (error) { 116 | reject(error); 117 | 118 | return; 119 | } 120 | 121 | resolve(); 122 | }); 123 | }); 124 | } 125 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types.js'; 2 | export * from './plugin.js'; 3 | -------------------------------------------------------------------------------- /src/options-schema.ts: -------------------------------------------------------------------------------- 1 | import { getHashes } from 'node:crypto'; 2 | 3 | import type { validate } from 'schema-utils'; 4 | 5 | type Schema = Parameters[0]; 6 | 7 | export const optionsSchema = { 8 | title: 'Webpack Assets Manifest options schema', 9 | description: 'Webpack Assets Manifest options', 10 | type: 'object', 11 | additionalProperties: false, 12 | properties: { 13 | enabled: { 14 | type: 'boolean', 15 | default: true, 16 | }, 17 | assets: { 18 | type: 'object', 19 | default: {}, 20 | }, 21 | output: { 22 | type: 'string', 23 | default: 'assets-manifest.json', 24 | }, 25 | replacer: { 26 | default: null, 27 | oneOf: [ 28 | { 29 | $ref: '#/definitions/functionOrNull', 30 | }, 31 | { 32 | type: 'array', 33 | }, 34 | ], 35 | }, 36 | space: { 37 | oneOf: [ 38 | { 39 | type: 'integer', 40 | multipleOf: 1.0, 41 | minimum: 0, 42 | }, 43 | { 44 | type: 'string', 45 | minLength: 1, 46 | }, 47 | ], 48 | default: 2, 49 | }, 50 | writeToDisk: { 51 | oneOf: [ 52 | { 53 | type: 'boolean', 54 | }, 55 | { 56 | const: 'auto', 57 | }, 58 | ], 59 | default: 'auto', 60 | }, 61 | fileExtRegex: { 62 | oneOf: [ 63 | { 64 | instanceof: 'RegExp', 65 | }, 66 | { 67 | type: 'null', 68 | }, 69 | { 70 | const: false, 71 | }, 72 | ], 73 | }, 74 | sortManifest: { 75 | default: true, 76 | oneOf: [ 77 | { 78 | type: 'boolean', 79 | }, 80 | { 81 | instanceof: 'Function', 82 | }, 83 | ], 84 | }, 85 | merge: { 86 | default: false, 87 | oneOf: [ 88 | { 89 | type: 'boolean', 90 | }, 91 | { 92 | const: 'customize', 93 | }, 94 | ], 95 | }, 96 | publicPath: { 97 | default: null, 98 | oneOf: [ 99 | { 100 | type: 'string', 101 | }, 102 | { 103 | type: 'boolean', 104 | }, 105 | { 106 | type: 'null', 107 | }, 108 | { 109 | instanceof: 'Function', 110 | }, 111 | ], 112 | }, 113 | contextRelativeKeys: { 114 | type: 'boolean', 115 | default: false, 116 | }, 117 | apply: { 118 | $ref: '#/definitions/functionOrNull', 119 | }, 120 | customize: { 121 | $ref: '#/definitions/functionOrNull', 122 | }, 123 | transform: { 124 | $ref: '#/definitions/functionOrNull', 125 | }, 126 | done: { 127 | $ref: '#/definitions/functionOrNull', 128 | }, 129 | entrypoints: { 130 | type: 'boolean', 131 | default: false, 132 | }, 133 | entrypointsKey: { 134 | default: 'entrypoints', 135 | oneOf: [ 136 | { 137 | type: 'string', 138 | }, 139 | { 140 | const: false, 141 | }, 142 | ], 143 | }, 144 | entrypointsUseAssets: { 145 | type: 'boolean', 146 | default: false, 147 | }, 148 | integrity: { 149 | type: 'boolean', 150 | default: 'a', 151 | }, 152 | integrityHashes: { 153 | type: 'array', 154 | items: { 155 | type: 'string', 156 | enum: getHashes(), 157 | }, 158 | default: ['sha256', 'sha384', 'sha512'], 159 | }, 160 | integrityPropertyName: { 161 | description: 'The `asset.info` property name where the SRI hash is stored', 162 | type: 'string', 163 | minLength: 1, 164 | default: 'integrity', 165 | }, 166 | extra: { 167 | description: 'A place to put your arbitrary data', 168 | type: 'object', 169 | default: {}, 170 | }, 171 | }, 172 | definitions: { 173 | functionOrNull: { 174 | default: null, 175 | oneOf: [ 176 | { 177 | instanceof: 'Function', 178 | }, 179 | { 180 | type: 'null', 181 | }, 182 | ], 183 | }, 184 | }, 185 | } satisfies Schema; 186 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, readFile, writeFile } from 'node:fs/promises'; 2 | import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; 3 | 4 | import { validate } from 'schema-utils'; 5 | import { AsyncSeriesHook, SyncHook, SyncWaterfallHook } from 'tapable'; 6 | 7 | import { asArray, findMapKeysByValue, getSortedObject, getSRIHash, group, lock, unlock } from './helpers.js'; 8 | import { optionsSchema } from './options-schema.js'; 9 | import { isKeyValuePair, isObject } from './type-predicate.js'; 10 | 11 | import type { 12 | AssetsStorage, 13 | AssetsStorageKey, 14 | AssetsStorageValue, 15 | JsonStringifyReplacer, 16 | JsonStringifySpace, 17 | KeyValuePair, 18 | } from './types.js'; 19 | import type { 20 | Asset, 21 | AssetInfo, 22 | Compilation, 23 | Compiler, 24 | LoaderContext, 25 | NormalModule, 26 | Stats, 27 | StatsAsset, 28 | StatsCompilation, 29 | WebpackPluginInstance, 30 | } from 'webpack'; 31 | 32 | const PLUGIN_NAME = 'WebpackAssetsManifest'; 33 | 34 | /** 35 | * @public 36 | */ 37 | export type Options = { 38 | /** 39 | * This is the assets manifest data that gets serialized into JSON. 40 | */ 41 | assets: AssetsStorage; 42 | contextRelativeKeys: boolean; 43 | enabled: boolean; 44 | entrypoints: boolean; 45 | entrypointsKey: string | false; 46 | entrypointsUseAssets: boolean; 47 | /** 48 | * Store arbitrary data here for use in customize/transform 49 | */ 50 | extra: Record; 51 | fileExtRegex: RegExp | false; 52 | integrity: boolean; 53 | integrityHashes: string[]; 54 | /** 55 | * The `asset.info` property name where the SRI hash is stored. 56 | */ 57 | integrityPropertyName: string; 58 | merge: boolean | 'customize'; 59 | output: string; 60 | publicPath?: ((filename: string, manifest: WebpackAssetsManifest) => string) | string | boolean; 61 | sortManifest: boolean | ((this: WebpackAssetsManifest, left: string, right: string) => number); 62 | writeToDisk: boolean | 'auto'; 63 | 64 | // JSON stringify parameters 65 | replacer: JsonStringifyReplacer; 66 | space: JsonStringifySpace; 67 | 68 | // Hooks 69 | apply?: (manifest: WebpackAssetsManifest) => void; 70 | customize?: ( 71 | entry: KeyValuePair | false | undefined | void, 72 | original: KeyValuePair, 73 | manifest: WebpackAssetsManifest, 74 | asset?: Asset, 75 | ) => KeyValuePair | false | undefined | void; 76 | done?: (manifest: WebpackAssetsManifest, stats: Stats) => Promise; 77 | transform?: (assets: AssetsStorage, manifest: WebpackAssetsManifest) => AssetsStorage; 78 | }; 79 | 80 | /** 81 | * This Webpack plugin will generate a JSON file that matches the original filename with the hashed version. 82 | * 83 | * @public 84 | */ 85 | export class WebpackAssetsManifest implements WebpackPluginInstance { 86 | public options: Options; 87 | 88 | public assets: AssetsStorage; 89 | 90 | // original filename : hashed filename 91 | public assetNames = new Map(); 92 | 93 | // The Webpack compiler instance 94 | public compiler?: Compiler; 95 | 96 | // This is passed to the customize() hook 97 | private currentAsset?: Asset; 98 | 99 | // Is a merge happening? 100 | #isMerging = false; 101 | 102 | /** 103 | * This is using hooks from {@link https://github.com/webpack/tapable | Tapable}. 104 | */ 105 | hooks = Object.freeze({ 106 | apply: new SyncHook<[manifest: WebpackAssetsManifest]>(['manifest']), 107 | customize: new SyncWaterfallHook< 108 | [ 109 | entry: KeyValuePair | false | undefined | void, 110 | original: KeyValuePair, 111 | manifest: WebpackAssetsManifest, 112 | asset: Asset | undefined, 113 | ] 114 | >(['entry', 'original', 'manifest', 'asset']), 115 | transform: new SyncWaterfallHook<[asset: AssetsStorage, manifest: WebpackAssetsManifest]>(['assets', 'manifest']), 116 | done: new AsyncSeriesHook<[manifest: WebpackAssetsManifest, stats: Stats]>(['manifest', 'stats']), 117 | options: new SyncWaterfallHook<[options: Options]>(['options']), 118 | afterOptions: new SyncHook<[options: Options, manifest: WebpackAssetsManifest]>(['options', 'manifest']), 119 | }); 120 | 121 | constructor(options: Partial = {}) { 122 | this.hooks.transform.tap(PLUGIN_NAME, (assets) => { 123 | const { sortManifest } = this.options; 124 | 125 | return sortManifest 126 | ? getSortedObject(assets, typeof sortManifest === 'function' ? sortManifest.bind(this) : undefined) 127 | : assets; 128 | }); 129 | 130 | this.hooks.afterOptions.tap(PLUGIN_NAME, (options, manifest) => { 131 | manifest.options = Object.assign(manifest.defaultOptions, options); 132 | 133 | validate(optionsSchema, manifest.options, { name: PLUGIN_NAME }); 134 | 135 | manifest.options.output = normalize(manifest.options.output); 136 | 137 | // Copy over any entries that may have been added to the manifest before `apply()` was called. 138 | // If the same key exists in assets and `options.assets`, `options.assets` should be used. 139 | manifest.assets = Object.assign(manifest.options.assets, manifest.assets, manifest.options.assets); 140 | 141 | // Tap some hooks 142 | manifest.options.apply && manifest.hooks.apply.tap(PLUGIN_NAME, manifest.options.apply); 143 | manifest.options.customize && manifest.hooks.customize.tap(PLUGIN_NAME, manifest.options.customize); 144 | manifest.options.transform && manifest.hooks.transform.tap(PLUGIN_NAME, manifest.options.transform); 145 | manifest.options.done && manifest.hooks.done.tapPromise(PLUGIN_NAME, manifest.options.done); 146 | }); 147 | 148 | this.options = Object.assign(this.defaultOptions, options); 149 | 150 | // This is what gets JSON stringified 151 | this.assets = this.options.assets; 152 | } 153 | 154 | /** 155 | * Hook into the Webpack compiler 156 | */ 157 | public apply(compiler: Compiler): void { 158 | this.compiler = compiler; 159 | 160 | // Allow hooks to modify options 161 | this.options = this.hooks.options.call(this.options); 162 | 163 | // Ensure options contain defaults and are valid 164 | this.hooks.afterOptions.call(this.options, this); 165 | 166 | if (!this.options.enabled) { 167 | return; 168 | } 169 | 170 | compiler.hooks.watchRun.tap(PLUGIN_NAME, this.handleWatchRun.bind(this)); 171 | 172 | compiler.hooks.compilation.tap(PLUGIN_NAME, this.handleCompilation.bind(this)); 173 | 174 | compiler.hooks.thisCompilation.tap(PLUGIN_NAME, this.handleThisCompilation.bind(this)); 175 | 176 | // Use `fs` to write the `manifest.json` to disk if `options.writeToDisk` is `true`. 177 | compiler.hooks.afterEmit.tapPromise(PLUGIN_NAME, this.handleAfterEmit.bind(this)); 178 | 179 | // The compilation has finished 180 | compiler.hooks.done.tapPromise(PLUGIN_NAME, async (stats) => await this.hooks.done.promise(this, stats)); 181 | 182 | // Setup is complete. 183 | this.hooks.apply.call(this); 184 | } 185 | 186 | get utils(): { 187 | isKeyValuePair: typeof isKeyValuePair; 188 | isObject: typeof isObject; 189 | getSRIHash: typeof getSRIHash; 190 | } { 191 | return { 192 | isKeyValuePair, 193 | isObject, 194 | getSRIHash, 195 | }; 196 | } 197 | 198 | /** 199 | * Get the default options. 200 | */ 201 | get defaultOptions(): Options { 202 | return { 203 | enabled: true, 204 | assets: Object.create(null), 205 | output: 'assets-manifest.json', 206 | replacer: null, 207 | space: 2, 208 | writeToDisk: 'auto', 209 | fileExtRegex: /\.\w{2,4}\.(?:map|gz|br)$|\.\w+$/i, 210 | sortManifest: true, 211 | merge: false, 212 | publicPath: undefined, 213 | contextRelativeKeys: false, 214 | 215 | // Hooks 216 | apply: undefined, // After setup is complete 217 | customize: undefined, // Customize each entry in the manifest 218 | transform: undefined, // Transform the entire manifest 219 | done: undefined, // Compilation is done and the manifest has been written 220 | 221 | // Include `compilation.entrypoints` in the manifest file 222 | entrypoints: false, 223 | entrypointsKey: 'entrypoints', 224 | entrypointsUseAssets: false, 225 | 226 | // https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity 227 | integrity: false, 228 | integrityHashes: ['sha256', 'sha384', 'sha512'], 229 | integrityPropertyName: 'integrity', 230 | 231 | // Store arbitrary data here for use in customize/transform 232 | extra: Object.create(null), 233 | }; 234 | } 235 | 236 | /** 237 | * Determine if the manifest data is currently being merged. 238 | */ 239 | get isMerging(): boolean { 240 | return this.#isMerging; 241 | } 242 | 243 | /** 244 | * Get the file extension. 245 | */ 246 | public getExtension(filename: string): string { 247 | if (!filename || typeof filename !== 'string') { 248 | return ''; 249 | } 250 | 251 | filename = filename.split(/[?#]/)[0]!; 252 | 253 | if (this.options.fileExtRegex instanceof RegExp) { 254 | const ext = filename.match(this.options.fileExtRegex); 255 | 256 | return ext && ext.length ? ext[0] : ''; 257 | } 258 | 259 | return extname(filename); 260 | } 261 | 262 | /** 263 | * Replace backslash with forward slash. 264 | */ 265 | public fixKey(key: AssetsStorageKey): AssetsStorageKey { 266 | return typeof key === 'string' ? key.replace(/\\/g, '/') : key; 267 | } 268 | 269 | /** 270 | * Add item to assets without modifying the key or value. 271 | */ 272 | public setRaw(key: AssetsStorageKey, value: AssetsStorageValue): this { 273 | this.assets[key] = value; 274 | 275 | return this; 276 | } 277 | 278 | /** 279 | * Add an item to the manifest. 280 | */ 281 | public set(key: AssetsStorageKey, value: AssetsStorageValue): this { 282 | if (this.isMerging && this.options.merge !== 'customize') { 283 | // Do not fix the key if merging since it should already be correct. 284 | return this.setRaw(key, value); 285 | } 286 | 287 | const fixedKey = this.fixKey(key); 288 | const publicPath = typeof value === 'string' ? this.getPublicPath(value) : value; 289 | 290 | const entry = this.hooks.customize.call( 291 | { 292 | key: fixedKey, 293 | value: publicPath, 294 | }, 295 | { 296 | key, 297 | value, 298 | }, 299 | this, 300 | this.currentAsset, 301 | ); 302 | 303 | // Allow the entry to be skipped 304 | if (entry === false) { 305 | return this; 306 | } 307 | 308 | // Use the customized values 309 | if (isKeyValuePair(entry)) { 310 | let { key = fixedKey, value = publicPath } = entry; 311 | 312 | // If the integrity should be returned but the entry value was 313 | // not customized lets do that now so it includes both. 314 | if (value === publicPath && this.options.integrity) { 315 | value = { 316 | src: value, 317 | integrity: this.currentAsset?.info[this.options.integrityPropertyName] ?? '', 318 | }; 319 | } 320 | 321 | return this.setRaw(key, value); 322 | } 323 | 324 | // If the `customize()` hook returns `undefined`, then lets use the initial key/value. 325 | return this.setRaw(fixedKey, publicPath); 326 | } 327 | 328 | /** 329 | * Determine if an item exist in the manifest. 330 | */ 331 | public has(key: AssetsStorageKey): boolean { 332 | return Object.hasOwn(this.assets, key) || Object.hasOwn(this.assets, this.fixKey(key)); 333 | } 334 | 335 | /** 336 | * Get an item from the manifest. 337 | */ 338 | public get(key: AssetsStorageKey, defaultValue?: AssetsStorageValue): AssetsStorageValue | undefined { 339 | return this.assets[key] || this.assets[this.fixKey(key)] || defaultValue; 340 | } 341 | 342 | /** 343 | * Delete an item from the manifest. 344 | */ 345 | public delete(key: AssetsStorageKey): boolean { 346 | if (Object.hasOwn(this.assets, key)) { 347 | return delete this.assets[key]; 348 | } 349 | 350 | key = this.fixKey(key); 351 | 352 | if (Object.hasOwn(this.assets, key)) { 353 | return delete this.assets[key]; 354 | } 355 | 356 | return false; 357 | } 358 | 359 | /** 360 | * Process compilation assets. 361 | * 362 | * @todo make this `private` 363 | */ 364 | public processAssetsByChunkName(assets: StatsCompilation['assetsByChunkName'], hmrFiles: Set): void { 365 | if (assets) { 366 | Object.keys(assets).forEach((chunkName) => { 367 | asArray(assets[chunkName]) 368 | .filter((filename): filename is string => typeof filename === 'string' && !hmrFiles.has(filename)) // Remove hot module replacement files 369 | .forEach((filename) => { 370 | this.assetNames.set(chunkName + this.getExtension(filename), filename); 371 | }); 372 | }); 373 | } 374 | } 375 | 376 | /** 377 | * Get the data for `JSON.stringify()`. 378 | */ 379 | public toJSON(): AssetsStorage { 380 | // This is the last chance to modify the data before the manifest file gets created. 381 | return this.hooks.transform.call(this.assets, this); 382 | } 383 | 384 | /** 385 | * `JSON.stringify()` the manifest. 386 | */ 387 | public toString(): string { 388 | return ( 389 | // TODO: replace this once TS handles `Parameters` from overloaded functions better. 390 | (typeof this.options.replacer === 'function' 391 | ? JSON.stringify(this, this.options.replacer, this.options.space) 392 | : JSON.stringify(this, this.options.replacer, this.options.space)) || '{}' 393 | ); 394 | } 395 | 396 | /** 397 | * Merge data if the output file already exists. 398 | */ 399 | private async maybeMerge(): Promise { 400 | if (this.options.merge) { 401 | try { 402 | const deepmerge = (await import('deepmerge')).default; 403 | 404 | this.#isMerging = true; 405 | 406 | const content = await readFile(this.getOutputPath(), { encoding: 'utf8' }); 407 | 408 | const data = JSON.parse(content); 409 | 410 | const arrayMerge = (_destArray: unknown[], srcArray: unknown[]): typeof srcArray => srcArray; 411 | 412 | for (const [key, oldValue] of Object.entries(data)) { 413 | if (this.has(key)) { 414 | const currentValue = this.get(key); 415 | 416 | if (isObject(oldValue) && isObject(currentValue)) { 417 | const newValue = deepmerge(oldValue, currentValue, { arrayMerge }); 418 | 419 | this.set(key, newValue); 420 | } 421 | } else { 422 | this.set(key, oldValue); 423 | } 424 | } 425 | } finally { 426 | this.#isMerging = false; 427 | } 428 | } 429 | } 430 | 431 | /** 432 | * Emit the assets manifest. 433 | */ 434 | private async emitAssetsManifest(compilation: Compilation): Promise { 435 | const outputPath = this.getOutputPath(); 436 | 437 | const output = this.getManifestPath( 438 | compilation, 439 | this.inDevServer() ? basename(this.options.output) : relative(compilation.compiler.outputPath, outputPath), 440 | ); 441 | 442 | if (this.options.merge) { 443 | await lock(outputPath); 444 | } 445 | 446 | await this.maybeMerge(); 447 | 448 | compilation.emitAsset(output, new compilation.compiler.webpack.sources.RawSource(this.toString(), false), { 449 | assetsManifest: true, 450 | generated: true, 451 | generatedBy: [PLUGIN_NAME], 452 | }); 453 | 454 | if (this.options.merge) { 455 | await unlock(outputPath); 456 | } 457 | } 458 | 459 | /** 460 | * Record details of Asset Modules. 461 | */ 462 | private handleProcessAssetsAnalyse(compilation: Compilation /* , assets */): void { 463 | const { contextRelativeKeys } = this.options; 464 | const { assetsInfo, chunkGraph, chunks, compiler, codeGenerationResults } = compilation; 465 | 466 | for (const chunk of chunks) { 467 | const modules = chunkGraph.getChunkModulesIterableBySourceType(chunk, 'asset'); 468 | 469 | if (modules) { 470 | const { NormalModule } = compilation.compiler.webpack; 471 | const infraLogger = compilation.compiler.getInfrastructureLogger(PLUGIN_NAME); 472 | 473 | for (const module of modules) { 474 | if (module instanceof NormalModule) { 475 | const codeGenData = codeGenerationResults.get(module, chunk.runtime).data; 476 | 477 | const filename: string | undefined = module.buildInfo?.['filename'] ?? codeGenData?.get('filename'); 478 | 479 | if (!filename) { 480 | infraLogger.warn(`Unable to get filename from module: "${module.rawRequest}"`); 481 | 482 | continue; 483 | } 484 | 485 | const assetInfo: AssetInfo | undefined = module.buildInfo?.['assetInfo'] ?? codeGenData?.get('assetInfo'); 486 | 487 | const info = { 488 | rawRequest: module.rawRequest, 489 | sourceFilename: relative(compiler.context, module.userRequest), 490 | ...assetInfo, 491 | }; 492 | 493 | assetsInfo.set(filename, info); 494 | 495 | this.assetNames.set( 496 | contextRelativeKeys ? info.sourceFilename : join(dirname(filename), basename(module.userRequest)), 497 | filename, 498 | ); 499 | } else { 500 | infraLogger.warn(`Unhandled module: ${module.constructor.name}`); 501 | } 502 | } 503 | } 504 | } 505 | } 506 | 507 | /** 508 | * When using webpack 5 persistent cache, `loaderContext.emitFile` sometimes 509 | * doesn't get called and so the asset names are not recorded. To work around 510 | * this, lets loop over the `stats.assets` and record the asset names. 511 | */ 512 | private processStatsAssets(assets: StatsAsset[] | undefined): void { 513 | const { contextRelativeKeys } = this.options; 514 | 515 | assets?.forEach((asset) => { 516 | if (asset.name && asset.info.sourceFilename) { 517 | this.assetNames.set( 518 | contextRelativeKeys 519 | ? asset.info.sourceFilename 520 | : join(dirname(asset.name), basename(asset.info.sourceFilename)), 521 | asset.name, 522 | ); 523 | } 524 | }); 525 | } 526 | 527 | /** 528 | * Get assets and hot module replacement files from a compilation object. 529 | */ 530 | private getCompilationAssets(compilation: Compilation): { 531 | assets: Asset[]; 532 | hmrFiles: Set; 533 | } { 534 | const hmrFiles = new Set(); 535 | 536 | const assets = compilation.getAssets().filter((asset) => { 537 | if (asset.info.hotModuleReplacement) { 538 | hmrFiles.add(asset.name); 539 | 540 | return false; 541 | } 542 | 543 | return !asset.info['assetsManifest']; 544 | }); 545 | 546 | return { 547 | assets, 548 | hmrFiles, 549 | }; 550 | } 551 | 552 | /** 553 | * Gather asset details. 554 | */ 555 | private async handleProcessAssetsReport(compilation: Compilation): Promise { 556 | // Look in DefaultStatsPresetPlugin.js for options 557 | const stats = compilation.getStats().toJson({ 558 | all: false, 559 | assets: true, 560 | cachedAssets: true, 561 | cachedModules: true, 562 | chunkGroups: this.options.entrypoints, 563 | chunkGroupChildren: this.options.entrypoints, 564 | }); 565 | 566 | const { assets, hmrFiles } = this.getCompilationAssets(compilation); 567 | 568 | this.processStatsAssets(stats.assets); 569 | 570 | this.processAssetsByChunkName(stats.assetsByChunkName, hmrFiles); 571 | 572 | const findAssetKeys = findMapKeysByValue(this.assetNames); 573 | 574 | const { contextRelativeKeys } = this.options; 575 | 576 | for (const asset of assets) { 577 | const sourceFilenames = findAssetKeys(asset.name); 578 | 579 | if (!sourceFilenames.length) { 580 | const { sourceFilename } = asset.info; 581 | const name = sourceFilename ? (contextRelativeKeys ? sourceFilename : basename(sourceFilename)) : asset.name; 582 | 583 | sourceFilenames.push(name); 584 | } 585 | 586 | sourceFilenames.forEach((key) => { 587 | this.currentAsset = asset; 588 | 589 | this.set(key, asset.name); 590 | 591 | this.currentAsset = undefined; 592 | }); 593 | } 594 | 595 | if (this.options.entrypoints) { 596 | const removeHMR = (file: string): boolean => !hmrFiles.has(file); 597 | const getExtensionGroup = (file: string): string => this.getExtension(file).substring(1).toLowerCase(); 598 | const getAssetOrFilename = (file: string): AssetsStorage[keyof AssetsStorage] | string => { 599 | let asset: AssetsStorage[keyof AssetsStorage] | undefined; 600 | 601 | if (this.options.entrypointsUseAssets) { 602 | const firstAssetKey = findAssetKeys(file).pop(); 603 | 604 | asset = firstAssetKey ? this.assets[firstAssetKey] || this.assets[file] : this.assets[file]; 605 | } 606 | 607 | return asset ? asset : this.getPublicPath(file); 608 | }; 609 | 610 | const entrypoints = Object.fromEntries( 611 | Array.from(compilation.entrypoints, ([name, entrypoint]) => { 612 | const value: Record> = { 613 | assets: group(entrypoint.getFiles().filter(removeHMR), getExtensionGroup, getAssetOrFilename), 614 | }; 615 | 616 | // This contains preload and prefetch 617 | const childAssets = stats.namedChunkGroups?.[name]?.childAssets; 618 | 619 | if (childAssets) { 620 | for (const [property, assets] of Object.entries(childAssets)) { 621 | value[property] = group(assets.filter(removeHMR), getExtensionGroup, getAssetOrFilename); 622 | } 623 | } 624 | 625 | return [name, value]; 626 | }), 627 | ); 628 | 629 | if (this.options.entrypointsKey === false) { 630 | for (const key in entrypoints) { 631 | this.setRaw(key, entrypoints[key]); 632 | } 633 | } else { 634 | this.setRaw(this.options.entrypointsKey, { 635 | ...this.get(this.options.entrypointsKey), 636 | ...entrypoints, 637 | }); 638 | } 639 | } 640 | 641 | await this.emitAssetsManifest(compilation); 642 | } 643 | 644 | /** 645 | * Get assets manifest file path. 646 | */ 647 | private getManifestPath(compilation: Compilation, filename: string): string { 648 | return compilation.getPath(filename, { 649 | chunk: { 650 | name: 'assets-manifest', 651 | id: '', 652 | hash: '', 653 | }, 654 | filename: 'assets-manifest.json', 655 | }); 656 | } 657 | 658 | /** 659 | * Write the asset manifest to the file system. 660 | */ 661 | public async writeTo(destination: string): Promise { 662 | await lock(destination); 663 | 664 | await mkdir(dirname(destination), { recursive: true }); 665 | 666 | await writeFile(destination, this.toString()); 667 | 668 | await unlock(destination); 669 | } 670 | 671 | public clear(): void { 672 | // Delete properties instead of setting to `{}` so that the variable reference 673 | // is maintained incase the `assets` is being shared in multi-compiler mode. 674 | Object.keys(this.assets).forEach((key) => { 675 | delete this.assets[key]; 676 | }); 677 | } 678 | 679 | /** 680 | * Cleanup before running Webpack. 681 | */ 682 | private handleWatchRun(): void { 683 | this.clear(); 684 | } 685 | 686 | /** 687 | * Determine if the manifest should be written to disk with `fs`. 688 | * 689 | * @todo make this `private` 690 | */ 691 | public shouldWriteToDisk(compilation: Compilation): boolean { 692 | if (this.options.writeToDisk === 'auto') { 693 | // Check to see if we let webpack-dev-server handle it. 694 | if (this.inDevServer()) { 695 | const wdsWriteToDisk: ((filePath: string) => boolean) | boolean | undefined = compilation.options.devServer 696 | ? (compilation.options.devServer['devMiddleware']?.writeToDisk ?? 697 | compilation.options.devServer['writeToDisk']) 698 | : undefined; 699 | 700 | if (wdsWriteToDisk === true) { 701 | return false; 702 | } 703 | 704 | const manifestPath = this.getManifestPath(compilation, this.getOutputPath()); 705 | 706 | if (typeof wdsWriteToDisk === 'function' && wdsWriteToDisk(manifestPath) === true) { 707 | return false; 708 | } 709 | 710 | if (this.compiler?.outputPath) { 711 | // Return true if the manifest output is above the compiler outputPath. 712 | return relative(this.compiler.outputPath, manifestPath).startsWith('..'); 713 | } 714 | } 715 | 716 | return false; 717 | } 718 | 719 | return this.options.writeToDisk; 720 | } 721 | 722 | /** 723 | * This is the last chance to write the manifest to disk. 724 | */ 725 | private async handleAfterEmit(compilation: Compilation): Promise { 726 | if (this.shouldWriteToDisk(compilation)) { 727 | await this.writeTo(this.getManifestPath(compilation, this.getOutputPath())); 728 | } 729 | } 730 | 731 | /** 732 | * Record asset names. 733 | * 734 | * @todo remove `as` casting the next time the minium webpack version is updated. The types were updated in webpack 5.94.0. 735 | */ 736 | private handleNormalModuleLoader(compilation: Compilation, loaderContext: object, module: NormalModule): void { 737 | const emitFile = (loaderContext as LoaderContext).emitFile.bind(module); 738 | 739 | const { contextRelativeKeys } = this.options; 740 | 741 | (loaderContext as LoaderContext).emitFile = (name, content, sourceMap, assetInfo) => { 742 | const info = Object.assign( 743 | { 744 | rawRequest: module.rawRequest, 745 | sourceFilename: relative(compilation.compiler.context, module.userRequest), 746 | }, 747 | assetInfo, 748 | ); 749 | 750 | this.assetNames.set( 751 | contextRelativeKeys ? info.sourceFilename : join(dirname(name), basename(module.userRequest)), 752 | name, 753 | ); 754 | 755 | emitFile(name, content, sourceMap, info); 756 | }; 757 | } 758 | 759 | /** 760 | * Add the SRI hash to the `assetsInfo` map. 761 | */ 762 | private recordSubresourceIntegrity(compilation: Compilation): void { 763 | const { integrityHashes, integrityPropertyName } = this.options; 764 | 765 | for (const asset of compilation.getAssets()) { 766 | if (!asset.info[integrityPropertyName]) { 767 | const sriHashes = new Map( 768 | integrityHashes.map((algorithm) => [algorithm, undefined]), 769 | ); 770 | 771 | // webpack-subresource-integrity@4+ stores the integrity hash on `asset.info.contenthash`. 772 | if (asset.info.contenthash) { 773 | asArray(asset.info.contenthash) 774 | .flatMap((contentHash) => contentHash.split(' ')) 775 | .filter((contentHash) => integrityHashes.some((algorithm) => contentHash.startsWith(`${algorithm}-`))) 776 | .forEach((sriHash) => sriHashes.set(sriHash.substring(0, sriHash.indexOf('-')), sriHash)); 777 | } 778 | 779 | const assetContent = asset.source.source(); 780 | 781 | sriHashes.forEach((value, key, map) => { 782 | if (typeof value === 'undefined') { 783 | map.set(key, getSRIHash(key, assetContent)); 784 | } 785 | }); 786 | 787 | asset.info[integrityPropertyName] = Array.from(sriHashes.values()).join(' '); 788 | 789 | compilation.assetsInfo.set(asset.name, asset.info); 790 | } 791 | } 792 | } 793 | 794 | /** 795 | * Hook into `Compilation` objects. 796 | */ 797 | private handleCompilation(compilation: Compilation): void { 798 | compilation.compiler.webpack.NormalModule.getCompilationHooks(compilation).loader.tap( 799 | PLUGIN_NAME, 800 | this.handleNormalModuleLoader.bind(this, compilation), 801 | ); 802 | 803 | compilation.hooks.processAssets.tap( 804 | { 805 | name: PLUGIN_NAME, 806 | stage: compilation.compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, 807 | }, 808 | this.handleProcessAssetsAnalyse.bind(this, compilation), 809 | ); 810 | } 811 | 812 | /** 813 | * Hook into the `Compilation` object. 814 | */ 815 | private handleThisCompilation(compilation: Compilation): void { 816 | if (this.options.integrity) { 817 | compilation.hooks.processAssets.tap( 818 | { 819 | name: PLUGIN_NAME, 820 | stage: compilation.compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, 821 | }, 822 | this.recordSubresourceIntegrity.bind(this, compilation), 823 | ); 824 | } 825 | 826 | compilation.hooks.processAssets.tapPromise( 827 | { 828 | name: PLUGIN_NAME, 829 | stage: compilation.compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, 830 | }, 831 | this.handleProcessAssetsReport.bind(this, compilation), 832 | ); 833 | } 834 | 835 | /** 836 | * Determine if `webpack-dev-server` is being used. 837 | * 838 | * The WEBPACK_DEV_SERVER / WEBPACK_SERVE env vars cannot be relied upon. 839 | * See issue {@link https://github.com/webdeveric/webpack-assets-manifest/issues/125} 840 | */ 841 | public inDevServer(): boolean { 842 | const [, webpackPath, serve] = process.argv; 843 | 844 | if ( 845 | (serve === 'serve' && webpackPath && basename(webpackPath) === 'webpack') || 846 | process.argv.some((arg) => arg.includes('webpack-dev-server')) 847 | ) { 848 | return true; 849 | } 850 | 851 | return ( 852 | isObject(this.compiler?.outputFileSystem) && 853 | // `memfs@4` package defines `fs.__vol`. 854 | ('__vol' in this.compiler.outputFileSystem || 855 | // webpack initially sets `compiler.outputFileSystem` and `compiler.intermediateFileSystem` to the same `graceful-fs` object. 856 | // webpack-dev-middleware changes only the `outputFileSystem` so lets check if they are still the same. 857 | !Object.is(this.compiler.outputFileSystem, this.compiler.intermediateFileSystem)) 858 | ); 859 | } 860 | 861 | /** 862 | * Get the file system path to the manifest. 863 | */ 864 | public getOutputPath(): string { 865 | return isAbsolute(this.options.output) 866 | ? this.options.output 867 | : this.compiler 868 | ? resolve(this.compiler.outputPath, this.options.output) 869 | : ''; 870 | } 871 | 872 | /** 873 | * Get the public path for the filename. 874 | */ 875 | public getPublicPath(filename: string): string { 876 | const { publicPath } = this.options; 877 | 878 | if (typeof publicPath === 'function') { 879 | return publicPath(filename, this); 880 | } 881 | 882 | if (publicPath) { 883 | const resolvePath = (filename: string, base: string): string => { 884 | try { 885 | return new URL(filename, base).toString(); 886 | } catch { 887 | return base + filename; 888 | } 889 | }; 890 | 891 | if (typeof publicPath === 'string') { 892 | return resolvePath(filename, publicPath); 893 | } 894 | 895 | const compilerPublicPath = this.compiler?.options.output.publicPath; 896 | 897 | if (typeof compilerPublicPath === 'string' && compilerPublicPath !== 'auto') { 898 | return resolvePath(filename, compilerPublicPath); 899 | } 900 | } 901 | 902 | return filename; 903 | } 904 | 905 | /** 906 | * Get a {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler} for the manifest. 907 | * This allows you to use `[]` to manage entries. 908 | */ 909 | public getProxy(raw = false): this & { 910 | [key: AssetsStorageKey]: AssetsStorageValue; 911 | } { 912 | const setMethod = raw ? 'setRaw' : 'set'; 913 | 914 | return new Proxy(this, { 915 | has(target, property: string) { 916 | return target.has(property); 917 | }, 918 | get(target, property: string) { 919 | return target.get(property); 920 | }, 921 | set(target, property: string, value: AssetsStorageValue) { 922 | return target[setMethod](property, value).has(property); 923 | }, 924 | deleteProperty(target, property: string) { 925 | return target.delete(property); 926 | }, 927 | }); 928 | } 929 | } 930 | -------------------------------------------------------------------------------- /src/type-predicate.ts: -------------------------------------------------------------------------------- 1 | import type { KeyValuePair, UnknownRecord } from './types.js'; 2 | 3 | /** 4 | * Determine if the input is an `Object`. 5 | * 6 | * @public 7 | */ 8 | export function isObject(input: unknown): input is T { 9 | return input !== null && typeof input === 'object' && !Array.isArray(input); 10 | } 11 | 12 | /** 13 | * Determine if the input is a `KeyValuePair`. 14 | * 15 | * @public 16 | */ 17 | export function isKeyValuePair(input: unknown): input is KeyValuePair { 18 | return isObject(input) && (Object.hasOwn(input, 'key') || Object.hasOwn(input, 'value')); 19 | } 20 | 21 | /** 22 | * Determine if the input is a `PropertyKey`. 23 | * 24 | * @public 25 | */ 26 | export function isPropertyKey(input: unknown): input is PropertyKey { 27 | return typeof input === 'string' || typeof input === 'number' || typeof input === 'symbol'; 28 | } 29 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | /** 4 | * @public 5 | */ 6 | export type UnknownRecord = Record; 7 | 8 | /** 9 | * @public 10 | */ 11 | export type AssetsStorage = Record; 12 | 13 | /** 14 | * @public 15 | */ 16 | export type AssetsStorageKey = keyof AssetsStorage; 17 | 18 | /** 19 | * @public 20 | */ 21 | export type AssetsStorageValue = AssetsStorage[AssetsStorageKey]; 22 | 23 | /** 24 | * @public 25 | */ 26 | export type KeyValuePair< 27 | K extends AssetsStorageKey = AssetsStorageKey, 28 | V extends AssetsStorageValue = AssetsStorageValue, 29 | > = 30 | | { 31 | key: K; 32 | value: V; 33 | } 34 | | { 35 | key: K; 36 | value?: V; 37 | } 38 | | { 39 | key?: K; 40 | value: V; 41 | }; 42 | 43 | /** 44 | * @public 45 | */ 46 | export type JsonStringifyReplacer = 47 | | ((this: any, key: string, value: any) => any) 48 | | (string | number)[] 49 | | null 50 | | undefined; 51 | 52 | /** 53 | * @public 54 | */ 55 | export type JsonStringifySpace = Parameters[2]; 56 | -------------------------------------------------------------------------------- /test/fixtures/bad-import.js: -------------------------------------------------------------------------------- 1 | import './styles/bad-import.css'; 2 | 3 | console.log('missing asset'); 4 | -------------------------------------------------------------------------------- /test/fixtures/client.js: -------------------------------------------------------------------------------- 1 | import './images/Ginger.asset.jpg'; 2 | import './images/Ginger.loader.jpg'; 3 | 4 | console.log('Client'); 5 | -------------------------------------------------------------------------------- /test/fixtures/complex.mjs: -------------------------------------------------------------------------------- 1 | import('./hello.js').then((module) => console.log(module)).catch(console.error); 2 | import(/* webpackChunkName: "load-styles" */ './load-styles.mjs') 3 | .then((module) => console.log(module)) 4 | .catch(console.error); 5 | import(/* webpackPrefetch: true */ './prefetch.js').then((module) => console.log(module)).catch(console.error); 6 | import(/* webpackPreload: true */ './preload.js').then((module) => console.log(module)).catch(console.error); 7 | 8 | console.log('Complex'); 9 | -------------------------------------------------------------------------------- /test/fixtures/hello.js: -------------------------------------------------------------------------------- 1 | export default function hello(name) { 2 | return 'Hello ' + name; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/images/Ginger.asset.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdeveric/webpack-assets-manifest/6534b41056d6dcd8d32402957e4e85e344725600/test/fixtures/images/Ginger.asset.jpg -------------------------------------------------------------------------------- /test/fixtures/images/Ginger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdeveric/webpack-assets-manifest/6534b41056d6dcd8d32402957e4e85e344725600/test/fixtures/images/Ginger.jpg -------------------------------------------------------------------------------- /test/fixtures/images/Ginger.loader.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdeveric/webpack-assets-manifest/6534b41056d6dcd8d32402957e4e85e344725600/test/fixtures/images/Ginger.loader.jpg -------------------------------------------------------------------------------- /test/fixtures/json/images.json: -------------------------------------------------------------------------------- 1 | { 2 | "Ginger.jpg": "images/Ginger.jpg" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/json/invalid-json.txt: -------------------------------------------------------------------------------- 1 | { 2 | invalid, 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/json/sample-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "Ginger.jpg": "images/Ginger.jpg", 3 | "main.js": "main.js", 4 | "entrypoints": { 5 | "main": { 6 | "assets": { 7 | "css": ["main.css"], 8 | "js": ["runtime.js", "main.js"] 9 | } 10 | }, 11 | "demo": { 12 | "assets": { 13 | "js": ["demo.js"] 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/load-styles.mjs: -------------------------------------------------------------------------------- 1 | import './styles/ginger.css'; 2 | 3 | console.log('CSS'); 4 | -------------------------------------------------------------------------------- /test/fixtures/main.js: -------------------------------------------------------------------------------- 1 | import './styles/main.css'; 2 | 3 | console.log('Main'); 4 | -------------------------------------------------------------------------------- /test/fixtures/prefetch.js: -------------------------------------------------------------------------------- 1 | export default () => console.log('prefetch'); 2 | -------------------------------------------------------------------------------- /test/fixtures/preload.js: -------------------------------------------------------------------------------- 1 | export default () => console.log('preload'); 2 | -------------------------------------------------------------------------------- /test/fixtures/readme.md: -------------------------------------------------------------------------------- 1 | # Webpack Assets Manifest test files 2 | -------------------------------------------------------------------------------- /test/fixtures/remote.js: -------------------------------------------------------------------------------- 1 | export default function remote(name) { 2 | return 'remote ' + name; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/server.js: -------------------------------------------------------------------------------- 1 | console.log('Server'); 2 | -------------------------------------------------------------------------------- /test/fixtures/styles/bad-import.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: url('../images/not-found.asset.jpg'); 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/styles/ginger.css: -------------------------------------------------------------------------------- 1 | .ginger { 2 | background: url('../images/Ginger.asset.jpg'); 3 | } 4 | 5 | .pug { 6 | background: url('../images/Ginger.loader.jpg'); 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/styles/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | background: #fff; 4 | } 5 | -------------------------------------------------------------------------------- /test/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { asArray, getSortedObject, findMapKeysByValue, group, getLockFilename, getSRIHash } from '../src/helpers.js'; 4 | 5 | describe('getLockFilename()', () => { 6 | it('Returns the sanitized filename with a .lock suffix', () => { 7 | expect( 8 | getLockFilename('/some-path/asset-manifest.json').endsWith('some-path-asset-manifest-json.lock'), 9 | ).toBeTruthy(); 10 | }); 11 | }); 12 | 13 | describe('asArray()', function () { 14 | it('returns input if it is an array', () => { 15 | const input = ['input']; 16 | 17 | expect(asArray(input)).toEqual(input); 18 | }); 19 | 20 | it('wraps non array input with an array', () => { 21 | expect(asArray(true)).toEqual([true]); 22 | }); 23 | }); 24 | 25 | describe('getSRIHash()', function () { 26 | it.each([ 27 | ['', 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='], 28 | ['test', 'sha256-n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg='], 29 | ])('Returns SRI hash', function (input, hash) { 30 | expect(getSRIHash('sha256', input)).toEqual(hash); 31 | expect(getSRIHash('sha256', Buffer.from(input))).toEqual(hash); 32 | }); 33 | 34 | it('Throws when provided an invalid hash algorithm', function () { 35 | expect(() => getSRIHash('bad-algorithm', '')).toThrow(); 36 | }); 37 | }); 38 | 39 | describe('getSortedObject()', function () { 40 | it('returns a sorted object', function () { 41 | const obj = { 42 | a: 'a', 43 | b: 'b', 44 | }; 45 | 46 | expect(JSON.stringify(getSortedObject(obj))).toEqual('{"a":"a","b":"b"}'); 47 | expect(JSON.stringify(getSortedObject(obj, (left, right) => (left > right ? -1 : left < right ? 1 : 0)))).toEqual( 48 | '{"b":"b","a":"a"}', 49 | ); 50 | }); 51 | }); 52 | 53 | describe('findMapKeysByValue()', function () { 54 | it('finds all keys that have the corresponding value', () => { 55 | const data = new Map(); 56 | 57 | data.set('Ginger', 'Eric'); 58 | data.set('Wilson', 'Eric'); 59 | data.set('Oliver', 'Amy'); 60 | data.set('Andy', 'Amy'); 61 | data.set('Francis', 'Amy'); 62 | 63 | const findPetsFor = findMapKeysByValue(data); 64 | 65 | expect(findPetsFor).toBeInstanceOf(Function); 66 | expect(findPetsFor('Eric')).toEqual(expect.arrayContaining(['Ginger', 'Wilson'])); 67 | expect(findPetsFor('Amy')).toEqual(expect.arrayContaining(['Oliver', 'Andy', 'Francis'])); 68 | expect(findPetsFor('None')).toHaveLength(0); 69 | }); 70 | }); 71 | 72 | describe('group()', () => { 73 | it('group items from an array based on a callback return value', () => { 74 | const grouped = group(['cat', 'dog', 'dinosaur'], (word) => word[0]); 75 | 76 | expect(grouped).toEqual({ 77 | c: ['cat'], 78 | d: ['dog', 'dinosaur'], 79 | }); 80 | }); 81 | 82 | it('prevent item from being grouped', () => { 83 | const grouped = group(['cat', 'dog', 'dinosaur'], (word) => (word === 'cat' ? undefined : word[0])); 84 | 85 | expect(grouped).toEqual({ 86 | d: ['dog', 'dinosaur'], 87 | }); 88 | }); 89 | 90 | it('can modify items with a callback', () => { 91 | const grouped = group( 92 | ['cat', 'dog', 'dinosaur'], 93 | (word) => word[0], 94 | (word, group) => `${word.toUpperCase()}-group-${String(group)}`, 95 | ); 96 | 97 | expect(grouped).toEqual({ 98 | c: ['CAT-group-c'], 99 | d: ['DOG-group-d', 'DINOSAUR-group-d'], 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { chmod, copyFile, mkdir, stat } from 'node:fs/promises'; 2 | import { resolve, dirname, join } from 'node:path'; 3 | import { PassThrough } from 'node:stream'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { promisify } from 'node:util'; 6 | 7 | import { rimraf } from 'rimraf'; 8 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; 9 | import { container, webpack, type Compilation } from 'webpack'; 10 | import webpackPackage from 'webpack/package.json' with { type: 'json' }; 11 | import WebpackDevServer from 'webpack-dev-server'; 12 | import webpackDevServerPackage from 'webpack-dev-server/package.json' with { type: 'json' }; 13 | 14 | import { WebpackAssetsManifest, type Options } from '../src/plugin.js'; 15 | import { isObject } from '../src/type-predicate.js'; 16 | 17 | import { create, createMulti, getWorkspace, makeCompiler, makeRun } from './utils.js'; 18 | import * as configs from './webpack-configs.js'; 19 | 20 | import type { AssetsStorage, JsonStringifySpace, KeyValuePair } from '../src/types.js'; 21 | 22 | const currentDirectory = fileURLToPath(new URL('.', import.meta.url)); 23 | 24 | console.log( 25 | `webpack version: ${webpackPackage.version}\nwebpack dev server version: ${webpackDevServerPackage.version}`, 26 | ); 27 | 28 | it('Is a webpack plugin', () => { 29 | const manifest = new WebpackAssetsManifest(); 30 | 31 | expect(typeof manifest.apply).toEqual('function'); 32 | }); 33 | 34 | describe('Methods', () => { 35 | describe('getExtension()', () => { 36 | const manifest = new WebpackAssetsManifest(); 37 | 38 | it('Should return the file extension', () => { 39 | expect(manifest.getExtension('main.css')).toEqual('.css'); 40 | }); 41 | 42 | it.each([ 43 | { input: 'main.js.map', output: '.js.map' }, 44 | { input: 'main.css.map', output: '.css.map' }, 45 | { input: 'archive.tar.gz', output: '.tar.gz' }, 46 | { input: 'some.unknown.ext', output: '.ext' }, 47 | ])('Should return two extensions for known formats ($input => $output)', function ({ input, output }) { 48 | expect(manifest.getExtension(input)).toEqual(output); 49 | }); 50 | 51 | it('Should return empty string when filename is undefined or empty', () => { 52 | expect(manifest.getExtension('')).toEqual(''); 53 | expect(manifest.getExtension(undefined as unknown as string)).toEqual(''); 54 | }); 55 | 56 | it('Should return empty string when filename does not have an extension', () => { 57 | expect(manifest.getExtension('no-extension')).toEqual(''); 58 | }); 59 | 60 | it('Should ignore query string and fragment', () => { 61 | expect(manifest.getExtension('main.js?a=1')).toEqual('.js'); 62 | expect(manifest.getExtension('main.js#b')).toEqual('.js'); 63 | expect(manifest.getExtension('main.js?a=1#b')).toEqual('.js'); 64 | }); 65 | }); 66 | 67 | describe('toJSON()', () => { 68 | const manifest = new WebpackAssetsManifest(); 69 | 70 | it('Should return an object', () => { 71 | expect(manifest.toJSON()).toEqual({}); 72 | expect(JSON.stringify(manifest)).toEqual('{}'); 73 | }); 74 | }); 75 | 76 | describe('toString()', () => { 77 | it('Should return a JSON string', () => { 78 | const manifest = new WebpackAssetsManifest(); 79 | 80 | manifest.hooks.afterOptions.call(manifest.options, manifest); 81 | 82 | expect(manifest.toString()).toEqual('{}'); 83 | expect(manifest + '').toEqual('{}'); 84 | expect(`${manifest}`).toEqual('{}'); 85 | }); 86 | 87 | it('can use tabs', () => { 88 | const manifest = new WebpackAssetsManifest({ 89 | space: '\t', 90 | }); 91 | 92 | manifest.hooks.afterOptions.call(manifest.options, manifest); 93 | 94 | manifest.set('test', 'test'); 95 | 96 | expect(manifest.toString()).toEqual('{\n\t"test": "test"\n}'); 97 | }); 98 | }); 99 | 100 | describe('getOutputPath()', () => { 101 | it('Should work with an absolute output path', () => { 102 | const { manifest } = create(configs.hello(), { 103 | output: '/manifest.json', 104 | }); 105 | 106 | expect(manifest.getOutputPath()).toEqual('/manifest.json'); 107 | }); 108 | 109 | it('Should work with a relative output path', () => { 110 | const { compiler, manifest } = create(configs.hello(), { 111 | output: '../manifest.json', 112 | }); 113 | 114 | expect(manifest.getOutputPath()).toEqual(resolve(String(compiler.options.output.path), '../manifest.json')); 115 | }); 116 | 117 | it('Should output manifest in compiler output.path by default', () => { 118 | const { compiler, manifest } = create(configs.hello()); 119 | 120 | expect(dirname(manifest.getOutputPath())).toEqual(compiler.options.output.path); 121 | }); 122 | 123 | it('Should return an empty string if manifest has not been applied yet', () => { 124 | const manifest = new WebpackAssetsManifest(); 125 | 126 | expect(manifest.getOutputPath()).toEqual(''); 127 | }); 128 | }); 129 | 130 | describe('getPublicPath()', () => { 131 | it('Returns the public path', async () => { 132 | const { manifest, run } = create( 133 | { 134 | entry: { 135 | hello: resolve(currentDirectory, './fixtures/hello.js'), 136 | }, 137 | output: { 138 | clean: true, 139 | path: resolve(currentDirectory, './fixtures/dist'), 140 | publicPath: 'https://example.com/', 141 | }, 142 | }, 143 | { 144 | publicPath: true, 145 | }, 146 | ); 147 | 148 | await run(); 149 | 150 | expect(manifest.get('hello.js')).toEqual('https://example.com/hello.js'); 151 | }); 152 | }); 153 | 154 | describe('fixKey()', () => { 155 | it('Should replace \\ with /', () => { 156 | const manifest = new WebpackAssetsManifest(); 157 | 158 | expect(manifest.fixKey('images\\Ginger.jpg')).toEqual('images/Ginger.jpg'); 159 | }); 160 | 161 | it('Should return the key if not a string', () => { 162 | const manifest = new WebpackAssetsManifest(); 163 | 164 | expect(manifest.fixKey(1)).toEqual(1); 165 | }); 166 | }); 167 | 168 | describe('set()', () => { 169 | it('Should add to manifest.assets', () => { 170 | const manifest = new WebpackAssetsManifest(); 171 | 172 | expect(manifest.assets).toEqual({}); 173 | 174 | manifest.set('main.js', 'main.123456.js'); 175 | manifest.set('styles/main.css', 'styles/main.123456.css'); 176 | 177 | expect(manifest.assets).toEqual({ 178 | 'main.js': 'main.123456.js', 179 | 'styles/main.css': 'styles/main.123456.css', 180 | }); 181 | }); 182 | 183 | it('Should transform backslashes to slashes', () => { 184 | const manifest = new WebpackAssetsManifest(); 185 | 186 | manifest.set('images\\a.jpg', 'images/a.123456.jpg'); 187 | 188 | expect(manifest.assets).toEqual({ 189 | 'images/a.jpg': 'images/a.123456.jpg', 190 | }); 191 | }); 192 | }); 193 | 194 | describe('setRaw()', () => { 195 | it('Uses keys without fixing them', () => { 196 | const manifest = new WebpackAssetsManifest(); 197 | 198 | manifest.setRaw('\\\\', 'image.jpg'); 199 | 200 | expect(manifest.has('\\\\')).toBeTruthy(); 201 | expect(manifest.get('\\\\')).toEqual('image.jpg'); 202 | }); 203 | }); 204 | 205 | describe('has()', () => { 206 | it('Should return a boolean', async () => { 207 | const images = (await import('./fixtures/json/images.json')).default; 208 | 209 | const manifest = new WebpackAssetsManifest({ 210 | assets: Object.assign({}, images), 211 | }); 212 | 213 | expect(manifest.has('Ginger.jpg')).toBeTruthy(); 214 | expect(manifest.has('dog.gif')).toBeFalsy(); 215 | }); 216 | }); 217 | 218 | describe('get()', async () => { 219 | const images = (await import('./fixtures/json/images.json')).default; 220 | 221 | const manifest = new WebpackAssetsManifest({ 222 | assets: Object.assign({}, images), 223 | }); 224 | 225 | it('gets a value from the manifest', () => { 226 | expect(manifest.get('Ginger.jpg')).toEqual('images/Ginger.jpg'); 227 | }); 228 | 229 | it('returns a default value', () => { 230 | const defaultValue = 'some/default.gif'; 231 | 232 | expect(manifest.get('dog.gif', defaultValue)).toEqual(defaultValue); 233 | }); 234 | 235 | it('returns undefined when no default value is provided', () => { 236 | expect(manifest.get('dog.gif')).toBeUndefined(); 237 | }); 238 | }); 239 | 240 | describe('delete()', () => { 241 | it('removes an asset from the manifest', () => { 242 | const manifest = new WebpackAssetsManifest(); 243 | const methods = ['set', 'setRaw'] satisfies (keyof WebpackAssetsManifest)[]; 244 | 245 | ['some/image.jpg', 'some\\image.jpg'].forEach((key) => { 246 | methods.forEach((method) => { 247 | manifest[method](key, 'image.jpg'); 248 | 249 | expect(manifest.has(key)).toBeTruthy(); 250 | 251 | manifest.delete(key); 252 | 253 | expect(manifest.has(key)).toBeFalsy(); 254 | }); 255 | }); 256 | 257 | expect(manifest.delete('404.js')).toBeFalsy(); 258 | }); 259 | }); 260 | 261 | describe('inDevServer()', () => { 262 | let originalArgv: (typeof process)['argv']; 263 | 264 | beforeEach(() => { 265 | originalArgv = process.argv.slice(0); 266 | }); 267 | 268 | afterEach(() => { 269 | process.argv = originalArgv; 270 | }); 271 | 272 | it('Identifies `webpack serve` from argv', () => { 273 | const manifest = new WebpackAssetsManifest(); 274 | 275 | expect(manifest.inDevServer()).toBeFalsy(); 276 | 277 | process.argv = [String(originalArgv[0]), join(dirname(String(originalArgv[1])), 'webpack'), 'serve']; 278 | 279 | expect(manifest.inDevServer()).toBeTruthy(); 280 | }); 281 | 282 | it('Identifies webpack-dev-server from argv', () => { 283 | const manifest = new WebpackAssetsManifest(); 284 | 285 | expect(manifest.inDevServer()).toBeFalsy(); 286 | 287 | process.argv.push('webpack-dev-server'); 288 | 289 | expect(manifest.inDevServer()).toBeTruthy(); 290 | }); 291 | 292 | it('Identifies webpack-dev-server from outputFileSystem (memfs)', () => { 293 | const { manifest } = create(configs.hello()); 294 | 295 | expect(manifest.inDevServer()).toBeTruthy(); 296 | }); 297 | 298 | it('Works correctly when outputFileSystem.prototype is null', () => { 299 | const { compiler, manifest } = create(configs.hello(), undefined, webpack); 300 | 301 | Object.setPrototypeOf(compiler.outputFileSystem, null); 302 | 303 | manifest.apply(compiler); 304 | 305 | expect(manifest.inDevServer()).toBeFalsy(); 306 | }); 307 | }); 308 | 309 | describe('getProxy()', () => { 310 | it('Returns a Proxy', () => { 311 | const manifest = new WebpackAssetsManifest(); 312 | 313 | [undefined, false, true].forEach((raw) => { 314 | const proxy = manifest.getProxy(raw); 315 | 316 | expect(proxy).instanceOf(WebpackAssetsManifest); 317 | 318 | proxy['test'] = 'test'; 319 | 320 | expect('test' in proxy).toBeTruthy(); 321 | expect(proxy['test']).toEqual('test'); 322 | 323 | delete proxy['test']; 324 | 325 | expect(proxy['test']).toBeUndefined(); 326 | expect('test' in proxy).toBeFalsy(); 327 | }); 328 | }); 329 | }); 330 | 331 | describe('clear()', () => { 332 | it('Clears data', async () => { 333 | const { manifest, run } = create(configs.hello()); 334 | 335 | expect(Object.keys(manifest.assets)).toHaveLength(0); 336 | 337 | await run(); 338 | 339 | expect(Object.keys(manifest.assets)).toHaveLength(1); 340 | 341 | manifest.clear(); 342 | 343 | expect(Object.keys(manifest.assets)).toHaveLength(0); 344 | }); 345 | }); 346 | }); 347 | 348 | describe('Options', () => { 349 | describe('enabled', () => { 350 | it('does nothing if not enabled', async () => { 351 | const { manifest, run } = create(configs.hello(), { 352 | enabled: false, 353 | }); 354 | 355 | await run(); 356 | 357 | expect(Object.keys(manifest.assets)).toHaveLength(0); 358 | }); 359 | }); 360 | 361 | describe('sortManifest', () => { 362 | const assets = { 363 | 'd.js': 'd.js', 364 | 'c.js': 'c.js', 365 | 'b.js': 'b.js', 366 | 'a.js': 'a.js', 367 | }; 368 | 369 | it('Should turn on sorting', () => { 370 | const manifest = new WebpackAssetsManifest({ 371 | assets, 372 | sortManifest: true, 373 | space: 0, 374 | }); 375 | 376 | expect(manifest.toString()).toEqual('{"a.js":"a.js","b.js":"b.js","c.js":"c.js","d.js":"d.js"}'); 377 | }); 378 | 379 | it('Should turn off sorting', () => { 380 | const manifest = new WebpackAssetsManifest({ 381 | assets, 382 | sortManifest: false, 383 | space: 0, 384 | }); 385 | 386 | expect(manifest.toString()).toEqual('{"d.js":"d.js","c.js":"c.js","b.js":"b.js","a.js":"a.js"}'); 387 | }); 388 | 389 | it('Should use custom comparison function', () => { 390 | const manifest = new WebpackAssetsManifest({ 391 | assets, 392 | sortManifest(left, right) { 393 | return left.localeCompare(right); 394 | }, 395 | space: 0, 396 | }); 397 | 398 | expect(manifest.toString()).toEqual('{"a.js":"a.js","b.js":"b.js","c.js":"c.js","d.js":"d.js"}'); 399 | }); 400 | }); 401 | 402 | describe('fileExtRegex', () => { 403 | it('Should use custom RegExp', () => { 404 | const manifest = new WebpackAssetsManifest({ 405 | fileExtRegex: /\.[A-Z]+$/, 406 | }); 407 | 408 | expect(manifest.getExtension('test.JS')).toEqual('.JS'); 409 | expect(manifest.getExtension('test.JS.map')).toEqual(''); 410 | }); 411 | 412 | it('Should fallback to path.extname', () => { 413 | const manifest = new WebpackAssetsManifest({ 414 | fileExtRegex: false, 415 | }); 416 | 417 | expect(manifest.getExtension('test.js')).toEqual('.js'); 418 | expect(manifest.getExtension('test.js.map')).toEqual('.map'); 419 | }); 420 | }); 421 | 422 | describe('replacer', () => { 423 | const assets = { 424 | 'logo.svg': 'images/logo.svg', 425 | }; 426 | 427 | it('Should remove all entries', () => { 428 | const manifest = new WebpackAssetsManifest({ 429 | assets, 430 | replacer: () => undefined, 431 | }); 432 | 433 | expect(manifest.toString()).toEqual('{}'); 434 | }); 435 | 436 | it('Should update values', () => { 437 | const manifest = new WebpackAssetsManifest({ 438 | assets, 439 | space: 0, 440 | replacer(_key, value) { 441 | if (typeof value === 'string') { 442 | return value.toUpperCase(); 443 | } 444 | 445 | return value; 446 | }, 447 | }); 448 | 449 | expect(manifest.toString()).toEqual('{"logo.svg":"IMAGES/LOGO.SVG"}'); 450 | }); 451 | }); 452 | 453 | describe('assets', () => { 454 | const assets: AssetsStorage = { 455 | 'logo.svg': 'images/logo.svg', 456 | }; 457 | 458 | it('Should set the initial assets data', async () => { 459 | const images = (await import('./fixtures/json/images.json')).default; 460 | 461 | const manifest = new WebpackAssetsManifest({ 462 | assets: Object.assign({}, images), 463 | space: 0, 464 | }); 465 | 466 | Object.keys(assets).forEach((key) => { 467 | manifest.set(key, assets[key]); 468 | }); 469 | 470 | expect(manifest.toString()).toEqual('{"Ginger.jpg":"images/Ginger.jpg","logo.svg":"images/logo.svg"}'); 471 | }); 472 | 473 | it('Should be sharable', () => { 474 | const sharedAssets = Object.create(null); 475 | 476 | const { manifest: manifest1 } = create(configs.hello(), { 477 | assets: sharedAssets, 478 | }); 479 | 480 | const { manifest: manifest2 } = create(configs.client(), { 481 | assets: sharedAssets, 482 | }); 483 | 484 | manifest1.set('main.js', 'main.js'); 485 | manifest2.set('subpage.js', 'subpage.js'); 486 | 487 | expect(manifest1.toString()).toEqual(manifest2.toString()); 488 | }); 489 | }); 490 | 491 | describe.sequential('merge', () => { 492 | beforeAll(async () => { 493 | await mkdir(getWorkspace(), { recursive: true, mode: 0o777 }); 494 | }); 495 | 496 | afterAll(async () => { 497 | await rimraf(getWorkspace()); 498 | }); 499 | 500 | async function setupManifest( 501 | manifest: WebpackAssetsManifest, 502 | jsonFilePath: string, 503 | ): Promise { 504 | await mkdir(dirname(manifest.getOutputPath()), { recursive: true, mode: 0o777 }); 505 | 506 | await copyFile(resolve(currentDirectory, jsonFilePath), manifest.getOutputPath()); 507 | 508 | return manifest; 509 | } 510 | 511 | it('Should merge data if output file exists', async () => { 512 | const { manifest, run } = create(configs.hello(), { 513 | entrypoints: true, 514 | merge: true, 515 | space: 0, 516 | }); 517 | 518 | await setupManifest(manifest, 'fixtures/json/sample-manifest.json'); 519 | await run(); 520 | 521 | expect(manifest.toString()).toEqual( 522 | '{"Ginger.jpg":"images/Ginger.jpg","entrypoints":{"main":{"assets":{"css":["main.css"],"js":["main.js"]}},"demo":{"assets":{"js":["demo.js"]}}},"main.js":"main.js"}', 523 | ); 524 | }); 525 | 526 | it('Can customize during merge', async () => { 527 | const mergingResults: boolean[] = []; 528 | const { manifest, run } = create(configs.hello(), { 529 | merge: 'customize', 530 | space: 0, 531 | customize(_entry, _original, manifest) { 532 | mergingResults.push(manifest.isMerging); 533 | }, 534 | }); 535 | 536 | await setupManifest(manifest, 'fixtures/json/sample-manifest.json'); 537 | 538 | await run(); 539 | 540 | expect(mergingResults).toEqual([false, true, true]); 541 | }); 542 | 543 | it('"merge: true" skips customize()', async () => { 544 | const mock = vi.fn(); 545 | 546 | const { manifest, run } = create(configs.hello(), { 547 | merge: true, 548 | customize(_entry, _original, manifest) { 549 | if (manifest.isMerging) { 550 | mock(); 551 | } 552 | }, 553 | }); 554 | 555 | await setupManifest(manifest, 'fixtures/json/sample-manifest.json'); 556 | await run(); 557 | 558 | expect(mock).not.toHaveBeenCalled(); 559 | }); 560 | 561 | it('Invalid JSON data throws an Error', async () => { 562 | const { manifest, run } = create(configs.hello(), { 563 | merge: true, 564 | }); 565 | 566 | await setupManifest(manifest, 'fixtures/json/invalid-json.txt'); 567 | 568 | await expect(run()).rejects.toThrowError(); 569 | }); 570 | }); 571 | 572 | describe('publicPath', () => { 573 | const img = 'images/photo.jpg'; 574 | const cdn = { 575 | default: 'https://cdn.example.com/', 576 | images: 'https://img-cdn.example.com/', 577 | }; 578 | 579 | it('Can be a string', () => { 580 | const manifest = new WebpackAssetsManifest({ 581 | publicPath: 'assets/', 582 | }); 583 | 584 | manifest.set('hello', 'world'); 585 | 586 | expect(manifest.get('hello')).toEqual('assets/world'); 587 | }); 588 | 589 | it('Can be true', async () => { 590 | const config = configs.hello(); 591 | 592 | config.output.publicPath = cdn.default; 593 | 594 | const { manifest, run } = create(config, { 595 | publicPath: true, 596 | }); 597 | 598 | await run(); 599 | 600 | expect(manifest.get('main.js')).toEqual(cdn.default + 'main.js'); 601 | }); 602 | 603 | it('Has no effect if false', async () => { 604 | const config = configs.hello(); 605 | 606 | config.output.publicPath = cdn.default; 607 | 608 | const { manifest, run } = create(config, { 609 | publicPath: false, 610 | }); 611 | 612 | await run(); 613 | 614 | expect(manifest.get('main.js')).toEqual('main.js'); 615 | }); 616 | 617 | it('Only prefixes strings', () => { 618 | const manifest = new WebpackAssetsManifest({ 619 | publicPath: cdn.default, 620 | }); 621 | 622 | manifest.set('obj', {}); 623 | 624 | expect(manifest.get('obj')).toEqual({}); 625 | }); 626 | 627 | it('Can be a custom function', () => { 628 | const manifest = new WebpackAssetsManifest({ 629 | publicPath: (value, manifest) => { 630 | if (manifest.getExtension(value).substring(1).toLowerCase()) { 631 | return cdn.images + value; 632 | } 633 | 634 | return cdn.default + value; 635 | }, 636 | }); 637 | 638 | expect(manifest.options.publicPath).instanceOf(Function); 639 | 640 | manifest.set(img, img); 641 | 642 | expect(manifest.get(img)).toEqual(cdn.images + img); 643 | }); 644 | }); 645 | 646 | describe('customize', () => { 647 | it('Customizes the key and value', () => { 648 | const { manifest } = create(configs.hello(), { 649 | customize(entry, _original, manifest) { 650 | return manifest.utils.isKeyValuePair(entry) 651 | ? { 652 | key: String(entry.key).toUpperCase(), 653 | value: entry.value.toUpperCase(), 654 | } 655 | : entry; 656 | }, 657 | }); 658 | 659 | manifest.set('hello', 'world'); 660 | 661 | expect(manifest.has('HELLO')).toBeTruthy(); 662 | expect(manifest.has('hello')).toBeFalsy(); 663 | }); 664 | 665 | it('Customizes the key', () => { 666 | const { manifest } = create(configs.hello(), { 667 | customize(entry, _original, manifest) { 668 | return manifest.utils.isKeyValuePair(entry) 669 | ? { 670 | key: String(entry.key).toUpperCase(), 671 | } 672 | : entry; 673 | }, 674 | }); 675 | 676 | manifest.set('hello', 'world'); 677 | 678 | expect(manifest.has('HELLO')).toBeTruthy(); 679 | expect(manifest.has('hello')).toBeFalsy(); 680 | expect(manifest.get('HELLO')).toEqual('world'); 681 | }); 682 | 683 | it('Customizes the value', () => { 684 | const { manifest } = create(configs.hello(), { 685 | customize(entry, _original, manifest) { 686 | return manifest.utils.isKeyValuePair(entry) 687 | ? { 688 | value: String(entry.value).toUpperCase(), 689 | } 690 | : entry; 691 | }, 692 | }); 693 | 694 | manifest.set('hello', 'world'); 695 | 696 | expect(manifest.has('HELLO')).toBeFalsy(); 697 | expect(manifest.has('hello')).toBeTruthy(); 698 | expect(manifest.get('hello')).toEqual('WORLD'); 699 | }); 700 | 701 | it('Has no effect unless a KeyValuePair or false is returned', () => { 702 | const { manifest } = create(configs.hello(), { 703 | customize() { 704 | return {} as KeyValuePair; 705 | }, 706 | }); 707 | 708 | manifest.set('hello', 'world'); 709 | 710 | expect(manifest.has('hello')).toBeTruthy(); 711 | expect(manifest.get('hello')).toEqual('world'); 712 | }); 713 | 714 | it('Skips adding asset if false is returned', () => { 715 | const { manifest } = create(configs.hello(), { 716 | customize() { 717 | return false; 718 | }, 719 | }); 720 | 721 | manifest.set('hello', 'world'); 722 | 723 | expect(manifest.has('hello')).toBeFalsy(); 724 | 725 | expect(Object.keys(manifest.assets)).toHaveLength(0); 726 | }); 727 | }); 728 | 729 | describe('integrityHashes', () => { 730 | it('Invalid crypto hashes throw an Error', () => { 731 | expect(() => { 732 | create(configs.hello(), { 733 | integrityHashes: ['sha256', 'invalid-algorithm'], 734 | }); 735 | }).toThrow(); 736 | }); 737 | }); 738 | 739 | describe('integrity', () => { 740 | it('Manifest entry contains an integrity property', async () => { 741 | const { manifest, run } = create(configs.hello(), { 742 | integrity: true, 743 | integrityHashes: ['sha256'], 744 | }); 745 | 746 | await run(); 747 | 748 | expect(manifest.get('main.js')).toEqual( 749 | expect.objectContaining({ 750 | src: 'main.js', 751 | integrity: expect.stringMatching(/^sha256-/), 752 | }), 753 | ); 754 | }); 755 | }); 756 | 757 | describe('integrityPropertyName', () => { 758 | it('Assigns SRI hashes to currentAsset.info[ integrityPropertyName ]', async () => { 759 | const integrityPropertyName = 'sri'; 760 | 761 | const { manifest, run } = create(configs.hello(), { 762 | integrity: true, 763 | integrityHashes: ['md5'], 764 | integrityPropertyName, 765 | customize(_entry, _original, _manifest, asset) { 766 | expect(integrityPropertyName in asset!.info).toBeTruthy(); 767 | }, 768 | }); 769 | 770 | await run(); 771 | 772 | expect(manifest.get('main.js')).toEqual( 773 | expect.objectContaining({ 774 | src: 'main.js', 775 | integrity: expect.stringMatching(/^md5-/), 776 | }), 777 | ); 778 | }); 779 | 780 | it('Does not overwrite existing currentAsset.info[ integrityPropertyName ]', async () => { 781 | const { manifest, run } = create(configs.hello(), { 782 | integrity: true, 783 | integrityHashes: ['md5'], 784 | apply(manifest) { 785 | manifest.compiler?.hooks.compilation.tap('test', (compilation) => { 786 | vi.spyOn(compilation.assetsInfo, 'get').mockImplementation(() => ({ 787 | [manifest.options.integrityPropertyName]: 'test', 788 | })); 789 | }); 790 | }, 791 | }); 792 | 793 | await run(); 794 | 795 | expect(manifest.get('main.js')[manifest.options.integrityPropertyName]).toEqual('test'); 796 | }); 797 | }); 798 | 799 | describe('entrypoints', () => { 800 | it('Entrypoints are included in manifest', async () => { 801 | const { manifest, run } = create(configs.hello(), { 802 | entrypoints: true, 803 | }); 804 | 805 | await run(); 806 | 807 | const entrypoints = manifest.get('entrypoints'); 808 | 809 | expect(entrypoints).toBeInstanceOf(Object); 810 | }); 811 | 812 | it('Entrypoints can use default values instead of values from this.assets', async () => { 813 | const { manifest, run } = create(configs.hello(), { 814 | entrypoints: true, 815 | entrypointsUseAssets: false, 816 | integrity: true, 817 | }); 818 | 819 | await run(); 820 | 821 | expect(manifest.get('entrypoints')).toEqual({ 822 | main: { 823 | assets: { 824 | js: ['main.js'], 825 | }, 826 | }, 827 | }); 828 | }); 829 | 830 | it('Entrypoints are prefixed with publicPath when entrypointsUseAssets is false', async () => { 831 | const { manifest, run } = create(configs.hello(), { 832 | entrypoints: true, 833 | entrypointsUseAssets: false, 834 | publicPath: 'https://example.com/', 835 | }); 836 | 837 | await run(); 838 | 839 | expect(manifest.get('entrypoints')).toEqual({ 840 | main: { 841 | assets: { 842 | js: ['https://example.com/main.js'], 843 | }, 844 | }, 845 | }); 846 | }); 847 | }); 848 | 849 | describe('entrypointsKey', () => { 850 | it('customize the key used for entrypoints', async () => { 851 | const { manifest, run } = create(configs.hello(), { 852 | entrypoints: true, 853 | entrypointsKey: 'myEntrypoints', 854 | }); 855 | 856 | await run(); 857 | 858 | expect(manifest.get('myEntrypoints')).toBeInstanceOf(Object); 859 | }); 860 | 861 | it('can be false', async () => { 862 | const { manifest, run } = create(configs.hello(), { 863 | entrypoints: true, 864 | entrypointsKey: false, 865 | }); 866 | 867 | await run(); 868 | 869 | expect(manifest.get('main')).toBeInstanceOf(Object); 870 | }); 871 | }); 872 | 873 | describe('entrypoints and assets', () => { 874 | it('Entrypoints in shared assets get merged', async () => { 875 | const options = { 876 | assets: Object.create(null), 877 | entrypoints: true, 878 | }; 879 | 880 | const { manifest, run: run1 } = create(configs.hello(), options); 881 | 882 | const { run: run2 } = create(configs.client(), options); 883 | 884 | await Promise.all([run1(), run2()]); 885 | 886 | const entrypointsKeys = Object.keys(manifest.get('entrypoints')); 887 | 888 | expect(entrypointsKeys).toEqual(expect.arrayContaining(['main', 'client'])); 889 | }); 890 | }); 891 | 892 | describe('done', () => { 893 | it('is called when compilation is done', async () => { 894 | const mock1 = vi.fn(async () => true); 895 | const mock2 = vi.fn(async () => true); 896 | const mock3 = vi.fn(); 897 | 898 | const { manifest, run } = create(configs.hello(), { 899 | async done() { 900 | await mock1(); 901 | }, 902 | }); 903 | 904 | manifest.hooks.done.tapPromise('test', async () => { 905 | await mock2(); 906 | }); 907 | 908 | manifest.hooks.done.tap('test', () => { 909 | mock3(); 910 | }); 911 | 912 | await run(); 913 | 914 | expect(mock1).toHaveBeenCalled(); 915 | expect(mock2).toHaveBeenCalled(); 916 | expect(mock3).toHaveBeenCalled(); 917 | }); 918 | }); 919 | 920 | describe('contextRelativeKeys', () => { 921 | it('keys are filepaths relative to the compiler context', async () => { 922 | const { manifest, run } = create(configs.client(), { 923 | contextRelativeKeys: true, 924 | }); 925 | 926 | await run(); 927 | 928 | expect(manifest.get('client.js')).toEqual('client.js'); 929 | expect(manifest.get('test/fixtures/images/Ginger.asset.jpg')).toEqual('images/Ginger.asset.jpg'); 930 | }); 931 | }); 932 | 933 | describe('writeToDisk', () => { 934 | it.each([ 935 | { fn: webpack, devServerWriteToDisk: true, result: false }, 936 | { fn: makeCompiler, devServerWriteToDisk: true, result: false }, 937 | { fn: makeCompiler, devServerWriteToDisk: false, result: false }, 938 | ])( 939 | '$fn.name with options.devServer.writeToDisk: $devServerWriteToDisk', 940 | async ({ fn, devServerWriteToDisk, result }) => { 941 | const { manifest } = create( 942 | configs.devServer(), 943 | { 944 | writeToDisk: 'auto', 945 | }, 946 | fn, 947 | ); 948 | const mockCompilation = { 949 | getPath: (filename: string) => filename, 950 | options: { 951 | devServer: { 952 | writeToDisk: devServerWriteToDisk, 953 | }, 954 | }, 955 | } as unknown as Compilation; 956 | 957 | // The plugin shouldn't write to disk if the dev server is configured to do it. 958 | expect(manifest.shouldWriteToDisk(mockCompilation)).toEqual(result); 959 | }, 960 | ); 961 | 962 | it('Calls options.devServer.writeToDisk() with manifest path', async () => { 963 | const { compiler, manifest, run } = create( 964 | configs.devServer(() => false), 965 | { 966 | writeToDisk: 'auto', 967 | }, 968 | ); 969 | 970 | type DevMiddleware = NonNullable; 971 | type WriteToDiskFn = Exclude, boolean>; 972 | type DevMiddlewareWithWriteToDiskFn = Omit & { writeToDisk: WriteToDiskFn }; 973 | 974 | let spy: MockInstance | undefined; 975 | 976 | compiler.hooks.compilation.tap('test', (compilation) => { 977 | if ( 978 | isObject(compilation.options.devServer) && 979 | isObject(compilation.options.devServer['devMiddleware']) && 980 | typeof compilation.options.devServer['devMiddleware'].writeToDisk === 'function' 981 | ) { 982 | spy = vi 983 | .spyOn(compilation.options.devServer['devMiddleware'], 'writeToDisk') 984 | .mockImplementation((filePath) => manifest.getOutputPath() === filePath); 985 | } 986 | }); 987 | 988 | await run(); 989 | 990 | expect(spy).not.toBeUndefined(); 991 | expect(spy).toHaveBeenCalled(); 992 | }); 993 | 994 | describe('extra', () => { 995 | it('Holds arbitrary data', async () => { 996 | const { manifest } = create(configs.hello(), { 997 | extra: { 998 | test: true, 999 | }, 1000 | }); 1001 | 1002 | expect(manifest.options.extra['test']).toEqual(true); 1003 | }); 1004 | }); 1005 | 1006 | describe('Default options', () => { 1007 | it('Defaults are used', () => { 1008 | const { manifest } = create(configs.hello()); 1009 | 1010 | expect(manifest.options).toEqual(manifest.defaultOptions); 1011 | }); 1012 | }); 1013 | 1014 | describe('Schema validation', () => { 1015 | it('Error is thrown if options schema validation fails', () => { 1016 | expect(() => { 1017 | create(configs.hello(), { 1018 | space: false as unknown as JsonStringifySpace, 1019 | }); 1020 | }).toThrow(); 1021 | }); 1022 | 1023 | it('Error is thrown when options has unknown property', () => { 1024 | expect(() => { 1025 | create(configs.hello(), { 1026 | someUnknownProperty: 'will fail', 1027 | } as unknown as Options); 1028 | }).toThrow(); 1029 | }); 1030 | }); 1031 | }); 1032 | 1033 | describe('Hooks', function () { 1034 | it('Callbacks passed in options are tapped', function () { 1035 | const { manifest } = create(configs.hello(), { 1036 | apply: () => {}, 1037 | customize: () => {}, 1038 | transform: (assets) => assets, 1039 | done: async () => {}, 1040 | }); 1041 | 1042 | expect(manifest.hooks.apply.taps.length).greaterThanOrEqual(1); 1043 | expect(manifest.hooks.customize.taps.length).greaterThanOrEqual(1); 1044 | expect(manifest.hooks.transform.taps.length).greaterThanOrEqual(1); 1045 | expect(manifest.hooks.done.taps.length).greaterThanOrEqual(1); 1046 | }); 1047 | 1048 | describe('Apply', function () { 1049 | it('Is called after the manifest is set up', function () { 1050 | const mock = vi.fn(); 1051 | 1052 | create(configs.hello(), { 1053 | apply: mock, 1054 | }); 1055 | 1056 | expect(mock).toHaveBeenCalled(); 1057 | }); 1058 | }); 1059 | 1060 | describe('Customize', function () { 1061 | it('Can customize an entry', function () { 1062 | const options: Partial = { 1063 | customize: vi.fn((entry, _original, manifest) => { 1064 | if (manifest.utils.isKeyValuePair(entry)) { 1065 | entry.value = 'customized'; 1066 | } 1067 | }), 1068 | }; 1069 | 1070 | const { manifest } = create(configs.hello(), options); 1071 | 1072 | manifest.set('key', 'not customized'); 1073 | 1074 | expect(manifest.get('key')).toEqual('customized'); 1075 | }); 1076 | 1077 | it('Can use manifest.utils', function () { 1078 | expect.assertions(1); 1079 | 1080 | const { manifest } = create(configs.hello(), { 1081 | customize(_entry, _original, manifest) { 1082 | const utils = Object.entries(manifest.utils); 1083 | 1084 | expect(utils).toEqual( 1085 | expect.arrayContaining([expect.arrayContaining([expect.any(String), expect.any(Function)])]), 1086 | ); 1087 | }, 1088 | }); 1089 | 1090 | manifest.set('key', 'value'); 1091 | }); 1092 | }); 1093 | 1094 | describe('Options', function () { 1095 | it('Options can be altered with a hook', function () { 1096 | const mock = vi.fn((options) => { 1097 | options.space = 0; 1098 | 1099 | return options; 1100 | }); 1101 | 1102 | const { compiler, manifest } = create(configs.hello()); 1103 | 1104 | manifest.hooks.options.tap('test', mock); 1105 | 1106 | manifest.apply(compiler); 1107 | 1108 | expect(mock).toHaveBeenCalled(); 1109 | 1110 | expect(manifest.options.space).toEqual(0); 1111 | }); 1112 | }); 1113 | 1114 | describe('Transform', function () { 1115 | it('Transforms the data', function () { 1116 | const { manifest } = create(configs.hello(), { 1117 | space: 0, 1118 | transform(assets) { 1119 | return { assets }; 1120 | }, 1121 | }); 1122 | 1123 | expect(`${manifest}`).toEqual('{"assets":{}}'); 1124 | }); 1125 | }); 1126 | 1127 | describe('Done', function () { 1128 | it('Is called when the compilation is done', async () => { 1129 | const mock = vi.fn(async () => {}); 1130 | 1131 | const { run } = create(configs.hello(), { 1132 | done: mock, 1133 | }); 1134 | 1135 | await run(); 1136 | 1137 | expect(mock).toHaveBeenCalledOnce(); 1138 | }); 1139 | }); 1140 | }); 1141 | 1142 | describe('Usage with webpack', () => { 1143 | describe.todo('cache', () => { 1144 | it('Can get data from codeGenerationResults', async () => { 1145 | const { manifest, run } = create({ 1146 | ...configs.hello(), 1147 | mode: 'production', 1148 | cache: { 1149 | type: 'filesystem', 1150 | cacheDirectory: resolve(currentDirectory, '.webpack-cache'), 1151 | }, 1152 | }); 1153 | 1154 | await run(); 1155 | 1156 | expect(manifest.has('main.js')).toBeTruthy(); 1157 | }); 1158 | }); 1159 | 1160 | describe('Calling set()', () => { 1161 | it('Can set before running', async () => { 1162 | const { manifest, run } = create(configs.hello()); 1163 | 1164 | manifest.set('before', 'value'); 1165 | 1166 | expect(manifest.has('before')).toBeTruthy(); 1167 | 1168 | await run(); 1169 | 1170 | expect(manifest.has('main.js')).toBeTruthy(); 1171 | }); 1172 | 1173 | it('May set empty integrity value', async () => { 1174 | const { manifest, run } = create(configs.hello(), { integrity: true }); 1175 | 1176 | manifest.set('before', 'value'); 1177 | 1178 | expect(manifest.get('before')).toEqual( 1179 | expect.objectContaining({ 1180 | src: 'value', 1181 | integrity: '', 1182 | }), 1183 | ); 1184 | 1185 | await run(); 1186 | 1187 | expect(manifest.has('main.js')).toBeTruthy(); 1188 | }); 1189 | }); 1190 | 1191 | describe.sequential('outputFileSystem', () => { 1192 | beforeAll(async () => { 1193 | await mkdir(getWorkspace(), { recursive: true, mode: 0o777 }); 1194 | }); 1195 | 1196 | afterAll(async () => { 1197 | await rimraf(getWorkspace()); 1198 | }); 1199 | 1200 | it('Writes to disk', async () => { 1201 | const { compiler, manifest, run } = create(configs.hello(), { 1202 | writeToDisk: true, 1203 | }); 1204 | 1205 | const spy = vi.spyOn(compiler.outputFileSystem!, 'writeFile'); 1206 | 1207 | await run(); 1208 | 1209 | expect(spy).toHaveBeenCalled(); 1210 | 1211 | const content = (await promisify(compiler.outputFileSystem!.readFile)(manifest.getOutputPath()))?.toString(); 1212 | 1213 | expect(manifest.toString()).toEqual(content); 1214 | }); 1215 | 1216 | it('Compiler has error if unable to create directory', async () => { 1217 | const { run } = create(configs.hello(), undefined, webpack); 1218 | 1219 | await chmod(getWorkspace(), 0o444); 1220 | 1221 | await expect(run()).rejects.toThrowError(/permission denied/i); 1222 | 1223 | await chmod(getWorkspace(), 0o777); 1224 | }); 1225 | }); 1226 | 1227 | it('Finds module assets', async () => { 1228 | const { manifest, run } = create(configs.client(true)); 1229 | 1230 | await run(); 1231 | 1232 | expect(manifest.has('images/Ginger.asset.jpg')).toBeTruthy(); 1233 | }); 1234 | 1235 | it('Should support multi compiler mode', async () => { 1236 | const assets = Object.create(null); 1237 | 1238 | const { run } = createMulti(configs.multi(), { assets }); 1239 | 1240 | await expect(run()).resolves.toEqual( 1241 | expect.objectContaining({ 1242 | stats: expect.arrayContaining([expect.any(Object), expect.any(Object)]), 1243 | }), 1244 | ); 1245 | 1246 | expect(assets).toMatchObject({ 1247 | 'server.js': 'server.js', 1248 | 'images/Ginger.loader.jpg': 'images/Ginger.loader.jpg', 1249 | 'client.js': 'client.js', 1250 | 'images/Ginger.asset.jpg': 'images/Ginger.asset.jpg', 1251 | }); 1252 | }); 1253 | 1254 | describe('Handles complex configurations', async () => { 1255 | const { manifest, run } = create(configs.complex(), { 1256 | output: './reports/assets-manifest.json', 1257 | integrity: true, 1258 | integrityHashes: ['md5'], 1259 | entrypoints: true, 1260 | entrypointsUseAssets: true, 1261 | publicPath: true, 1262 | contextRelativeKeys: false, 1263 | customize(entry, original, manifest, asset) { 1264 | if (entry) { 1265 | if (typeof entry.key === 'string' && entry.key?.toLowerCase().startsWith('main')) { 1266 | return false; 1267 | } 1268 | 1269 | return { 1270 | value: { 1271 | publicPath: entry.value, 1272 | value: original.value, 1273 | integrity: asset?.info[manifest.options.integrityPropertyName], 1274 | }, 1275 | }; 1276 | } 1277 | 1278 | return entry; 1279 | }, 1280 | transform(assets) { 1281 | const { entrypoints, ...others } = assets; 1282 | 1283 | return { 1284 | entrypoints, 1285 | assets: others, 1286 | }; 1287 | }, 1288 | }); 1289 | 1290 | await run(); 1291 | 1292 | it('main assets were excluded in customize()', () => { 1293 | const { assets } = manifest.toJSON(); 1294 | 1295 | expect(assets).not.toHaveProperty('main.js'); 1296 | expect(assets).not.toHaveProperty('main.css'); 1297 | }); 1298 | 1299 | it('Entrypoints use values from assets (could be a customized value)', () => { 1300 | const { assets, entrypoints } = manifest.toJSON(); 1301 | 1302 | expect(entrypoints.complex.assets.js[0]).toEqual(assets['complex.js']); 1303 | }); 1304 | 1305 | it('Entrypoints use default values when corresponding asset is not found (excluded during customize)', () => { 1306 | const { entrypoints } = manifest.toJSON(); 1307 | 1308 | expect(entrypoints.main.assets).toEqual({ 1309 | css: ['https://assets.example.com/main-HASH.css'], 1310 | js: ['https://assets.example.com/main-HASH.js'], 1311 | }); 1312 | }); 1313 | }); 1314 | 1315 | describe('Handles multiple plugin instances being used', () => { 1316 | it('manifests does not contain other manifests', async () => { 1317 | const config = configs.complex(); 1318 | const manifest = new WebpackAssetsManifest(); 1319 | const integrityManifest = new WebpackAssetsManifest({ 1320 | output: 'reports/integrity-manifest.json', 1321 | integrity: true, 1322 | }); 1323 | 1324 | config.plugins.push(manifest, integrityManifest); 1325 | 1326 | const compiler = makeCompiler(config); 1327 | 1328 | const run = makeRun(compiler); 1329 | 1330 | await expect(run()).resolves.toBeInstanceOf(Object); 1331 | 1332 | expect(manifest.has(integrityManifest.options.output)).toBeFalsy(); 1333 | expect(integrityManifest.has(manifest.options.output)).toBeFalsy(); 1334 | }); 1335 | }); 1336 | 1337 | describe('Uses asset.info.sourceFilename when assetNames does not have a matching asset', () => { 1338 | it('contextRelativeKeys is on', async () => { 1339 | const { manifest, run } = create(configs.client(), { 1340 | contextRelativeKeys: true, 1341 | }); 1342 | 1343 | // Pretend like assetNames is empty. 1344 | const mock = vi.spyOn(manifest.assetNames, 'entries').mockImplementation(() => new Map().entries()); 1345 | 1346 | await run(); 1347 | 1348 | expect(mock).toHaveBeenCalled(); 1349 | 1350 | expect(manifest.has('test/fixtures/images/Ginger.asset.jpg')).toBeTruthy(); 1351 | }); 1352 | 1353 | it('contextRelativeKeys is off', async () => { 1354 | const { manifest, run } = create(configs.client(), { 1355 | contextRelativeKeys: false, 1356 | }); 1357 | 1358 | // Pretend like assetNames is empty. 1359 | const mock = vi.spyOn(manifest.assetNames, 'entries').mockImplementation(() => new Map().entries()); 1360 | 1361 | await run(); 1362 | 1363 | expect(mock).toHaveBeenCalled(); 1364 | expect(manifest.has('Ginger.asset.jpg')).toBeTruthy(); 1365 | }); 1366 | }); 1367 | 1368 | it('Finds css files', async () => { 1369 | const { manifest, run } = create(configs.styles()); 1370 | 1371 | await run(); 1372 | 1373 | expect(manifest.toString()).toEqual(expect.stringContaining('styles.css')); 1374 | }); 1375 | 1376 | it('Should ignore HMR files', function () { 1377 | const config = configs.hello(); 1378 | 1379 | const { manifest } = create(config); 1380 | 1381 | manifest.processAssetsByChunkName( 1382 | { 1383 | main: ['main.123456.js', '0.123456.hot-update.js'], 1384 | }, 1385 | new Set(['0.123456.hot-update.js']), 1386 | ); 1387 | 1388 | expect(manifest.assetNames.get('main.js')).toEqual('main.123456.js'); 1389 | expect([...manifest.assetNames.values()].includes('0.123456.hot-update.js')).toBeFalsy(); 1390 | }); 1391 | 1392 | it('Logs warning when unable to determine filename', async () => { 1393 | const write = vi.fn(); 1394 | 1395 | const { run } = create( 1396 | configs.badImport({ 1397 | stream: new PassThrough({ write }), 1398 | }), 1399 | ); 1400 | 1401 | const stats = await run(); 1402 | 1403 | expect(stats?.compilation.errors.length).toBeGreaterThan(0); 1404 | 1405 | expect(write).toHaveBeenCalled(); 1406 | expect(write.mock.calls.at(0)?.at(0)?.toString()).toEqual( 1407 | expect.stringContaining('Unable to get filename from module'), 1408 | ); 1409 | }); 1410 | }); 1411 | 1412 | describe('Usage with webpack-dev-server', () => { 1413 | it('inDevServer() should return true', async () => { 1414 | const { compiler, manifest } = create(configs.devServer(), undefined, webpack); 1415 | 1416 | const server = new WebpackDevServer(undefined, compiler); 1417 | 1418 | await server.start(); 1419 | 1420 | expect(manifest.inDevServer()).toBeTruthy(); 1421 | 1422 | await server.stop(); 1423 | }); 1424 | 1425 | it('Should serve the assets manifest JSON file', async () => { 1426 | const { compiler } = create(configs.devServer(), undefined, webpack); 1427 | 1428 | const server = new WebpackDevServer( 1429 | { 1430 | host: 'localhost', 1431 | }, 1432 | compiler, 1433 | ); 1434 | 1435 | await server.start(); 1436 | 1437 | await expect( 1438 | fetch(`http://${server.options.host}:${server.options.port}/assets-manifest.json`), 1439 | ).resolves.toHaveProperty('status', 200); 1440 | 1441 | await server.stop(); 1442 | }); 1443 | 1444 | describe('writeToDisk', () => { 1445 | beforeAll(async () => { 1446 | await mkdir(getWorkspace(), { recursive: true, mode: 0o777 }); 1447 | }); 1448 | 1449 | afterAll(async () => { 1450 | await rimraf(getWorkspace()); 1451 | }); 1452 | 1453 | it('Should write to disk using absolute output path', async () => { 1454 | const config = configs.devServer(); 1455 | const { compiler, manifest } = create(config, { 1456 | output: join(config.output.path, 'assets', 'assets-manifest.json'), 1457 | writeToDisk: true, 1458 | }); 1459 | 1460 | const server = new WebpackDevServer( 1461 | { 1462 | host: 'localhost', 1463 | }, 1464 | compiler, 1465 | ); 1466 | 1467 | await server.start(); 1468 | 1469 | await expect( 1470 | fetch(`http://${server.options.host}:${server.options.port}/assets-manifest.json`), 1471 | ).resolves.toHaveProperty('status', 200); 1472 | 1473 | const manifestStats = await stat(manifest.getOutputPath()); 1474 | 1475 | expect(manifestStats.isFile()).toBeTruthy(); 1476 | 1477 | await server.stop(); 1478 | }); 1479 | 1480 | it('Should write to compiler.outputPath if no output paths are specified', async () => { 1481 | const config = { 1482 | ...configs.devServer(), 1483 | output: undefined, 1484 | }; 1485 | 1486 | const { compiler, manifest } = create(config, { 1487 | writeToDisk: true, 1488 | }); 1489 | 1490 | const server = new WebpackDevServer( 1491 | { 1492 | host: 'localhost', 1493 | }, 1494 | compiler, 1495 | ); 1496 | 1497 | await server.start(); 1498 | 1499 | await expect( 1500 | fetch(`http://${server.options.host}:${server.options.port}/assets-manifest.json`), 1501 | ).resolves.toHaveProperty('status', 200); 1502 | 1503 | const manifestStats = await stat(manifest.getOutputPath()); 1504 | 1505 | expect(manifest.getOutputPath().startsWith(compiler.outputPath)).toBeTruthy(); 1506 | 1507 | expect(manifestStats.isFile()).toBeTruthy(); 1508 | 1509 | await server.stop(); 1510 | }); 1511 | 1512 | it('writeToDisk: auto', async () => { 1513 | const { compiler, manifest } = create( 1514 | configs.devServer(), 1515 | { 1516 | writeToDisk: 'auto', 1517 | }, 1518 | webpack, 1519 | ); 1520 | 1521 | const server = new WebpackDevServer(undefined, compiler); 1522 | 1523 | await server.start(); 1524 | 1525 | const mockCompilation = { 1526 | options: { 1527 | devServer: { 1528 | writeToDisk: true, 1529 | }, 1530 | }, 1531 | } as unknown as Compilation; 1532 | 1533 | expect(manifest.shouldWriteToDisk(mockCompilation)).toBeFalsy(); 1534 | 1535 | await server.stop(); 1536 | }); 1537 | }); 1538 | }); 1539 | 1540 | describe('Usage with webpack plugins', () => { 1541 | describe('webpack.ModuleFederationPlugin', () => { 1542 | it('Finds `exposes` entries', async () => { 1543 | const { manifest, run } = create({ 1544 | ...configs.hello(), 1545 | devtool: 'cheap-source-map', 1546 | plugins: [ 1547 | new container.ModuleFederationPlugin({ 1548 | name: 'remote', 1549 | filename: 'remote-entry.js', 1550 | exposes: { 1551 | './remote': resolve(currentDirectory, 'fixtures/remote.js'), 1552 | }, 1553 | }), 1554 | ], 1555 | }); 1556 | 1557 | await run(); 1558 | 1559 | expect(manifest.has('remote.js')).toBeTruthy(); 1560 | }); 1561 | }); 1562 | 1563 | describe('copy-webpack-plugin', () => { 1564 | it('Finds copied files', async () => { 1565 | const { manifest, run } = create(configs.copy()); 1566 | 1567 | await run(); 1568 | 1569 | expect(manifest.get('readme.md')).toEqual('readme-copied.md'); 1570 | }); 1571 | }); 1572 | 1573 | describe('compression-webpack-plugin', () => { 1574 | it('Adds gz filenames to the manifest', async () => { 1575 | const { manifest, run } = create(configs.compression()); 1576 | 1577 | await run(); 1578 | 1579 | expect(manifest.get('main.js.gz')).toEqual('main.js.gz'); 1580 | }); 1581 | }); 1582 | 1583 | describe('webpack-subresource-integrity', () => { 1584 | it('Uses integrity value from webpack-subresource-integrity plugin', async () => { 1585 | const { manifest, run } = create(configs.sri(), { 1586 | integrity: true, 1587 | integrityHashes: ['md5', 'sha256'], 1588 | integrityPropertyName: 'sri', 1589 | }); 1590 | 1591 | await run(); 1592 | 1593 | expect(manifest.get('main.js')).toEqual( 1594 | expect.objectContaining({ 1595 | integrity: expect.stringMatching(/^md5-.+\ssha256-/), 1596 | }), 1597 | ); 1598 | }); 1599 | 1600 | it('Uses integrity value from this plugin', async () => { 1601 | const { manifest, run } = create(configs.sri(), { 1602 | integrity: true, 1603 | integrityPropertyName: 'md5', 1604 | integrityHashes: ['md5'], 1605 | }); 1606 | 1607 | await run(); 1608 | 1609 | expect(manifest.get('main.js').integrity.startsWith('md5-')).toBeTruthy(); 1610 | }); 1611 | }); 1612 | }); 1613 | }); 1614 | -------------------------------------------------------------------------------- /test/type-predicate.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { isKeyValuePair, isObject, isPropertyKey } from '../src/type-predicate.js'; 4 | 5 | describe('isObject()', function () { 6 | it('returns true when given an object', () => { 7 | expect(isObject({})).toBeTruthy(); 8 | expect(isObject(Object.create(null))).toBeTruthy(); 9 | expect(isObject(new (class {})())).toBeTruthy(); 10 | }); 11 | 12 | it('returns false when given null', () => { 13 | expect(isObject(null)).toBeFalsy(); 14 | }); 15 | }); 16 | 17 | describe('isKeyValuePair()', () => { 18 | it('Returns true for valid input', () => { 19 | expect( 20 | isKeyValuePair({ 21 | key: 'key', 22 | value: 'value', 23 | }), 24 | ).toBeTruthy(); 25 | 26 | expect( 27 | isKeyValuePair({ 28 | key: 'key', 29 | }), 30 | ).toBeTruthy(); 31 | 32 | expect( 33 | isKeyValuePair({ 34 | value: 'value', 35 | }), 36 | ).toBeTruthy(); 37 | 38 | expect(isKeyValuePair(false)).toBeFalsy(); 39 | }); 40 | }); 41 | 42 | describe('isPropertyKey()', () => { 43 | it.each(['string-key', 123, Symbol('symbol-key')])('Returns true for %s', (input) => { 44 | expect(isPropertyKey(input)).toBeTruthy(); 45 | }); 46 | 47 | it.each([false, null, undefined, {}, []])('Returns false for %s', (input) => { 48 | expect(isPropertyKey(input)).toBeFalsy(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import { tmpdir } from 'node:os'; 3 | import { join } from 'node:path'; 4 | 5 | import { createFsFromVolume, Volume } from 'memfs'; 6 | import { type Compiler, type Configuration, type Stats, webpack, type MultiCompiler, type MultiStats } from 'webpack'; 7 | 8 | import { WebpackAssetsManifest } from '../src/plugin.js'; 9 | 10 | export function getWorkspace(): string { 11 | return join(tmpdir(), 'webpack-assets-manifest'); 12 | } 13 | 14 | export function tmpDirPath(): string { 15 | return join(getWorkspace(), randomUUID()); 16 | } 17 | 18 | export function makeCompiler(configuration: Configuration): Compiler { 19 | const compiler = webpack({ 20 | mode: 'development', 21 | stats: 'errors-only', 22 | infrastructureLogging: { 23 | level: 'none', 24 | debug: false, 25 | }, 26 | ...configuration, 27 | }); 28 | 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 30 | compiler.outputFileSystem = createFsFromVolume(new Volume()) as any; 31 | 32 | return compiler; 33 | } 34 | 35 | export function makeMultiCompiler(configurations: Configuration[]): MultiCompiler { 36 | const compiler = webpack( 37 | configurations.map( 38 | (config) => 39 | ({ 40 | mode: 'development', 41 | stats: 'errors-only', 42 | infrastructureLogging: { 43 | level: 'none', 44 | debug: false, 45 | }, 46 | ...config, 47 | }) satisfies Configuration, 48 | ), 49 | ); 50 | 51 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 52 | compiler.outputFileSystem = createFsFromVolume(new Volume()) as any; 53 | 54 | return compiler; 55 | } 56 | 57 | export const makeRun = (compiler: Compiler) => (): Promise => 58 | new Promise((resolve, reject) => { 59 | compiler.run((err, stats) => (err ? reject(err) : resolve(stats))); 60 | }); 61 | 62 | export function create( 63 | configuration: Configuration, 64 | pluginOptions?: ConstructorParameters[0], 65 | comp = makeCompiler, 66 | ): { 67 | compiler: Compiler; 68 | manifest: WebpackAssetsManifest; 69 | run: () => Promise; 70 | } { 71 | const manifest = new WebpackAssetsManifest(pluginOptions); 72 | 73 | const compiler = comp({ 74 | ...configuration, 75 | plugins: [...(configuration.plugins ?? []), manifest], 76 | }); 77 | 78 | return { 79 | compiler, 80 | manifest, 81 | run: makeRun(compiler), 82 | }; 83 | } 84 | 85 | export function createMulti( 86 | configurations: Configuration[], 87 | pluginOptions?: ConstructorParameters[0], 88 | comp = makeMultiCompiler, 89 | ): { 90 | compiler: MultiCompiler; 91 | manifest: WebpackAssetsManifest; 92 | run: () => Promise; 93 | } { 94 | const manifest = new WebpackAssetsManifest(pluginOptions); 95 | 96 | const compiler = comp( 97 | configurations.map( 98 | (config) => 99 | ({ 100 | mode: 'development', 101 | stats: 'errors-only', 102 | infrastructureLogging: { 103 | level: 'none', 104 | debug: false, 105 | }, 106 | ...config, 107 | plugins: [...(config.plugins ?? []), manifest], 108 | }) satisfies Configuration, 109 | ), 110 | ); 111 | 112 | const run = (): Promise => 113 | new Promise((resolve, reject) => { 114 | compiler.run((err, stats) => (err ? reject(err) : resolve(stats))); 115 | }); 116 | 117 | return { compiler, manifest, run }; 118 | } 119 | -------------------------------------------------------------------------------- /test/webpack-configs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { join, resolve } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | import CompressionPlugin from 'compression-webpack-plugin'; 6 | import CopyPlugin from 'copy-webpack-plugin'; 7 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 8 | import { SubresourceIntegrityPlugin } from 'webpack-subresource-integrity'; 9 | 10 | import { tmpDirPath } from './utils.js'; 11 | 12 | import type { Configuration } from 'webpack'; 13 | import type Server from 'webpack-dev-server'; 14 | 15 | type Output = NonNullable; 16 | 17 | type OutputWithPath = Omit & Required>; 18 | 19 | export type ConfigurationForTests = Omit & 20 | Required> & { output: OutputWithPath }; 21 | 22 | const fixturesDir = fileURLToPath(new URL('./fixtures', import.meta.url)); 23 | 24 | export function hello(): ConfigurationForTests { 25 | return { 26 | mode: 'development', 27 | infrastructureLogging: { 28 | debug: true, 29 | level: 'verbose', 30 | }, 31 | entry: { 32 | main: resolve(fixturesDir, './hello.js'), 33 | }, 34 | output: { 35 | path: tmpDirPath(), 36 | }, 37 | module: { 38 | rules: [], 39 | }, 40 | plugins: [], 41 | }; 42 | } 43 | 44 | export function client(hashed = false): ConfigurationForTests { 45 | return { 46 | mode: 'development', 47 | target: 'web', 48 | infrastructureLogging: { 49 | debug: true, 50 | level: 'verbose', 51 | }, 52 | entry: { 53 | client: resolve(fixturesDir, './client.js'), 54 | }, 55 | output: { 56 | path: tmpDirPath(), 57 | filename: hashed ? '[name]-[contenthash:6]-[chunkhash].js' : '[name].js', 58 | }, 59 | optimization: { 60 | realContentHash: true, 61 | }, 62 | module: { 63 | rules: [ 64 | { 65 | test: /\.loader\.jpg$/i, 66 | loader: 'file-loader', 67 | options: { 68 | name: hashed ? 'images/[name]-[contenthash:6].[ext]' : 'images/[name].[ext]', 69 | }, 70 | }, 71 | { 72 | test: /\.asset\.jpg$/i, 73 | type: 'asset/resource', 74 | generator: { 75 | filename: hashed ? 'images/[name]-[contenthash:6][ext]' : 'images/[name][ext]', 76 | }, 77 | }, 78 | ], 79 | }, 80 | plugins: [], 81 | }; 82 | } 83 | 84 | export function badImport(loggingConfig: Configuration['infrastructureLogging'] = {}): ConfigurationForTests { 85 | return { 86 | mode: 'development', 87 | target: 'web', 88 | infrastructureLogging: { 89 | level: 'verbose', 90 | ...loggingConfig, 91 | }, 92 | entry: { 93 | badImport: resolve(fixturesDir, './bad-import.js'), 94 | }, 95 | output: { 96 | path: tmpDirPath(), 97 | filename: '[name].js', 98 | }, 99 | module: { 100 | rules: [ 101 | { 102 | test: /\.css$/i, 103 | exclude: /node_modules/, 104 | type: 'asset/resource', 105 | use: [ 106 | { 107 | loader: 'sass-loader', 108 | }, 109 | ], 110 | }, 111 | ], 112 | }, 113 | plugins: [], 114 | }; 115 | } 116 | 117 | export function styles(): ConfigurationForTests { 118 | return { 119 | mode: 'development', 120 | target: 'web', 121 | entry: { 122 | styles: resolve(fixturesDir, './load-styles.mjs'), 123 | }, 124 | output: { 125 | path: tmpDirPath(), 126 | filename: '[name].js', 127 | publicPath: '/', 128 | }, 129 | module: { 130 | rules: [ 131 | { 132 | test: /\.jpg$/i, 133 | type: 'asset/resource', 134 | generator: { 135 | filename: 'images/[name]-[contenthash:6][ext]', 136 | }, 137 | }, 138 | { 139 | test: /\.css$/, 140 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 141 | }, 142 | ], 143 | }, 144 | plugins: [ 145 | new MiniCssExtractPlugin({ 146 | filename: '[name].css', 147 | }), 148 | ], 149 | }; 150 | } 151 | 152 | export function copy(): ConfigurationForTests { 153 | const config = hello(); 154 | 155 | config.plugins.push( 156 | new CopyPlugin({ 157 | patterns: [ 158 | { 159 | from: join(fixturesDir, 'readme.md'), 160 | to: './readme-copied.md', 161 | }, 162 | ], 163 | }), 164 | ); 165 | 166 | return config; 167 | } 168 | 169 | export function compression(): ConfigurationForTests { 170 | const config = hello(); 171 | 172 | config.plugins.push(new CompressionPlugin()); 173 | 174 | return config; 175 | } 176 | 177 | export function sri(): ConfigurationForTests { 178 | const config = hello(); 179 | 180 | config.output = { 181 | crossOriginLoading: 'anonymous', 182 | path: tmpDirPath(), 183 | }; 184 | 185 | config.plugins.push( 186 | new SubresourceIntegrityPlugin({ 187 | enabled: true, 188 | hashFuncNames: ['sha256'], 189 | }), 190 | ); 191 | 192 | return config; 193 | } 194 | 195 | export function complex(): ConfigurationForTests { 196 | return { 197 | mode: 'development', 198 | target: 'web', 199 | context: fixturesDir, 200 | entry: { 201 | main: './main.js', 202 | complex: './complex.mjs', 203 | }, 204 | output: { 205 | path: tmpDirPath(), 206 | filename: '[name]-HASH.js', 207 | publicPath: 'https://assets.example.com/', 208 | }, 209 | module: { 210 | rules: [ 211 | { 212 | test: /\.jpg$/i, 213 | type: 'asset/resource', 214 | generator: { 215 | filename: 'images/[name].HASH[ext][query]', 216 | }, 217 | }, 218 | { 219 | test: /\.css$/i, 220 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 221 | }, 222 | ], 223 | }, 224 | plugins: [ 225 | new MiniCssExtractPlugin({ 226 | filename: '[name]-HASH.css', 227 | }), 228 | ], 229 | }; 230 | } 231 | 232 | export function server(): ConfigurationForTests { 233 | return { 234 | mode: 'development', 235 | target: 'node', 236 | entry: { 237 | server: resolve(fixturesDir, './server.js'), 238 | }, 239 | infrastructureLogging: { 240 | level: 'none', 241 | }, 242 | stats: 'errors-only', 243 | output: { 244 | path: tmpDirPath(), 245 | filename: '[name].js', 246 | }, 247 | module: { 248 | rules: [], 249 | }, 250 | plugins: [], 251 | }; 252 | } 253 | 254 | export function devServer( 255 | writeToDisk: NonNullable['writeToDisk'] = false, 256 | ): ConfigurationForTests { 257 | const config = server(); 258 | 259 | config.devServer = { 260 | hot: true, 261 | devMiddleware: { 262 | stats: 'errors-only', 263 | writeToDisk, 264 | }, 265 | }; 266 | 267 | return config; 268 | } 269 | 270 | export function multi(): Configuration[] { 271 | const clientConfig = client(); 272 | const serverConfig = server(); 273 | 274 | clientConfig.output.path = serverConfig.output.path = tmpDirPath(); 275 | 276 | return [clientConfig, serverConfig]; 277 | } 278 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUnreachableCode": false, 6 | "allowUnusedLabels": false, 7 | "baseUrl": ".", 8 | "checkJs": false, 9 | "composite": true, 10 | "declaration": true, 11 | "declarationDir": "./dist/types", 12 | "declarationMap": false, 13 | "downlevelIteration": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "incremental": true, 17 | "isolatedModules": true, 18 | "lib": ["es2023"], 19 | "listEmittedFiles": true, 20 | "module": "NodeNext", 21 | "moduleResolution": "NodeNext", 22 | "noEmit": true, 23 | "noErrorTruncation": true, 24 | "noImplicitOverride": true, 25 | "noImplicitReturns": true, 26 | "noPropertyAccessFromIndexSignature": true, 27 | "noUncheckedIndexedAccess": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "outDir": "./dist", 31 | "removeComments": true, 32 | "resolveJsonModule": true, 33 | "rootDir": "./", 34 | "skipLibCheck": true, 35 | "sourceMap": false, 36 | "strict": true, 37 | "target": "ES2022" 38 | }, 39 | "exclude": ["node_modules", "private"] 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "./src", 6 | "outDir": "./dist/cjs", 7 | "module": "CommonJS", 8 | "moduleResolution": "Node", 9 | "tsBuildInfoFile": "./cache/cjs.tsbuildinfo" 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.configs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "noEmit": true, 7 | "rootDir": "./", 8 | "tsBuildInfoFile": "./cache/configs.tsbuildinfo" 9 | }, 10 | "include": ["./*.ts", "./*.mts", "./*.cts", "./*.js", "./*.mjs", "./*.cjs"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "rootDir": "./", 6 | "tsBuildInfoFile": "./cache/examples.tsbuildinfo" 7 | }, 8 | "include": ["./package.json", "./examples/*.ts"], 9 | "references": [ 10 | { 11 | "path": "./tsconfig.mjs.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "files": [], 4 | "compilerOptions": { 5 | "tsBuildInfoFile": "./cache/.tsbuildinfo" 6 | }, 7 | "references": [ 8 | { "path": "./tsconfig.mjs.json" }, 9 | { "path": "./tsconfig.cjs.json" }, 10 | { "path": "./tsconfig.test.json" }, 11 | { "path": "./tsconfig.configs.json" }, 12 | { "path": "./tsconfig.examples.json" } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.mjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "./src", 6 | "outDir": "./dist/mjs", 7 | "verbatimModuleSyntax": true, 8 | "tsBuildInfoFile": "./cache/mjs.tsbuildinfo" 9 | }, 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "verbatimModuleSyntax": true, 6 | "tsBuildInfoFile": "./cache/test.tsbuildinfo" 7 | }, 8 | "include": ["test/**/*", "test/fixtures/**/*.json"], 9 | "references": [ 10 | { 11 | "path": "./tsconfig.mjs.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'node', 6 | include: ['./test/**/*.test.ts'], 7 | coverage: { 8 | all: false, 9 | provider: 'v8', 10 | reporter: ['json', 'html'], 11 | }, 12 | }, 13 | }); 14 | --------------------------------------------------------------------------------