[
  {
    "path": ".eslintignore",
    "content": "*.js"
  },
  {
    "path": ".eslintrc.js",
    "content": "module.exports = {\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    project: './tsconfig.json',\n    tsconfigRootDir: __dirname,\n    sourceType: 'module',\n  },\n  plugins: ['@typescript-eslint/eslint-plugin', 'simple-import-sort'],\n  extends: [\n    'plugin:@typescript-eslint/recommended',\n    'plugin:prettier/recommended',\n    'plugin:import/recommended',\n    'plugin:import/typescript',\n  ],\n  root: true,\n  env: {\n    node: true,\n    jest: true,\n  },\n  ignorePatterns: ['.eslintrc.js'],\n  rules: {\n    '@typescript-eslint/interface-name-prefix': 'off',\n    '@typescript-eslint/explicit-function-return-type': 'error',\n    '@typescript-eslint/explicit-module-boundary-types': 'error',\n    '@typescript-eslint/explicit-member-accessibility': 'error',\n    '@typescript-eslint/no-explicit-any': 'off',\n    'max-len': [\n      'error',\n      {\n        code: 80,\n        ignoreComments: true,\n      }\n    ],\n    'simple-import-sort/imports': 'error',\n    'simple-import-sort/exports': 'error',\n    'import/first': 'error',\n    'import/newline-after-import': 'error',\n    'import/no-duplicates': 'error',\n    'padding-line-between-statements': [\n      'error',\n      { blankLine: 'always', prev: '*', next: 'return' },\n      { blankLine: 'always', prev: '*', next: 'throw' },\n      { blankLine: 'always', prev: '*', next: 'continue' },\n    ],\n  },\n  settings: {\n    'import/resolver': {\n      typescript: true,\n      node: true,\n    },\n  },\n};\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n  - https://www.buymeacoffee.com/momocow\n"
  },
  {
    "path": ".github/workflows/docs.yaml",
    "content": "name: Deploy docs to Pages\n\non:\n  push:\n    branches: ['main']\n\n  workflow_dispatch:\n\n# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\n# Allow one concurrent deployment\nconcurrency:\n  group: 'pages'\n  cancel-in-progress: true\n\njobs:\n  # Single deploy job since we're just deploying\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 'lts/*'\n      - name: Install dependencies\n        run: npm ci\n      - name: Build\n        run: npm run docs\n      - name: Setup Pages\n        uses: actions/configure-pages@v3\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v1\n        with:\n          # Upload entire repository\n          path: 'docs/'\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v1\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "name: Release\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - main\n      - alpha\n\njobs:\n  release:\n    name: Release\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 0\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 'lts/*'\n      - name: Install dependencies\n        run: npm ci\n      - name: Clean\n        run: npm run clean\n      - name: Build\n        run: npm run build\n      - name: Release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}\n          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: npm run release\n"
  },
  {
    "path": ".github/workflows/test.yaml",
    "content": "name: Test\n\non:\n  - push\n  - pull_request\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [lts/*, node]\n\n    steps:\n      - uses: actions/checkout@v3\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v3\n        with:\n          node-version: ${{ matrix.node-version }}\n      - run: npm ci\n      - run: npm run lint\n      - run: npm test\n"
  },
  {
    "path": ".gitignore",
    "content": ".vscode/\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (http://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Typescript v1 declaration files\ntypings/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n\ndist/\ndocs/"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\n# ignore errors\nnpx lint-staged || exit 0\n"
  },
  {
    "path": ".lintstagedrc.json",
    "content": "{\n  \"**/*.ts\": [\"prettier --write\", \"eslint --fix\"]\n}\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"singleQuote\": true,\n  \"trailingComma\": \"all\",\n  \"printWidth\": 80\n}\n"
  },
  {
    "path": ".releaserc.json",
    "content": "{\n  \"branches\": [\n    \"main\",\n    {\n      \"name\": \"alpha\",\n      \"prerelease\": true\n    }\n  ],\n  \"plugins\": [\n    \"semantic-release-gitmoji\",\n    \"@semantic-release/github\",\n    \"@semantic-release/npm\",\n    [\n      \"@semantic-release/git\",\n      {\n        \"message\": \":bookmark: v${nextRelease.version} [skip ci]\\n\\nhttps://github.com/momocow/webpack-userscript/releases/tag/${nextRelease.gitTag}\"\n      }\n    ]\n  ]\n}"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 MomoCow <momocow.me@gmail.com>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# webpack-userscript\n\n[![Test Status](https://github.com/momocow/webpack-userscript/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/momocow/webpack-userscript/actions/workflows/test.yaml)\n[![Release Status](https://github.com/momocow/webpack-userscript/actions/workflows/release.yaml/badge.svg?branch=main)](https://github.com/momocow/webpack-userscript/actions/workflows/release.yaml)\n[![Coding Style](https://img.shields.io/badge/coding%20style-recommended-orange.svg?style=flat)](https://gitmoji.carloscuesta.me/)\n[![npm](https://img.shields.io/npm/v/webpack-userscript.svg)](https://www.npmjs.com/package/webpack-userscript/v/latest)\n[![Gitmoji](https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=flat-square)](https://gitmoji.carloscuesta.me/)\n\nA Webpack plugin for userscript projects 🙈\n\n- [webpack-userscript](#webpack-userscript)\n  - [Overview](#overview)\n    - [Installation](#installation)\n    - [Usage](#usage)\n    - [Options](#options)\n  - [Concepts](#concepts)\n    - [What does it actually do?](#what-does-it-actually-do)\n      - [Prepend headers to userscripts](#prepend-headers-to-userscripts)\n      - [Generate metadata files](#generate-metadata-files)\n      - [Generate proxyscript files](#generate-proxyscript-files)\n    - [Headers pipeline](#headers-pipeline)\n      - [Load headers](#load-headers)\n      - [Rename ambiguous tags](#rename-ambiguous-tags)\n      - [Resolve base URLs](#resolve-base-urls)\n      - [Process SSRIs](#process-ssris)\n      - [Provide default values for tags](#provide-default-values-for-tags)\n      - [Generate proxyscripts](#generate-proxyscripts)\n      - [Interpolate templates inside values](#interpolate-templates-inside-values)\n      - [Validate headers](#validate-headers)\n      - [Render headers](#render-headers)\n  - [Guides](#guides)\n    - [Hot Development](#hot-development)\n    - [Integration with Webpack Dev Server and TamperMonkey](#integration-with-webpack-dev-server-and-tampermonkey)\n    - [I18n headers](#i18n-headers)\n  - [Furthermore](#furthermore)\n\n## Overview\n\n### Installation\n\n```bash\nnpm i webpack-userscript -D\n```\n\n### Usage\n\nImport and configure the plugin in the `webpack.config.js` as the following example.\n\n```js\nconst { UserscriptPlugin } = require('webpack-userscript');\n\nmodule.exports = {\n  plugins: [new UserscriptPlugin(/* optionally provide more options here */)],\n};\n```\n\n### Options\n\nSee [UserscriptOptions](https://cow.moe/webpack-userscript/types/UserscriptOptions.html) for all configurations.\n\n## Concepts\n\n### What does it actually do?\n\n#### Prepend headers to userscripts\n\nThe main purpose of this plugin is to generate userscript headers, and prepend them as a comment block into output entry scripts whose names are conventionally ending with `.user.js`.\n\nThere are several userscript engines on the internet and some of them call userscript headers in different names; but don't worry because they share the same concept and **almost** the same format.\n\nHere are some references to headers definitions of userscript engines:\n\n- [TamperMonkey: userscript headers](https://www.tampermonkey.net/documentation.php#meta)\n- [GreaseMonkey: metadata block](https://wiki.greasespot.net/Metadata_Block)\n- [GreasyFork: meta keys](https://greasyfork.org/en/help/meta-keys)\n- [ViolentMonkey: metadata block](https://violentmonkey.github.io/api/metadata-block/)\n\n#### Generate metadata files\n\nBesides prepending headers to entry scripts, it can optionally generate metadata files which are userscript files without codes; that is, they contain headers only. Metadata files are used to save bandwidth when checking updates. By convention, their names are ending with `.meta.js`.\n\n#### Generate proxyscript files\n\nThe concept of proxyscript is introduced by this plugin, unlike userscripts and metadata files which are commonly known. The name of a proxyscript should end with `.proxy.user.js`. It is mainly designed to work around the caching behavior of userscript engines like TamperMonkey. It was a pain point for userscript developers who set up a development environment with Webpack Dev Server and require fresh reloads to test their scripts.\n\n > See more details in [issue #63](https://github.com/momocow/webpack-userscript/issues/63)\n\nIt is worth mentioning that with ViolentMonkey you might experience a better reload story, according to [a feedback in issue #63](https://github.com/momocow/webpack-userscript/issues/63#issuecomment-1500167848).\n\n### Headers pipeline\n\n#### Load headers\n\nHeaders can be provided directly as an object, a string referencing to a file, or a function returning an object of headers.\n\nThe plugin will also try to load initial headers from the following fields, `name`, `description`, `version`, `author`, `homepage` and `bugs` in `package.json`.\n\nHeaders files are added as a file dependency; therefore, changes to headers files are watched by Webpack during developing in the watch mode.\n\n#### Rename ambiguous tags\n\nThe main purpose is to fix misspelling or wrong letter case.\n\n- `updateUrl` => `updateURL`\n- `iconUrl` => `iconURL`\n- `icon64Url` => `icon64URL`\n- `installUrl` => `installURL`\n- `supportUrl` => `supportURL`\n- `downloadUrl` => `downloadURL`\n- `homepageUrl` => `homepageURL`\n\n#### Resolve base URLs\n\nBase URLs are resolved for `downloadURL` and `updateURL`.\n\nIf `updateBaseURL` is not provided, `downloadBaseURL` will be used; if metajs is disabled, `updateURL` will point to the file of userjs.\n\n#### Process SSRIs\n\n[Subresource Integrity](https://www.tampermonkey.net/documentation.php#api:Subresource_Integrity) is used to ensure the 3rd-party assets do not get mocked by the man in the middle.\n\nURLs in `@require` and `@resource` tags can have their SSRIs be generated and locked in a SSRI lock file whose name is default to `ssri-lock.json`.\n\nMissing SSRIs will be computed right in the compilation, which indicates that developers have to ensure their 3rd-party assets to be trustable during compilation.\n\nIf one cannot ensure 3rd-party assets to be trustable, he can modify the lock file himself with trustable integrities of assets.\n\n> Note that the lock file should be commited into the version control system just like `package-lock.json`.\n\n#### Provide default values for tags\n\nIf there is no any `@include` or `@match` tag provided, a wildcard `@match`, `*://*/*`, is used.\n\n#### Generate proxyscripts\n\nThe content of a proxyscript looks similar to a metajs except that its `@require` tag will include an URL linked to its userjs file and it won't have any one of these tags, `downloadURL`, `updateURL` and `installURL`.\n\n#### Interpolate templates inside values\n\nLeaf values of headers can be interpolable templates. Template variables can be represented in a format, `[var]`, just like how [template strings in Webpack output options](https://webpack.js.org/configuration/output/#template-strings) look like.\n\nPossible template variables are as follows.\n\n- `[name]`: chunk name\n- `[buildNo]`: build number starting from 1 at beginning of watch mode\n- `[buildTime]`: the timestamp in millisecond when the compilation starts\n- `[file]`: full path of the file\n  - = `[dirname]` + `[basename]` + `[extname]` + `[query]`\n- `[filename]`: file path\n  - = `[basename]` + `[extname]`\n- `[dirname]`: directory path\n- `[basename]`: file base name\n- `[extname]`: file extension starting with `.`\n- `[query]`: query string starting with `?`\n\n> Note that `[buildNo]` starts from 0 and will increase during developing in the watch mode.\n> Once exiting from the watch mode, it will be reset.\n\nFor example, one can embed the build time into `@version` tag via the following configuration.\n\n```js\nnew UserscriptPlugin({\n  headers: {\n    version: '0.0.1-beta.[buildTime]'\n  },\n})\n```\n\n#### Validate headers\n\nHeaders will be transformed and validated with the help of [`class-transformer`](https://github.com/typestack/class-transformer) and [`class-validator`](https://github.com/typestack/class-validator).\n\nThe configuration defaults to strict mode, which means extra tags are not allowed and type checking to headers values are performed.\n\nOne can provide `headersClass` option to override the default `Headers` class; but it is suggested to inherit from the original one.\n\n> Note that the `headersClass` is used for both main headers and i18n headers.\n> Check the [default implementation](lib/features/validate-headers/headers.ts) before customizing your own.\n\n#### Render headers\n\nHeaders in all locales are merged and rendered.\n\nThere are 2 useful options here, `pretty` and `tagOrder`.\n\nThe `pretty` option is a boolean deciding whether to render the headers as a table or not.\n\nThe `tagOrder` option is a precedence list of tag names which should be followed. Listed tags are rendered first; unlisted tags are rendered after listed ones, in ASCII order.\n\n## Guides\n\n### Hot Development\n\nThe following example can be used in development mode with the help of [`webpack-dev-server`](https://github.com/webpack/webpack-dev-server).\n\n`webpack-dev-server` will build the userscript in **watch** mode. Each time the project is built, the `buildNo` variable will increase by 1.\n\n> **Notes**: `buildNo` will be reset to 0 if the dev server is terminated. In this case, if you expect the build version to be persisted during dev server restarting, you can use the `buildTime` variable instead.\n\nIn the following configuration, a portion of the `version` contains the `buildNo`; therefore, each time there is a build, the `version` is also increased so as to indicate a new update available for the script engine like Tampermonkey or GreaseMonkey.\n\nAfter the first time starting the `webpack-dev-server`, you can install the script via `http://localhost:8080/<project-name>.user.js` (the URL is actually refered to your configuration of `webpack-dev-server`). Once installed, there is no need to manually reinstall the script until you stop the server. To update the script, the script engine has an **update** button on the GUI for you.\n\n- `webpack.config.dev.js`\n\n```js\nconst path = require('path');\nconst { UserscriptPlugin } = require('webpack-userscript');\nconst dev = process.env.NODE_ENV === 'development';\n\nmodule.exports = {\n  mode: dev ? 'development' : 'production',\n  entry: path.resolve(__dirname, 'src', 'index.js'),\n  output: {\n    path: path.resolve(__dirname, 'dist'),\n    filename: '<project-name>.user.js',\n  },\n  devServer: {\n    contentBase: path.join(__dirname, 'dist'),\n  },\n  plugins: [\n    new UserscriptPlugin({\n      headers(original) {\n        if (dev) {\n          return {\n            ...original,\n            version: `${original.version}-build.[buildNo]`,\n          }\n        }\n\n        return original;\n      },\n    }),\n  ],\n};\n```\n\n### Integration with Webpack Dev Server and TamperMonkey\n\nIf you feel tired with firing the update button on TamperMonkey GUI, maybe you can have a try at proxy script.\n\nA proxy script actually looks similar with the content of `*.meta.js` except that it contains additional `@require` field to include the main userscript. A proxy script is used since TamperMonkey has an option that makes external scripts always be update-to-date without caching, and external scripts are included into userscripts via the `@require` meta field. (You may also want to read this issue, [Tampermonkey/tampermonkey#767](https://github.com/Tampermonkey/tampermonkey/issues/767#issuecomment-542813282))\n\nTo avoid caching and make the main script always be updated after each page refresh, we have to make our main script **\"an external resource\"**. That is where the proxy script comes in, it provides TamperMonkey with a `@require` pointint to the URL of the main script on the dev server, and each time you reload your testing page, it will trigger the update.\n\n> Actually it requires 2 reloads for each change to take effect on the page. The first reload trigger the update of external script but without execution (it runs the legacy version of the script), the second reload will start to run the updated script.\n>\n> I have no idea why TamperMonkey is desinged this way. But..., it's up to you!\n\nTo enable the proxy script, provide a `proxyScript` configuration to the plugin constructor.\n\n`baseURL` should be the base URL of the dev server, and the `filename` is for the proxy script.\n\n> Note: `filename` will be interpolated.\n\nAfter starting the dev server, you can find your proxy script under `<baseURL>/<filename>`. In the example below, assume the entry filename is `index.js`, you should visit `http://127.0.0.1:12345/index.proxy.user.js` to install the proxy script on TamperMonkey.\n\nSee [Issue#63](https://github.com/momocow/webpack-userscript/issues/63) for more information.\n\n```js\nnew WebpackUserscript({\n  // <...your other configs...>,\n  proxyScript: {\n    baseURL: 'http://127.0.0.1:12345',\n    filename: '[basename].proxy.user.js',\n  },\n});\n```\n\n\n\n\n### I18n headers\n\nI18n headers can be provided as an object, a string (a.k.a headers file) or a function (a.k.a headers provider), just like the main headers.\n\n```js\nnew UserscriptPlugin({\n  headers: {\n    name: 'this is the main script name'\n  },\n  i18n: {\n    // headers object\n    'en-US': {\n      name: 'this is a localized name'\n    },\n  },\n})\n```\n\n\n```js\nnew UserscriptPlugin({\n  headers: {\n    name: 'this is the main script name'\n  },\n  i18n: {\n    // headers file\n    'en-US': '/dir/to/headers.json' // whose content is `{\"name\": \"this is a localized name\"}`\n  },\n})\n```\n\n```js\nnew UserscriptPlugin({\n  headers: {\n    name: 'this is the main script name'\n  },\n  i18n: {\n    // headers provider\n    'en-US': (headers) => ({\n      ...headers,\n      name: 'this is a localized name'\n    }),\n  },\n})\n```\n\n\nWith the above configurations will generate the following headers,\n\n```js\n// ==UserScript==\n// @name this is the main script name\n// @name:en-US this is a localized name\n// @version 0.0.0\n// @match *://*/*\n// ==/UserScript==\n```\n\n## Furthermore\n- [Get started with Webpack](https://webpack.js.org/guides/getting-started/)\n- [How to write userscript in TypeScript?](https://github.com/momocow/webpack-userscript/issues/95)\n- [Solution to userscript not refreshing on every page load](https://github.com/momocow/webpack-userscript/issues/63)"
  },
  {
    "path": "jest.config.ts",
    "content": "import { pathsToModuleNameMapper } from 'ts-jest';\n\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst { compilerOptions } = require('./tsconfig.json');\n\nconst EXCLUDE_PATHS = new Set(['class-transformer/cjs/storage']);\n\nmodule.exports = {\n  clearMocks: true,\n  testMatch: [\n    '**/__tests__/**/*.+(ts|tsx|js)',\n    '**/?(*.)+(spec|test).+(ts|tsx|js)',\n  ],\n  transform: {\n    '^.+\\\\.(ts|tsx)$': [\n      'ts-jest',\n      {\n        diagnostics: { warnOnly: process.env.NODE_ENV === 'development' },\n      },\n    ],\n  },\n  modulePaths: [compilerOptions.baseUrl],\n  moduleNameMapper: pathsToModuleNameMapper(\n    Object.fromEntries(\n      Object.entries(compilerOptions.paths).filter(\n        (e): e is [string, string[]] => !EXCLUDE_PATHS.has(e[0]),\n      ),\n    ),\n  ),\n  setupFiles: ['./test/setup.ts'],\n  setupFilesAfterEnv: ['jest-extended/all'],\n};\n"
  },
  {
    "path": "lib/const.ts",
    "content": "export const DEFAULT_LOCALE_KEY = '';\n"
  },
  {
    "path": "lib/features/default-tags.ts",
    "content": "import { DEFAULT_LOCALE_KEY } from '..//const';\nimport { UserscriptPluginInstance } from '../types';\nimport { Feature } from './feature';\n\nexport class SetDefaultTags extends Feature {\n  public readonly name = 'SetDefaultTags';\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    hooks.headers.tap(this.constructor.name, (headers, { locale }) => {\n      if (\n        locale === DEFAULT_LOCALE_KEY &&\n        headers.include === undefined &&\n        headers.match === undefined\n      ) {\n        return {\n          ...headers,\n          match: '*://*/*',\n        };\n      }\n\n      return headers;\n    });\n  }\n}\n"
  },
  {
    "path": "lib/features/feature.ts",
    "content": "import { UserscriptPluginInstance } from '../types';\n\nexport abstract class Feature<Options = unknown> {\n  public constructor(public readonly options: Options) {}\n\n  public abstract readonly name: string;\n  public abstract apply(plugin: UserscriptPluginInstance): void;\n}\n"
  },
  {
    "path": "lib/features/fix-tags.ts",
    "content": "import { StrictHeadersProps, UserscriptPluginInstance } from '../types';\nimport { Feature } from './feature';\n\nexport class FixTags extends Feature {\n  public readonly name = 'FixTags';\n\n  public readonly fixableTagNames = new Map<string, keyof StrictHeadersProps>([\n    ['updateUrl', 'updateURL'],\n    ['iconUrl', 'iconURL'],\n    ['icon64Url', 'icon64URL'],\n    ['installUrl', 'installURL'],\n    ['supportUrl', 'supportURL'],\n    ['downloadUrl', 'downloadURL'],\n    ['homepageUrl', 'homepageURL'],\n  ]);\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    hooks.headers.tap(this.name, (headers) => {\n      for (const [source, target] of this.fixableTagNames) {\n        if (headers[source] !== undefined) {\n          if (headers[target] !== undefined) {\n            throw new Error(`ambiguous tags: (\"${source}\", \"${target}\")`);\n          }\n\n          headers = {\n            ...headers,\n            [source]: undefined,\n            [target]: headers[source],\n          };\n        }\n      }\n\n      return headers;\n    });\n  }\n}\n"
  },
  {
    "path": "lib/features/index.ts",
    "content": "export * from './default-tags';\nexport * from './feature';\nexport * from './fix-tags';\nexport * from './interpolater';\nexport * from './load-headers';\nexport * from './proxy-script';\nexport * from './render-headers';\nexport * from './resolve-base-urls';\nexport * from './ssri';\nexport * from './validate-headers';\n"
  },
  {
    "path": "lib/features/interpolater.ts",
    "content": "import {\n  HeadersProps,\n  UserscriptPluginInstance,\n  ValueType,\n  WaterfallContext,\n} from '../types';\nimport { Feature } from './feature';\n\nexport class Interpolater extends Feature {\n  public readonly name = 'Interpolater';\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    hooks.headers.tap(this.name, (headers, ctx) =>\n      this.interpolate(headers, this.getVariables(ctx)),\n    );\n\n    hooks.proxyHeaders.tap(this.name, (headers, ctx) =>\n      this.interpolate(headers, this.getVariables(ctx)),\n    );\n\n    hooks.proxyScriptFile.tap(this.name, (filepath, ctx) =>\n      this.interpolate(filepath, this.getVariables(ctx)),\n    );\n  }\n\n  private getVariables({\n    fileInfo: {\n      chunk,\n      originalFile,\n      filename,\n      basename,\n      query,\n      dirname,\n      extname,\n    },\n    buildNo,\n    buildTime,\n  }: WaterfallContext): Record<string, string> {\n    return {\n      name: chunk.name ?? '',\n      file: originalFile,\n      filename,\n      basename,\n      query,\n      dirname,\n      extname,\n      buildNo: buildNo.toString(),\n      buildTime: buildTime.toISOString(),\n    };\n  }\n\n  private interpolate(\n    data: HeadersProps,\n    info: Record<string, string>,\n  ): HeadersProps;\n  private interpolate<T extends ValueType>(\n    data: T,\n    info: Record<string, string>,\n  ): T;\n  private interpolate(\n    data: HeadersProps | ValueType,\n    info: Record<string, string>,\n  ): HeadersProps | ValueType {\n    if (typeof data === 'string') {\n      return Object.entries(info).reduce((value, [dataKey, dataVal]) => {\n        return value.replace(new RegExp(`\\\\[${dataKey}\\\\]`, 'g'), `${dataVal}`);\n      }, data);\n    }\n\n    if (Array.isArray(data)) {\n      return data.map((item) => this.interpolate(item, info));\n    }\n\n    if (typeof data === 'object' && data !== null) {\n      return Object.fromEntries(\n        Object.entries(data).map(([k, v]) => [\n          this.interpolate(k, info),\n          this.interpolate(v, info),\n        ]),\n      ) as HeadersProps;\n    }\n\n    return data;\n  }\n}\n"
  },
  {
    "path": "lib/features/load-headers/impl.ts",
    "content": "import { DEFAULT_LOCALE_KEY } from '../../const';\nimport {\n  HeadersProps,\n  UserscriptPluginInstance,\n  WaterfallContext,\n} from '../../types';\nimport { Feature } from '../feature';\nimport {\n  FileLoader,\n  HeadersFile,\n  HeadersProvider,\n  ObjectLoader,\n  PackageLoader,\n  ProviderLoader,\n} from './loaders';\n\nexport type HeadersOption = HeadersProps | HeadersFile | HeadersProvider;\n\nexport interface LoadHeadersOptions {\n  root?: string;\n  headers?: HeadersOption;\n  i18n?: Record<string, HeadersOption>;\n}\n\nexport class LoadHeaders extends Feature<LoadHeadersOptions> {\n  public readonly name = 'LoadHeaders';\n\n  private packageLoader!: PackageLoader;\n  private objectLoaders: Map<string, ObjectLoader> = new Map();\n  private fileLoaders: Map<string, FileLoader> = new Map();\n  private providerLoaders: Map<string, ProviderLoader> = new Map();\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    const { headers: headersOption, i18n = {}, root } = this.options;\n\n    this.packageLoader = new PackageLoader(root);\n\n    if (headersOption) {\n      this.addLoader(DEFAULT_LOCALE_KEY, headersOption);\n    }\n\n    for (const [locale, i18nHeadersOption] of Object.entries(i18n)) {\n      this.addLoader(locale, i18nHeadersOption);\n    }\n\n    hooks.init.tapPromise(this.name, async (compiler) => {\n      await this.packageLoader.load(compiler);\n    });\n\n    if (this.fileLoaders.size > 0) {\n      hooks.preprocess.tapPromise(this.name, async (compilation) => {\n        await Promise.all(\n          Array.from(this.fileLoaders.values()).map((fileLoader) =>\n            fileLoader.load(compilation),\n          ),\n        );\n      });\n    }\n\n    hooks.headers.tapPromise(this.name, async (_, ctx) =>\n      this.provideHeaders(ctx),\n    );\n  }\n\n  private addLoader(locale: string, headersOption: HeadersOption): void {\n    if (typeof headersOption === 'object') {\n      this.objectLoaders.set(locale, new ObjectLoader(headersOption));\n\n      return;\n    }\n\n    if (typeof headersOption === 'string') {\n      this.fileLoaders.set(\n        locale,\n        new FileLoader(headersOption, this.options.root),\n      );\n\n      return;\n    }\n\n    this.providerLoaders.set(locale, new ProviderLoader(headersOption));\n  }\n\n  private async provideHeaders(ctx: WaterfallContext): Promise<HeadersProps> {\n    const { locale } = ctx;\n    const headersBase = {};\n\n    if (locale === DEFAULT_LOCALE_KEY) {\n      Object.assign(headersBase, this.packageLoader.headers);\n    }\n    Object.assign(headersBase, this.objectLoaders.get(locale)?.headers);\n    Object.assign(headersBase, this.fileLoaders.get(locale)?.headers);\n\n    return (\n      this.providerLoaders.get(locale)?.load(headersBase, ctx) ?? headersBase\n    );\n  }\n}\n"
  },
  {
    "path": "lib/features/load-headers/index.ts",
    "content": "export * from './impl';\nexport * from './loaders';\n"
  },
  {
    "path": "lib/features/load-headers/loaders.ts",
    "content": "import path from 'node:path';\nimport { promisify } from 'node:util';\n\nimport { Compilation, Compiler } from 'webpack';\n\nimport { findPackage, FsReadFile, FsStat, readJSON } from '../../fs';\nimport { HeadersProps, WaterfallContext } from '../../types';\n\nexport class ObjectLoader {\n  public constructor(public headers: HeadersProps) {}\n\n  public load(): HeadersProps {\n    return this.headers;\n  }\n}\n\nexport type HeadersFile = string;\n\nexport class FileLoader {\n  public headers?: HeadersProps;\n\n  private headersFileTimestamp = 0;\n\n  public constructor(private file: HeadersFile, private root?: string) {}\n\n  public async load(compilation: Compilation): Promise<HeadersProps> {\n    const getFileTimestampAsync = promisify(\n      compilation.fileSystemInfo.getFileTimestamp.bind(\n        compilation.fileSystemInfo,\n      ),\n    );\n\n    const resolvedHeadersFile = path.resolve(\n      this.root ?? compilation.compiler.context,\n      this.file,\n    );\n\n    const ts = await getFileTimestampAsync(resolvedHeadersFile);\n\n    if (\n      this.headers &&\n      ts &&\n      typeof ts === 'object' &&\n      typeof ts.timestamp === 'number' &&\n      this.headersFileTimestamp >= ts.timestamp\n    ) {\n      // file no change\n      return this.headers;\n    }\n\n    if (ts && typeof ts === 'object') {\n      this.headersFileTimestamp = ts.timestamp ?? this.headersFileTimestamp;\n    }\n\n    compilation.fileDependencies.add(resolvedHeadersFile);\n\n    return (this.headers = await readJSON<HeadersProps>(\n      resolvedHeadersFile,\n      compilation.inputFileSystem as FsReadFile,\n    ));\n  }\n}\n\nexport type HeadersProvider = (\n  headers: HeadersProps,\n  ctx: WaterfallContext,\n) => HeadersProps | Promise<HeadersProps>;\n\nexport class ProviderLoader {\n  public constructor(private provider: HeadersProvider) {}\n\n  public async load(\n    headers: HeadersProps,\n    ctx: WaterfallContext,\n  ): Promise<HeadersProps> {\n    return this.provider(headers, ctx);\n  }\n}\n\ninterface PackageInfo {\n  name?: string;\n  version?: string;\n  description?: string;\n  author?: string;\n  homepage?: string;\n  bugs?: string | { url?: string };\n}\n\nexport class PackageLoader {\n  public headers?: HeadersProps;\n\n  public constructor(private root?: string) {}\n\n  public async load(compiler: Compiler): Promise<HeadersProps> {\n    if (!this.headers) {\n      try {\n        const cwd = await findPackage(\n          this.root ?? compiler.context,\n          compiler.inputFileSystem as FsStat,\n        );\n        const packageJson = await readJSON<PackageInfo>(\n          path.join(cwd, 'package.json'),\n          compiler.inputFileSystem as FsReadFile,\n        );\n\n        this.headers = {\n          name: packageJson.name,\n          version: packageJson.version,\n          description: packageJson.description,\n          author: packageJson.author,\n          homepage: packageJson.homepage,\n          supportURL:\n            typeof packageJson.bugs === 'string'\n              ? packageJson.bugs\n              : typeof packageJson.bugs === 'object' &&\n                typeof packageJson.bugs.url === 'string'\n              ? packageJson.bugs.url\n              : undefined,\n        };\n      } catch (e) {\n        this.headers = {};\n      }\n    }\n\n    return this.headers;\n  }\n}\n"
  },
  {
    "path": "lib/features/proxy-script.ts",
    "content": "import { URL } from 'node:url';\n\nimport { UserscriptPluginInstance } from '../types';\nimport { Feature } from './feature';\n\nexport interface ProxyScriptFeatureOptions {\n  filename?: string;\n  baseURL?: string;\n}\n\nexport interface ProxyScriptOptions {\n  proxyScript?: ProxyScriptFeatureOptions;\n}\n\nexport class ProcessProxyScript extends Feature<ProxyScriptOptions> {\n  public readonly name = 'ProcessProxyScript';\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    const { proxyScript } = this.options;\n\n    if (proxyScript) {\n      hooks.proxyHeaders.tap(\n        this.name,\n        (headers, { fileInfo: { userjsFile } }) => {\n          const devBaseUrl = !proxyScript.baseURL\n            ? 'http://localhost:8080/'\n            : proxyScript.baseURL;\n\n          const requireTags = Array.isArray(headers.require)\n            ? headers.require\n            : typeof headers.require === 'string'\n            ? [headers.require]\n            : [];\n\n          headers = {\n            ...headers,\n            require: [\n              ...requireTags,\n              new URL(userjsFile, devBaseUrl).toString(),\n            ],\n            downloadURL: undefined,\n            updateURL: undefined,\n            installURL: undefined,\n          };\n\n          return headers;\n        },\n      );\n\n      hooks.proxyScriptFile.tap(this.name, () => {\n        if (!proxyScript.filename) {\n          return '[basename].proxy.user.js';\n        } else {\n          return proxyScript.filename;\n        }\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "lib/features/render-headers.ts",
    "content": "import { getBorderCharacters, table } from 'table';\n\nimport { DEFAULT_LOCALE_KEY } from '../const';\nimport {\n  HeadersProps,\n  TagType,\n  UserscriptPluginInstance,\n  ValueType,\n} from '../types';\nimport { Feature } from './feature';\n\nexport interface RenderHeadersOptions {\n  prefix?: string;\n  suffix?: string;\n  pretty?: boolean;\n  tagOrder?: TagType[];\n  proxyScript?: unknown;\n}\n\nexport class RenderHeaders extends Feature<RenderHeadersOptions> {\n  public readonly name = 'RenderHeaders';\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    hooks.renderHeaders.tap(this.name, (headersMap) =>\n      this.render(this.mergeHeadersMap(headersMap), this.options),\n    );\n\n    if (this.options.proxyScript) {\n      hooks.renderProxyHeaders.tap(this.name, (headers) =>\n        this.render(headers, this.options),\n      );\n    }\n  }\n\n  private mergeHeadersMap(headersMap: Map<string, HeadersProps>): HeadersProps {\n    return Array.from(headersMap)\n      .map(\n        ([locale, headers]) =>\n          Object.fromEntries(\n            Object.entries(headers).map(([tag, value]) => [\n              locale === DEFAULT_LOCALE_KEY ? tag : `${tag}:${locale}`,\n              value,\n            ]),\n          ) as HeadersProps,\n      )\n      .reduce((h1, h2) => ({ ...h1, ...h2 }));\n  }\n\n  private render(\n    headers: HeadersProps,\n    {\n      prefix = '// ==UserScript==\\n',\n      suffix = '// ==/UserScript==\\n',\n      pretty = false,\n      tagOrder = [\n        'name',\n        'description',\n        'version',\n        'author',\n        'homepage',\n        'supportURL',\n        'include',\n        'exclude',\n        'match',\n      ],\n    }: RenderHeadersOptions = {},\n  ): string {\n    const orderRevMap = new Map(tagOrder.map((tag, index) => [tag, index]));\n    const rows = Object.entries(headers)\n      .sort(\n        ([tag1], [tag2]) =>\n          (orderRevMap.get(this.getTagName(tag1)) ?? orderRevMap.size) -\n            (orderRevMap.get(this.getTagName(tag2)) ?? orderRevMap.size) ||\n          (tag1 > tag2 ? 1 : tag1 < tag2 ? -1 : 0),\n      )\n      .flatMap(([tag, value]) => this.renderTag(tag, value));\n    const body = pretty\n      ? table(rows, {\n          border: getBorderCharacters('void'),\n          columnDefault: {\n            paddingLeft: 0,\n            paddingRight: 1,\n          },\n          drawHorizontalLine: () => false,\n        })\n          .split('\\n')\n          .map((line) => line.trim())\n          .join('\\n')\n      : rows.map((cols) => cols.join(' ')).join('\\n') + '\\n';\n\n    return prefix + body + suffix;\n  }\n\n  protected renderTag(tag: TagType, value: ValueType): string[][] {\n    if (Array.isArray(value)) {\n      return value.map((v) => [`// @${tag}`, v ?? '']);\n    }\n    if (typeof value === 'object') {\n      return Object.entries(value).map(([k, v]) => [\n        `// @${tag}`,\n        `${k} ${v ?? ''}`,\n      ]);\n    }\n    if (typeof value === 'string') {\n      return [[`// @${tag}`, value]];\n    }\n    if (value === true) {\n      return [[`// @${tag}`, '']];\n    }\n\n    return [];\n  }\n\n  private getTagName(tag: string): string {\n    return tag.replace(/:.+$/, '');\n  }\n}\n"
  },
  {
    "path": "lib/features/resolve-base-urls.ts",
    "content": "import { URL } from 'node:url';\n\nimport { UserscriptPluginInstance } from '../types';\nimport { Feature } from './feature';\n\nexport interface ResolveBaseURLsOptions {\n  downloadBaseURL?: string | URL;\n  updateBaseURL?: string | URL;\n  metajs?: boolean;\n}\n\nexport class ResolveBaseURLs extends Feature<ResolveBaseURLsOptions> {\n  public readonly name = 'ResolveBaseURLs';\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    const { metajs, downloadBaseURL, updateBaseURL } = this.options;\n\n    if (downloadBaseURL === undefined) {\n      return;\n    }\n\n    hooks.headers.tap(\n      this.name,\n      (headers, { fileInfo: { userjsFile, metajsFile } }) => {\n        if (headers.downloadURL === undefined) {\n          headers = {\n            ...headers,\n            downloadURL: new URL(userjsFile, downloadBaseURL).toString(),\n          };\n        }\n\n        if (headers.updateURL === undefined) {\n          headers = {\n            ...headers,\n            updateURL: new URL(\n              metajs ? metajsFile : userjsFile,\n              updateBaseURL ?? downloadBaseURL,\n            ).toString(),\n          };\n        }\n\n        return headers;\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/features/ssri.ts",
    "content": "import path from 'node:path';\n\nimport fetch from 'node-fetch';\nimport pLimit from 'p-limit';\nimport {\n  fromStream,\n  IntegrityMap,\n  parse as parseSSRI,\n  stringify as stringifySSRI,\n} from 'ssri';\nimport { Readable } from 'stream';\nimport { URL } from 'url';\n\nimport {\n  FsMkdir,\n  FsReadFile,\n  FsWriteFile,\n  mkdirp,\n  readJSON,\n  writeJSON,\n} from '../fs';\nimport { HeadersProps, UserscriptPluginInstance } from '../types';\nimport { Feature } from './feature';\n\nexport type RawSSRILock = Record<string, string>;\nexport type SSRILock = Record<string, IntegrityMap>;\n\nexport type SSRIAlgorithm = 'sha256' | 'sha384' | 'sha512';\n\nexport type SSRITag = 'require' | 'resource';\n\nexport type URLFilter = (tag: SSRITag, value: URL) => boolean;\n\nexport interface SSRIFeatureOptions {\n  include?: URLFilter;\n  exclude?: URLFilter;\n  algorithms?: SSRIAlgorithm[];\n  strict?: boolean;\n  lock?: boolean | string;\n  concurrency?: number;\n}\n\nexport interface SSRIOptions {\n  root?: string;\n  ssri?: SSRIFeatureOptions;\n}\n\nexport class ProcessSSRI extends Feature<SSRIOptions> {\n  public readonly name = 'ProcessSSRI';\n\n  public readonly allowedProtocols = new Set(['http:', 'https:']);\n\n  private ssriLockDirty = false;\n  private ssriLock: SSRILock = {};\n\n  private readonly limit = pLimit(\n    (typeof this.options.ssri === 'object'\n      ? this.options.ssri.concurrency\n      : undefined) ?? 6,\n  );\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    const { ssri, root } = this.options;\n\n    if (!ssri) {\n      return;\n    }\n\n    // read lock\n    hooks.init.tapPromise(this.name, async (compiler) => {\n      const lockfile = this.getSSRILockFile(ssri);\n\n      if (!lockfile) {\n        return;\n      }\n\n      try {\n        this.ssriLock = this.parseSSRILock(\n          await readJSON<RawSSRILock>(\n            path.resolve(root ?? compiler.context, lockfile),\n            compiler.inputFileSystem as FsReadFile,\n          ),\n        );\n      } catch {}\n      this.ssriLockDirty = false;\n    });\n\n    // write lock\n    hooks.close.tapPromise(this.name, async (compiler) => {\n      const lock = this.getSSRILockFile(ssri);\n\n      if (!lock) {\n        return;\n      }\n\n      const lockfile = path.resolve(root ?? compiler.context, lock);\n\n      if (this.ssriLockDirty) {\n        const { intermediateFileSystem } = compiler;\n\n        const dir = path.dirname(lockfile);\n        const isNotRoot = path.dirname(dir) !== dir;\n\n        if (isNotRoot) {\n          await mkdirp(dir, intermediateFileSystem as FsMkdir);\n        }\n\n        await writeJSON(\n          lockfile,\n          this.toRawSSRILock(this.ssriLock),\n          intermediateFileSystem as FsWriteFile,\n        );\n\n        this.ssriLockDirty = false;\n      }\n    });\n\n    hooks.headers.tapPromise(this.name, async (headers) => {\n      const targetURLs = this.getTargetURLs(headers, ssri).reduce(\n        (map, url) => map.set(url, this.normalizeURL(url)),\n        new Map<string, string>(),\n      );\n\n      if (targetURLs.size === 0) {\n        return headers;\n      }\n\n      // merge integrities from ssri-lock\n      // and those provided within headers option (in respective tags)\n      for (const [url, normalizedURL] of targetURLs) {\n        const integrity = parseSSRI(this.parseSSRILike(url), {\n          strict: true,\n        }) as IntegrityMap | null;\n\n        if (integrity) {\n          if (this.ssriLock[normalizedURL]) {\n            integrity.merge(this.ssriLock[normalizedURL], {\n              strict: true,\n            });\n          }\n\n          this.ssriLockDirty = true;\n          this.ssriLock[normalizedURL] = integrity;\n        }\n      }\n\n      // compute and merge missing hashes based on specified algorithms option\n      await Promise.all(\n        Array.from(targetURLs.values()).map(async (normalizedURL) => {\n          const expectedAlgorithms = ssri.algorithms ?? ['sha512'];\n          const missingAlgorithms = expectedAlgorithms.filter(\n            (alg) => !this.ssriLock[normalizedURL]?.[alg],\n          );\n\n          const newIntegrity =\n            missingAlgorithms.length > 0\n              ? await this.limit(() =>\n                  this.computeSSRI(normalizedURL, missingAlgorithms, {\n                    strict: ssri.strict,\n                  }),\n                )\n              : null;\n\n          if (newIntegrity) {\n            if (this.ssriLock[normalizedURL]) {\n              newIntegrity.merge(this.ssriLock[normalizedURL]);\n            }\n\n            this.ssriLock[normalizedURL] = newIntegrity;\n            this.ssriLockDirty = true;\n          }\n        }),\n      );\n\n      return {\n        ...headers,\n        ...this.patchHeaders(headers, this.ssriLock),\n      };\n    });\n  }\n\n  private getSSRILockFile({ lock = true }: SSRIFeatureOptions = {}):\n    | string\n    | undefined {\n    return typeof lock === 'string'\n      ? lock\n      : lock\n      ? 'ssri-lock.json'\n      : undefined;\n  }\n\n  private getTargetURLs(\n    headers: HeadersProps,\n    options: Pick<SSRIFeatureOptions, 'include' | 'exclude'>,\n  ): string[] {\n    const urls: string[] = [];\n\n    if (headers.require !== undefined) {\n      const requireURLs = (\n        Array.isArray(headers.require) ? headers.require : [headers.require]\n      ).filter((url): url is string => typeof url === 'string');\n\n      for (const urlStr of requireURLs) {\n        const url = this.parseURL(urlStr);\n        if (this.filterURL(url, 'require', options)) {\n          urls.push(this.stringifyURL(url));\n        }\n      }\n    }\n\n    if (headers.resource !== undefined) {\n      for (const urlStr of Object.values(headers.resource).filter(\n        (url): url is string => typeof url === 'string',\n      )) {\n        const url = this.parseURL(urlStr);\n        if (this.filterURL(url, 'resource', options)) {\n          urls.push(this.stringifyURL(url));\n        }\n      }\n    }\n\n    return urls;\n  }\n\n  private normalizeURL(url: string): string {\n    const u = new URL('', this.parseURL(url));\n    u.hash = '';\n\n    return u.toString();\n  }\n\n  private filterURL(\n    url: URL,\n    tag: SSRITag,\n    { include, exclude }: Pick<SSRIFeatureOptions, 'include' | 'exclude'> = {},\n  ): boolean {\n    if (!this.allowedProtocols.has(url.protocol)) {\n      return false;\n    }\n\n    if (include && !include(tag, url)) {\n      return false;\n    }\n\n    if (exclude && exclude(tag, url)) {\n      return false;\n    }\n\n    return true;\n  }\n\n  private async computeSSRI(\n    url: string,\n    algorithms: SSRIAlgorithm[],\n    { strict }: SSRIFeatureOptions,\n  ): Promise<IntegrityMap> {\n    const response = await fetch(url);\n\n    if (response.status !== 200 || response.body === null) {\n      throw new Error(\n        `Failed to fetch SSRI sources. ` +\n          `[${response.status} ${response.statusText}] ${url}`,\n      );\n    }\n\n    return await fromStream(response.body as Readable, {\n      algorithms,\n      strict,\n    });\n  }\n\n  private parseSSRILike(url: string): string {\n    return this.parseURL(url).hash.slice(1).replace(/[,;]/g, ' ');\n  }\n\n  private parseSSRILock(rawSSRILock: RawSSRILock): SSRILock {\n    return Object.fromEntries(\n      Object.entries(rawSSRILock).map(([url, integrityLike]) => [\n        url,\n        parseSSRI(integrityLike, { strict: true }),\n      ]),\n    );\n  }\n\n  private toRawSSRILock(ssriLock: SSRILock): RawSSRILock {\n    return Object.fromEntries(\n      Object.entries(ssriLock).map(\n        ([url, integrity]) =>\n          [url, stringifySSRI(integrity, { strict: true })] as const,\n      ),\n    );\n  }\n\n  private parseURL(url: string): URL {\n    return new URL(url);\n  }\n\n  private stringifyURL(url: URL): string {\n    return url.toString();\n  }\n\n  private updateURL(url: string, ssriLock: SSRILock): string {\n    const integrity = ssriLock[url];\n\n    if (!integrity) return url;\n\n    const urlObj = this.parseURL(url);\n    urlObj.hash = '#' + stringifySSRI(integrity, { sep: ',', strict: true });\n\n    return urlObj.toString();\n  }\n\n  private patchHeaders(\n    headers: HeadersProps,\n    ssriLock: SSRILock,\n  ): HeadersProps {\n    const headersProps: HeadersProps = {};\n\n    if (headers.require !== undefined) {\n      if (Array.isArray(headers.require)) {\n        headersProps.require = headers.require\n          .filter((url): url is string => typeof url === 'string')\n          .map((url) => this.updateURL(url, ssriLock));\n      } else {\n        headersProps.require = this.updateURL(headers.require, ssriLock);\n      }\n    }\n\n    if (headers.resource !== undefined) {\n      headersProps.resource = Object.fromEntries(\n        Object.entries(headers.resource)\n          .filter(\n            (entry): entry is [string, string] => typeof entry[1] === 'string',\n          )\n          .map(([name, url]) => [name, this.updateURL(url, ssriLock)]),\n      );\n    }\n\n    return headersProps;\n  }\n}\n"
  },
  {
    "path": "lib/features/validate-headers/headers.ts",
    "content": "import {\n  CompatibilityValue,\n  InjectInto,\n  MultiValue,\n  NamedValue,\n  RunAt,\n  Sandbox,\n  SingleValue,\n  StrictHeadersProps,\n  SwitchValue,\n} from '../../types';\nimport {\n  IsDefined,\n  IsEnumValue,\n  IsMultiValue,\n  IsNamedValue,\n  IsNestedValue,\n  IsOptional,\n  IsSingleValue,\n  IsSwitchValue,\n  IsUnique,\n  IsURLValue,\n  partialGroups,\n} from './utils';\n\nexport enum ValidationGroup {\n  Main = 'main',\n  I18n = 'i18n',\n}\n\nexport const Main = partialGroups(ValidationGroup.Main);\nexport const I18n = partialGroups(ValidationGroup.I18n);\nexport const Always = partialGroups(ValidationGroup.Main, ValidationGroup.I18n);\n\nexport class Compatibility implements CompatibilityValue {\n  [x: string]: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly firefox?: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly chrome?: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly opera?: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly safari?: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly edge?: SingleValue;\n}\n\nexport class Headers implements StrictHeadersProps {\n  @Main(IsDefined(), IsSingleValue())\n  @I18n(IsOptional(), IsSingleValue())\n  public readonly name?: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly version?: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly namespace?: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly author?: SingleValue;\n\n  @Always(IsOptional(), IsSingleValue())\n  public readonly description?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('homepage'))\n  public readonly homepage?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('homepage'))\n  public readonly homepageURL?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('homepage'))\n  public readonly website?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('homepage'))\n  public readonly source?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('icon'))\n  public readonly icon?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('icon'))\n  public readonly iconURL?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('icon'))\n  public readonly defaulticon?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('icon64'))\n  public readonly icon64?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('icon64'))\n  public readonly icon64URL?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue())\n  public readonly updateURL?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('downloadURL'))\n  public readonly downloadURL?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue(), IsUnique('downloadURL'))\n  public readonly installURL?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue())\n  public readonly supportURL?: SingleValue;\n\n  @Main(IsOptional(), IsMultiValue())\n  public readonly include?: MultiValue;\n\n  @Main(IsOptional(), IsMultiValue())\n  public readonly match?: MultiValue;\n\n  @Main(IsOptional(), IsMultiValue())\n  public readonly 'exclude-match'?: MultiValue;\n\n  @Main(IsOptional(), IsMultiValue())\n  public readonly exclude?: MultiValue;\n\n  @Main(IsOptional(), IsMultiValue())\n  public readonly require?: MultiValue;\n\n  @Main(IsOptional(), IsNamedValue())\n  public readonly resource?: NamedValue;\n\n  @Main(IsOptional(), IsMultiValue())\n  public readonly connect?: MultiValue;\n\n  @Main(IsOptional(), IsMultiValue())\n  public readonly grant?: MultiValue;\n\n  @Main(IsOptional(), IsMultiValue())\n  public readonly webRequest?: MultiValue;\n\n  @Main(IsOptional(), IsSwitchValue())\n  public readonly noframes?: SwitchValue;\n\n  @Main(IsOptional(), IsSwitchValue())\n  public readonly unwrap?: SwitchValue;\n\n  @Always(IsOptional(), IsNamedValue())\n  public readonly antifeature?: NamedValue;\n\n  @Main(IsOptional(), IsEnumValue(RunAt))\n  public readonly 'run-at'?: RunAt;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly copyright?: SingleValue;\n\n  @Main(IsOptional(), IsEnumValue(Sandbox))\n  public readonly sandbox?: Sandbox;\n\n  @Main(IsOptional(), IsEnumValue(InjectInto))\n  public readonly 'inject-into'?: InjectInto;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly license?: SingleValue;\n\n  @Main(IsOptional(), IsURLValue())\n  public readonly contributionURL?: SingleValue;\n\n  @Main(IsOptional(), IsSingleValue())\n  public readonly contributionAmount?: SingleValue;\n\n  @Main(IsOptional(), IsNestedValue(Compatibility))\n  public readonly compatible?: Compatibility;\n\n  @Main(IsOptional(), IsNestedValue(Compatibility))\n  public readonly incompatible?: Compatibility;\n}\n"
  },
  {
    "path": "lib/features/validate-headers/impl.ts",
    "content": "import { instanceToPlain, plainToInstance } from 'class-transformer';\nimport { validateSync } from 'class-validator';\n\nimport { DEFAULT_LOCALE_KEY } from '../../const';\nimport { HeadersProps, UserscriptPluginInstance } from '../../types';\nimport { Feature } from '../feature';\nimport { Headers, ValidationGroup } from './headers';\n\nexport interface HeadersValidatorOptions {\n  strict?: boolean;\n  whitelist?: boolean;\n}\n\nexport type HeaderClass = { new (): object };\n\nexport interface ValidateHeadersOptions extends HeadersValidatorOptions {\n  proxyScript?: unknown;\n  headersClass?: HeaderClass;\n}\n\nexport class ValidateHeaders extends Feature<ValidateHeadersOptions> {\n  public readonly name = 'ValidateHeaders';\n\n  public apply({ hooks }: UserscriptPluginInstance): void {\n    const HeadersClass = this.options.headersClass ?? Headers;\n\n    hooks.headers.tap(this.name, (headersProps, { locale }) =>\n      this.validateHeaders(locale, headersProps, HeadersClass, this.options),\n    );\n\n    if (this.options.proxyScript) {\n      hooks.proxyHeaders.tap(this.name, (headersProps, { locale }) =>\n        this.validateHeaders(locale, headersProps, HeadersClass, this.options),\n      );\n    }\n  }\n\n  private validateHeaders(\n    locale: string,\n    headersProps: HeadersProps,\n    HeadersClass: HeaderClass,\n    { whitelist, strict }: HeadersValidatorOptions = {},\n  ): HeadersProps {\n    const validatorGroups = [\n      locale === DEFAULT_LOCALE_KEY\n        ? ValidationGroup.Main\n        : ValidationGroup.I18n,\n    ];\n\n    const transformerGroups = whitelist\n      ? validatorGroups\n      : [ValidationGroup.Main, ValidationGroup.I18n];\n\n    const headers = plainToInstance(HeadersClass, headersProps, {\n      exposeDefaultValues: true,\n      excludeExtraneousValues: whitelist,\n      exposeUnsetFields: false,\n      groups: transformerGroups,\n    });\n\n    if (strict) {\n      const errors = validateSync(headers, {\n        forbidNonWhitelisted: true,\n        whitelist: true,\n        stopAtFirstError: false,\n        groups: validatorGroups,\n      });\n\n      if (errors.length > 0) {\n        throw new Error(\n          `Validation groups: ${validatorGroups}\\n` +\n            errors\n              .map((err) => err.toString(undefined, undefined, undefined, true))\n              .join('\\n'),\n        );\n      }\n    }\n\n    return instanceToPlain(headers, {\n      exposeUnsetFields: false,\n      groups: transformerGroups,\n    });\n  }\n}\n"
  },
  {
    "path": "lib/features/validate-headers/index.ts",
    "content": "export * from './headers';\nexport * from './impl';\nexport * from './utils';\n"
  },
  {
    "path": "lib/features/validate-headers/utils.ts",
    "content": "import * as transformer from 'class-transformer';\nimport { defaultMetadataStorage } from 'class-transformer/cjs/storage';\nimport * as validator from 'class-validator';\n\nimport { applyDecorators, IsRecord, MutuallyExclusive } from '../../utils';\n\nexport interface GroupsOptions {\n  groups?: string[];\n}\n\nexport type Validator = (options?: GroupsOptions) => PropertyDecorator;\nexport type ValidatorFactory<T extends any[] = []> = (...args: T) => Validator;\n\n/**\n * `@Expose()` from class-transformer is not stackable,\n * wrap it in a new `@Expose()` implementation to stack for `groups` options.\n */\nexport const Expose: Validator =\n  (options: transformer.ExposeOptions = {}) =>\n  (target, prop) => {\n    const metadata = defaultMetadataStorage.findExposeMetadata(\n      target.constructor,\n      prop as string,\n    );\n\n    if (!metadata) {\n      transformer.Expose(options)(target, prop);\n\n      return;\n    }\n\n    // merge expose options\n    Object.assign(\n      metadata.options,\n      options,\n      options.groups\n        ? {\n            groups: (metadata.options.groups ?? []).concat(options.groups),\n          }\n        : undefined,\n    );\n  };\n\nexport const partialGroups =\n  (...groups: string[]) =>\n  (...decorators: Validator[]): PropertyDecorator =>\n    applyDecorators(...decorators.map((deco) => deco({ groups })));\n\nexport const IsOptional: ValidatorFactory = () => validator.IsOptional;\n\nexport const IsDefined: ValidatorFactory = () => validator.IsDefined;\n\nexport const IsUnique: ValidatorFactory<[string]> = (name) => (options) =>\n  MutuallyExclusive(name, options);\n\nexport const IsSingleValue: ValidatorFactory = () => (options) =>\n  applyDecorators(Expose(options), validator.IsString(options));\n\nexport const IsMultiValue: ValidatorFactory = () => (options) =>\n  applyDecorators(\n    Expose(options),\n    validator.IsString({ ...options, each: true }),\n  );\n\nexport const IsURLValue: ValidatorFactory = () => (options) =>\n  applyDecorators(Expose(options), validator.IsUrl(undefined, options));\n\nexport const IsVersionValue: ValidatorFactory = () => (options) =>\n  applyDecorators(Expose(options), validator.IsSemVer(options));\n\nexport const IsSwitchValue: ValidatorFactory = () => (options) =>\n  applyDecorators(Expose(options), validator.IsBoolean(options));\n\nexport const IsNamedValue: ValidatorFactory = () => (options) =>\n  applyDecorators(\n    Expose(options),\n    IsRecord([validator.isString], [validator.isString], {\n      ...options,\n      message: ({ property }) => ` \"${property}\" is not a valid named value`,\n    }),\n  );\n\nexport const IsEnumValue: ValidatorFactory<[Record<string, unknown>]> =\n  (entity) => (options) =>\n    applyDecorators(Expose(options), validator.IsEnum(entity, options));\n\nexport const IsNestedValue: ValidatorFactory<[{ new (): object }]> =\n  (clazz) => (options) =>\n    applyDecorators(\n      Expose(options),\n      transformer.Type(() => clazz),\n      validator.ValidateNested(options),\n    );\n"
  },
  {
    "path": "lib/fs.ts",
    "content": "/**\n * FS-implementation aware functions.\n * @module\n */\nimport path from 'path';\nimport { promisify } from 'util';\n\nexport interface Stats {\n  isFile: () => boolean;\n  isDirectory: () => boolean;\n}\n\nexport interface FsStat {\n  stat(path: string, callback: (err: Error | null, stats: Stats) => void): void;\n}\n\nexport interface FsReadFile {\n  readFile(\n    path: string,\n    callback: (err: Error | null, content: Buffer) => void,\n  ): void;\n}\n\nexport interface FsWriteFile {\n  writeFile(\n    path: string,\n    data: string | Buffer,\n    callback: (err: Error | null) => void,\n  ): void;\n}\n\nexport interface FsMkdir {\n  mkdir(\n    path: string,\n    options: { recursive?: boolean },\n    callback: (err: Error | null, path?: string) => void,\n  ): void;\n}\n\nexport async function findPackage(cwd: string, fs: FsStat): Promise<string> {\n  const statAsync = promisify(fs.stat);\n\n  let dir = cwd;\n  while (true) {\n    const parent = path.dirname(dir);\n    try {\n      const pkg = await statAsync(path.join(dir, 'package.json'));\n      if (pkg.isFile()) {\n        return dir;\n      }\n    } catch (e) {\n      // root directory\n      if (dir === parent) {\n        throw new Error(`package.json is not found`);\n      }\n    }\n    dir = parent;\n  }\n}\n\nexport async function readJSON<T>(file: string, fs: FsReadFile): Promise<T> {\n  const readFileAsync = promisify(fs.readFile);\n  const buf = await readFileAsync(file);\n\n  return JSON.parse(buf.toString('utf-8'));\n}\n\nexport async function writeJSON(\n  file: string,\n  data: unknown,\n  fs: FsWriteFile,\n): Promise<void> {\n  const writeFileAsync = promisify(fs.writeFile);\n  await writeFileAsync(file, Buffer.from(JSON.stringify(data), 'utf-8'));\n}\n\nexport async function mkdirp(\n  dir: string,\n  fs: FsMkdir,\n): Promise<string | undefined> {\n  const mkdirAsync = promisify(fs.mkdir);\n\n  return await mkdirAsync(dir, { recursive: true });\n}\n"
  },
  {
    "path": "lib/index.ts",
    "content": "import 'reflect-metadata';\n\nimport { UserscriptPlugin } from './plugin';\n\nexport default UserscriptPlugin;\n\nexport {\n  Feature,\n  HeaderClass,\n  HeadersProvider,\n  LoadHeadersOptions,\n  ProxyScriptFeatureOptions,\n  ProxyScriptOptions,\n  RenderHeadersOptions,\n  ResolveBaseURLsOptions,\n  SSRIAlgorithm,\n  SSRIFeatureOptions,\n  SSRIOptions,\n  SSRITag,\n  URLFilter,\n  ValidateHeadersOptions,\n} from './features';\nexport * from './plugin';\nexport * from './types';\n"
  },
  {
    "path": "lib/plugin.ts",
    "content": "import path from 'node:path';\n\nimport {\n  AsyncParallelHook,\n  AsyncSeriesBailHook,\n  AsyncSeriesWaterfallHook,\n} from 'tapable';\nimport { Compilation, Compiler, sources, WebpackPluginInstance } from 'webpack';\n\nimport { DEFAULT_LOCALE_KEY } from './const';\nimport {\n  Feature,\n  FixTags,\n  Interpolater,\n  LoadHeaders,\n  LoadHeadersOptions,\n  ProcessProxyScript,\n  ProcessSSRI,\n  ProxyScriptOptions,\n  RenderHeaders,\n  RenderHeadersOptions,\n  ResolveBaseURLs,\n  ResolveBaseURLsOptions,\n  SetDefaultTags,\n  SSRIOptions,\n  ValidateHeaders,\n  ValidateHeadersOptions,\n} from './features';\nimport {\n  CompilationContext,\n  FileInfo,\n  HeadersProps,\n  UserscriptPluginInstance,\n  WaterfallContext,\n} from './types';\nimport { date } from './utils';\n\nconst { ConcatSource, RawSource } = sources;\n\nexport interface UserscriptPluginOptions {\n  metajs?: boolean;\n  skip?: (fileInfo: FileInfo) => boolean;\n  proxyScript?: unknown;\n  i18n?: Record<string, unknown>;\n}\n\nexport type UserscriptOptions = LoadHeadersOptions &\n  ResolveBaseURLsOptions &\n  SSRIOptions &\n  ProxyScriptOptions &\n  RenderHeadersOptions &\n  ValidateHeadersOptions &\n  UserscriptPluginOptions;\n\nexport class UserscriptPlugin\n  implements WebpackPluginInstance, UserscriptPluginInstance\n{\n  public readonly name = 'UserscriptPlugin';\n\n  public readonly features: Feature[];\n\n  public readonly hooks = {\n    init: new AsyncParallelHook<[Compiler]>(['compiler']),\n    close: new AsyncParallelHook<[Compiler]>(['compiler']),\n    preprocess: new AsyncParallelHook<[Compilation, CompilationContext]>([\n      'compilation',\n      'context',\n    ]),\n    process: new AsyncParallelHook<[Compilation, CompilationContext]>([\n      'compilation',\n      'context',\n    ]),\n    headers: new AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>([\n      'headersProps',\n      'context',\n    ]),\n    proxyHeaders: new AsyncSeriesWaterfallHook<\n      [HeadersProps, WaterfallContext]\n    >(['headersProps', 'context']),\n    proxyScriptFile: new AsyncSeriesWaterfallHook<[string, WaterfallContext]>([\n      'proxyScriptFile',\n      'context',\n    ]),\n    renderHeaders: new AsyncSeriesBailHook<Map<string, HeadersProps>, string>([\n      'headersProps',\n    ]),\n    renderProxyHeaders: new AsyncSeriesBailHook<HeadersProps, string>([\n      'headersProps',\n    ]),\n  };\n\n  private readonly contexts = new WeakMap<Compilation, CompilationContext>();\n  private options: UserscriptPluginOptions = {};\n\n  public constructor(options: UserscriptOptions = {}) {\n    const { metajs = true, strict = true } = options;\n    Object.assign(options, { metajs, strict } as UserscriptOptions);\n\n    this.features = [\n      new LoadHeaders(options),\n      new FixTags(options),\n      new ResolveBaseURLs(options),\n      new ProcessSSRI(options),\n      new SetDefaultTags(options),\n      new ProcessProxyScript(options),\n      new Interpolater(options),\n      new ValidateHeaders(options),\n      new RenderHeaders(options),\n    ];\n\n    this.options = options;\n  }\n\n  public apply(compiler: Compiler): void {\n    const name = this.name;\n    let buildNo = 0;\n\n    const initPromise = new Promise<void>((resolve) =>\n      queueMicrotask(() => resolve(this.init(compiler))),\n    );\n\n    compiler.hooks.beforeCompile.tapPromise(name, () => initPromise);\n\n    compiler.hooks.compilation.tap(name, (compilation) => {\n      this.contexts.set(compilation, {\n        buildNo: ++buildNo,\n        buildTime: date(),\n        fileInfo: [],\n      });\n\n      compilation.hooks.processAssets.tapPromise(\n        {\n          name,\n          stage: Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS,\n        },\n        () => this.preprocess(compilation),\n      );\n\n      compilation.hooks.processAssets.tapPromise(\n        {\n          name,\n          // we should generate userscript files\n          // only if optimization of source files are complete\n          stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,\n        },\n        () => this.process(compilation),\n      );\n    });\n\n    compiler.hooks.done.tapPromise(name, () => this.close(compiler));\n\n    for (const feature of this.features) {\n      feature.apply(this);\n    }\n  }\n\n  private async init(compiler: Compiler): Promise<void> {\n    await this.hooks.init.promise(compiler);\n  }\n\n  private async close(compiler: Compiler): Promise<void> {\n    await this.hooks.close.promise(compiler);\n  }\n\n  private async preprocess(compilation: Compilation): Promise<void> {\n    const context = this.contexts.get(compilation);\n\n    /* istanbul ignore next */\n    if (!context) {\n      return;\n    }\n\n    context.fileInfo = this.collectFileInfo(compilation);\n\n    await this.hooks.preprocess.promise(compilation, context);\n  }\n\n  private async process(compilation: Compilation): Promise<void> {\n    const context = this.contexts.get(compilation);\n\n    /* istanbul ignore next */\n    if (!context) {\n      return;\n    }\n\n    await Promise.all(\n      context.fileInfo.map((fileInfo) =>\n        this.emitUserscript(compilation, context, fileInfo),\n      ),\n    );\n\n    for (const { originalFile, userjsFile } of context.fileInfo) {\n      if (originalFile !== userjsFile) {\n        compilation.deleteAsset(originalFile);\n      }\n    }\n\n    await this.hooks.process.promise(compilation, context);\n  }\n\n  private collectFileInfo(compilation: Compilation): FileInfo[] {\n    const fileInfo: FileInfo[] = [];\n\n    for (const entrypoint of compilation.entrypoints.values()) {\n      const chunk = entrypoint.getEntrypointChunk();\n      for (const originalFile of chunk.files) {\n        let q = originalFile.indexOf('?');\n        if (q < 0) {\n          q = originalFile.length;\n        }\n        const filepath = originalFile.slice(0, q);\n        const query = originalFile.slice(q);\n        const dirname = path.dirname(filepath);\n        const filename = path.basename(filepath);\n        const basename = filepath.endsWith('.user.js')\n          ? path.basename(filepath, '.user.js')\n          : filepath.endsWith('.js')\n          ? path.basename(filepath, '.js')\n          : filepath;\n        const extname = path.extname(filepath);\n\n        const userjsFile = path.join(dirname, basename + '.user.js') + query;\n        const metajsFile = path.join(dirname, basename + '.meta.js');\n\n        const fileInfoEntry = {\n          chunk,\n          originalFile,\n          userjsFile,\n          metajsFile,\n          filename,\n          dirname,\n          basename,\n          query,\n          extname,\n        };\n\n        if (this.options.skip?.(fileInfoEntry) ?? extname !== '.js') {\n          continue;\n        }\n\n        fileInfo.push(fileInfoEntry);\n      }\n    }\n\n    return fileInfo;\n  }\n\n  private async emitUserscript(\n    compilation: Compilation,\n    context: CompilationContext,\n    fileInfo: FileInfo,\n  ): Promise<void> {\n    const { metajs, proxyScript, i18n } = this.options;\n    const { originalFile, chunk, metajsFile, userjsFile } = fileInfo;\n    const sourceAsset = compilation.getAsset(originalFile);\n    const waterfall = {\n      ...context,\n      fileInfo,\n      compilation,\n    };\n\n    if (!sourceAsset) {\n      /* istanbul ignore next */\n      return;\n    }\n\n    const localizedHeaders = new Map<string, HeadersProps>();\n\n    const headers = await this.hooks.headers.promise(\n      {},\n      { ...waterfall, locale: DEFAULT_LOCALE_KEY },\n    );\n    localizedHeaders.set(DEFAULT_LOCALE_KEY, headers);\n\n    if (i18n) {\n      await Promise.all(\n        Object.keys(i18n).map(async (locale) => {\n          localizedHeaders.set(\n            locale,\n            await this.hooks.headers.promise({}, { ...waterfall, locale }),\n          );\n        }),\n      );\n    }\n\n    const headersStr = await this.hooks.renderHeaders.promise(localizedHeaders);\n\n    const proxyHeaders = proxyScript\n      ? await this.hooks.proxyHeaders.promise(headers, {\n          ...waterfall,\n          locale: DEFAULT_LOCALE_KEY,\n        })\n      : undefined;\n    const proxyScriptFile = proxyScript\n      ? await this.hooks.proxyScriptFile.promise('', {\n          ...waterfall,\n          locale: DEFAULT_LOCALE_KEY,\n        })\n      : undefined;\n\n    const proxyHeadersStr = proxyHeaders\n      ? await this.hooks.renderProxyHeaders.promise(proxyHeaders)\n      : undefined;\n\n    if (userjsFile !== originalFile) {\n      compilation.emitAsset(\n        userjsFile,\n        new ConcatSource(headersStr, '\\n', sourceAsset.source),\n        {\n          minimized: true,\n        },\n      );\n      chunk.files.add(userjsFile);\n    } else {\n      compilation.updateAsset(\n        userjsFile,\n        new ConcatSource(headersStr, '\\n', sourceAsset.source),\n        {\n          minimized: true,\n        },\n      );\n    }\n\n    if (metajs !== false) {\n      compilation.emitAsset(metajsFile, new RawSource(headersStr), {\n        minimized: true,\n      });\n      chunk.auxiliaryFiles.add(metajsFile);\n    }\n\n    if (proxyScriptFile !== undefined && proxyHeadersStr !== undefined) {\n      compilation.emitAsset(proxyScriptFile, new RawSource(proxyHeadersStr), {\n        minimized: true,\n      });\n      chunk.auxiliaryFiles.add(proxyScriptFile);\n    }\n  }\n}\n"
  },
  {
    "path": "lib/types.ts",
    "content": "import {\n  AsyncParallelHook,\n  AsyncSeriesBailHook,\n  AsyncSeriesWaterfallHook,\n} from 'tapable';\nimport { Chunk, Compilation, Compiler } from 'webpack';\n\nexport type SingleValue = string | undefined;\nexport type MultiValue = SingleValue | SingleValue[];\nexport type NamedValue = Record<string, SingleValue>;\nexport type SwitchValue = boolean;\n\nexport type TagType = string;\nexport type ValueType =\n  | NamedValue\n  | MultiValue\n  | SingleValue\n  | SwitchValue\n  | undefined;\n\nexport type EnumValue<T extends string> = T | `${T}`;\n\nexport enum RunAt {\n  DocumentStart = 'document-start',\n  DocumentBody = 'document-body',\n  DocumentEnd = 'document-end',\n  DocumentIdle = 'document-idle',\n  ContextMenu = 'context-menu',\n}\n\nexport enum Sandbox {\n  Raw = 'raw',\n  JavaScript = 'JavaScript',\n  DOM = 'DOM',\n}\n\nexport enum InjectInto {\n  Page = 'page',\n  Content = 'content',\n  Auto = 'auto',\n}\n\nexport interface CompatibilityValue extends NamedValue {\n  firefox?: string;\n  chrome?: string;\n  opera?: string;\n  safari?: string;\n  edge?: string;\n}\n\nexport interface StrictHeadersProps {\n  name?: SingleValue;\n  version?: SingleValue;\n  namespace?: SingleValue;\n  author?: SingleValue;\n  description?: SingleValue;\n  homepage?: SingleValue;\n  homepageURL?: SingleValue;\n  website?: SingleValue;\n  source?: SingleValue;\n  icon?: SingleValue;\n  iconURL?: SingleValue;\n  defaulticon?: SingleValue;\n  icon64?: SingleValue;\n  icon64URL?: SingleValue;\n  updateURL?: SingleValue;\n  downloadURL?: SingleValue;\n  installURL?: SingleValue;\n  supportURL?: SingleValue;\n  include?: MultiValue;\n  match?: MultiValue;\n  'exclude-match'?: MultiValue;\n  exclude?: MultiValue;\n  require?: MultiValue;\n  resource?: NamedValue;\n  connect?: MultiValue;\n  grant?: MultiValue;\n  webRequest?: MultiValue;\n  noframes?: SwitchValue;\n  unwrap?: SwitchValue;\n  antifeature?: NamedValue;\n  'run-at'?: EnumValue<RunAt>;\n  copyright?: SingleValue;\n  sandbox?: EnumValue<Sandbox>;\n  'inject-into'?: EnumValue<InjectInto>;\n  license?: SingleValue;\n  contributionURL?: SingleValue;\n  contributionAmount?: SingleValue;\n  compatible?: CompatibilityValue;\n  incompatible?: CompatibilityValue;\n}\n\nexport interface HeadersProps extends StrictHeadersProps {\n  [tag: TagType]: ValueType;\n}\n\nexport interface FileInfo {\n  chunk: Chunk;\n  originalFile: string;\n  userjsFile: string;\n  metajsFile: string;\n  filename: string;\n  basename: string;\n  query: string;\n  dirname: string;\n  extname: string;\n}\n\nexport interface CompilationContext {\n  buildNo: number;\n  buildTime: Date;\n  fileInfo: FileInfo[];\n}\n\nexport interface WaterfallContext {\n  buildNo: number;\n  buildTime: Date;\n  fileInfo: FileInfo;\n  compilation: Compilation;\n  locale: string;\n}\n\nexport interface UserscriptPluginInstance {\n  hooks: {\n    init: AsyncParallelHook<[Compiler]>;\n    close: AsyncParallelHook<[Compiler]>;\n    preprocess: AsyncParallelHook<[Compilation, CompilationContext]>;\n    process: AsyncParallelHook<[Compilation, CompilationContext]>;\n    headers: AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>;\n    proxyHeaders: AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>;\n    proxyScriptFile: AsyncSeriesWaterfallHook<[string, WaterfallContext]>;\n    renderHeaders: AsyncSeriesBailHook<Map<string, HeadersProps>, string>;\n    renderProxyHeaders: AsyncSeriesBailHook<HeadersProps, string>;\n  };\n}\n"
  },
  {
    "path": "lib/utils.ts",
    "content": "import {\n  registerDecorator,\n  ValidationArguments,\n  ValidationOptions,\n} from 'class-validator';\n\nexport function date(): Date {\n  return new Date();\n}\n\n/**\n * Shipped from NestJs#applyDecorators()\n * @see {@link https://github.com/nestjs/nest/blob/bee462e031f9562210c65b9eb8e8a20cab1f301f/packages/common/decorators/core/apply-decorators.ts github:nestjs/nest}\n */\nexport function applyDecorators(\n  ...decorators: Array<ClassDecorator | MethodDecorator | PropertyDecorator>\n) {\n  return <TFunction extends (...args: any[]) => any, Y>(\n    target: TFunction | object,\n    propertyKey?: string | symbol,\n    descriptor?: TypedPropertyDescriptor<Y>,\n  ): void => {\n    for (const decorator of decorators) {\n      if (target instanceof Function && !descriptor) {\n        (decorator as ClassDecorator)(target);\n\n        continue;\n      }\n      (decorator as MethodDecorator | PropertyDecorator)(\n        target,\n        propertyKey as string | symbol,\n        descriptor as TypedPropertyDescriptor<Y>,\n      );\n    }\n  };\n}\n/**\n * @see {@link https://github.com/typestack/class-validator/issues/759#issuecomment-712361384 github:typestack/class-validator#759}\n */\nexport function MutuallyExclusive(\n  group: string,\n  validationOptions?: ValidationOptions,\n): PropertyDecorator {\n  const key = MutuallyExclusive.getMetaKey(group);\n\n  return function (object: any, propertyName: string | symbol): void {\n    const existing = Reflect.getMetadata(key, object) ?? [];\n\n    Reflect.defineMetadata(key, [...existing, propertyName], object);\n\n    registerDecorator({\n      name: 'MutuallyExclusive',\n      target: object.constructor,\n      propertyName: propertyName as string,\n      constraints: [group],\n      options: validationOptions,\n      validator: {\n        validate(_: any, args: ValidationArguments) {\n          const mutuallyExclusiveProps: Array<string> = Reflect.getMetadata(\n            key,\n            args.object,\n          );\n\n          return (\n            mutuallyExclusiveProps.reduce(\n              (p, c) => ((args.object as any)[c] !== undefined ? ++p : p),\n              0,\n            ) === 1\n          );\n        },\n        defaultMessage(validationArguments?: ValidationArguments) {\n          if (!validationArguments) {\n            return `Mutually exclusive group \"${group}\" is violated`;\n          }\n\n          const mutuallyExclusiveProps = (\n            Reflect.getMetadata(key, validationArguments.object) as string[]\n          ).filter(\n            (prop) => (validationArguments.object as any)[prop] !== undefined,\n          );\n\n          const propsString = mutuallyExclusiveProps\n            .map((p) => `\"${p}\"`)\n            .join(', ');\n\n          return (\n            `Mutually exclusive group \"${group}\" is violated, ` +\n            `${propsString}.`\n          );\n        },\n      },\n    });\n  };\n}\n\nMutuallyExclusive.getMetaKey = (tag: string): symbol =>\n  Symbol.for(`custom:__@rst/validator_mutually_exclusive_${tag}__`);\n\nexport function IsRecord(\n  keyValidators: ((k: string | symbol) => boolean)[] = [],\n  valueValidators: ((v: any) => boolean)[] = [],\n  validationOptions?: ValidationOptions,\n): PropertyDecorator {\n  return function (object: any, propertyName: string | symbol): void {\n    registerDecorator({\n      name: 'IsRecord',\n      target: object.constructor,\n      propertyName: propertyName as string,\n      constraints: [],\n      options: validationOptions,\n      validator: {\n        validate(value: any) {\n          return (\n            typeof value === 'object' &&\n            Object.entries(value).every(\n              ([k, v]) =>\n                keyValidators.every((validator) => validator(k)) &&\n                valueValidators.every((validator) => validator(v)),\n            )\n          );\n        },\n\n        defaultMessage() {\n          return 'record validation failed';\n        },\n      },\n    });\n  };\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"webpack-userscript\",\n  \"version\": \"3.2.3\",\n  \"description\": \"A Webpack plugin for userscript projects.\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/momocow/webpack-userscript.git\"\n  },\n  \"author\": \"MomoCow <momocow.me@gmail.com>\",\n  \"keywords\": [\n    \"webpack\",\n    \"userscript\",\n    \"tampermonkey\",\n    \"greasemonkey\"\n  ],\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/momocow/webpack-userscript/issues\"\n  },\n  \"homepage\": \"https://github.com/momocow/webpack-userscript#readme\",\n  \"main\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"files\": [\n    \"dist\"\n  ],\n  \"scripts\": {\n    \"lint\": \"eslint lib/ --ext .js,.jsx,.ts,.tsx\",\n    \"test\": \"jest --coverage --collectCoverageFrom \\\"lib/**/*.ts\\\"\",\n    \"test:dev\": \"jest\",\n    \"clean\": \"shx rm -rf dist\",\n    \"ts-node\": \"ts-node\",\n    \"docs\": \"typedoc\",\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"commit\": \"gitmoji -c\",\n    \"prepare\": \"husky install\",\n    \"release\": \"semantic-release\"\n  },\n  \"devDependencies\": {\n    \"@semantic-release/git\": \"^10.0.1\",\n    \"@semantic-release/github\": \"^8.0.7\",\n    \"@semantic-release/npm\": \"^10.0.3\",\n    \"@types/express\": \"^4.17.17\",\n    \"@types/jest\": \"^29.4.0\",\n    \"@types/node\": \"^18.11.18\",\n    \"@types/node-fetch\": \"^2.6.2\",\n    \"@types/ssri\": \"^7.1.1\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.49.0\",\n    \"@typescript-eslint/parser\": \"^5.49.0\",\n    \"eslint\": \"^8.33.0\",\n    \"eslint-config-prettier\": \"^8.6.0\",\n    \"eslint-import-resolver-typescript\": \"^3.5.3\",\n    \"eslint-plugin-import\": \"^2.27.5\",\n    \"eslint-plugin-prettier\": \"^4.2.1\",\n    \"eslint-plugin-simple-import-sort\": \"^10.0.0\",\n    \"express\": \"^4.18.2\",\n    \"gitmoji-cli\": \"^7.0.3\",\n    \"husky\": \"^8.0.3\",\n    \"jest\": \"29.4.1\",\n    \"jest-extended\": \"^3.2.3\",\n    \"lint-staged\": \"^13.1.0\",\n    \"memfs\": \"^3.4.13\",\n    \"prettier\": \"^2.8.3\",\n    \"semantic-release\": \"^21.0.1\",\n    \"semantic-release-gitmoji\": \"^1.6.4\",\n    \"shx\": \"^0.3.4\",\n    \"ts-jest\": \"^29.0.5\",\n    \"ts-node\": \"^10.9.1\",\n    \"typedoc\": \"^0.23.24\",\n    \"typescript\": \"^4.9.4\",\n    \"typescript-memoize\": \"^1.1.1\",\n    \"webpack\": \"^5.75.0\"\n  },\n  \"dependencies\": {\n    \"class-transformer\": \"0.5.1\",\n    \"class-validator\": \"^0.14.0\",\n    \"node-fetch\": \"^2.6.9\",\n    \"p-limit\": \"^3.1.0\",\n    \"reflect-metadata\": \"^0.1.13\",\n    \"ssri\": \"^10.0.1\",\n    \"table\": \"^6.8.1\",\n    \"tapable\": \"^2.2.1\",\n    \"tslib\": \"^2.0.0\"\n  },\n  \"peerDependencies\": {\n    \"webpack\": \"5\"\n  }\n}\n"
  },
  {
    "path": "test/integration/default-tags/fixtures.ts",
    "content": "import { GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n  public static readonly defaultMatchValue = '*://*/*';\n\n  public static readonly httpsMatchValue = 'https://*/*';\n\n  public static readonly httpsIncludeValue = 'https://*';\n\n  public static readonly customValue = '__custom__';\n}\n"
  },
  {
    "path": "test/integration/default-tags/index.test.ts",
    "content": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile, findTags } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('default tags', () => {\n  let input: Volume;\n\n  const httpsMatchTags = findTags.bind(\n    undefined,\n    'match',\n    Fixtures.httpsMatchValue,\n  );\n  const httpsIncludeTags = findTags.bind(\n    undefined,\n    'include',\n    Fixtures.httpsIncludeValue,\n  );\n\n  const defaultMatchTags = findTags.bind(\n    undefined,\n    'match',\n    Fixtures.defaultMatchValue,\n  );\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  // eslint-disable-next-line max-len\n  it('should use default match if no include or match specified', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [new UserscriptPlugin()],\n    });\n\n    const userJs = output\n      .readFileSync('/dist/output.user.js')\n      .toString('utf-8');\n    const metaJs = output\n      .readFileSync('/dist/output.meta.js')\n      .toString('utf-8');\n\n    expect(defaultMatchTags(userJs)).toHaveLength(1);\n    expect(defaultMatchTags(metaJs)).toHaveLength(1);\n  });\n\n  it('should not use default match if include is provided', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            include: Fixtures.httpsIncludeValue,\n          },\n        }),\n      ],\n    });\n\n    const userJs = output\n      .readFileSync('/dist/output.user.js')\n      .toString('utf-8');\n    const metaJs = output\n      .readFileSync('/dist/output.meta.js')\n      .toString('utf-8');\n\n    expect(defaultMatchTags(userJs)).toHaveLength(0);\n    expect(httpsIncludeTags(userJs)).toHaveLength(1);\n\n    expect(defaultMatchTags(metaJs)).toHaveLength(0);\n    expect(httpsIncludeTags(metaJs)).toHaveLength(1);\n  });\n\n  it('should not use default match if match is provided', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            match: Fixtures.httpsMatchValue,\n          },\n        }),\n      ],\n    });\n\n    const userJs = output\n      .readFileSync('/dist/output.user.js')\n      .toString('utf-8');\n    const metaJs = output\n      .readFileSync('/dist/output.meta.js')\n      .toString('utf-8');\n\n    expect(defaultMatchTags(userJs)).toHaveLength(0);\n    expect(httpsMatchTags(userJs)).toHaveLength(1);\n\n    expect(defaultMatchTags(metaJs)).toHaveLength(0);\n    expect(httpsMatchTags(metaJs)).toHaveLength(1);\n  });\n});\n"
  },
  {
    "path": "test/integration/fix-tags/fixtures.ts",
    "content": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n  @File(__dirname, 'headers.txt')\n  public static readonly headers: string;\n}\n"
  },
  {
    "path": "test/integration/fix-tags/headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @downloadURL http://example.com\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/fix-tags/index.test.ts",
    "content": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('fix-tags', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  it('should fix tag names', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: { downloadUrl: 'http://example.com' },\n          strict: false,\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),\n      '/dist/output.meta.js': Fixtures.headers,\n    });\n  });\n\n  it('should throw on ambiguous tag names', () => {\n    const promise = compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            downloadUrl: 'http://1.example.com',\n            downloadURL: 'http://2.example.com',\n          },\n          strict: false,\n        }),\n      ],\n    });\n\n    return expect(promise).toReject();\n  });\n});\n"
  },
  {
    "path": "test/integration/fixtures/entry.js.txt",
    "content": "(function () {\n  console.log('hello');\n})();\n"
  },
  {
    "path": "test/integration/fixtures/entry.min.js.txt",
    "content": "console.log(\"hello\");"
  },
  {
    "path": "test/integration/fixtures/headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/fixtures/package.json.txt",
    "content": "{\n  \"name\": \"userscript\",\n  \"version\": \"0.0.0\",\n  \"description\": \"this is a fantastic userscript\",\n  \"someUnrelatedProperty\": true\n}"
  },
  {
    "path": "test/integration/fixtures.ts",
    "content": "import { readFileSync } from 'node:fs';\nimport path from 'node:path';\n\nimport { Configuration } from 'webpack';\n\nexport const FIXTURES_DIR = path.join(__dirname, 'fixtures');\n\nexport const File =\n  (...paths: string[]): PropertyDecorator =>\n  (target, prop) => {\n    Object.defineProperty(target, prop, {\n      value: readFileSync(path.join(...paths), 'utf-8'),\n      enumerable: true,\n      configurable: false,\n      writable: false,\n    });\n  };\n\nexport class GlobalFixtures {\n  @File(FIXTURES_DIR, 'entry.js.txt')\n  public static readonly entryJs: string;\n\n  @File(FIXTURES_DIR, 'entry.min.js.txt')\n  public static readonly entryMinJs: string;\n\n  @File(FIXTURES_DIR, 'headers.txt')\n  public static readonly headers: string;\n\n  @File(FIXTURES_DIR, 'package.json.txt')\n  public static readonly packageJson: string;\n\n  public static readonly webpackConfig: Configuration = Object.seal({\n    context: '/',\n    mode: 'production',\n    entry: '/entry.js',\n    output: {\n      path: '/dist',\n      filename: 'output.js',\n    },\n  });\n\n  public static entryUserJs(headers: string): string {\n    return headers + '\\n' + this.entryMinJs;\n  }\n}\n"
  },
  {
    "path": "test/integration/general/fixtures.ts",
    "content": "import { GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {}\n"
  },
  {
    "path": "test/integration/general/index.test.ts",
    "content": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('general', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  it('should successfully compile with default options', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [new UserscriptPlugin()],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),\n      '/dist/output.meta.js': Fixtures.headers,\n    });\n  });\n\n  it('should skip files based on the skip option', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          skip: (): boolean => true,\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.js': Fixtures.entryMinJs,\n    });\n  });\n\n  // eslint-disable-next-line max-len\n  it('should successfully compile even the file extension is .user.js', async () => {\n    input.renameSync('/entry.js', '/entry.user.js');\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      entry: '/entry.user.js',\n      output: {\n        path: '/dist',\n        filename: 'output.user.js',\n      },\n      plugins: [new UserscriptPlugin()],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),\n      '/dist/output.meta.js': Fixtures.headers,\n    });\n  });\n});\n"
  },
  {
    "path": "test/integration/headers/fixtures.ts",
    "content": "import { HeadersProps, ValueType } from 'webpack-userscript';\n\nimport { File, GlobalFixtures } from '../fixtures';\n\ninterface TagSample {\n  value: ValueType;\n  expect: string | string[];\n}\n\ninterface TagCase {\n  validValues: TagSample[];\n  invalidValues: Omit<TagSample, 'expect'>[];\n}\n\nexport class Fixtures extends GlobalFixtures {\n  public static readonly customValue = '__custom__';\n\n  @File(__dirname, 'pretty-headers.txt')\n  public static readonly prettyHeaders: string;\n\n  @File(__dirname, 'tag-order-headers.txt')\n  public static readonly tagOrderHeaders: string;\n\n  public static readonly tagSamples: Record<string, TagCase> = {\n    'run-at': {\n      validValues: [\n        { value: 'document-start', expect: 'document-start' },\n        { value: 'document-body', expect: 'document-body' },\n        { value: 'document-end', expect: 'document-end' },\n        { value: 'document-idle', expect: 'document-idle' },\n        { value: 'context-menu', expect: 'context-menu' },\n      ],\n      invalidValues: [{ value: 'a' }],\n    },\n    sandbox: {\n      validValues: [\n        { value: 'raw', expect: 'raw' },\n        { value: 'JavaScript', expect: 'JavaScript' },\n        { value: 'DOM', expect: 'DOM' },\n      ],\n      invalidValues: [{ value: 'a' }],\n    },\n    'inject-into': {\n      validValues: [\n        { value: 'page', expect: 'page' },\n        { value: 'content', expect: 'content' },\n        { value: 'auto', expect: 'auto' },\n      ],\n      invalidValues: [{ value: 'a' }],\n    },\n    compatible: {\n      validValues: [\n        {\n          value: {\n            firefox: 'compatible string',\n            chrome: 'compatible string',\n            opera: 'compatible string',\n            safari: 'compatible string',\n            edge: 'compatible string',\n          },\n          expect: [\n            'firefox compatible string',\n            'chrome compatible string',\n            'opera compatible string',\n            'safari compatible string',\n            'edge compatible string',\n          ],\n        },\n      ],\n      invalidValues: [{ value: { unknownBrowser: 'compatible string' } }],\n    },\n    incompatible: {\n      validValues: [\n        {\n          value: {\n            firefox: 'compatible string',\n            chrome: 'compatible string',\n            opera: 'compatible string',\n            safari: 'compatible string',\n            edge: 'compatible string',\n          },\n          expect: [\n            'firefox compatible string',\n            'chrome compatible string',\n            'opera compatible string',\n            'safari compatible string',\n            'edge compatible string',\n          ],\n        },\n      ],\n      invalidValues: [{ value: { unknownBrowser: 'incompatible string' } }],\n    },\n  };\n\n  public static readonly mutuallyExclusiveTags: HeadersProps[] = [\n    {\n      homepage: 'https://home.example.com',\n      homepageURL: 'https://home.example.com',\n      website: 'https://home.example.com',\n      source: 'https://home.example.com',\n    },\n    {\n      icon: 'https://icon.example.com',\n      iconURL: 'https://icon.example.com',\n      defaulticon: 'https://icon.example.com',\n    },\n    {\n      downloadURL: 'https://download.example.com',\n      installURL: 'https://install.example.com',\n    },\n  ];\n}\n"
  },
  {
    "path": "test/integration/headers/index.test.ts",
    "content": "import { UserscriptOptions, UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile, findTags } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('headers', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  describe('validate-headers', () => {\n    describe('strict & whiltelist', () => {\n      const testCustomTags =\n        (\n          count: number,\n          {\n            strict,\n            whitelist,\n          }: Pick<UserscriptOptions, 'strict' | 'whitelist'> = {},\n        ) =>\n        async (): Promise<void> => {\n          const customTags = findTags.bind(\n            undefined,\n            'custom',\n            Fixtures.customValue,\n          );\n\n          const output = await compile(input, {\n            ...Fixtures.webpackConfig,\n            plugins: [\n              new UserscriptPlugin({\n                headers: {\n                  custom: Fixtures.customValue,\n                },\n                strict,\n                whitelist,\n              }),\n            ],\n          });\n\n          const userJs = output\n            .readFileSync('/dist/output.user.js')\n            .toString('utf-8');\n          const metaJs = output\n            .readFileSync('/dist/output.meta.js')\n            .toString('utf-8');\n\n          expect(customTags(userJs)).toHaveLength(count);\n          expect(customTags(metaJs)).toHaveLength(count);\n        };\n\n      it('should throw for custom tags in strict but non-whitelist mode', () =>\n        expect(\n          testCustomTags(0, {\n            strict: true,\n            whitelist: false,\n          })(),\n        ).toReject());\n\n      it(\n        'should render custom tags in non-strict and non-whitelist mode',\n        testCustomTags(1, {\n          strict: false,\n          whitelist: false,\n        }),\n      );\n\n      it(\n        'should not render custom tags in non-strict but whitelist mode',\n        testCustomTags(0, {\n          strict: false,\n          whitelist: true,\n        }),\n      );\n\n      it(\n        'should not render custom tags in strict and whitelist mode',\n        testCustomTags(0, {\n          strict: true,\n          whitelist: true,\n        }),\n      );\n    });\n\n    describe('headersClass', () => {\n      it('should use custom headersClass if provided', () => {\n        class EmptyHeaders {}\n\n        const promise = compile(input, {\n          ...Fixtures.webpackConfig,\n          plugins: [\n            new UserscriptPlugin({\n              headers: {\n                custom: Fixtures.customValue,\n              },\n              headersClass: EmptyHeaders,\n              strict: true,\n              whitelist: false,\n            }),\n          ],\n        });\n\n        return expect(promise).toReject();\n      });\n\n      for (const headers of Fixtures.mutuallyExclusiveTags) {\n        it(\n          'should throw error if mutually exclusive tags present: ' +\n            Object.keys(headers).join(', '),\n          () => {\n            const promise = compile(input, {\n              ...Fixtures.webpackConfig,\n              plugins: [\n                new UserscriptPlugin({\n                  headers,\n                  strict: true,\n                }),\n              ],\n            });\n\n            return expect(promise).toReject();\n          },\n        );\n      }\n\n      for (const [tag, { validValues, invalidValues }] of Object.entries(\n        Fixtures.tagSamples,\n      )) {\n        describe(tag, () => {\n          for (const { value, expect: expectedOutput } of validValues) {\n            it('valid case: ' + JSON.stringify(value), async () => {\n              const output = await compile(input, {\n                ...Fixtures.webpackConfig,\n                plugins: [\n                  new UserscriptPlugin({\n                    headers: {\n                      [tag]: value,\n                    },\n                  }),\n                ],\n              });\n\n              const userJs = output\n                .readFileSync('/dist/output.user.js')\n                .toString('utf-8');\n              const metaJs = output\n                .readFileSync('/dist/output.meta.js')\n                .toString('utf-8');\n\n              const expectedOutputList = !Array.isArray(expectedOutput)\n                ? [expectedOutput]\n                : expectedOutput;\n              for (const outputValue of expectedOutputList) {\n                expect(findTags(tag, outputValue, userJs)).toHaveLength(1);\n                expect(findTags(tag, outputValue, metaJs)).toHaveLength(1);\n              }\n            });\n          }\n\n          for (const { value } of invalidValues) {\n            it('invalid case: ' + JSON.stringify(value), () => {\n              const promise = compile(input, {\n                ...Fixtures.webpackConfig,\n                plugins: [\n                  new UserscriptPlugin({\n                    headers: {\n                      [tag]: value,\n                    },\n                  }),\n                ],\n              });\n\n              return expect(promise).toReject();\n            });\n          }\n        });\n      }\n    });\n  });\n\n  describe('render-headers', () => {\n    it('should be rendered prettily', async () => {\n      const output = await compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: {\n              resource: {\n                test: 'http://example.com/demo.jpg',\n              },\n              include: ['https://example.com/', 'http://example.com/'],\n              noframes: true,\n              unwrap: false,\n            },\n            pretty: true,\n          }),\n        ],\n      });\n\n      expect(output.toJSON()).toEqual({\n        '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.prettyHeaders),\n        '/dist/output.meta.js': Fixtures.prettyHeaders,\n      });\n    });\n\n    it('should respect the specified tag order', async () => {\n      const output = await compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            // though \"@include\" tag does not present in the headers,\n            // it is fine to be in the tagOrder list\n            tagOrder: ['include', 'match', 'version', 'description', 'name'],\n          }),\n        ],\n      });\n\n      expect(output.toJSON()).toEqual({\n        '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.tagOrderHeaders),\n        '/dist/output.meta.js': Fixtures.tagOrderHeaders,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/integration/headers/pretty-headers.txt",
    "content": "// ==UserScript==\n// @name        userscript\n// @description this is a fantastic userscript\n// @version     0.0.0\n// @include     https://example.com/\n// @include     http://example.com/\n// @noframes\n// @resource    test http://example.com/demo.jpg\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/headers/tag-order-headers.txt",
    "content": "// ==UserScript==\n// @match *://*/*\n// @version 0.0.0\n// @description this is a fantastic userscript\n// @name userscript\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/i18n/fixtures.ts",
    "content": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n  @File(__dirname, 'i18n.headers.txt')\n  public static readonly i18nHeaders: string;\n\n  @File(__dirname, 'non-strict-i18n.headers.txt')\n  public static readonly nonStrictI18nHeaders: string;\n}\n"
  },
  {
    "path": "test/integration/i18n/i18n.headers.txt",
    "content": "// ==UserScript==\n// @name i18n\n// @name:en localized name\n// @description this is a fantastic userscript\n// @description:en i18n description\n// @version 0.0.0\n// @match *://*/*\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/i18n/index.test.ts",
    "content": "import { HeadersProps, UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('i18n', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  it('headers object', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            name: 'i18n',\n          },\n          i18n: {\n            en: {\n              name: 'localized name',\n              description: 'i18n description',\n            },\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders),\n      '/dist/output.meta.js': Fixtures.i18nHeaders,\n    });\n  });\n\n  it('headers file', async () => {\n    input.writeFileSync(\n      '/headers.json',\n      JSON.stringify({\n        name: 'localized name',\n        description: 'i18n description',\n      }),\n    );\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            name: 'i18n',\n          },\n          i18n: {\n            en: '/headers.json',\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders),\n      '/dist/output.meta.js': Fixtures.i18nHeaders,\n    });\n  });\n\n  it('headers provider', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            name: 'i18n',\n          },\n          i18n: {\n            en: (headers): HeadersProps => ({\n              ...headers,\n              name: 'localized name',\n              description: 'i18n description',\n            }),\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders),\n      '/dist/output.meta.js': Fixtures.i18nHeaders,\n    });\n  });\n\n  describe('unlocalizable tags', () => {\n    it('are rejected in strict mode', () => {\n      const promise = compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: {\n              name: 'i18n',\n            },\n            i18n: {\n              en: {\n                name: 'localized name',\n                downloadURL: 'https://example.com',\n              },\n            },\n          }),\n        ],\n      });\n\n      return expect(promise).toReject();\n    });\n\n    it('are allowed in non-strict mode', async () => {\n      const output = await compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: {\n              name: 'non-strict i18n',\n            },\n            i18n: {\n              en: (headers): HeadersProps => ({\n                ...headers,\n                downloadURL: 'https://example.com',\n              }),\n            },\n            strict: false,\n          }),\n        ],\n      });\n\n      expect(output.toJSON()).toEqual({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.nonStrictI18nHeaders,\n        ),\n        '/dist/output.meta.js': Fixtures.nonStrictI18nHeaders,\n      });\n    });\n\n    it('are stripped in whitelist mode', async () => {\n      const output = await compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: {\n              name: 'i18n',\n            },\n            i18n: {\n              en: {\n                name: 'localized name',\n                description: 'i18n description',\n                // downloadURL will be stripped\n                downloadURL: 'https://example.com',\n              },\n            },\n            whitelist: true,\n          }),\n        ],\n      });\n\n      expect(output.toJSON()).toEqual({\n        '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders),\n        '/dist/output.meta.js': Fixtures.i18nHeaders,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/integration/i18n/non-strict-i18n.headers.txt",
    "content": "// ==UserScript==\n// @name non-strict i18n\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @downloadURL:en https://example.com\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/interpolater/fixtures.ts",
    "content": "import { GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {}\n"
  },
  {
    "path": "test/integration/interpolater/index.test.ts",
    "content": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile, findTags } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\njest.mock('webpack-userscript/utils', () => ({\n  ...jest.requireActual('webpack-userscript/utils'),\n  date: (): Date => new Date(0),\n}));\n\ndescribe('interpolater', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  for (const [name, expectedName] of [\n    ['[name]', 'customEntry'],\n    ['[file]', 'output.js'],\n    ['[filename]', 'output.js'],\n    ['[basename]', 'output'],\n    ['[query]', ''],\n    ['[dirname]', '.'],\n    ['[buildNo]', '1'],\n    ['[buildTime]', '1970-01-01T00:00:00.000Z'],\n  ]) {\n    it(name, async () => {\n      const output = await compile(input, {\n        ...Fixtures.webpackConfig,\n        entry: {\n          customEntry: '/entry.js',\n        },\n        plugins: [\n          new UserscriptPlugin({\n            headers: {\n              name,\n            },\n          }),\n        ],\n      });\n\n      const userJs = output\n        .readFileSync('/dist/output.user.js')\n        .toString('utf-8');\n      const metaJs = output\n        .readFileSync('/dist/output.meta.js')\n        .toString('utf-8');\n\n      expect(findTags('name', expectedName, userJs)).toHaveLength(1);\n      expect(findTags('name', expectedName, metaJs)).toHaveLength(1);\n    });\n  }\n});\n"
  },
  {
    "path": "test/integration/load-headers/fixtures.ts",
    "content": "import { Memoize } from 'typescript-memoize';\n\nimport { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n  @Memoize()\n  public static get headersJson(): string {\n    return JSON.stringify({\n      name: 'load-headers',\n    });\n  }\n\n  @File(__dirname, 'load-headers.headers.txt')\n  public static readonly loadHeadersHeaders: string;\n}\n"
  },
  {
    "path": "test/integration/load-headers/index.test.ts",
    "content": "import path from 'node:path';\n\nimport { HeadersProps, UserscriptPlugin } from 'webpack-userscript';\nimport * as fs from 'webpack-userscript/fs';\n\nimport { compile, watchCompile } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('load-headers', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  it('can be loaded from headers object', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            name: 'load-headers',\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.loadHeadersHeaders),\n      '/dist/output.meta.js': Fixtures.loadHeadersHeaders,\n    });\n  });\n\n  describe('headers file', () => {\n    it('can be loaded from headers file', async () => {\n      input.writeFileSync('/headers.json', Fixtures.headersJson);\n\n      const output = await compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: '/headers.json',\n          }),\n        ],\n      });\n\n      expect(output.toJSON()).toEqual({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.loadHeadersHeaders,\n        ),\n        '/dist/output.meta.js': Fixtures.loadHeadersHeaders,\n      });\n    });\n\n    // eslint-disable-next-line max-len\n    it('should throw error if headers file is not in .json format', async () => {\n      input.writeFileSync('/headers.json', '{\"name\": \"invalid-json\",');\n\n      const promise = compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: '/headers.json',\n          }),\n        ],\n      });\n\n      await expect(promise).toReject();\n    });\n\n    // eslint-disable-next-line max-len\n    it('should reuse from headers file if the file is not changed', async () => {\n      input.writeFileSync('/headers.json', Fixtures.headersJson);\n\n      const plugin = new UserscriptPlugin({\n        headers: './headers.json',\n      });\n\n      const readJSONSpy = jest.spyOn(fs, 'readJSON');\n\n      const entry = './entry.js';\n      let step = 0;\n      let inputFullPath = '';\n\n      await watchCompile(\n        input,\n        {\n          ...Fixtures.webpackConfig,\n          context: '/',\n          entry,\n          plugins: [plugin],\n        },\n        async ({ output, writeFile, cwd }) => {\n          switch (++step) {\n            case 1:\n              expect(output.toJSON()).toEqual({\n                '/dist/output.user.js': Fixtures.entryUserJs(\n                  Fixtures.loadHeadersHeaders,\n                ),\n                '/dist/output.meta.js': Fixtures.loadHeadersHeaders,\n              });\n              await writeFile(entry, Fixtures.entryJs);\n              break;\n\n            case 2:\n              break;\n\n            default:\n              fail('invalid steps');\n          }\n\n          inputFullPath = cwd;\n\n          return step < 2;\n        },\n      );\n\n      if (step !== 2) {\n        fail('invalid steps');\n      }\n\n      const headersJsonPath = path.join(inputFullPath, 'headers.json');\n\n      // headers.json has only been read once\n      expect(\n        readJSONSpy.mock.calls.reduce(\n          (count, call) => (call[0] === headersJsonPath ? ++count : count),\n          0,\n        ),\n      ).toEqual(1);\n\n      readJSONSpy.mockRestore();\n    });\n  });\n\n  it('should compile if package.json does not exist', async () => {\n    input.rmSync('/package.json');\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            name: 'userscript',\n            version: '0.0.0',\n            description: 'this is a fantastic userscript',\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),\n      '/dist/output.meta.js': Fixtures.headers,\n    });\n  });\n\n  describe('headers provider', () => {\n    it('can be loaded from headers provider function', async () => {\n      const output = await compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: (headers): HeadersProps => ({\n              ...headers,\n              name: 'load-headers',\n            }),\n          }),\n        ],\n      });\n\n      expect(output.toJSON()).toEqual({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.loadHeadersHeaders,\n        ),\n        '/dist/output.meta.js': Fixtures.loadHeadersHeaders,\n      });\n    });\n\n    it('can be loaded from async headers provider function', async () => {\n      const output = await compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: async (headers): Promise<HeadersProps> => ({\n              ...headers,\n              name: 'load-headers',\n            }),\n          }),\n        ],\n      });\n\n      expect(output.toJSON()).toEqual({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.loadHeadersHeaders,\n        ),\n        '/dist/output.meta.js': Fixtures.loadHeadersHeaders,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/integration/load-headers/load-headers.headers.txt",
    "content": "// ==UserScript==\n// @name load-headers\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/multi-entry/entry1.headers.txt",
    "content": "// ==UserScript==\n// @name entry1\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/multi-entry/entry2.headers.txt",
    "content": "// ==UserScript==\n// @name entry2\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/multi-entry/entry3.headers.txt",
    "content": "// ==UserScript==\n// @name entry3\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/multi-entry/fixtures.ts",
    "content": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n  @File(__dirname, 'entry1.headers.txt')\n  public static readonly entry1Headers: string;\n\n  @File(__dirname, 'entry2.headers.txt')\n  public static readonly entry2Headers: string;\n\n  @File(__dirname, 'entry3.headers.txt')\n  public static readonly entry3Headers: string;\n}\n"
  },
  {
    "path": "test/integration/multi-entry/index.test.ts",
    "content": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('multi-entry', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry1.js': Fixtures.entryJs,\n      '/entry2.js': Fixtures.entryJs,\n      '/entry3.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  it('should call headers provider against each entry', async () => {\n    const headersProvider = jest.fn().mockImplementation(\n      (\n        headers,\n        {\n          fileInfo: {\n            chunk: { name },\n          },\n        },\n      ) => ({ ...headers, name }),\n    );\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      entry: {\n        entry1: '/entry1.js',\n        entry2: '/entry2.js',\n        entry3: '/entry3.js',\n      },\n      output: {\n        path: '/dist',\n        filename: '[name].js',\n      },\n      plugins: [\n        new UserscriptPlugin({\n          headers: headersProvider,\n        }),\n      ],\n    });\n\n    expect(headersProvider).toBeCalledTimes(3);\n\n    expect(output.toJSON()).toEqual({\n      '/dist/entry1.user.js': Fixtures.entryUserJs(Fixtures.entry1Headers),\n      '/dist/entry1.meta.js': Fixtures.entry1Headers,\n      '/dist/entry2.user.js': Fixtures.entryUserJs(Fixtures.entry2Headers),\n      '/dist/entry2.meta.js': Fixtures.entry2Headers,\n      '/dist/entry3.user.js': Fixtures.entryUserJs(Fixtures.entry3Headers),\n      '/dist/entry3.meta.js': Fixtures.entry3Headers,\n    });\n  });\n\n  it('should interpolate headers against each entry', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      entry: {\n        entry1: '/entry1.js',\n        entry2: '/entry2.js',\n        entry3: '/entry3.js',\n      },\n      output: {\n        path: '/dist',\n        filename: '[name].js',\n      },\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            name: '[name]',\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/entry1.user.js': Fixtures.entryUserJs(Fixtures.entry1Headers),\n      '/dist/entry1.meta.js': Fixtures.entry1Headers,\n      '/dist/entry2.user.js': Fixtures.entryUserJs(Fixtures.entry2Headers),\n      '/dist/entry2.meta.js': Fixtures.entry2Headers,\n      '/dist/entry3.user.js': Fixtures.entryUserJs(Fixtures.entry3Headers),\n      '/dist/entry3.meta.js': Fixtures.entry3Headers,\n    });\n  });\n});\n"
  },
  {
    "path": "test/integration/package-json/bugs.headers.txt",
    "content": "// ==UserScript==\n// @name package-json-bugs\n// @supportURL https://bugs.example.com/\n// @match *://*/*\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/package-json/fixtures.ts",
    "content": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n  @File(__dirname, './root-option.headers.txt')\n  public static readonly rootOptionHeaders: string;\n\n  @File(__dirname, './bugs.headers.txt')\n  public static readonly bugsHeaders: string;\n}\n"
  },
  {
    "path": "test/integration/package-json/index.test.ts",
    "content": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('package-json', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n    });\n  });\n\n  it('should find package.json using root option', async () => {\n    input.mkdirpSync('/some/deep/deep/dir');\n    input.writeFileSync(\n      '/some/deep/deep/dir/package.json',\n      JSON.stringify({\n        name: 'package-json-deep',\n        version: '0.0.1',\n        description: 'description of package-json-deep',\n        author: 'author of package-json-deep',\n        homepage: 'https://homepage.package-json-deep.com/',\n      }),\n    );\n    input.writeFileSync(\n      '/package.json',\n      JSON.stringify({\n        name: 'package-json',\n        version: '0.0.0',\n        description: 'description of package-json',\n        author: 'author of package-json',\n        homepage: 'https://homepage.package-json.com/',\n      }),\n    );\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          root: '/some/deep/deep/dir/',\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.rootOptionHeaders),\n      '/dist/output.meta.js': Fixtures.rootOptionHeaders,\n    });\n  });\n\n  describe('various types of \"bugs\" in package.json', () => {\n    const bugsValues = [\n      'https://bugs.example.com/',\n      { url: 'https://bugs.example.com/' },\n    ];\n\n    for (const bugs of bugsValues) {\n      it('should parse \"bugs\" into \"supportURL\"', async () => {\n        input.writeFileSync(\n          '/package.json',\n          JSON.stringify({ name: 'package-json-bugs', bugs }),\n        );\n\n        const output = await compile(input, {\n          ...Fixtures.webpackConfig,\n          plugins: [new UserscriptPlugin({})],\n        });\n\n        expect(output.toJSON()).toEqual({\n          '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.bugsHeaders),\n          '/dist/output.meta.js': Fixtures.bugsHeaders,\n        });\n      });\n    }\n  });\n});\n"
  },
  {
    "path": "test/integration/package-json/root-option.headers.txt",
    "content": "// ==UserScript==\n// @name package-json-deep\n// @description description of package-json-deep\n// @version 0.0.1\n// @author author of package-json-deep\n// @homepage https://homepage.package-json-deep.com/\n// @match *://*/*\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/proxy-script/base-url-proxy-script.headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @downloadURL http://example.com\n// @updateURL http://example.com\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/proxy-script/base-url-proxy-script.proxy-headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @require http://base.example.com/output.user.js\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/proxy-script/fixtures.ts",
    "content": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n  @File(__dirname, 'proxy-script.headers.txt')\n  public static readonly proxyScriptHeaders: string;\n\n  @File(__dirname, 'proxy-script.proxy-headers.txt')\n  public static readonly proxyScriptProxyHeaders: string;\n\n  @File(__dirname, 'base-url-proxy-script.proxy-headers.txt')\n  public static readonly baseURLProxyScriptProxyHeaders: string;\n\n  @File(__dirname, 'base-url-proxy-script.headers.txt')\n  public static readonly baseURLProxyScriptHeaders: string;\n}\n"
  },
  {
    "path": "test/integration/proxy-script/index.test.ts",
    "content": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('proxy script', () => {\n  let input: Volume;\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  it('should generate proxy script at default location', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            // these URLs should be ignored in proxy scripts\n            updateURL: 'http://example.com',\n            installURL: 'http://example.com',\n            // require tag will be extended in the proxy script\n            require: ['http://require.example.com'],\n          },\n          proxyScript: {},\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.proxy.user.js': Fixtures.proxyScriptProxyHeaders,\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.proxyScriptHeaders),\n      '/dist/output.meta.js': Fixtures.proxyScriptHeaders,\n    });\n  });\n\n  it('should generate proxy script at specified location', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            // these URLs should be ignored in proxy scripts\n            updateURL: 'http://example.com',\n            installURL: 'http://example.com',\n            // require tag will be extended in the proxy script\n            require: 'http://require.example.com',\n          },\n          proxyScript: {\n            filename: 'custom.proxy.user.js',\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/custom.proxy.user.js': Fixtures.proxyScriptProxyHeaders,\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.proxyScriptHeaders),\n      '/dist/output.meta.js': Fixtures.proxyScriptHeaders,\n    });\n  });\n\n  it('should generate proxy script with custom baseURL of userjs', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            // these URLs should be ignored in proxy scripts\n            updateURL: 'http://example.com',\n            downloadURL: 'http://example.com',\n          },\n          proxyScript: {\n            baseURL: 'http://base.example.com',\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.proxy.user.js': Fixtures.baseURLProxyScriptProxyHeaders,\n      '/dist/output.user.js': Fixtures.entryUserJs(\n        Fixtures.baseURLProxyScriptHeaders,\n      ),\n      '/dist/output.meta.js': Fixtures.baseURLProxyScriptHeaders,\n    });\n  });\n});\n"
  },
  {
    "path": "test/integration/proxy-script/proxy-script.headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @installURL http://example.com\n// @require http://require.example.com\n// @updateURL http://example.com\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/proxy-script/proxy-script.proxy-headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @require http://require.example.com\n// @require http://localhost:8080/output.user.js\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/resolve-base-urls/fixtures.ts",
    "content": "import { GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n  public static readonly downloadURLWithUserjs =\n    'http://download.example.com/output.user.js';\n\n  public static readonly downloadURLWithMetajs =\n    'http://download.example.com/output.meta.js';\n\n  public static readonly updateURLWithMetajs =\n    'http://update.example.com/output.meta.js';\n\n  public static readonly updateURLWithUserjs =\n    'http://update.example.com/output.user.js';\n}\n"
  },
  {
    "path": "test/integration/resolve-base-urls/index.test.ts",
    "content": "import { URL } from 'node:url';\n\nimport { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile, findTags } from '../util';\nimport { Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\ndescribe('resolve base urls', () => {\n  let input: Volume;\n\n  const findDownloadURL = findTags.bind(\n    undefined,\n    'downloadURL',\n    Fixtures.downloadURLWithUserjs,\n  );\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  it('should resolve downloadURL and updateURL', async () => {\n    const findUpdateURLByMetajs = findTags.bind(\n      undefined,\n      'updateURL',\n      Fixtures.updateURLWithMetajs,\n    );\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          downloadBaseURL: new URL('http://download.example.com'),\n          updateBaseURL: 'http://update.example.com',\n        }),\n      ],\n    });\n\n    const userJs = output\n      .readFileSync('/dist/output.user.js')\n      .toString('utf-8');\n    const metaJs = output\n      .readFileSync('/dist/output.meta.js')\n      .toString('utf-8');\n\n    expect(findDownloadURL(userJs)).toHaveLength(1);\n    expect(findDownloadURL(metaJs)).toHaveLength(1);\n\n    expect(findUpdateURLByMetajs(userJs)).toHaveLength(1);\n    expect(findUpdateURLByMetajs(metaJs)).toHaveLength(1);\n  });\n\n  it('should resolve updateURL with updateBaseURL and userjs', async () => {\n    const findUpdateURLByUserjs = findTags.bind(\n      undefined,\n      'updateURL',\n      Fixtures.updateURLWithUserjs,\n    );\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          downloadBaseURL: new URL('http://download.example.com'),\n          updateBaseURL: 'http://update.example.com',\n          metajs: false,\n        }),\n      ],\n    });\n\n    const userJs = output\n      .readFileSync('/dist/output.user.js')\n      .toString('utf-8');\n\n    expect(findDownloadURL(userJs)).toHaveLength(1);\n    expect(findUpdateURLByUserjs(userJs)).toHaveLength(1);\n  });\n\n  it('should resolve updateURL by downloadBaseURL and userjs', async () => {\n    const findUpdateURLByDownloadURL = findTags.bind(\n      undefined,\n      'updateURL',\n      Fixtures.downloadURLWithUserjs,\n    );\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          downloadBaseURL: new URL('http://download.example.com'),\n          metajs: false,\n        }),\n      ],\n    });\n\n    const userJs = output\n      .readFileSync('/dist/output.user.js')\n      .toString('utf-8');\n\n    expect(findDownloadURL(userJs)).toHaveLength(1);\n    expect(findUpdateURLByDownloadURL(userJs)).toHaveLength(1);\n  });\n\n  it('should resolve updateURL by downloadBaseURL and metajs', async () => {\n    const findUpdateURLByDownloadURL = findTags.bind(\n      undefined,\n      'updateURL',\n      Fixtures.downloadURLWithMetajs,\n    );\n\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          downloadBaseURL: new URL('http://download.example.com'),\n          metajs: true,\n        }),\n      ],\n    });\n\n    const userJs = output\n      .readFileSync('/dist/output.user.js')\n      .toString('utf-8');\n\n    expect(findDownloadURL(userJs)).toHaveLength(1);\n    expect(findUpdateURLByDownloadURL(userJs)).toHaveLength(1);\n  });\n});\n"
  },
  {
    "path": "test/integration/ssri/algorithms-ssri-headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha256-TCTf0oeErSvvs9r6rGvx7U581YzOcT2aCyKNQm6BK68=\n// @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40=\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/ssri/algorithms-ssri-lock.json.txt",
    "content": "{\"http://localhost:${PORT}/travis-webpack-userscript.svg\":\"sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40=\",\"http://localhost:${PORT}/jquery-3.4.1.min.js\":\"sha256-TCTf0oeErSvvs9r6rGvx7U581YzOcT2aCyKNQm6BK68=\"}"
  },
  {
    "path": "test/integration/ssri/filters-ssri-headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==\n// @require http://example.com/example.txt\n// @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/ssri/filters-ssri-lock.json.txt",
    "content": "{\"http://localhost:${PORT}/jquery-3.4.1.min.js\":\"sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==\"}"
  },
  {
    "path": "test/integration/ssri/fixtures.ts",
    "content": "import path from 'node:path';\n\nimport { Memoize } from 'typescript-memoize';\n\nimport { File, GlobalFixtures } from '../fixtures';\nimport { template } from '../util';\n\nexport class Fixtures extends GlobalFixtures {\n  @File(path.join(__dirname, 'ssri-lock.json.txt'))\n  private static readonly _ssriLockJson: string;\n\n  public static get ssriLockJson(): (data: any) => string {\n    return template(this._ssriLockJson);\n  }\n\n  @File(path.join(__dirname, 'ssri-headers.txt'))\n  private static readonly _ssriHeaders: string;\n\n  @Memoize()\n  public static get ssriHeaders(): (data: any) => string {\n    return template(this._ssriHeaders);\n  }\n\n  @File(path.join(__dirname, 'filters-ssri-headers.txt'))\n  private static readonly _filtersSSRIHeaders: string;\n\n  @Memoize()\n  public static get filtersSSRIHeaders(): (data: any) => string {\n    return template(this._filtersSSRIHeaders);\n  }\n\n  @File(path.join(__dirname, 'filters-ssri-lock.json.txt'))\n  private static readonly _filtersSSRILockJson: string;\n\n  @Memoize()\n  public static get filtersSSRILockJson(): (data: any) => string {\n    return template(this._filtersSSRILockJson);\n  }\n\n  @File(path.join(__dirname, 'algorithms-ssri-headers.txt'))\n  private static readonly _algorithmsSSRIHeaders: string;\n\n  @Memoize()\n  public static get algorithmsSSRIHeaders(): (data: any) => string {\n    return template(this._algorithmsSSRIHeaders);\n  }\n\n  @File(path.join(__dirname, 'algorithms-ssri-lock.json.txt'))\n  private static readonly _algorithmsSSRILockJson: string;\n\n  @Memoize()\n  public static get algorithmsSSRILockJson(): (data: any) => string {\n    return template(this._algorithmsSSRILockJson);\n  }\n\n  @File(__dirname, 'multi-algo-ssri-lock.json.txt')\n  private static readonly _multiAlgorithmsSSRILockJson: string;\n\n  @Memoize()\n  public static get multiAlgorithmsSSRILockJson(): (data: any) => string {\n    return template(this._multiAlgorithmsSSRILockJson);\n  }\n\n  @File(__dirname, 'unsupported-protocols.headers.txt')\n  public static readonly _unsupportedProtocolsHeaders: string;\n\n  @Memoize()\n  public static get unsupportedProtocolsHeaders(): (data: any) => string {\n    return template(this._unsupportedProtocolsHeaders);\n  }\n}\n"
  },
  {
    "path": "test/integration/ssri/index.test.ts",
    "content": "import path from 'node:path';\n\nimport fetch from 'node-fetch';\nimport { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile, readJSON, servceStatic, ServeStatic } from '../util';\nimport { createFsFromVolume, Volume } from '../volume';\nimport { Fixtures } from './fixtures';\n\njest.mock('node-fetch', () =>\n  jest.fn(jest.requireActual('node-fetch') as typeof fetch),\n);\n\ndescribe('ssri', () => {\n  let input: Volume;\n  let server: ServeStatic;\n  let tplData: { PORT: string };\n\n  beforeAll(async () => {\n    server = await servceStatic(path.join(__dirname, 'static'));\n    tplData = {\n      PORT: String(server.port),\n    };\n  });\n\n  afterAll(async () => {\n    await server.close();\n  });\n\n  beforeEach(async () => {\n    input = Volume.fromJSON({\n      '/entry.js': Fixtures.entryJs,\n      '/package.json': Fixtures.packageJson,\n    });\n  });\n\n  it('should generate SSRIs and ssri-lock.json under the context', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      context: '/home',\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,\n            resource: {\n              // eslint-disable-next-line max-len\n              'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,\n            },\n          },\n          ssri: {},\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual(\n      expect.objectContaining({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.ssriHeaders(tplData),\n        ),\n        '/dist/output.meta.js': Fixtures.ssriHeaders(tplData),\n      }),\n    );\n\n    expect(readJSON(output, '/home/ssri-lock.json')).toEqual(\n      JSON.parse(Fixtures.ssriLockJson(tplData)),\n    );\n  });\n\n  it('should generate SSRIs and ssri-lock.json under the root', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      context: '/home',\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,\n            resource: {\n              // eslint-disable-next-line max-len\n              'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,\n            },\n          },\n          root: '/data',\n          ssri: {},\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual(\n      expect.objectContaining({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.ssriHeaders(tplData),\n        ),\n        '/dist/output.meta.js': Fixtures.ssriHeaders(tplData),\n      }),\n    );\n\n    expect(readJSON(output, '/data/ssri-lock.json')).toEqual(\n      JSON.parse(Fixtures.ssriLockJson(tplData)),\n    );\n  });\n\n  it('should generate ssri-lock in custom lockfile', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      context: '/home',\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,\n            resource: {\n              // eslint-disable-next-line max-len\n              'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,\n            },\n          },\n          ssri: {\n            lock: '/some/deep/dir/custom-lock.json',\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual(\n      expect.objectContaining({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.ssriHeaders(tplData),\n        ),\n        '/dist/output.meta.js': Fixtures.ssriHeaders(tplData),\n      }),\n    );\n\n    expect(readJSON(output, '/some/deep/dir/custom-lock.json')).toEqual(\n      JSON.parse(Fixtures.ssriLockJson(tplData)),\n    );\n  });\n\n  it('should apply url filters to determine SSRI target urls', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require: [\n              `http://localhost:${server.port}/jquery-3.4.1.min.js`,\n              `http://example.com/example.txt`,\n            ],\n            resource: {\n              // eslint-disable-next-line max-len\n              'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,\n            },\n          },\n          ssri: {\n            include: (_, url): boolean => url.hostname.includes('localhost'),\n            exclude: (tag, url): boolean =>\n              tag === 'resource' || !url.pathname.endsWith('.js'),\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual(\n      expect.objectContaining({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.filtersSSRIHeaders(tplData),\n        ),\n        '/dist/output.meta.js': Fixtures.filtersSSRIHeaders(tplData),\n      }),\n    );\n\n    expect(readJSON(output, '/ssri-lock.json')).toEqual(\n      JSON.parse(Fixtures.filtersSSRILockJson(tplData)),\n    );\n  });\n\n  it('should not generate SSRIs for unsupported protocols', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      context: '/home',\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,\n            resource: {\n              // eslint-disable-next-line max-len\n              'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,\n              'unsupported-url': 'ftp://example.com',\n            },\n          },\n          root: '/data',\n          ssri: {},\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual(\n      expect.objectContaining({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.unsupportedProtocolsHeaders(tplData),\n        ),\n        '/dist/output.meta.js': Fixtures.unsupportedProtocolsHeaders(tplData),\n      }),\n    );\n\n    expect(readJSON(output, '/data/ssri-lock.json')).toEqual(\n      JSON.parse(Fixtures.ssriLockJson(tplData)),\n    );\n  });\n\n  it('should generate SSRIs based on provided algorithms', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,\n            resource: {\n              // eslint-disable-next-line max-len\n              'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,\n            },\n          },\n          ssri: {\n            algorithms: ['sha256'],\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual(\n      expect.objectContaining({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.algorithmsSSRIHeaders(tplData),\n        ),\n        '/dist/output.meta.js': Fixtures.algorithmsSSRIHeaders(tplData),\n      }),\n    );\n\n    expect(readJSON(output, '/ssri-lock.json')).toEqual(\n      JSON.parse(Fixtures.algorithmsSSRILockJson(tplData)),\n    );\n  });\n\n  it('should generate SSRIs without ssri-lock.json', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,\n            resource: {\n              // eslint-disable-next-line max-len\n              'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,\n            },\n          },\n          ssri: {\n            lock: false,\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(\n        Fixtures.ssriHeaders(tplData),\n      ),\n      '/dist/output.meta.js': Fixtures.ssriHeaders(tplData),\n    });\n  });\n\n  it('should generate SSRIs with existing ssri-lock.json', async () => {\n    input.mkdirpSync('/data');\n    input.writeFileSync('/data/ssri-lock.json', Fixtures.ssriLockJson(tplData));\n\n    const intermediateFileSystem = createFsFromVolume(new Volume());\n    const writeFileSpy = jest.spyOn(intermediateFileSystem, 'writeFile');\n\n    const output = await compile(\n      input,\n      {\n        ...Fixtures.webpackConfig,\n        context: '/data',\n        plugins: [\n          new UserscriptPlugin({\n            headers: {\n              require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,\n              resource: {\n                // eslint-disable-next-line max-len\n                'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,\n              },\n            },\n            ssri: {},\n          }),\n        ],\n      },\n      {\n        intermediateFileSystem,\n      },\n    );\n\n    expect(fetch).not.toBeCalled();\n    expect(writeFileSpy).not.toBeCalled();\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(\n        Fixtures.ssriHeaders(tplData),\n      ),\n      '/dist/output.meta.js': Fixtures.ssriHeaders(tplData),\n      // there is no ssri-lock.json in output FS\n      // since ssri-lock remains unchanged (no write happened)\n    });\n\n    writeFileSpy.mockRestore();\n  });\n\n  it('should generate SSRIs with existing SSRIs in headers', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      context: '/data',\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require:\n              `http://localhost:${server.port}/jquery-3.4.1.min.js` +\n              `#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` +\n              `T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`,\n            resource: {\n              'legacy-badge':\n                `http://localhost:${server.port}` +\n                `/travis-webpack-userscript.svg` +\n                `#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` +\n                `Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`,\n            },\n          },\n          ssri: {},\n        }),\n      ],\n    });\n\n    expect(fetch).not.toBeCalled();\n\n    expect(output.toJSON()).toEqual(\n      expect.objectContaining({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.ssriHeaders(tplData),\n        ),\n        '/dist/output.meta.js': Fixtures.ssriHeaders(tplData),\n      }),\n    );\n\n    expect(readJSON(output, '/data/ssri-lock.json')).toEqual(\n      JSON.parse(Fixtures.ssriLockJson(tplData)),\n    );\n  });\n\n  it(\n    'should throw error if SSRIs mismatch between those from headers and ' +\n      'those from ssri-lock.json',\n    async () => {\n      input.mkdirpSync('/data');\n      // correct SSRIs are in ssri-lock.json\n      input.writeFileSync(\n        '/data/ssri-lock.json',\n        Fixtures.ssriLockJson(tplData),\n      );\n\n      const promise = compile(input, {\n        ...Fixtures.webpackConfig,\n        context: '/data',\n        plugins: [\n          new UserscriptPlugin({\n            // I switch SSRIs of jquery-3.4.1.min.js\n            // and travis-webpack-userscript.svg\n            // to simulate a mismatch\n            headers: {\n              require:\n                `http://localhost:${server.port}/jquery-3.4.1.min.js` +\n                `#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` +\n                `Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`,\n              resource: {\n                'legacy-badge':\n                  `http://localhost:${server.port}` +\n                  `/travis-webpack-userscript.svg` +\n                  `#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` +\n                  `T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`,\n              },\n            },\n            ssri: {},\n          }),\n        ],\n      });\n\n      expect(fetch).not.toBeCalled();\n      await expect(promise).toReject();\n    },\n  );\n\n  it('should compile if no urls are found', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      plugins: [\n        new UserscriptPlugin({\n          ssri: {},\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual({\n      '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),\n      '/dist/output.meta.js': Fixtures.headers,\n    });\n  });\n\n  it('should generate SSRIs against missing algorithms', async () => {\n    const output = await compile(input, {\n      ...Fixtures.webpackConfig,\n      context: '/data',\n      plugins: [\n        new UserscriptPlugin({\n          headers: {\n            require:\n              `http://localhost:${server.port}/jquery-3.4.1.min.js` +\n              `#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` +\n              `T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`,\n            resource: {\n              'legacy-badge':\n                `http://localhost:${server.port}` +\n                `/travis-webpack-userscript.svg` +\n                `#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` +\n                `Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`,\n            },\n          },\n          ssri: {\n            algorithms: ['sha256'],\n          },\n        }),\n      ],\n    });\n\n    expect(output.toJSON()).toEqual(\n      expect.objectContaining({\n        '/dist/output.user.js': Fixtures.entryUserJs(\n          Fixtures.ssriHeaders(tplData),\n        ),\n        '/dist/output.meta.js': Fixtures.ssriHeaders(tplData),\n      }),\n    );\n\n    expect(readJSON(output, '/data/ssri-lock.json')).toEqual(\n      JSON.parse(Fixtures.multiAlgorithmsSSRILockJson(tplData)),\n    );\n  });\n\n  it('should throw if fetching sources falied', () => {\n    return expect(\n      compile(input, {\n        ...Fixtures.webpackConfig,\n        plugins: [\n          new UserscriptPlugin({\n            headers: {\n              require: `http://localhost:${server.port}/not-exist.js`,\n            },\n            ssri: {},\n          }),\n        ],\n      }),\n    ).rejects.toThrow(/404 Not Found/);\n  });\n});\n"
  },
  {
    "path": "test/integration/ssri/multi-algo-ssri-lock.json.txt",
    "content": "{\"http://localhost:${PORT}/travis-webpack-userscript.svg\":\"sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40= sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==\",\"http://localhost:${PORT}/jquery-3.4.1.min.js\":\"sha256-TCTf0oeErSvvs9r6rGvx7U581YzOcT2aCyKNQm6BK68= sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==\"}"
  },
  {
    "path": "test/integration/ssri/ssri-headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==\n// @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/ssri/ssri-lock.json.txt",
    "content": "{\"http://localhost:${PORT}/travis-webpack-userscript.svg\":\"sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==\",\"http://localhost:${PORT}/jquery-3.4.1.min.js\":\"sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==\"}"
  },
  {
    "path": "test/integration/ssri/static/.eslintignore",
    "content": "./**/*"
  },
  {
    "path": "test/integration/ssri/unsupported-protocols.headers.txt",
    "content": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==\n// @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==\n// @resource unsupported-url ftp://example.com\n// ==/UserScript==\n"
  },
  {
    "path": "test/integration/util.ts",
    "content": "import { mkdtemp, rm, writeFile } from 'node:fs/promises';\nimport { AddressInfo } from 'node:net';\nimport { tmpdir } from 'node:os';\nimport path from 'node:path';\nimport { promisify } from 'node:util';\n\nimport express, { static as expressStatic } from 'express';\nimport { Compiler, Configuration, webpack } from 'webpack';\n\nimport { createFsFromVolume, Volume } from './volume';\n\nexport const GLOBAL_FIXTURES_DIR = path.join(__dirname, 'fixtures');\n\nexport interface CompilerFileSystems {\n  intermediateFileSystem?: Volume;\n}\n\ntype IntermediateFileSystem = Compiler['intermediateFileSystem'];\n\nexport async function compile(\n  input: Volume,\n  config: Configuration,\n  { intermediateFileSystem }: CompilerFileSystems = {},\n): Promise<Volume> {\n  const output = new Volume();\n  const compiler = webpack(config);\n\n  compiler.inputFileSystem = createFsFromVolume(input);\n  compiler.outputFileSystem = createFsFromVolume(output);\n  compiler.intermediateFileSystem = (intermediateFileSystem ??\n    compiler.outputFileSystem) as IntermediateFileSystem;\n\n  const stats = await promisify(compiler.run.bind(compiler))();\n  await promisify(compiler.close.bind(compiler))();\n\n  if (stats?.hasErrors() || stats?.hasWarnings()) {\n    const details = stats.toJson();\n\n    if (details.errorsCount) {\n      console.error(details.errors);\n    }\n    if (details.warningsCount) {\n      console.error(details.warnings);\n    }\n\n    throw new Error('invalid fixtures');\n  }\n\n  return output;\n}\n\nexport interface WatchStep {\n  cwd: string;\n  output: Volume;\n  writeFile: (\n    file: string,\n    body: string,\n    encode?: BufferEncoding,\n  ) => Promise<void>;\n}\n\nexport async function watchCompile(\n  input: Volume,\n  config: Configuration,\n  handle: (ctx: WatchStep) => Promise<boolean>,\n): Promise<void> {\n  return new Promise<void>(async (resolve, reject) => {\n    const watchDir = await mkdtemp(\n      path.join(tmpdir(), 'webpack-userscript_test_'),\n    );\n\n    for (const [file, body] of Object.entries(input.toJSON())) {\n      if (body === null) {\n        continue;\n      }\n\n      await writeFile(path.join(watchDir, file), body, 'utf-8');\n    }\n\n    const output = new Volume();\n    const compiler = webpack({\n      ...config,\n      context:\n        typeof config.context === 'string'\n          ? path.join(watchDir, config.context)\n          : undefined,\n    });\n\n    compiler.outputFileSystem = createFsFromVolume(output);\n\n    const watching = compiler.watch({}, async (err, stats) => {\n      if (err) {\n        await close();\n        reject(err);\n\n        return;\n      }\n\n      if (stats?.hasErrors() || stats?.hasWarnings()) {\n        const details = stats.toJson();\n\n        if (details.errorsCount) {\n          console.error(details.errors);\n        }\n        if (details.warningsCount) {\n          console.error(details.warnings);\n        }\n\n        await close();\n        reject(new Error('invalid fixtures'));\n\n        return;\n      }\n\n      try {\n        const conti = await handle({\n          cwd: watchDir,\n          output,\n          writeFile: writeFileInWatchDir,\n        });\n\n        if (!conti) {\n          await close();\n          resolve();\n        }\n      } catch (err) {\n        await close();\n        reject(err);\n      }\n    });\n\n    const closeWatching = promisify(watching.close.bind(watching));\n    const closeCompiler = promisify(compiler.close.bind(compiler));\n\n    const close = async (): Promise<void> => {\n      await closeWatching();\n      await closeCompiler();\n      await rm(watchDir, { recursive: true, force: true });\n    };\n\n    const writeFileInWatchDir = (\n      file: string,\n      body: string,\n      encode?: BufferEncoding,\n    ): Promise<void> => writeFile(path.join(watchDir, file), body, encode);\n  });\n}\n\nexport interface ServeStatic {\n  port: number;\n  close: () => Promise<void>;\n}\n\nexport async function servceStatic(root: string): Promise<ServeStatic> {\n  return new Promise((resolve, reject) => {\n    const app = express();\n    app.use(expressStatic(root));\n    const server = app\n      .listen(() => {\n        const { port } = server.address() as AddressInfo;\n        resolve({ port, close: promisify(server.close.bind(server)) });\n      })\n      .on('error', (err) => {\n        reject(err);\n      });\n  });\n}\n\nexport function escapeRegex(str: string): string {\n  return str.replace(/[/\\-\\\\^$*+?.()|[\\]{}]/g, '\\\\$&');\n}\n\nexport function findTags(\n  tag: string,\n  value: string,\n  content: string,\n): string[] {\n  return (\n    content.match(\n      new RegExp(`// @${escapeRegex(tag)} ${escapeRegex(value)}\\n`, 'g'),\n    ) ?? []\n  );\n}\n\nexport function template<T extends Record<string, string>>(\n  tpl: string,\n): (data: T) => string {\n  return function (data: T): string {\n    return tpl.replace(\n      /\\$\\{([^}]+)\\}/g,\n      (matched, matchedKey) => data[matchedKey] ?? matched,\n    );\n  };\n}\n\nexport function readJSON(vol: Volume, file: string): unknown {\n  return JSON.parse(vol.readFileSync(file).toString('utf-8'));\n}\n"
  },
  {
    "path": "test/integration/volume.ts",
    "content": "import { createFsFromVolume as memfsCreateFsFromVolume } from 'memfs';\nimport { Volume } from 'memfs/lib/volume';\nimport { InputFileSystem, OutputFileSystem } from 'webpack';\n\nexport const createFsFromVolume = memfsCreateFsFromVolume as unknown as (\n  ...args: Parameters<typeof memfsCreateFsFromVolume>\n) => Volume & InputFileSystem & OutputFileSystem;\n\nexport { Volume };\n"
  },
  {
    "path": "test/setup.ts",
    "content": "import 'jest-extended';\n"
  },
  {
    "path": "tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"exclude\": [\"test\", \"jest.config.ts\"]\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"target\": \"es6\",\n    \"module\": \"commonjs\",\n    \"moduleResolution\": \"node\",\n    \"declaration\": true,\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"strictFunctionTypes\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noImplicitReturns\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"importHelpers\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"experimentalDecorators\": true,\n    \"sourceMap\": true,\n    \"outDir\": \"./dist/\",\n    \"types\": [\"node\", \"jest\"],\n    \"lib\": [\"ESNext\"],\n    \"emitDecoratorMetadata\": true,\n    \"paths\": {\n      \"webpack-userscript\": [\"lib\"],\n      \"webpack-userscript/*\": [\"lib/*\"],\n      \"class-transformer/cjs/storage\": [\"node_modules/class-transformer/types/storage\"]\n    }\n  },\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "typedoc.json",
    "content": "{\n  \"entryPoints\": [\"lib/index.ts\"],\n  \"out\": \"docs\"\n}\n"
  }
]