diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b2a395..1da6296 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,10 +45,11 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - registry-url: 'https://registry.npmjs.org' - always-auth: true + + - name: Update npm + run: npm install -g npm@latest - name: Install dependencies run: npm ci @@ -63,8 +64,6 @@ jobs: DEBUG: release-it:*,@release-it/* HUSKY: 0 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | VERSION_ARG="" if [ -n "${{ inputs.version }}" ]; then @@ -94,4 +93,3 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} env: HUSKY: 0 - diff --git a/.gitignore b/.gitignore index 18c6b09..fdbed48 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ stats.html .tool-versions .cache *-stats.txt +.npmrc diff --git a/.husky/pre-commit b/.husky/pre-commit index 7a43b16..1118438 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,5 @@ #!/usr/bin/env sh -# Husky pre-commit hook: format and run related tests on staged files +# Husky pre-commit hook: format and lint staged files -npm run lint:fix:aggressive || exit 1; -npm run test || exit 1; -npm run format || exit 1; +npx --no -- lint-staged diff --git a/.husky/pre-push b/.husky/pre-push index 6ea0353..4c4af85 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -4,4 +4,3 @@ npm run typecheck || exit 1 npm run test:ci || exit 1 -npm run build || exit 1 diff --git a/.mailmap b/.mailmap deleted file mode 100644 index 2f327b1..0000000 --- a/.mailmap +++ /dev/null @@ -1,2 +0,0 @@ -Addon Stack <191148085+addon-stack@users.noreply.github.com> -Addon Stack \ No newline at end of file diff --git a/.release-it.cjs b/.release-it.cjs index f39a630..f979ad8 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -107,7 +107,71 @@ const types = new Map([ const normalizeRepoUrl = url => url.replace(/^git\+/, "").replace(/\.git$/, ""); const repoUrl = pkg?.repository?.url ? normalizeRepoUrl(pkg.repository.url) : null; -module.exports = () => { +const breakingChangePattern = /\bBREAKING(?: |-)?CHANGE\b/i; + +function hasBreakingChange(commit) { + if (commit.breaking) { + return true; + } + + const type = String(commit.type || "").trim(); + + if (type.endsWith("!")) { + return true; + } + + if (typeof commit.header === "string" && /^\w+(?:\([^)]+\))?!:/.test(commit.header)) { + return true; + } + + if ( + commit.notes?.some(note => + [note.title, note.text].some(value => typeof value === "string" && breakingChangePattern.test(value)) + ) + ) { + return true; + } + + return typeof commit.footer === "string" && breakingChangePattern.test(commit.footer); +} + +function whatBump(commits, currentVersion = pkg.version) { + let isBreaking = false; + let isMinor = false; + let isPatch = false; + + for (const commit of commits) { + if (hasBreakingChange(commit)) { + isBreaking = true; + } + + const type = String(commit.type || "") + .trim() + .toLowerCase() + .replace(/!+$/, ""); + + if (["feat", "revert"].includes(type)) { + isMinor = true; + } + + if (["fix", "perf", "refactor", "ci"].includes(type)) { + isPatch = true; + } + } + + if (isBreaking) { + const currentMajor = Number.parseInt(String(currentVersion).replace(/^v/i, "").split(".")[0], 10); + + return {level: Number.isNaN(currentMajor) || currentMajor >= 1 ? 0 : 1}; + } + + if (isMinor) return {level: 1}; + if (isPatch) return {level: 2}; + + return null; +} + +const createReleaseConfig = () => { const contributors = getContributors(); return { @@ -139,8 +203,11 @@ module.exports = () => { npm: { publish: true, + skipChecks: true, + provenance: true, + access: "public", + registry: "https://registry.npmjs.org/", versionArgs: ["--no-git-tag-version"], - publishArgs: ["--provenance", "--access", "public"], }, plugins: { @@ -165,37 +232,7 @@ module.exports = () => { contributors, }, - recommendedBumpOpts: { - preset: "conventionalcommits", - whatBump: commits => { - let isMajor = false; - let isMinor = false; - let isPatch = false; - - for (const commit of commits) { - if (commit.notes?.some(n => /BREAKING CHANGE/i.test(n.title || n.text || ""))) { - isMajor = true; - break; - } - - const type = (commit.type || "").toLowerCase(); - - if (type === "feat") { - isMinor = true; - } - - if (["fix", "perf", "refactor", "ci"].includes(type)) { - isPatch = true; - } - } - - if (isMajor) return {level: 0}; - if (isMinor) return {level: 1}; - if (isPatch) return {level: 2}; - - return null; - }, - }, + whatBump, writerOpts: { headerPartial: "## πŸš€ Release {{#if name}}`{{name}}` {{else}}{{#if @root.pkg}}`{{@root.pkg.name}}` {{/if}}{{/if}}v{{version}} ({{date}})\n\n", @@ -254,3 +291,5 @@ module.exports = () => { }, }; }; + +module.exports = Object.assign(createReleaseConfig, {whatBump}); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6827e25..80076f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ # Contributing to @addon-core/inject-css -This document outlines the process for contributing, reporting issues, and submitting patches. By participating, you agree to abide by the project’s [Code of Conduct](CODE_OF_CONDUCT.md). +This package provides one typed CSS insertion and removal contract across Manifest V2 and Manifest V3. Contributions should preserve that boundary, keep unsupported capabilities explicit, and include verification for every affected adapter. By participating, you agree to abide by the project’s [Code of Conduct](CODE_OF_CONDUCT.md). ## Table of Contents @@ -70,17 +70,32 @@ The following scripts are available and should be used during development: - `npm run build` β€” build the project with tsup - `npm run build:watch` β€” build in watch mode +- `npm run dev` β€” build in watch mode - `npm run format` β€” format code with Biome - `npm run format:check` β€” check formatting only - `npm run lint` β€” lint code with Biome - `npm run lint:fix` β€” attempt to automatically fix lint issues - `npm run lint:fix:aggressive` β€” fix lint issues using unsafe rules +- `npm run lint:fix:unsafe` β€” fix lint issues using unsafe rules - `npm run typecheck` β€” run TypeScript type checks -- `npm run test` β€” run tests with Jest +- `npm run test:types` β€” type-check public API contract fixtures +- `npm run test` β€” type-check fixtures, build the package, and run Jest - `npm run test:ci` β€” run tests in CI with coverage - `npm run release` β€” trigger release via release-it -Note: Husky hooks are configured. On commit, your message is validated with commitlint; on pre-commit, linting/formatting/tests are run. +Note: Husky hooks are configured. Commit messages are validated with commitlint, pre-commit runs `lint-staged`, and pre-push runs the full type/test/build path. + +## Contract Guidelines + +- Every operation has one explicit `target`. +- `allFrames`, `frameIds`, and `documentIds` remain mutually exclusive. +- Unsupported targets and options fail explicitly; they are never removed silently or replaced with a broader target. +- `insert()`, `file()`, `remove()`, and `removeFile()` remain strict `Promise` operations because native CSS APIs expose no portable per-frame result. +- File order is part of the CSS cascade contract and must be preserved. +- Omitted options preserve native defaults. +- Explicit `undefined` values passed to `options()` reset the corresponding option. +- Removal must preserve the caller's exact source, target, and origin; the package does not track prior insertions. +- Frame enumeration, document discovery, and application-specific result aggregation remain outside this package. ## Pull Request Workflow @@ -98,7 +113,7 @@ Note: Husky hooks are configured. On commit, your message is validated with comm npm run test ``` 4. Commit changes using [Conventional Commits](https://www.conventionalcommits.org/). -5. Push to your fork and open a Pull Request against the `main` branch. +5. Push to your fork and open a Pull Request against the `develop` branch. 6. Provide a clear title and description, referencing related issues (e.g., `Closes #123`). ## Code Style & Quality diff --git a/README.md b/README.md index 31cbd7e..10262a0 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,277 @@ # @addon-core/inject-css -[![npm version](https://img.shields.io/npm/v/%40addon-core%2Finject-css.svg?logo=npm)](https://www.npmjs.com/package/@addon-core/inject-css) -[![npm downloads](https://img.shields.io/npm/dm/%40addon-core%2Finject-css.svg)](https://www.npmjs.com/package/@addon-core/inject-css) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) -[![CI](https://github.com/addon-stack/inject-css/actions/workflows/ci.yml/badge.svg)](https://github.com/addon-stack/inject-css/actions/workflows/ci.yml) +[![npm version](https://img.shields.io/npm/v/%40addon-core%2Finject-css.svg?logo=npm&style=for-the-badge)](https://www.npmjs.com/package/@addon-core/inject-css) +[![npm downloads](https://img.shields.io/npm/dm/%40addon-core%2Finject-css.svg?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@addon-core/inject-css) +[![CI](https://img.shields.io/github/actions/workflow/status/addon-stack/inject-css/ci.yml?style=for-the-badge)](https://github.com/addon-stack/inject-css/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE.md) -A lightweight, TypeScript-ready library for injecting CSS into browser extension pages. -Automatically detects Chrome Extension Manifest V2 and V3 and delegates to the appropriate API via [@addon-core/browser](https://github.com/addon-stack/browser). +Insert and remove CSS code or extension stylesheets in browser tabs with one typed API for Manifest V2 and Manifest V3. -## Table of Contents +`@addon-core/inject-css` selects the correct native adapter, validates the target before delivery, and keeps unsupported browser behavior explicit. -- [Installation](#installation) -- [Usage](#usage) - - [Injecting CSS Code](#injecting-css-code) - - [Injecting CSS Files](#injecting-css-files) - - [Updating Options](#updating-options) -- [API](#api) -- [Options](#options) -- [Examples](#examples) -- [License](#license) +- One target model for the top frame, all frames, selected frames, or selected documents +- Runtime validation that matches the TypeScript contract +- Ordered stylesheet injection +- Matching stylesheet removal where the browser exposes a native removal API +- Stable package errors for invalid, unsupported, failed, and timed-out operations +- No silent selector fallback and no extra frame-enumeration permissions -## Installation - -### npm: +## Install ```bash npm install @addon-core/inject-css ``` -### pnpm: - ```bash pnpm add @addon-core/inject-css ``` -### yarn: +Your extension still needs the native permissions required for CSS injection, including `scripting` in MV3 and appropriate host or `activeTab` access. The package does not modify the manifest. -```bash -yarn add @addon-core/inject-css +## Quick start + +```ts +import injectCss from "@addon-core/inject-css"; + +const injector = injectCss({ + target: {tabId: 123}, +}); + +await injector.insert("body { background: #f5f5f5; }"); + +// The source, target, and origin match the insertion. +await injector.remove("body { background: #f5f5f5; }"); ``` +The package detects the current manifest version automatically. Insertion uses `tabs.insertCSS` in MV2 and `scripting.insertCSS` in MV3; removal uses the matching native removal API when available. + +## Choose what to target + +Every injector has exactly one target. Selectors are mutually exclusive in TypeScript and validated again at runtime. -## Usage +| Need | Target | +| --- | --- | +| Main frame | `{tabId: 123}` | +| Every injectable frame | `{tabId: 123, allFrames: true}` | +| One frame | `{tabId: 123, frameIds: [7]}` | +| Selected frames | `{tabId: 123, frameIds: [0, 7, 12]}` | +| Selected documents | `{tabId: 123, documentIds: ["document-a", "document-b"]}` | ```ts -import injectCss, {InjectCssOptions} from "@addon-core/inject-css"; +const topFrame = injectCss({ + target: {tabId: 123}, +}); + +const selectedFrames = injectCss({ + target: {tabId: 123, frameIds: [0, 7]}, +}); + +const allFrames = injectCss({ + target: {tabId: 123, allFrames: true}, +}); +``` + +`allFrames` accepts only the literal `true`. Omitting a selector means the top frame; there is no `allFrames: false` mode. -// Initialize an injector with a target tab ID -const injector = injectCss({tabId: 123}); +`documentIds` require an MV3 runtime with native document targeting. An unsupported target throws `UnsupportedInjectCssTargetError`; the package never drops `documentIds` or falls back to a broader target. -// Inject raw CSS code into the page -await injector.insert("body { background-color: #f0f0f0; }"); +`allFrames` remains one native browser operation. The package does not enumerate frames or promise an exhaustive frame snapshot. -// Inject one or more CSS files (paths relative to extension) -await injector.file("styles/main.css"); -await injector.file(["styles/reset.css", "styles/theme.css"]); +## Insert CSS code -// Update options dynamically and reuse the injector -injector.options({frameId: true, origin: "USER"}); -await injector.insert("p { color: red; }"); +```ts +await injector.insert(` + html { + color-scheme: dark; + } + + body { + background: #111; + color: #eee; + } +`); ``` -### Injecting CSS Code +The CSS source must be a non-empty string. -Use the `insert(css: string)` method to inject raw CSS code. +## Insert CSS files -### Injecting CSS Files +```ts +await injector.file("styles/content.css"); -Use the `file(files: string | string[])` method to inject CSS file(s). +await injector.file([ + "styles/reset.css", + "styles/theme.css", +]); +``` -### Updating Options +File lists must be non-empty and every path must be a non-empty string. Files are injected in the provided order. In MV2, one file completes for the requested target before the next file starts, preserving CSS cascade order. -Use the `options(opts: Partial)` method to merge or override options on an existing injector instance. +## Remove CSS -## API +Remove CSS code with `remove()` and extension stylesheets with `removeFile()`: -### `injectCss(options: InjectCssOptions): InjectCssContract` +```ts +await injector.remove("body { background: #f5f5f5; }"); -Creates a new CSS injector instance. Detects the manifest version (V2 or V3) via `@addon-core/browser` and delegates to the appropriate implementation. +await injector.removeFile("styles/content.css"); -#### `InjectCssContract` +await injector.removeFile([ + "styles/reset.css", + "styles/theme.css", +]); +``` -- `insert(css: string): Promise` β€” Inject raw CSS code. -- `file(files: string | string[]): Promise` β€” Inject one or more CSS files. -- `options(opts: Partial): this` β€” Update injector options. +Removal uses the injector's current target and origin. The CSS source, file list, target, and origin must match the values used for insertion. Removing a stylesheet that is not present is a native no-op. -## Options +MV3 uses `scripting.removeCSS`. MV2 uses `tabs.removeCSS` only when the current browser exposes it; otherwise removal rejects with `UnsupportedInjectCssOperationError`. This capability is checked when removal is requested, so insertion remains available in MV2 browsers without `tabs.removeCSS`. -The injector accepts the following options (passed to `injectCss(options)` and/or `injector.options(opts)`): +MV2 removes multiple files sequentially in the provided order, matching its insertion behavior. `runAt` affects insertion only because the MV2 removal API has no corresponding field. -| Option | Type | Description | -| --------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| tabId | number | Required. Target browser tab ID. | -| frameId | boolean \| number \| number[] | Optional. Select frames to inject into: `true` for all frames; number or array for specific. | -| matchAboutBlank | boolean | Optional. (V2 only) Include `about:blank` and similar subframes. Defaults to `true`. | -| runAt | 'document_start' \| 'document_end' \| 'document_idle' | Optional. (V2 only) Injection timing, matches Chrome's `runAt` in `insertCSS`. | -| documentId | string \| string[] | Optional. (V3 only) Document IDs for scripting targets. | -| origin | 'AUTHOR' \| 'USER' | Optional. CSS origin matching Chrome's API (`cssOrigin` in V2, `origin` in V3). | +## Reuse an injector -## Examples +Replace the complete target with `target()`: ```ts -import injectCss from "@addon-core/inject-css"; +injector + .target({tabId: 123, frameIds: [7]}) + .target({tabId: 123, allFrames: true}); +``` + +The second call replaces the previous selector instead of merging with it. A validation failure leaves the existing target unchanged. + +Update only execution options with `options()`: + +```ts +injector.options({ + origin: "USER", + timeoutMs: 8_000, +}); +``` + +`options()` never accepts or changes a target. Passing an explicit `undefined` resets that option instead of retaining its previous value: -// Initialize with a mix of options +```ts +injector.options({ + origin: undefined, + timeoutMs: undefined, +}); +``` + +The next operation then uses the native origin default and the package's default timeout. + +## Execution options + +The portable baseline is to omit adapter-specific options: + +```ts const injector = injectCss({ - tabId: 123, - frameId: [1, 2], // (V2 & V3) - runAt: "document_end", // (V2 only) - documentId: "main-doc-id", // (V3 only) - origin: "AUTHOR", // 'AUTHOR' or 'USER' + target: {tabId: 123}, + origin: "AUTHOR", + timeoutMs: 5_000, }); +``` + +| Option | MV2 | MV3 | +| --- | --- | --- | +| `origin` | Mapped to `author` or `user` | Passed as `AUTHOR` or `USER` | +| `timeoutMs` | Supported; default `4_000` ms | Supported; default `4_000` ms | +| `matchAboutBlank` | Passed only when explicitly set | Rejected; no native equivalent | +| `runAt` | Passed to `tabs.insertCSS` | Rejected; no native equivalent | + +When `matchAboutBlank` is omitted, the package preserves the native default instead of forcing it to `true`. + +Explicit unsupported options throw `UnsupportedInjectCssOptionError`. They are never ignored silently. + +## Handle failures + +```ts +import {InjectCssBaseError} from "@addon-core/inject-css"; + +try { + await injector.file("styles/content.css"); +} catch (error) { + if (error instanceof InjectCssBaseError) { + console.error(error.code, error.message, error.cause); + } else { + throw error; + } +} +``` + +Every rejected package operation exposes an error derived from `InjectCssBaseError` with a stable `code`. Delivery and timeout errors also retain the request target and expose `operation` as `"insert"` or `"remove"`. For explicit MV2 frame targets, a delivery error may contain an `InjectCssFrameDeliveryError` cause with code `ERR_INJECT_CSS_FRAME_DELIVERY`, the failed `tabId`, `frameId`, operation, and native cause. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist. -// Inject raw CSS code -await injector.insert("body { background-color: #fafafa; }"); +Known validation and adapter incompatibilities fail before delivery. Browser capabilities discovered only by a native call are normalized after that call. -// Inject one or more CSS files -await injector.file(["styles/reset.css", "styles/theme.css"]); +### What `Promise` means + +Native CSS injection and removal APIs do not provide a portable per-frame result. `insert()`, `file()`, `remove()`, and `removeFile()` therefore resolve with no value. + +A resolved promise means the native operation completed. It does not prove that CSS was inserted or removed in every requested frame. A rejected multi-target operation is not transactional: some targets or earlier files may already have completed the requested change. + +In MV2, a timeout stops the package from starting later files in a sequential insertion or removal batch. In MV3, the complete file list is handed to the browser in one native call before a timeout can occur. In either adapter, a timeout cannot cancel a native browser operation that is already in progress. + +## API reference + +The factory is available as both a default and named export: + +```ts +import injectCss from "@addon-core/inject-css"; +import {injectCss} from "@addon-core/inject-css"; ``` +```ts +interface InjectCssContract { + insert(css: string): Promise; + file(files: string | NonEmptyReadonlyArray): Promise; + remove(css: string): Promise; + removeFile(files: string | NonEmptyReadonlyArray): Promise; + target(target: InjectCssTarget): this; + options(options: InjectCssExecutionOptionsPatch): this; +} +``` + +Runtime exports: + +```text +injectCss +InjectCssBaseError +InjectCssDeliveryError +InjectCssFrameDeliveryError +InjectCssTimeoutError +InvalidInjectCssCodeError +InvalidInjectCssFilesError +InvalidInjectCssOptionsError +InvalidInjectCssTargetError +UnsupportedInjectCssOptionError +UnsupportedInjectCssOperationError +UnsupportedInjectCssTargetError +``` + +Core type exports: + +```text +InjectCssContract +InjectCssOptions +InjectCssExecutionOptions +InjectCssExecutionOptionsPatch +InjectCssOperation +InjectCssOrigin +InjectCssTarget +InjectCssTopFrameTarget +InjectCssAllFramesTarget +InjectCssFramesTarget +InjectCssDocumentsTarget +InjectCssErrorCode +NonEmptyReadonlyArray +``` + +## Design boundaries + +The package focuses on portable programmatic CSS insertion and removal. It does not enumerate frames, discover document IDs, track which stylesheets were inserted, aggregate application-specific per-frame results, or claim atomic delivery across targets. + +Callers remain responsible for retaining the exact source, target, and origin needed for later removal. ## License -MIT Β© Addon Stack +[MIT](LICENSE.md) diff --git a/SECURITY.md b/SECURITY.md index f6e9282..9f0de63 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ Please report security issues privately and avoid opening public issues with exp When reporting, please include (if possible): -- Affected version(s) and package name (adnbn) and how you installed it +- Affected version(s), the package name (`@addon-core/inject-css`), and how you installed it - Environment details (OS, Node.js version, browser/runtime, relevant configs) - Steps to reproduce and a minimal proof of concept (PoC) - Impact assessment (what an attacker can do and likely severity) diff --git a/biome.json b/biome.json index 22ecd90..88aad84 100644 --- a/biome.json +++ b/biome.json @@ -25,6 +25,7 @@ "includes": [ "src/**/*.{ts,tsx,js,jsx}", "!src/**/*.test.{ts,tsx,js,jsx}", + "tests/**/*.{ts,tsx,js,jsx,cjs,mjs}", "**/*.{json,jsonc,md,mdx,cjs,mjs}", "!coverage/**", "!dist/**" diff --git a/package-lock.json b/package-lock.json index 1f200c9..fde45d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,29 +9,30 @@ "version": "0.3.1", "license": "MIT", "dependencies": { - "@addon-core/browser": "^0.2.2" + "@addon-core/browser": "^0.7.2" }, "devDependencies": { "@biomejs/biome": "^2.2.4", "@commitlint/cli": "^20.0.0", "@commitlint/config-conventional": "^20.0.0", "@release-it/conventional-changelog": "^10.0.1", - "@types/chrome": "^0.1.12", + "@types/chrome": "^0.2.7", "@types/jest": "^30.0.0", "husky": "^9.1.7", "jest": "^30.1.3", + "lint-staged": "^16.2.1", "release-it": "^19.0.5", "tsup": "^8.5.0", "typescript": "^5.9.2" } }, "node_modules/@addon-core/browser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.2.3.tgz", - "integrity": "sha512-q0wIKy682I8WxssCB5YmKzZzx1kD1wXur5ddCFD9zupZGmY7k+F7+w3gPh983oCGLDpTEk72s19cUfdQppIXGg==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.7.2.tgz", + "integrity": "sha512-noDIPQktJOl7HVFUBZONL1iAVK4Spq1K+JgtN3OTsNPSvkgogrWUvpjq00p99R02y+YWORR70gu3tR+NDh09vw==", "license": "MIT", - "peerDependencies": { - "@types/chrome": "*" + "dependencies": { + "@types/chrome": "^0.2.2" } }, "node_modules/@babel/code-frame": { @@ -1456,16 +1457,6 @@ "node": ">=18" } }, - "node_modules/@hutson/parse-repository-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@hutson/parse-repository-url/-/parse-repository-url-5.0.0.tgz", - "integrity": "sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@inquirer/ansi": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz", @@ -2753,25 +2744,99 @@ } }, "node_modules/@release-it/conventional-changelog": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-10.0.1.tgz", - "integrity": "sha512-Qp+eyMGCPyq5xiWoNK91cWVIR/6HD1QAUNeG6pV2G4kxotWl81k/KDQqDNvrNVmr9+zDp53jI7pVVYQp6mi4zA==", + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-10.0.6.tgz", + "integrity": "sha512-aUb0IkcsBTMcOH5PPQ9Jv9lEOOVu2+rSgkE1ny+dzsTziQm2BhDRAtaFK/dw/HflthuXMWrqhhyfJhAV1AOEPQ==", "dev": true, "license": "MIT", "dependencies": { + "@conventional-changelog/git-client": "^2.6.0", "concat-stream": "^2.0.0", - "conventional-changelog": "^6.0.0", - "conventional-recommended-bump": "^10.0.0", - "git-semver-tags": "^8.0.0", - "semver": "^7.6.3" + "conventional-changelog": "^7.2.0", + "conventional-changelog-angular": "^8.3.0", + "conventional-changelog-conventionalcommits": "^9.3.0", + "conventional-recommended-bump": "^11.2.0", + "semver": "^7.7.4" }, "engines": { - "node": "^20.9.0 || >=22.0.0" + "node": "^20.12.0 || >=22.0.0" }, "peerDependencies": { "release-it": "^18.0.0 || ^19.0.0" } }, + "node_modules/@release-it/conventional-changelog/node_modules/@conventional-changelog/git-client": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", + "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.4.0" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } + } + }, + "node_modules/@release-it/conventional-changelog/node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@release-it/conventional-changelog/node_modules/conventional-changelog-conventionalcommits": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", + "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@release-it/conventional-changelog/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", @@ -3080,6 +3145,48 @@ "win32" ] }, + "node_modules/@simple-libs/child-process-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", + "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/hosted-git-info": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/hosted-git-info/-/hosted-git-info-1.0.2.tgz", + "integrity": "sha512-aAmGQdMH+ZinytKuA2832u0ATeOFNYNk4meBEXtB5xaPotUgggYNhq5tYU/v17wEbmTW5P9iHNqNrFyrhnqBAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -3171,9 +3278,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.1.24", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.24.tgz", - "integrity": "sha512-9iO9HL2bMeGS4C8m6gNFWUyuPE5HEUFk+rGh+7oriUjg+ata4Fc9PoVlu8xvGm7yoo3AmS3J6fAjoFj61NL2rw==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.7.tgz", + "integrity": "sha512-9kjBozQ+jyDVt1eai3VZqjHDTN95JCuRmbRHOryOltBJmzrTmUw/9r/DznmjRaQ8WlyIfeCA4WNzoj0IvormJA==", "license": "MIT", "dependencies": { "@types/filesystem": "*", @@ -3280,13 +3387,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3600,13 +3700,6 @@ "node": ">=0.4.0" } }, - "node_modules/add-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", - "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4194,6 +4287,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4329,6 +4456,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4391,23 +4525,24 @@ } }, "node_modules/conventional-changelog": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-6.0.0.tgz", - "integrity": "sha512-tuUH8H/19VjtD9Ig7l6TQRh+Z0Yt0NZ6w/cCkkyzUbGQTnUEmKfGtkC9gGfVgCfOL1Rzno5NgNF4KY8vR+Jo3w==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-7.2.1.tgz", + "integrity": "sha512-smcpqCeNRCAoL586/Iql8GfVsduP1JDrr7SAzKHWM5C5voDzKhc3QtqwVWeKMhvVw4iYfAr+znz1TF4kPXVm3A==", "dev": true, "license": "MIT", "dependencies": { - "conventional-changelog-angular": "^8.0.0", - "conventional-changelog-atom": "^5.0.0", - "conventional-changelog-codemirror": "^5.0.0", - "conventional-changelog-conventionalcommits": "^8.0.0", - "conventional-changelog-core": "^8.0.0", - "conventional-changelog-ember": "^5.0.0", - "conventional-changelog-eslint": "^6.0.0", - "conventional-changelog-express": "^5.0.0", - "conventional-changelog-jquery": "^6.0.0", - "conventional-changelog-jshint": "^5.0.0", - "conventional-changelog-preset-loader": "^5.0.0" + "@conventional-changelog/git-client": "^2.7.0", + "@simple-libs/hosted-git-info": "^1.0.2", + "@types/normalize-package-data": "^2.4.4", + "conventional-changelog-preset-loader": "^5.0.0", + "conventional-changelog-writer": "^8.4.0", + "conventional-commits-parser": "^6.4.0", + "fd-package-json": "^2.0.0", + "meow": "^13.0.0", + "normalize-package-data": "^7.0.0" + }, + "bin": { + "conventional-changelog": "dist/cli/index.js" }, "engines": { "node": ">=18" @@ -4426,26 +4561,6 @@ "node": ">=16" } }, - "node_modules/conventional-changelog-atom": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-atom/-/conventional-changelog-atom-5.0.0.tgz", - "integrity": "sha512-WfzCaAvSCFPkznnLgLnfacRAzjgqjLUjvf3MftfsJzQdDICqkOOpcMtdJF3wTerxSpv2IAAjX8doM3Vozqle3g==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-codemirror": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-codemirror/-/conventional-changelog-codemirror-5.0.0.tgz", - "integrity": "sha512-8gsBDI5Y3vrKUCxN6Ue8xr6occZ5nsDEc4C7jO/EovFGozx8uttCAyfhRrvoUAWi2WMm3OmYs+0mPJU7kQdYWQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, "node_modules/conventional-changelog-conventionalcommits": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", @@ -4459,36 +4574,45 @@ "node": ">=16" } }, - "node_modules/conventional-changelog-core": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-core/-/conventional-changelog-core-8.0.0.tgz", - "integrity": "sha512-EATUx5y9xewpEe10UEGNpbSHRC6cVZgO+hXQjofMqpy+gFIrcGvH3Fl6yk2VFKh7m+ffenup2N7SZJYpyD9evw==", + "node_modules/conventional-changelog-preset-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-5.0.0.tgz", + "integrity": "sha512-SetDSntXLk8Jh1NOAl1Gu5uLiCNSYenB5tm0YVeZKePRIgDW9lQImromTwLa3c/Gae298tsgOM+/CYT9XAl0NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-changelog-writer": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz", + "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", "dev": true, "license": "MIT", "dependencies": { - "@hutson/parse-repository-url": "^5.0.0", - "add-stream": "^1.0.0", - "conventional-changelog-writer": "^8.0.0", - "conventional-commits-parser": "^6.0.0", - "git-raw-commits": "^5.0.0", - "git-semver-tags": "^8.0.0", - "hosted-git-info": "^7.0.0", - "normalize-package-data": "^6.0.0", - "read-package-up": "^11.0.0", - "read-pkg": "^9.0.0" + "@simple-libs/stream-utils": "^1.2.0", + "conventional-commits-filter": "^5.0.0", + "handlebars": "^4.7.7", + "meow": "^13.0.0", + "semver": "^7.5.2" + }, + "bin": { + "conventional-changelog-writer": "dist/cli/index.js" }, "engines": { "node": ">=18" } }, - "node_modules/conventional-changelog-core/node_modules/@conventional-changelog/git-client": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-1.0.1.tgz", - "integrity": "sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw==", + "node_modules/conventional-changelog/node_modules/@conventional-changelog/git-client": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", + "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", "dev": true, "license": "MIT", "dependencies": { - "@types/semver": "^7.5.5", + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", "semver": "^7.5.2" }, "engines": { @@ -4496,7 +4620,7 @@ }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0" + "conventional-commits-parser": "^6.4.0" }, "peerDependenciesMeta": { "conventional-commits-filter": { @@ -4507,13 +4631,14 @@ } } }, - "node_modules/conventional-changelog-core/node_modules/conventional-commits-parser": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.0.tgz", - "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", + "node_modules/conventional-changelog/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, "license": "MIT", "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { @@ -4523,209 +4648,85 @@ "node": ">=18" } }, - "node_modules/conventional-changelog-core/node_modules/git-raw-commits": { + "node_modules/conventional-commits-filter": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.0.tgz", - "integrity": "sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg==", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", "dev": true, "license": "MIT", - "dependencies": { - "@conventional-changelog/git-client": "^1.0.0", - "meow": "^13.0.0" - }, - "bin": { - "git-raw-commits": "src/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-ember": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-ember/-/conventional-changelog-ember-5.0.0.tgz", - "integrity": "sha512-RPflVfm5s4cSO33GH/Ey26oxhiC67akcxSKL8CLRT3kQX2W3dbE19sSOM56iFqUJYEwv9mD9r6k79weWe1urfg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-eslint": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-eslint/-/conventional-changelog-eslint-6.0.0.tgz", - "integrity": "sha512-eiUyULWjzq+ybPjXwU6NNRflApDWlPEQEHvI8UAItYW/h22RKkMnOAtfCZxMmrcMO1OKUWtcf2MxKYMWe9zJuw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-express": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-express/-/conventional-changelog-express-5.0.0.tgz", - "integrity": "sha512-D8Q6WctPkQpvr2HNCCmwU5GkX22BVHM0r4EW8vN0230TSyS/d6VQJDAxGb84lbg0dFjpO22MwmsikKL++Oo/oQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-jquery": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-jquery/-/conventional-changelog-jquery-6.0.0.tgz", - "integrity": "sha512-2kxmVakyehgyrho2ZHBi90v4AHswkGzHuTaoH40bmeNqUt20yEkDOSpw8HlPBfvEQBwGtbE+5HpRwzj6ac2UfA==", - "dev": true, - "license": "ISC", "engines": { "node": ">=18" } }, - "node_modules/conventional-changelog-jshint": { + "node_modules/conventional-commits-parser": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-jshint/-/conventional-changelog-jshint-5.0.0.tgz", - "integrity": "sha512-gGNphSb/opc76n2eWaO6ma4/Wqu3tpa2w7i9WYqI6Cs2fncDSI2/ihOfMvXveeTTeld0oFvwMVNV+IYQIk3F3g==", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "compare-func": "^2.0.0" + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" }, "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/conventional-changelog-preset-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-5.0.0.tgz", - "integrity": "sha512-SetDSntXLk8Jh1NOAl1Gu5uLiCNSYenB5tm0YVeZKePRIgDW9lQImromTwLa3c/Gae298tsgOM+/CYT9XAl0NA==", + "node_modules/conventional-commits-parser/node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/conventional-changelog-writer": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.2.0.tgz", - "integrity": "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw==", + "node_modules/conventional-recommended-bump": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-11.2.0.tgz", + "integrity": "sha512-lqIdmw330QdMBgfL0e6+6q5OMKyIpy4OZNmepit6FS3GldhkG+70drZjuZ0A5NFpze5j85dlYs3GabQXl6sMHw==", "dev": true, "license": "MIT", "dependencies": { + "@conventional-changelog/git-client": "^2.5.1", + "conventional-changelog-preset-loader": "^5.0.0", "conventional-commits-filter": "^5.0.0", - "handlebars": "^4.7.7", - "meow": "^13.0.0", - "semver": "^7.5.2" + "conventional-commits-parser": "^6.1.0", + "meow": "^13.0.0" }, "bin": { - "conventional-changelog-writer": "dist/cli/index.js" + "conventional-recommended-bump": "dist/cli/index.js" }, "engines": { "node": ">=18" } }, - "node_modules/conventional-changelog/node_modules/conventional-changelog-angular": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", - "integrity": "sha512-CLf+zr6St0wIxos4bmaKHRXWAcsCXrJU6F4VdNDrGRK3B8LDLKoX3zuMV5GhtbGkVR/LohZ6MT6im43vZLSjmA==", + "node_modules/conventional-recommended-bump/node_modules/@conventional-changelog/git-client": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", + "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog/node_modules/conventional-changelog-conventionalcommits": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-8.0.0.tgz", - "integrity": "sha512-eOvlTO6OcySPyyyk8pKz2dP4jjElYunj9hn9/s0OB+gapTO8zwS9UQWrZ1pmF2hFs3vw1xhonOLGcGjy/zgsuA==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-commits-filter": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", - "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-commits-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", - "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-text-path": "^2.0.0", - "JSONStream": "^1.3.5", - "meow": "^12.0.1", - "split2": "^4.0.0" - }, - "bin": { - "conventional-commits-parser": "cli.mjs" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/conventional-commits-parser/node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/conventional-recommended-bump": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-10.0.0.tgz", - "integrity": "sha512-RK/fUnc2btot0oEVtrj3p2doImDSs7iiz/bftFCDzels0Qs1mxLghp+DFHMaOC0qiCI6sWzlTDyBFSYuot6pRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@conventional-changelog/git-client": "^1.0.0", - "conventional-changelog-preset-loader": "^5.0.0", - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0", - "meow": "^13.0.0" - }, - "bin": { - "conventional-recommended-bump": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-recommended-bump/node_modules/@conventional-changelog/git-client": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-1.0.1.tgz", - "integrity": "sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/semver": "^7.5.5", - "semver": "^7.5.2" + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" }, "engines": { "node": ">=18" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0" + "conventional-commits-parser": "^6.4.0" }, "peerDependenciesMeta": { "conventional-commits-filter": { @@ -4737,12 +4738,13 @@ } }, "node_modules/conventional-recommended-bump/node_modules/conventional-commits-parser": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.0.tgz", - "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, "license": "MIT", "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { @@ -5030,6 +5032,19 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5171,6 +5186,13 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5288,6 +5310,16 @@ "bser": "2.1.1" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5319,19 +5351,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-up-simple": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", - "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -5387,9 +5406,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -5486,67 +5505,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/git-semver-tags": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/git-semver-tags/-/git-semver-tags-8.0.0.tgz", - "integrity": "sha512-N7YRIklvPH3wYWAR2vysaqGLPRcpwQ0GKdlqTiVN5w1UmCdaeY3K8s6DMKRCh54DDdzyt/OAB6C8jgVtb7Y2Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@conventional-changelog/git-client": "^1.0.0", - "meow": "^13.0.0" - }, - "bin": { - "git-semver-tags": "src/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/git-semver-tags/node_modules/@conventional-changelog/git-client": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-1.0.1.tgz", - "integrity": "sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/semver": "^7.5.5", - "semver": "^7.5.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0" - }, - "peerDependenciesMeta": { - "conventional-commits-filter": { - "optional": true - }, - "conventional-commits-parser": { - "optional": true - } - } - }, - "node_modules/git-semver-tags/node_modules/conventional-commits-parser": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.0.tgz", - "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/git-up": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/git-up/-/git-up-8.1.1.tgz", @@ -5611,9 +5569,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5643,16 +5601,16 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", + "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^10.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/html-escaper": { @@ -5801,19 +5759,6 @@ "node": ">=0.8.19" } }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7315,6 +7260,127 @@ "dev": true, "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/lint-staged/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -7456,6 +7522,131 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -7780,18 +7971,18 @@ "license": "MIT" }, "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz", + "integrity": "sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^8.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/normalize-path": { @@ -8410,88 +8601,6 @@ "dev": true, "license": "MIT" }, - "node_modules/read-package-up": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", - "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up-simple": "^1.0.0", - "read-pkg": "^9.0.0", - "type-fest": "^4.6.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-package-up/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8681,6 +8790,13 @@ "node": ">= 4" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.52.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", @@ -8785,9 +8901,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -8837,6 +8953,52 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -8930,9 +9092,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -8989,6 +9151,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9227,11 +9399,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -9596,6 +9771,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -9925,6 +10110,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 14c0be8..2b1b12c 100644 --- a/package.json +++ b/package.json @@ -18,17 +18,18 @@ ], "repository": { "type": "git", - "url": "https://github.com/addon-stack/inject-css" + "url": "git+https://github.com/addon-stack/inject-css.git" + }, + "publishConfig": { + "access": "public", + "provenance": true }, "homepage": "https://github.com/addon-stack/inject-css", "bugs": { "url": "https://github.com/addon-stack/inject-css/issues" }, "license": "MIT", - "author": "Addon Stack ", - "contributors": [ - "Anjey Tsibylskij (https://github.com/atldays)" - ], + "author": "Anjey Tsibylskij (https://github.com/atldays)", "type": "module", "main": "dist/index.cjs", "module": "dist/index.js", @@ -48,29 +49,47 @@ "prepare": "husky", "build": "tsup", "build:watch": "tsup --watch", + "dev": "tsup --watch", "prepublishOnly": "npm run build", "format": "biome format --write .", "format:check": "biome check --formatter-enabled=true --linter-enabled=false .", "lint": "biome check .", "lint:fix": "biome check --write .", "lint:fix:aggressive": "biome check --write --unsafe .", + "lint:fix:unsafe": "biome check --write --unsafe .", + "test": "npm run test:types && npm run build && jest --bail", + "test:ci": "npm run test:types && npm run build && jest --ci --coverage", + "test:types": "tsc -p tests/tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "jest --bail --passWithNoTests", - "test:ci": "jest --ci --passWithNoTests --coverage", "release": "release-it" }, + "lint-staged": { + "{src,tests}/**/*.{js,jsx,ts,tsx,cjs,mjs}": [ + "biome check --write --unsafe" + ], + "*.{json,css,scss,html}": [ + "biome format --write" + ] + }, + "jest": { + "coverageProvider": "v8", + "testMatch": [ + "/tests/**/*.test.cjs" + ] + }, "dependencies": { - "@addon-core/browser": "^0.2.2" + "@addon-core/browser": "^0.7.2" }, "devDependencies": { "@biomejs/biome": "^2.2.4", "@commitlint/cli": "^20.0.0", "@commitlint/config-conventional": "^20.0.0", "@release-it/conventional-changelog": "^10.0.1", - "@types/chrome": "^0.1.12", + "@types/chrome": "^0.2.7", "@types/jest": "^30.0.0", "husky": "^9.1.7", "jest": "^30.1.3", + "lint-staged": "^16.2.1", "release-it": "^19.0.5", "tsup": "^8.5.0", "typescript": "^5.9.2" diff --git a/src/AbstractInjectCss.ts b/src/AbstractInjectCss.ts index d2127ed..78348e0 100644 --- a/src/AbstractInjectCss.ts +++ b/src/AbstractInjectCss.ts @@ -1,33 +1,116 @@ -import type {InjectCssContract, InjectCssOptions} from "./types"; +import {InjectCssDeliveryError, InjectCssTimeoutError} from "./errors"; +import { + validateInjectCssCode, + validateInjectCssExecutionOptions, + validateInjectCssFiles, + validateInjectCssOptions, + validateInjectCssTarget, +} from "./validation"; +import type { + InjectCssContract, + InjectCssExecutionOptions, + InjectCssExecutionOptionsPatch, + InjectCssOperation, + InjectCssOptions, + InjectCssTarget, + NonEmptyReadonlyArray, +} from "./types"; + +const DEFAULT_TIMEOUT_MS = 4_000; export default abstract class implements InjectCssContract { - constructor(protected _options: InjectCssOptions) {} + protected _target: InjectCssTarget; + protected _execution: InjectCssExecutionOptions; + + public constructor(options: InjectCssOptions) { + const normalized = validateInjectCssOptions(options); + + this._target = normalized.target; + this._execution = normalized.execution; + } + + public target(target: InjectCssTarget): this { + const normalizedTarget = validateInjectCssTarget(target); + + this.assertAdapterSupport(normalizedTarget, this._execution); + this._target = normalizedTarget; - public options(options: Partial): this { - this._options = {...this._options, ...options, tabId: options.tabId ?? this._options.tabId}; + return this; + } + + public options(options: InjectCssExecutionOptionsPatch): this { + const normalizedOptions = validateInjectCssExecutionOptions(options); + const nextExecution = {...this._execution, ...normalizedOptions}; + + this.assertAdapterSupport(this._target, nextExecution); + this._execution = nextExecution; return this; } public abstract insert(css: string): Promise; - public abstract file(files: string | string[]): Promise; + public abstract file(files: string | NonEmptyReadonlyArray): Promise; + + public abstract remove(css: string): Promise; + + public abstract removeFile(files: string | NonEmptyReadonlyArray): Promise; - protected get frameIds(): number[] | undefined { - const {frameId} = this._options; + protected abstract assertAdapterSupport(target: InjectCssTarget, execution: InjectCssExecutionOptions): void; - return typeof frameId === "number" ? [frameId] : typeof frameId !== "boolean" ? frameId : undefined; + protected validateCode(css: string): string { + return validateInjectCssCode(css); } - protected get allFrames(): boolean | undefined { - const {frameId} = this._options; + protected normalizeFiles(files: string | NonEmptyReadonlyArray): string[] { + return validateInjectCssFiles(files); + } + + protected snapshotTarget(): InjectCssTarget { + return validateInjectCssTarget(this._target); + } + + protected snapshotExecution(): InjectCssExecutionOptions { + return {...this._execution}; + } + + protected get timeoutMs(): number { + return this._execution.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + protected async withTimeout( + task: Promise, + target: InjectCssTarget, + timeoutMs: number, + operation: InjectCssOperation = "insert" + ): Promise { + return new Promise((resolve, reject) => { + let settled = false; + + const finish = (callback: () => void): void => { + if (settled) return; + + settled = true; + clearTimeout(timeoutId); + callback(); + }; + + const timeoutId = setTimeout(() => { + finish(() => reject(new InjectCssTimeoutError(target, timeoutMs, operation))); + }, timeoutMs); - return typeof frameId === "boolean" ? frameId : undefined; + task.then( + value => finish(() => resolve(value)), + error => finish(() => reject(error)) + ); + }); } - protected get matchAboutBlank(): boolean { - const {matchAboutBlank} = this._options; + protected deliveryError(target: InjectCssTarget, error: unknown, operation: InjectCssOperation = "insert"): Error { + if (error instanceof InjectCssDeliveryError || error instanceof InjectCssTimeoutError) { + return error; + } - return typeof matchAboutBlank === "boolean" ? matchAboutBlank : true; + return new InjectCssDeliveryError(target, error, operation); } } diff --git a/src/InjectCssV2.ts b/src/InjectCssV2.ts index 0bbdcdf..a6bf4ec 100644 --- a/src/InjectCssV2.ts +++ b/src/InjectCssV2.ts @@ -1,59 +1,192 @@ -import {insertCssTab} from "@addon-core/browser"; +import {browser, insertCssTab, removeCssTab} from "@addon-core/browser"; import AbstractInjectCss from "./AbstractInjectCss"; +import { + InjectCssFrameDeliveryError, + UnsupportedInjectCssOperationError, + UnsupportedInjectCssOptionError, + UnsupportedInjectCssTargetError, +} from "./errors"; +import {isUnsupportedOriginCapabilityError, isUnsupportedRemovalCapabilityError} from "./native-errors"; +import type { + InjectCssExecutionOptions, + InjectCssOperation, + InjectCssOptions, + InjectCssTarget, + NonEmptyReadonlyArray, +} from "./types"; type CSSOrigin = chrome.extensionTypes.CSSOrigin; type InjectDetails = chrome.extensionTypes.InjectDetails; export default class extends AbstractInjectCss { - public async insert(code: string): Promise { - const {tabId, runAt} = this._options; - - const details: InjectDetails = { - code, - runAt, - cssOrigin: this.cssOrigin, - matchAboutBlank: this.matchAboutBlank, + public constructor(options: InjectCssOptions) { + super(options); + this.assertAdapterSupport(this._target, this._execution); + } + + public async insert(css: string): Promise { + const code = this.validateCode(css); + + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + const details = this.createDetails("insert", execution, {code}); + + try { + await this.withTimeout(this.execute("insert", target, details), target, timeoutMs); + } catch (error) { + this.throwDeliveryError("insert", target, execution, error); + } + } + + public async file(files: string | NonEmptyReadonlyArray): Promise { + const fileList = this.normalizeFiles(files); + + await this.deliverFiles("insert", fileList); + } + + public async remove(css: string): Promise { + const code = this.validateCode(css); + + await this.removeSource({code}); + } + + public async removeFile(files: string | NonEmptyReadonlyArray): Promise { + const fileList = this.normalizeFiles(files); + + await this.deliverFiles("remove", fileList); + } + + protected assertAdapterSupport(target: InjectCssTarget, _execution: InjectCssExecutionOptions): void { + if ("documentIds" in target && target.documentIds !== undefined) { + throw new UnsupportedInjectCssTargetError('"documentIds" are not supported by the MV2 adapter.'); + } + } + + private createDetails( + operation: InjectCssOperation, + execution: InjectCssExecutionOptions, + source: Pick | Pick + ): InjectDetails { + return { + ...source, + ...(operation === "insert" && execution.runAt !== undefined ? {runAt: execution.runAt} : {}), + ...(execution.origin !== undefined ? {cssOrigin: execution.origin.toLowerCase() as CSSOrigin} : {}), + ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), }; + } - if (this.allFrames) { - await insertCssTab(tabId, {...details, allFrames: true}); - } else if (this.frameIds) { - await Promise.all(this.frameIds.map(frameId => insertCssTab(tabId, {...details, frameId}))); - } else { - await insertCssTab(tabId, details); + private async execute( + operation: InjectCssOperation, + target: InjectCssTarget, + details: InjectDetails + ): Promise { + const deliver = operation === "insert" ? insertCssTab : removeCssTab; + + if ("allFrames" in target && target.allFrames === true) { + await deliver(target.tabId, {...details, allFrames: true}); + return; + } + + if ("frameIds" in target && target.frameIds !== undefined) { + await Promise.all( + target.frameIds.map(frameId => + this.executeFrame(operation, target.tabId, frameId, {...details, frameId}) + ) + ); + return; + } + + await deliver(target.tabId, details); + } + + private async executeFrame( + operation: InjectCssOperation, + tabId: number, + frameId: number, + details: InjectDetails + ): Promise { + try { + const deliver = operation === "insert" ? insertCssTab : removeCssTab; + + await deliver(tabId, details); + } catch (error) { + throw new InjectCssFrameDeliveryError(tabId, frameId, error, operation); } } - public async file(files: string | string[]): Promise { - const {tabId, runAt} = this._options; + private async removeSource(source: Pick | Pick): Promise { + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; - const fileList = typeof files === "string" ? [files] : files; + this.assertRemovalSupport(); - const injectTasks: Promise[] = []; + try { + await this.withTimeout( + this.execute("remove", target, this.createDetails("remove", execution, source)), + target, + timeoutMs, + "remove" + ); + } catch (error) { + this.throwDeliveryError("remove", target, execution, error); + } + } - for (const file of fileList) { - const details: InjectDetails = { - file, - runAt, - cssOrigin: this.cssOrigin, - matchAboutBlank: this.matchAboutBlank, - }; + private async deliverFiles(operation: InjectCssOperation, fileList: readonly string[]): Promise { + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + let stopped = false; - if (this.allFrames) { - injectTasks.push(insertCssTab(tabId, {...details, allFrames: true})); - } else if (this.frameIds) { - injectTasks.push(...this.frameIds.map(frameId => insertCssTab(tabId, {...details, frameId}))); - } else { - injectTasks.push(insertCssTab(tabId, details)); + if (operation === "remove") { + this.assertRemovalSupport(); + } + + const task = (async (): Promise => { + for (const file of fileList) { + if (stopped) return; + + await this.execute(operation, target, this.createDetails(operation, execution, {file})); } + })(); + + try { + await this.withTimeout(task, target, timeoutMs, operation); + } catch (error) { + stopped = true; + this.throwDeliveryError(operation, target, execution, error); } + } - await Promise.all(injectTasks); + private assertRemovalSupport(): void { + if (typeof browser().tabs?.removeCSS !== "function") { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV2 browser does not expose "tabs.removeCSS".' + ); + } } - protected get cssOrigin(): CSSOrigin | undefined { - const {origin} = this._options; + private throwDeliveryError( + operation: InjectCssOperation, + target: InjectCssTarget, + execution: InjectCssExecutionOptions, + error: unknown + ): never { + if (operation === "remove" && isUnsupportedRemovalCapabilityError(error)) { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV2 browser does not support "tabs.removeCSS".', + error + ); + } + + if (execution.origin !== undefined && isUnsupportedOriginCapabilityError(error)) { + throw new UnsupportedInjectCssOptionError('"origin" is not supported by the current browser.', error); + } - return origin && (origin.toLowerCase() as CSSOrigin); + throw this.deliveryError(target, error, operation); } } diff --git a/src/InjectCssV3.ts b/src/InjectCssV3.ts index 8ccc941..10b3553 100644 --- a/src/InjectCssV3.ts +++ b/src/InjectCssV3.ts @@ -1,58 +1,175 @@ -import {insertCss} from "@addon-core/browser"; +import {browser, insertCss, removeCss} from "@addon-core/browser"; import AbstractInjectCss from "./AbstractInjectCss"; +import { + UnsupportedInjectCssOperationError, + UnsupportedInjectCssOptionError, + UnsupportedInjectCssTargetError, +} from "./errors"; +import { + isUnsupportedDocumentTargetCapabilityError, + isUnsupportedOriginCapabilityError, + isUnsupportedRemovalCapabilityError, +} from "./native-errors"; +import type { + InjectCssExecutionOptions, + InjectCssOperation, + InjectCssOptions, + InjectCssTarget, + NonEmptyReadonlyArray, +} from "./types"; +type CSSInjection = chrome.scripting.CSSInjection; type InjectionTarget = chrome.scripting.InjectionTarget; +type CSSSource = {css: string; files?: never} | {files: string[]; css?: never}; export default class extends AbstractInjectCss { + public constructor(options: InjectCssOptions) { + super(options); + this.assertAdapterSupport(this._target, this._execution); + } + public async insert(css: string): Promise { - await insertCss({ - target: this.target, - origin: this._options.origin, - css, - }); + const code = this.validateCode(css); + + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + + await this.execute( + "insert", + target, + execution, + { + target: this.toNativeTarget(target), + css: code, + ...(execution.origin !== undefined ? {origin: execution.origin} : {}), + }, + timeoutMs + ); } - public async file(fileList: string | string[]): Promise { - await insertCss({ - target: this.target, - origin: this._options.origin, - files: typeof fileList === "string" ? [fileList] : fileList, - }); + public async file(files: string | NonEmptyReadonlyArray): Promise { + const fileList = this.normalizeFiles(files); + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + + await this.execute( + "insert", + target, + execution, + { + target: this.toNativeTarget(target), + files: fileList, + ...(execution.origin !== undefined ? {origin: execution.origin} : {}), + }, + timeoutMs + ); } - protected get target(): InjectionTarget { - const target = {tabId: this._options.tabId}; + public async remove(css: string): Promise { + const code = this.validateCode(css); + + await this.removeInjection({css: code}); + } - if (this.frameIds && this.frameIds.length > 0) { - return {...target, frameIds: this.frameIds}; + public async removeFile(files: string | NonEmptyReadonlyArray): Promise { + const fileList = this.normalizeFiles(files); + + await this.removeInjection({files: fileList}); + } + + protected assertAdapterSupport(_target: InjectCssTarget, execution: InjectCssExecutionOptions): void { + if (execution.matchAboutBlank !== undefined) { + throw new UnsupportedInjectCssOptionError('"matchAboutBlank" is not supported by the MV3 adapter.'); } - if (this.allFrames === true) { - return {...target, allFrames: true}; + if (execution.runAt !== undefined) { + throw new UnsupportedInjectCssOptionError('"runAt" is not supported by the MV3 adapter.'); } + } - // Firefox does not support `documentIds` in the target - // getBrowserInfo is only available in firefox - let isFirefox = false; + private async execute( + operation: InjectCssOperation, + target: InjectCssTarget, + execution: InjectCssExecutionOptions, + injection: CSSInjection, + timeoutMs: number + ): Promise { try { - // @ts-expect-error - isFirefox = !!browser().runtime.getBrowserInfo; - } catch (_e) {} + const task = operation === "insert" ? insertCss(injection) : removeCss(injection); + + await this.withTimeout(task, target, timeoutMs, operation); + } catch (error) { + if ( + "documentIds" in target && + target.documentIds !== undefined && + isUnsupportedDocumentTargetCapabilityError(error) + ) { + throw new UnsupportedInjectCssTargetError( + '"documentIds" are not supported by the current browser.', + error + ); + } - if (!isFirefox) { - const documentIds = this.documentIds; + if (operation === "remove" && isUnsupportedRemovalCapabilityError(error)) { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV3 browser does not support "scripting.removeCSS".', + error + ); + } - if (documentIds && documentIds.length > 0) { - return {...target, documentIds}; + if (execution.origin !== undefined && isUnsupportedOriginCapabilityError(error)) { + throw new UnsupportedInjectCssOptionError('"origin" is not supported by the current browser.', error); } + + throw this.deliveryError(target, error, operation); } + } + + private async removeInjection(source: CSSSource): Promise { + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; - return target; + this.assertRemovalSupport(); + + await this.execute( + "remove", + target, + execution, + { + target: this.toNativeTarget(target), + ...source, + ...(execution.origin !== undefined ? {origin: execution.origin} : {}), + }, + timeoutMs + ); } - protected get documentIds(): string[] | undefined { - const {documentId} = this._options; + private assertRemovalSupport(): void { + if (typeof browser().scripting?.removeCSS !== "function") { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV3 browser does not expose "scripting.removeCSS".' + ); + } + } + + private toNativeTarget(target: InjectCssTarget): InjectionTarget { + if ("frameIds" in target && target.frameIds !== undefined) { + return {tabId: target.tabId, frameIds: [...target.frameIds]}; + } + + if ("documentIds" in target && target.documentIds !== undefined) { + return {tabId: target.tabId, documentIds: [...target.documentIds]}; + } + + if ("allFrames" in target && target.allFrames === true) { + return {tabId: target.tabId, allFrames: true}; + } - return typeof documentId === "string" ? [documentId] : documentId; + return {tabId: target.tabId}; } } diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..8507f7a --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,144 @@ +import type {InjectCssOperation, InjectCssTarget} from "./types"; + +export type InjectCssErrorCode = + | "ERR_INJECT_CSS_DELIVERY" + | "ERR_INJECT_CSS_FRAME_DELIVERY" + | "ERR_INJECT_CSS_INVALID_CODE" + | "ERR_INJECT_CSS_INVALID_FILES" + | "ERR_INJECT_CSS_INVALID_OPTIONS" + | "ERR_INJECT_CSS_INVALID_TARGET" + | "ERR_INJECT_CSS_TIMEOUT" + | "ERR_INJECT_CSS_UNSUPPORTED_OPTION" + | "ERR_INJECT_CSS_UNSUPPORTED_OPERATION" + | "ERR_INJECT_CSS_UNSUPPORTED_TARGET"; + +export class InjectCssBaseError extends Error { + public readonly code: InjectCssErrorCode; + public override readonly cause?: unknown; + + protected constructor(name: string, code: InjectCssErrorCode, message: string, cause?: unknown) { + super(message); + this.name = name; + this.code = code; + + if (cause !== undefined) { + this.cause = cause; + } + } +} + +export class InvalidInjectCssTargetError extends InjectCssBaseError { + public constructor(message: string) { + super("InvalidInjectCssTargetError", "ERR_INJECT_CSS_INVALID_TARGET", `Invalid InjectCss target: ${message}`); + } +} + +export class UnsupportedInjectCssTargetError extends InjectCssBaseError { + public constructor(message: string, cause?: unknown) { + super( + "UnsupportedInjectCssTargetError", + "ERR_INJECT_CSS_UNSUPPORTED_TARGET", + `Unsupported InjectCss target: ${message}`, + cause + ); + } +} + +export class InvalidInjectCssOptionsError extends InjectCssBaseError { + public constructor(message: string) { + super( + "InvalidInjectCssOptionsError", + "ERR_INJECT_CSS_INVALID_OPTIONS", + `Invalid InjectCss options: ${message}` + ); + } +} + +export class UnsupportedInjectCssOptionError extends InjectCssBaseError { + public constructor(message: string, cause?: unknown) { + super( + "UnsupportedInjectCssOptionError", + "ERR_INJECT_CSS_UNSUPPORTED_OPTION", + `Unsupported InjectCss option: ${message}`, + cause + ); + } +} + +export class UnsupportedInjectCssOperationError extends InjectCssBaseError { + public readonly operation: InjectCssOperation; + + public constructor(operation: InjectCssOperation, message: string, cause?: unknown) { + super( + "UnsupportedInjectCssOperationError", + "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + `Unsupported InjectCss operation "${operation}": ${message}`, + cause + ); + this.operation = operation; + } +} + +export class InvalidInjectCssCodeError extends InjectCssBaseError { + public constructor(message: string) { + super("InvalidInjectCssCodeError", "ERR_INJECT_CSS_INVALID_CODE", `Invalid InjectCss code: ${message}`); + } +} + +export class InvalidInjectCssFilesError extends InjectCssBaseError { + public constructor(message: string) { + super("InvalidInjectCssFilesError", "ERR_INJECT_CSS_INVALID_FILES", `Invalid InjectCss files: ${message}`); + } +} + +export class InjectCssTimeoutError extends InjectCssBaseError { + public readonly target: InjectCssTarget; + public readonly timeoutMs: number; + public readonly operation: InjectCssOperation; + + public constructor(target: InjectCssTarget, timeoutMs: number, operation: InjectCssOperation = "insert") { + const action = operation === "insert" ? "injection" : "removal"; + + super("InjectCssTimeoutError", "ERR_INJECT_CSS_TIMEOUT", `CSS ${action} timed out after ${timeoutMs} ms.`); + this.target = target; + this.timeoutMs = timeoutMs; + this.operation = operation; + } +} + +export class InjectCssFrameDeliveryError extends InjectCssBaseError { + public readonly tabId: number; + public readonly frameId: number; + public readonly operation: InjectCssOperation; + + public constructor(tabId: number, frameId: number, cause: unknown, operation: InjectCssOperation = "insert") { + const message = cause instanceof Error ? cause.message : String(cause); + const action = operation === "insert" ? "injection" : "removal"; + + super( + "InjectCssFrameDeliveryError", + "ERR_INJECT_CSS_FRAME_DELIVERY", + `CSS ${action} failed in frame ${frameId} of tab ${tabId}: ${message}`, + cause + ); + this.tabId = tabId; + this.frameId = frameId; + this.operation = operation; + } +} + +export class InjectCssDeliveryError extends InjectCssBaseError { + public readonly target: InjectCssTarget; + public readonly operation: InjectCssOperation; + + public constructor(target: InjectCssTarget, cause: unknown, operation: InjectCssOperation = "insert") { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + const action = operation === "insert" ? "injection" : "removal"; + const message = + cause instanceof InjectCssFrameDeliveryError ? causeMessage : `CSS ${action} failed: ${causeMessage}`; + + super("InjectCssDeliveryError", "ERR_INJECT_CSS_DELIVERY", message, cause); + this.target = target; + this.operation = operation; + } +} diff --git a/src/index.ts b/src/index.ts index 4a91110..e1e418f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,37 @@ import InjectCssV2 from "./InjectCssV2"; import InjectCssV3 from "./InjectCssV3"; import type {InjectCssContract, InjectCssOptions} from "./types"; -export type {InjectCssContract, InjectCssOptions}; +export { + InjectCssBaseError, + InjectCssDeliveryError, + InjectCssFrameDeliveryError, + InjectCssTimeoutError, + InvalidInjectCssCodeError, + InvalidInjectCssFilesError, + InvalidInjectCssOptionsError, + InvalidInjectCssTargetError, + UnsupportedInjectCssOperationError, + UnsupportedInjectCssOptionError, + UnsupportedInjectCssTargetError, +} from "./errors"; +export type {InjectCssErrorCode} from "./errors"; +export type { + InjectCssAllFramesTarget, + InjectCssContract, + InjectCssDocumentsTarget, + InjectCssExecutionOptions, + InjectCssExecutionOptionsPatch, + InjectCssFramesTarget, + InjectCssOperation, + InjectCssOptions, + InjectCssOrigin, + InjectCssTarget, + InjectCssTopFrameTarget, + NonEmptyReadonlyArray, +} from "./types"; -export default (options: InjectCssOptions): InjectCssContract => { +export const injectCss = (options: InjectCssOptions): InjectCssContract => { return isManifestVersion3() ? new InjectCssV3(options) : new InjectCssV2(options); }; + +export default injectCss; diff --git a/src/native-errors.ts b/src/native-errors.ts new file mode 100644 index 0000000..2855ac4 --- /dev/null +++ b/src/native-errors.ts @@ -0,0 +1,29 @@ +const UNSUPPORTED_API_PATTERN = /(not supported|unsupported|not (?:available|implemented)|is not a function)\b/i; +const UNSUPPORTED_FIELD_PATTERN = + /(not supported|unsupported|unexpected|unknown|unrecognized|invalid|not (?:a )?valid)\b/i; +const UNSUPPORTED_TARGET_FIELD_PATTERN = /(not supported|unsupported|unexpected|unknown|unrecognized)\b/i; + +export const getNativeErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : String(error); +}; + +export const isUnsupportedRemovalCapabilityError = (error: unknown): boolean => { + const message = getNativeErrorMessage(error); + + return /remove\s*css/i.test(message) && UNSUPPORTED_API_PATTERN.test(message); +}; + +export const isUnsupportedOriginCapabilityError = (error: unknown): boolean => { + const message = getNativeErrorMessage(error); + + // MV2 usually names the field "cssOrigin", while MV3 uses "origin" and some browsers describe it as a + // "style origin". Treat those spellings as the same package capability. + return /\b(?:(?:css|style)[\s_-]*)?origin\b/i.test(message) && UNSUPPORTED_FIELD_PATTERN.test(message); +}; + +export const isUnsupportedDocumentTargetCapabilityError = (error: unknown): boolean => { + const message = getNativeErrorMessage(error); + + // Keep this intentionally narrower than option detection: "Invalid documentId" can mean a stale target. + return /documentIds?/i.test(message) && UNSUPPORTED_TARGET_FIELD_PATTERN.test(message); +}; diff --git a/src/types.ts b/src/types.ts index a046d8f..599c6fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,23 +1,70 @@ type RunAt = chrome.extensionTypes.RunAt; type StyleOrigin = chrome.scripting.StyleOrigin; -export interface InjectCssOptions { +export type NonEmptyReadonlyArray = readonly [T, ...T[]]; + +export type InjectCssOrigin = StyleOrigin | `${StyleOrigin}`; +export type InjectCssOperation = "insert" | "remove"; + +export interface InjectCssTopFrameTarget { tabId: number; - frameId?: boolean | number | number[]; - matchAboutBlank?: boolean; - origin?: StyleOrigin; + allFrames?: never; + frameIds?: never; + documentIds?: never; +} + +export interface InjectCssAllFramesTarget { + tabId: number; + allFrames: true; + frameIds?: never; + documentIds?: never; +} + +export interface InjectCssFramesTarget { + tabId: number; + frameIds: NonEmptyReadonlyArray; + allFrames?: never; + documentIds?: never; +} - // Options for MV2 +export interface InjectCssDocumentsTarget { + tabId: number; + documentIds: NonEmptyReadonlyArray; + allFrames?: never; + frameIds?: never; +} + +export type InjectCssTarget = + | InjectCssTopFrameTarget + | InjectCssAllFramesTarget + | InjectCssFramesTarget + | InjectCssDocumentsTarget; + +export interface InjectCssExecutionOptions { + matchAboutBlank?: boolean; runAt?: RunAt; + origin?: InjectCssOrigin; + timeoutMs?: number; +} + +export type InjectCssExecutionOptionsPatch = { + [Key in keyof InjectCssExecutionOptions]?: InjectCssExecutionOptions[Key] | undefined; +}; - // Options for MV3 - documentId?: string | string[]; +export interface InjectCssOptions extends InjectCssExecutionOptions { + target: InjectCssTarget; } export interface InjectCssContract { - insert: (css: string) => Promise; + insert(css: string): Promise; + + file(files: string | NonEmptyReadonlyArray): Promise; + + remove(css: string): Promise; + + removeFile(files: string | NonEmptyReadonlyArray): Promise; - file: (files: string | string[]) => Promise; + target(target: InjectCssTarget): this; - options: (options: Partial) => this; + options(options: InjectCssExecutionOptionsPatch): this; } diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..6983f08 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,173 @@ +import { + InvalidInjectCssCodeError, + InvalidInjectCssFilesError, + InvalidInjectCssOptionsError, + InvalidInjectCssTargetError, +} from "./errors"; +import type {InjectCssExecutionOptions, InjectCssTarget, NonEmptyReadonlyArray} from "./types"; + +const TARGET_KEYS = new Set(["tabId", "allFrames", "frameIds", "documentIds"]); +const EXECUTION_OPTION_KEYS = new Set(["matchAboutBlank", "runAt", "origin", "timeoutMs"]); +const INJECT_CSS_OPTION_KEYS = new Set(["target", ...EXECUTION_OPTION_KEYS]); +const RUN_AT_VALUES = new Set(["document_start", "document_end", "document_idle"]); +const ORIGIN_VALUES = new Set(["AUTHOR", "USER"]); + +const isObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +const assertKnownKeys = (value: Record, keys: Set, subject: string): void => { + const unknownKeys = Object.keys(value).filter(key => !keys.has(key)); + + if (unknownKeys.length > 0) { + throw new InvalidInjectCssOptionsError( + `${subject} contains unknown ${unknownKeys.length === 1 ? "field" : "fields"}: ${unknownKeys + .map(key => `"${key}"`) + .join(", ")}.` + ); + } +}; + +const cloneTarget = (target: InjectCssTarget): InjectCssTarget => { + if ("frameIds" in target && target.frameIds !== undefined) { + return {tabId: target.tabId, frameIds: [...target.frameIds] as NonEmptyReadonlyArray}; + } + + if ("documentIds" in target && target.documentIds !== undefined) { + return {tabId: target.tabId, documentIds: [...target.documentIds] as NonEmptyReadonlyArray}; + } + + if ("allFrames" in target && target.allFrames === true) { + return {tabId: target.tabId, allFrames: true}; + } + + return {tabId: target.tabId}; +}; + +export const validateInjectCssTarget = (value: unknown): InjectCssTarget => { + if (!isObject(value)) { + throw new InvalidInjectCssTargetError("target must be an object."); + } + + const unknownKeys = Object.keys(value).filter(key => !TARGET_KEYS.has(key)); + + if (unknownKeys.length > 0) { + throw new InvalidInjectCssTargetError( + `target contains unknown ${unknownKeys.length === 1 ? "field" : "fields"}: ${unknownKeys + .map(key => `"${key}"`) + .join(", ")}.` + ); + } + + if (!Number.isInteger(value.tabId) || (value.tabId as number) < 0) { + throw new InvalidInjectCssTargetError('"tabId" must be a non-negative integer.'); + } + + const selectors = ["allFrames", "frameIds", "documentIds"].filter(key => value[key] !== undefined); + + if (selectors.length > 1) { + throw new InvalidInjectCssTargetError('"allFrames", "frameIds", and "documentIds" are mutually exclusive.'); + } + + if (value.allFrames !== undefined && value.allFrames !== true) { + throw new InvalidInjectCssTargetError('"allFrames" must be exactly true when provided.'); + } + + if (value.frameIds !== undefined) { + if (!Array.isArray(value.frameIds) || value.frameIds.length === 0) { + throw new InvalidInjectCssTargetError('"frameIds" must contain at least one frame ID.'); + } + + if (value.frameIds.some(frameId => !Number.isInteger(frameId) || frameId < 0)) { + throw new InvalidInjectCssTargetError("frame ID must be a non-negative integer."); + } + + if (new Set(value.frameIds).size !== value.frameIds.length) { + throw new InvalidInjectCssTargetError('"frameIds" must not contain duplicate frame IDs.'); + } + } + + if (value.documentIds !== undefined) { + if (!Array.isArray(value.documentIds) || value.documentIds.length === 0) { + throw new InvalidInjectCssTargetError('"documentIds" must contain at least one document ID.'); + } + + if (value.documentIds.some(documentId => typeof documentId !== "string" || documentId.trim().length === 0)) { + throw new InvalidInjectCssTargetError("document ID must be a non-empty string."); + } + + if (new Set(value.documentIds).size !== value.documentIds.length) { + throw new InvalidInjectCssTargetError('"documentIds" must not contain duplicate document IDs.'); + } + } + + return cloneTarget(value as unknown as InjectCssTarget); +}; + +export const validateInjectCssExecutionOptions = (value: unknown): InjectCssExecutionOptions => { + if (!isObject(value)) { + throw new InvalidInjectCssOptionsError("execution options must be an object."); + } + + assertKnownKeys(value, EXECUTION_OPTION_KEYS, "execution options"); + + if (value.matchAboutBlank !== undefined && typeof value.matchAboutBlank !== "boolean") { + throw new InvalidInjectCssOptionsError('"matchAboutBlank" must be a boolean.'); + } + + if (value.runAt !== undefined && (typeof value.runAt !== "string" || !RUN_AT_VALUES.has(value.runAt))) { + throw new InvalidInjectCssOptionsError('"runAt" must be "document_start", "document_end", or "document_idle".'); + } + + if (value.origin !== undefined && (typeof value.origin !== "string" || !ORIGIN_VALUES.has(value.origin))) { + throw new InvalidInjectCssOptionsError('"origin" must be "AUTHOR" or "USER".'); + } + + if ( + value.timeoutMs !== undefined && + (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs <= 0) + ) { + throw new InvalidInjectCssOptionsError('"timeoutMs" must be a positive integer.'); + } + + return {...(value as InjectCssExecutionOptions)}; +}; + +export const validateInjectCssOptions = ( + value: unknown +): {target: InjectCssTarget; execution: InjectCssExecutionOptions} => { + if (!isObject(value)) { + throw new InvalidInjectCssOptionsError("options must be an object."); + } + + assertKnownKeys(value, INJECT_CSS_OPTION_KEYS, "options"); + + const {target, ...execution} = value; + + return { + target: validateInjectCssTarget(target), + execution: validateInjectCssExecutionOptions(execution), + }; +}; + +export const validateInjectCssCode = (value: unknown): string => { + if (typeof value !== "string" || value.trim().length === 0) { + throw new InvalidInjectCssCodeError("code must be a non-empty string."); + } + + return value; +}; + +export const validateInjectCssFiles = (files: string | NonEmptyReadonlyArray): string[] => { + const fileList = typeof files === "string" ? [files] : files; + + if (!Array.isArray(fileList) || fileList.length === 0) { + throw new InvalidInjectCssFilesError("at least one file is required."); + } + + if (fileList.some(file => typeof file !== "string" || file.trim().length === 0)) { + throw new InvalidInjectCssFilesError("each file must be a non-empty string."); + } + + return [...fileList]; +}; diff --git a/tests/inject-css.test.cjs b/tests/inject-css.test.cjs new file mode 100644 index 0000000..81e4bbb --- /dev/null +++ b/tests/inject-css.test.cjs @@ -0,0 +1,1081 @@ +const { + default: injectCss, + injectCss: namedInjectCss, + InjectCssBaseError, + InjectCssDeliveryError, + InjectCssFrameDeliveryError, + InjectCssTimeoutError, + InvalidInjectCssCodeError, + InvalidInjectCssFilesError, + InvalidInjectCssOptionsError, + InvalidInjectCssTargetError, + UnsupportedInjectCssOptionError, + UnsupportedInjectCssOperationError, + UnsupportedInjectCssTargetError, +} = require("../dist/index.cjs"); + +const createRuntime = manifestVersion => ({ + id: "test-extension", + lastError: undefined, + getManifest: () => ({manifest_version: manifestVersion}), +}); + +const flushAsync = () => new Promise(resolve => setImmediate(resolve)); + +describe("package exports", () => { + test("exports the factory as both default and named", () => { + expect(namedInjectCss).toBe(injectCss); + }); +}); + +describe("InjectCss target and execution options", () => { + afterEach(() => { + delete global.chrome; + delete global.browser; + }); + + test.each([ + [{target: {tabId: -1}}, '"tabId" must be a non-negative integer'], + [{target: {tabId: 1, allFrames: false}}, '"allFrames" must be exactly true'], + [{target: {tabId: 1, frameIds: []}}, '"frameIds" must contain at least one'], + [{target: {tabId: 1, frameIds: [1, 1]}}, '"frameIds" must not contain duplicate'], + [{target: {tabId: 1, frameIds: [1.5]}}, "frame ID must be a non-negative integer"], + [{target: {tabId: 1, documentIds: []}}, '"documentIds" must contain at least one'], + [{target: {tabId: 1, documentIds: [""]}}, "document ID must be a non-empty string"], + [{target: {tabId: 1, documentIds: ["doc", "doc"]}}, '"documentIds" must not contain duplicate'], + [ + {target: {tabId: 1, allFrames: true, frameIds: [1]}}, + '"allFrames", "frameIds", and "documentIds" are mutually exclusive', + ], + [{target: {tabId: 1, frameId: 2}}, 'unknown field: "frameId"'], + ])("rejects invalid target %#", (options, message) => { + global.chrome = {runtime: createRuntime(3)}; + + expect(() => injectCss(options)).toThrow(InvalidInjectCssTargetError); + expect(() => injectCss(options)).toThrow(message); + + try { + injectCss(options); + } catch (error) { + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error.code).toBe("ERR_INJECT_CSS_INVALID_TARGET"); + } + }); + + test.each([ + [{timeoutMs: 0}, '"timeoutMs" must be a positive integer'], + [{timeoutMs: 1.5}, '"timeoutMs" must be a positive integer'], + [{matchAboutBlank: "yes"}, '"matchAboutBlank" must be a boolean'], + [{runAt: "immediately"}, '"runAt" must be'], + [{origin: "author"}, '"origin" must be "AUTHOR" or "USER"'], + [{unexpected: true}, 'unknown field: "unexpected"'], + ])("rejects invalid execution options %#", (execution, message) => { + global.chrome = {runtime: createRuntime(3)}; + + expect(() => injectCss({target: {tabId: 1}, ...execution})).toThrow(InvalidInjectCssOptionsError); + expect(() => injectCss({target: {tabId: 1}, ...execution})).toThrow(message); + + try { + injectCss({target: {tabId: 1}, ...execution}); + } catch (error) { + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error.code).toBe("ERR_INJECT_CSS_INVALID_OPTIONS"); + } + }); + + test.each(["", " ", 42, null])("rejects invalid CSS code %# before native injection", async css => { + const insertCSS = jest.fn((_details, callback) => callback()); + global.chrome = {runtime: createRuntime(3), scripting: {insertCSS}}; + + const rejection = injectCss({target: {tabId: 1}}) + .insert(css) + .catch(error => error); + const error = await rejection; + + expect(error).toBeInstanceOf(InvalidInjectCssCodeError); + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error.code).toBe("ERR_INJECT_CSS_INVALID_CODE"); + expect(insertCSS).not.toHaveBeenCalled(); + }); + + test.each([[[]], [["/valid.css", " "]], [""], [" "], [42]])( + "rejects invalid CSS files %# before native injection", + async files => { + const insertCSS = jest.fn((_details, callback) => callback()); + global.chrome = {runtime: createRuntime(3), scripting: {insertCSS}}; + + const error = await injectCss({target: {tabId: 1}}) + .file(files) + .catch(cause => cause); + + expect(error).toBeInstanceOf(InvalidInjectCssFilesError); + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error.code).toBe("ERR_INJECT_CSS_INVALID_FILES"); + expect(insertCSS).not.toHaveBeenCalled(); + } + ); + + test("validates removal sources before checking native removal support", async () => { + global.chrome = {runtime: createRuntime(3), scripting: {}}; + const injector = injectCss({target: {tabId: 1}}); + + await expect(injector.remove(" ")).rejects.toBeInstanceOf(InvalidInjectCssCodeError); + await expect(injector.removeFile([])).rejects.toBeInstanceOf(InvalidInjectCssFilesError); + }); + + test("copies target arrays instead of retaining caller-owned state", async () => { + const calls = []; + const frameIds = [1]; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + const injector = injectCss({target: {tabId: 4, frameIds}}); + frameIds.push(2); + + await injector.insert("body { color: red; }"); + + expect(calls[0].target).toEqual({tabId: 4, frameIds: [1]}); + }); + + test("atomically replaces targets and prevents options() from mutating them", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + const injector = injectCss({target: {tabId: 4, frameIds: [2]}}); + + expect(() => injector.target({tabId: 4, frameIds: []})).toThrow(InvalidInjectCssTargetError); + expect(() => injector.options({target: {tabId: 9}})).toThrow(InvalidInjectCssOptionsError); + expect(injector.options({origin: "AUTHOR"})).toBe(injector); + + await injector.file("/content.css"); + + expect(calls[0].target).toEqual({tabId: 4, frameIds: [2]}); + }); +}); + +describe("MV3 adapter", () => { + afterEach(() => { + delete global.chrome; + delete global.browser; + jest.useRealTimers(); + }); + + test.each([ + [{tabId: 7}, {tabId: 7}], + [ + {tabId: 7, allFrames: true}, + {tabId: 7, allFrames: true}, + ], + [ + {tabId: 7, frameIds: [0, 2]}, + {tabId: 7, frameIds: [0, 2]}, + ], + [ + {tabId: 7, documentIds: ["doc-a", "doc-b"]}, + {tabId: 7, documentIds: ["doc-a", "doc-b"]}, + ], + ])("maps target %# to one native CSS injection", async (target, nativeTarget) => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await expect(injectCss({target}).insert("body { color: red; }")).resolves.toBeUndefined(); + + expect(calls).toEqual([{target: nativeTarget, css: "body { color: red; }"}]); + }); + + test("supports Promise-based MV3 delivery through the browser namespace", async () => { + const calls = []; + + global.browser = { + runtime: createRuntime(3), + scripting: { + insertCSS: details => { + calls.push(details); + return Promise.resolve(); + }, + }, + }; + + await expect(injectCss({target: {tabId: 12}}).file("/content.css")).resolves.toBeUndefined(); + expect(calls).toEqual([{target: {tabId: 12}, files: ["/content.css"]}]); + }); + + test("passes ordered files as a native batch and preserves canonical origin", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await expect( + injectCss({target: {tabId: 7}, origin: "USER"}).file(["/first.css", "/second.css"]) + ).resolves.toBeUndefined(); + + expect(calls).toEqual([ + { + target: {tabId: 7}, + files: ["/first.css", "/second.css"], + origin: "USER", + }, + ]); + }); + + test("removes code and ordered files with the exact MV3 source, target, and origin", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + removeCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + const injector = injectCss({target: {tabId: 7, documentIds: ["doc"]}, origin: "USER"}); + + await expect(injector.remove("body { color: red; }")).resolves.toBeUndefined(); + await expect(injector.removeFile(["/first.css", "/second.css"])).resolves.toBeUndefined(); + + expect(calls).toEqual([ + { + target: {tabId: 7, documentIds: ["doc"]}, + css: "body { color: red; }", + origin: "USER", + }, + { + target: {tabId: 7, documentIds: ["doc"]}, + files: ["/first.css", "/second.css"], + origin: "USER", + }, + ]); + }); + + test("reports missing MV3 removal capability with a typed operation error", async () => { + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (_details, callback) => callback(), + }, + }; + + const injector = injectCss({target: {tabId: 7}}); + + await expect(injector.insert("body { color: red; }")).resolves.toBeUndefined(); + + const error = await injector.remove("body { color: red; }").catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOperationError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + operation: "remove", + }); + }); + + test("normalizes a native MV3 removal capability error", async () => { + global.chrome = { + runtime: createRuntime(3), + scripting: { + removeCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: "scripting.removeCSS is not supported"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 7}}) + .removeFile("/content.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOperationError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + operation: "remove", + cause: expect.any(Error), + }); + }); + + test("reports MV3 removal delivery failures with the operation", async () => { + global.chrome = { + runtime: createRuntime(3), + scripting: { + removeCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const target = {tabId: 7, frameIds: [2]}; + const error = await injectCss({target}) + .remove("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + operation: "remove", + cause: expect.any(Error), + }); + expect(error.message).toContain("CSS removal failed"); + }); + + test("reports MV3 removal timeouts with the operation", async () => { + jest.useFakeTimers(); + + global.chrome = { + runtime: createRuntime(3), + scripting: { + removeCSS: () => {}, + }, + }; + + const target = {tabId: 7, allFrames: true}; + const pending = injectCss({target, timeoutMs: 5}) + .removeFile("/content.css") + .catch(cause => cause); + + jest.advanceTimersByTime(5); + const error = await pending; + + expect(error).toBeInstanceOf(InjectCssTimeoutError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_TIMEOUT", + target, + timeoutMs: 5, + operation: "remove", + }); + expect(error.message).toContain("CSS removal timed out"); + }); + + test("does not materialize an omitted origin", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await injectCss({target: {tabId: 7}}).file("/content.css"); + + expect(calls[0]).not.toHaveProperty("origin"); + }); + + test.each([ + [{matchAboutBlank: false}, '"matchAboutBlank" is not supported'], + [{matchAboutBlank: true}, '"matchAboutBlank" is not supported'], + [{runAt: "document_start"}, '"runAt" is not supported'], + [{runAt: "document_end"}, '"runAt" is not supported'], + [{runAt: "document_idle"}, '"runAt" is not supported'], + ])("rejects unsupported execution option %# before native injection", (execution, message) => { + const insertCSS = jest.fn(); + global.chrome = {runtime: createRuntime(3), scripting: {insertCSS}}; + + expect(() => injectCss({target: {tabId: 1}, ...execution})).toThrow(UnsupportedInjectCssOptionError); + expect(() => injectCss({target: {tabId: 1}, ...execution})).toThrow(message); + + try { + injectCss({target: {tabId: 1}, ...execution}); + } catch (error) { + expect(error.code).toBe("ERR_INJECT_CSS_UNSUPPORTED_OPTION"); + } + + expect(insertCSS).not.toHaveBeenCalled(); + }); + + test("passes document targets directly without browser-name fallback", async () => { + const calls = []; + const runtime = {...createRuntime(3), getBrowserInfo: () => Promise.resolve({name: "Firefox"})}; + + global.chrome = { + runtime, + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await injectCss({target: {tabId: 7, documentIds: ["doc"]}}).file("/content.css"); + + expect(calls[0].target).toEqual({tabId: 7, documentIds: ["doc"]}); + }); + + test("normalizes a native documentIds capability error without falling back", async () => { + const runtime = createRuntime(3); + const calls = []; + + global.chrome = { + runtime, + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + global.chrome.runtime.lastError = {message: 'Unexpected property "documentIds"'}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 2, documentIds: ["doc"]}}) + .file("/file.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssTargetError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_TARGET", + cause: expect.any(Error), + }); + expect(calls).toHaveLength(1); + expect(calls[0].target).toEqual({tabId: 2, documentIds: ["doc"]}); + }); + + test("keeps a stale documentId failure classified as a delivery error", async () => { + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: "Invalid documentId: stale-doc"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const target = {tabId: 2, documentIds: ["stale-doc"]}; + const error = await injectCss({target}) + .file("/file.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).not.toBeInstanceOf(UnsupportedInjectCssTargetError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + cause: expect.any(Error), + }); + }); + + test.each(['Unexpected property "origin"', "Invalid style origin"])( + "normalizes an MV3 origin capability error: %s", + async message => { + const runtime = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + insertCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 2}, origin: "USER"}) + .insert("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOptionError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPTION", + cause: expect.any(Error), + }); + } + ); + + test("reports native delivery failures with the target and cause", async () => { + const runtime = createRuntime(3); + const target = {tabId: 2, frameIds: [0, 3]}; + + global.chrome = { + runtime, + scripting: { + insertCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target}) + .insert("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + cause: expect.any(Error), + }); + expect(error.cause.message).toBe("Missing host permission"); + }); + + test("reports timeouts with the target and configured timeout", async () => { + jest.useFakeTimers(); + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: () => {}, + }, + }; + + const target = {tabId: 2, allFrames: true}; + const pending = injectCss({target, timeoutMs: 5}) + .file("/file.css") + .catch(error => error); + + jest.advanceTimersByTime(5); + const error = await pending; + + expect(error).toBeInstanceOf(InjectCssTimeoutError); + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_TIMEOUT", + target, + timeoutMs: 5, + }); + }); +}); + +describe("MV2 adapter", () => { + afterEach(() => { + delete global.chrome; + delete global.browser; + jest.useRealTimers(); + }); + + test.each([ + [{tabId: 7}, [{tabId: 7, details: {code: "body { color: red; }"}}]], + [{tabId: 7, allFrames: true}, [{tabId: 7, details: {code: "body { color: red; }", allFrames: true}}]], + [ + {tabId: 7, frameIds: [0, 2]}, + [ + {tabId: 7, details: {code: "body { color: red; }", frameId: 0}}, + {tabId: 7, details: {code: "body { color: red; }", frameId: 2}}, + ], + ], + ])("maps target %# to tabs.insertCSS calls", async (target, expectedCalls) => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callback(); + }, + }, + }; + + await expect(injectCss({target}).insert("body { color: red; }")).resolves.toBeUndefined(); + + expect(calls).toEqual(expectedCalls); + }); + + test("supports Promise-based MV2 delivery through the browser namespace", async () => { + const calls = []; + + global.browser = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details) => { + calls.push({tabId, details}); + return Promise.resolve(); + }, + }, + }; + + await expect(injectCss({target: {tabId: 12}}).insert("body { color: red; }")).resolves.toBeUndefined(); + expect(calls).toEqual([{tabId: 12, details: {code: "body { color: red; }"}}]); + }); + + test("removes MV2 code from explicit frames without forwarding runAt", async () => { + const calls = []; + + global.browser = { + runtime: createRuntime(2), + tabs: { + removeCSS: (tabId, details) => { + calls.push({tabId, details}); + return Promise.resolve(); + }, + }, + }; + + await expect( + injectCss({ + target: {tabId: 12, frameIds: [0, 3]}, + origin: "USER", + matchAboutBlank: true, + runAt: "document_start", + }).remove("body { color: red; }") + ).resolves.toBeUndefined(); + + expect(calls).toEqual([ + { + tabId: 12, + details: { + code: "body { color: red; }", + cssOrigin: "user", + matchAboutBlank: true, + frameId: 0, + }, + }, + { + tabId: 12, + details: { + code: "body { color: red; }", + cssOrigin: "user", + matchAboutBlank: true, + frameId: 3, + }, + }, + ]); + }); + + test("reports missing MV2 removal capability with a typed operation error", async () => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, _details, callback) => callback(), + }, + }; + + const error = await injectCss({target: {tabId: 7}}) + .removeFile("/content.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOperationError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + operation: "remove", + }); + }); + + test("normalizes a native MV2 removal capability error", async () => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + removeCSS: (_tabId, _details, callback) => { + global.chrome.runtime.lastError = {message: "tabs.removeCSS is not supported"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 7}}) + .remove("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOperationError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + operation: "remove", + cause: expect.any(Error), + }); + }); + + test("removes MV2 files sequentially while dispatching each file to frames in parallel", async () => { + const calls = []; + const callbacks = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + removeCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callbacks.push(callback); + }, + }, + }; + + const pending = injectCss({target: {tabId: 6, frameIds: [0, 3]}}).removeFile(["/first.css", "/second.css"]); + + expect(calls.map(call => call.details)).toEqual([ + {file: "/first.css", frameId: 0}, + {file: "/first.css", frameId: 3}, + ]); + + callbacks.splice(0).forEach(callback => { + callback(); + }); + await flushAsync(); + + expect(calls.map(call => call.details)).toEqual([ + {file: "/first.css", frameId: 0}, + {file: "/first.css", frameId: 3}, + {file: "/second.css", frameId: 0}, + {file: "/second.css", frameId: 3}, + ]); + + callbacks.splice(0).forEach(callback => { + callback(); + }); + await expect(pending).resolves.toBeUndefined(); + }); + + test("retains the failed MV2 removal frame and operation", async () => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + removeCSS: (_tabId, details, callback) => { + if (details.frameId === 3) { + global.chrome.runtime.lastError = {message: "Frame 3 is unavailable"}; + } + + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const target = {tabId: 2, frameIds: [0, 3]}; + const error = await injectCss({target}) + .removeFile("/content.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + operation: "remove", + message: "CSS removal failed in frame 3 of tab 2: Frame 3 is unavailable", + }); + expect(error.cause).toBeInstanceOf(InjectCssFrameDeliveryError); + expect(error.cause).toBeInstanceOf(InjectCssBaseError); + expect(error.cause).toMatchObject({ + code: "ERR_INJECT_CSS_FRAME_DELIVERY", + tabId: 2, + frameId: 3, + operation: "remove", + cause: expect.objectContaining({message: "Frame 3 is unavailable"}), + }); + }); + + test("maps canonical origin to MV2 casing and preserves supported execution options", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await injectCss({ + target: {tabId: 1}, + origin: "USER", + matchAboutBlank: true, + runAt: "document_start", + }).file("/content.css"); + + expect(calls[0]).toEqual({ + file: "/content.css", + cssOrigin: "user", + matchAboutBlank: true, + runAt: "document_start", + }); + }); + + test("does not materialize omitted matchAboutBlank, runAt, or origin", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await injectCss({target: {tabId: 1}}).file("/content.css"); + + expect(calls[0]).toEqual({file: "/content.css"}); + expect(calls[0]).not.toHaveProperty("matchAboutBlank"); + }); + + test("resets execution options when options() receives explicit undefined values", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, details, callback) => { + calls.push(details); + setTimeout(callback, 10); + }, + }, + }; + + const injector = injectCss({ + target: {tabId: 1}, + origin: "USER", + matchAboutBlank: true, + runAt: "document_start", + timeoutMs: 1, + }); + + injector.options({ + origin: undefined, + matchAboutBlank: undefined, + runAt: undefined, + timeoutMs: undefined, + }); + + await expect(injector.file("/content.css")).resolves.toBeUndefined(); + expect(calls[0]).toEqual({file: "/content.css"}); + }); + + test("rejects document targets before native injection and retains the previous target", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callback(); + }, + }, + }; + + expect(() => injectCss({target: {tabId: 1, documentIds: ["doc"]}})).toThrow(UnsupportedInjectCssTargetError); + + const injector = injectCss({target: {tabId: 1}}); + expect(() => injector.target({tabId: 9, documentIds: ["doc"]})).toThrow(UnsupportedInjectCssTargetError); + + try { + injector.target({tabId: 9, documentIds: ["doc"]}); + } catch (error) { + expect(error.code).toBe("ERR_INJECT_CSS_UNSUPPORTED_TARGET"); + } + + await injector.file("/content.css"); + + expect(calls).toEqual([{tabId: 1, details: {file: "/content.css"}}]); + }); + + test("reports native delivery failures with the target and cause", async () => { + const runtime = createRuntime(2); + const target = {tabId: 2}; + + global.chrome = { + runtime, + tabs: { + insertCSS: (_tabId, _details, callback) => { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target}) + .insert("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + cause: expect.any(Error), + }); + }); + + test("retains the failed frame in the MV2 delivery cause", async () => { + const runtime = createRuntime(2); + const target = {tabId: 2, frameIds: [0, 3]}; + + global.chrome = { + runtime, + tabs: { + insertCSS: (_tabId, details, callback) => { + if (details.frameId === 3) { + global.chrome.runtime.lastError = {message: "Frame 3 is unavailable"}; + } + + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target}) + .insert("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error.target).toEqual(target); + expect(error.message).toBe("CSS injection failed in frame 3 of tab 2: Frame 3 is unavailable"); + expect(error.cause).toBeInstanceOf(InjectCssFrameDeliveryError); + expect(error.cause).toBeInstanceOf(InjectCssBaseError); + expect(error.cause).toMatchObject({ + code: "ERR_INJECT_CSS_FRAME_DELIVERY", + tabId: 2, + frameId: 3, + cause: expect.objectContaining({message: "Frame 3 is unavailable"}), + }); + }); + + test.each(['Unexpected property "cssOrigin"', "Invalid style origin"])( + "normalizes an MV2 origin capability error: %s", + async message => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, _details, callback) => { + global.chrome.runtime.lastError = {message}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 2}, origin: "USER"}) + .file("/content.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOptionError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPTION", + cause: expect.any(Error), + }); + } + ); + + test("injects files sequentially while dispatching each file to frames in parallel", async () => { + const calls = []; + const callbacks = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callbacks.push(callback); + }, + }, + }; + + const pending = injectCss({target: {tabId: 6, frameIds: [0, 3]}}).file(["/first.css", "/second.css"]); + + expect(calls).toEqual([ + {tabId: 6, details: {file: "/first.css", frameId: 0}}, + {tabId: 6, details: {file: "/first.css", frameId: 3}}, + ]); + + callbacks.shift()(); + await flushAsync(); + expect(calls).toHaveLength(2); + + callbacks.shift()(); + await flushAsync(); + expect(calls).toEqual([ + {tabId: 6, details: {file: "/first.css", frameId: 0}}, + {tabId: 6, details: {file: "/first.css", frameId: 3}}, + {tabId: 6, details: {file: "/second.css", frameId: 0}}, + {tabId: 6, details: {file: "/second.css", frameId: 3}}, + ]); + + callbacks.splice(0).forEach(callback => { + callback(); + }); + await expect(pending).resolves.toBeUndefined(); + }); + + test.each([ + ["insertion", "insertCSS", "file", "insert"], + ["removal", "removeCSS", "removeFile", "remove"], + ])( + "does not start a later MV2 file after %s times out", + async (_label, nativeMethod, contractMethod, operation) => { + jest.useFakeTimers(); + + const calls = []; + const callbacks = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + [nativeMethod]: (tabId, details, callback) => { + calls.push({tabId, details}); + callbacks.push(callback); + }, + }, + }; + + const target = {tabId: 6, frameIds: [0, 3]}; + const pending = injectCss({target, timeoutMs: 5}) + [contractMethod](["/first.css", "/second.css"]) + .catch(error => error); + + expect(calls.map(call => call.details.file)).toEqual(["/first.css", "/first.css"]); + + jest.advanceTimersByTime(5); + const error = await pending; + + expect(error).toBeInstanceOf(InjectCssTimeoutError); + expect(error).toMatchObject({target, timeoutMs: 5, operation}); + + callbacks.forEach(callback => { + callback(); + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(calls.map(call => call.details.file)).toEqual(["/first.css", "/first.css"]); + } + ); +}); diff --git a/tests/release-it.test.cjs b/tests/release-it.test.cjs new file mode 100644 index 0000000..5c17b5d --- /dev/null +++ b/tests/release-it.test.cjs @@ -0,0 +1,43 @@ +const {whatBump} = require("../.release-it.cjs"); + +describe("release-it version policy", () => { + describe("breaking changes", () => { + test.each([ + ["parser breaking field", {type: "feat", breaking: "!"}], + ["type suffix", {type: "feat!"}], + ["header suffix", {type: "feat", header: "feat(inject-css)!: remove legacy API"}], + ["BREAKING CHANGE note", {type: "fix", notes: [{title: "BREAKING CHANGE", text: "new contract"}]}], + ["BREAKING-CHANGE footer", {type: "fix", footer: "BREAKING-CHANGE: new contract"}], + ])("treats %s as a pre-1.0 minor bump", (_label, commit) => { + expect(whatBump([commit], "0.3.1")).toEqual({level: 1}); + }); + + test("becomes a major bump after 1.0", () => { + expect( + whatBump([{type: "fix", notes: [{title: "BREAKING CHANGE", text: "new contract"}]}], "1.4.2") + ).toEqual({level: 0}); + }); + + test("takes precedence over lower-level changes after 1.0", () => { + expect(whatBump([{type: "fix"}, {type: "feat"}, {type: "refactor", breaking: true}], "2.0.0")).toEqual({ + level: 0, + }); + }); + }); + + test.each(["feat", "revert"])("uses a minor bump for %s", type => { + expect(whatBump([{type}], "0.3.1")).toEqual({level: 1}); + }); + + test.each(["fix", "perf", "refactor", "ci"])("uses a patch bump for %s", type => { + expect(whatBump([{type}], "0.3.1")).toEqual({level: 2}); + }); + + test("uses the highest non-breaking bump", () => { + expect(whatBump([{type: "fix"}, {type: "feat"}], "0.3.1")).toEqual({level: 1}); + }); + + test.each(["docs", "test", "chore", "build"])("does not release for %s alone", type => { + expect(whatBump([{type}], "0.3.1")).toBeNull(); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..32399e8 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "noEmit": true, + "rootDir": ".." + }, + "include": [ + "../src/**/*.ts", + "./types.test.ts" + ], + "exclude": [] +} diff --git a/tests/types.test.ts b/tests/types.test.ts new file mode 100644 index 0000000..8d9c69f --- /dev/null +++ b/tests/types.test.ts @@ -0,0 +1,115 @@ +import injectCss, { + type InjectCssErrorCode, + type InjectCssExecutionOptions, + type InjectCssExecutionOptionsPatch, + type InjectCssOperation, + type InjectCssOrigin, + type InjectCssTarget, + type NonEmptyReadonlyArray, + injectCss as namedInjectCss, +} from "../src/index"; + +declare const tabId: number; +declare const frameId: number; +declare const documentId: string; + +const topFrame = injectCss({target: {tabId}}); + +namedInjectCss({target: {tabId}}); + +injectCss({target: {tabId, allFrames: true}}); +injectCss({target: {tabId, frameIds: [0, frameId]}}); +injectCss({target: {tabId, documentIds: [documentId]}}); + +// @ts-expect-error selectors are mutually exclusive +injectCss({target: {tabId, allFrames: true, frameIds: [frameId]}}); + +// @ts-expect-error selectors are mutually exclusive +injectCss({target: {tabId, frameIds: [frameId], documentIds: [documentId]}}); + +// @ts-expect-error explicit frame targets must not be empty +injectCss({target: {tabId, frameIds: []}}); + +// @ts-expect-error explicit document targets must not be empty +injectCss({target: {tabId, documentIds: []}}); + +// @ts-expect-error allFrames only accepts literal true +injectCss({target: {tabId, allFrames: false}}); + +const authorOrigin: InjectCssOrigin = "AUTHOR"; +const userOrigin: InjectCssOrigin = "USER"; + +injectCss({target: {tabId}, origin: authorOrigin}); +injectCss({target: {tabId}, origin: userOrigin}); +injectCss({target: {tabId}, runAt: "document_start", matchAboutBlank: false, timeoutMs: 50}); + +// @ts-expect-error origins use the canonical WebExtension casing +injectCss({target: {tabId}, origin: "author"}); + +const inserted: Promise = topFrame.insert("body { color: red; }"); +const singleFile: Promise = topFrame.file("/content.css"); +const files: NonEmptyReadonlyArray = ["/first.css", "/second.css"]; +const multipleFiles: Promise = topFrame.file(files); +const removed: Promise = topFrame.remove("body { color: red; }"); +const removedFile: Promise = topFrame.removeFile("/content.css"); +const removedFiles: Promise = topFrame.removeFile(files); + +topFrame.file(["/content.css"]); + +// @ts-expect-error at least one file is required +topFrame.file([]); + +// @ts-expect-error every file must be a string +topFrame.file([1]); + +// @ts-expect-error at least one file is required +topFrame.removeFile([]); + +// @ts-expect-error every file must be a string +topFrame.removeFile([1]); + +// @ts-expect-error CSS code must be a string +topFrame.insert(42); + +// @ts-expect-error CSS code must be a string +topFrame.remove(42); + +topFrame.target({tabId, frameIds: [frameId]}).options({origin: "USER", timeoutMs: 100}); +topFrame.options({matchAboutBlank: undefined, runAt: undefined, origin: undefined, timeoutMs: undefined}); + +// @ts-expect-error target changes belong to target(), not options() +topFrame.options({target: {tabId: 99}}); + +// @ts-expect-error legacy flat target fields are not execution options +topFrame.options({tabId: 99}); + +const executionOptions: InjectCssExecutionOptions = { + matchAboutBlank: true, + origin: "AUTHOR", + runAt: "document_idle", + timeoutMs: 1_000, +}; +const executionOptionsPatch: InjectCssExecutionOptionsPatch = { + origin: undefined, + timeoutMs: 2_000, +}; +const target: InjectCssTarget = {tabId, documentIds: [documentId]}; +const errorCode: InjectCssErrorCode = "ERR_INJECT_CSS_DELIVERY"; +const frameDeliveryCode: InjectCssErrorCode = "ERR_INJECT_CSS_FRAME_DELIVERY"; +const unsupportedOperationCode: InjectCssErrorCode = "ERR_INJECT_CSS_UNSUPPORTED_OPERATION"; +const operation: InjectCssOperation = "remove"; + +topFrame.options(executionOptions); +topFrame.options(executionOptionsPatch); +topFrame.target(target); +errorCode.toUpperCase(); +frameDeliveryCode.toUpperCase(); +unsupportedOperationCode.toUpperCase(); +operation.toUpperCase(); + +void inserted; +void singleFile; +void multipleFiles; +void removed; +void removedFile; +void removedFiles; diff --git a/tsconfig.json b/tsconfig.json index 1958e6d..535196a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,13 +14,10 @@ "noEmitOnError": false, "noEmit": false, "skipLibCheck": true, - "noImplicitAny": false, "typeRoots": [ "node_modules/@types" ], - "baseUrl": "./src", "isolatedModules": true, - "allowJs": true, "resolveJsonModule": true, "sourceMap": true, "declarationMap": true