diff --git a/.gitea/workflows/firefox-extension.yml b/.gitea/workflows/firefox-extension.yml new file mode 100644 index 0000000..4a5c567 --- /dev/null +++ b/.gitea/workflows/firefox-extension.yml @@ -0,0 +1,54 @@ +name: Firefox Extension CI + +on: + pull_request: + # edited: removing the WIP: prefix starts the real run. + types: [opened, synchronize, reopened, edited] + push: + branches: + - develop + - main + workflow_dispatch: + +# The gate and concurrency use the github.* context, Gitea's alias for gitea.*: +# it is the form the pattern was canaried with and the one pr-flow detects. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Cancel superseded PR runs, never a push run: main's push uploads the package. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + validate: + # WIP gate: skip PRs titled WIP: so early PRs cost no run per push. Gate on + # the title and nothing else, since a skipped job reports success on the + # required check (see ~/.claude/docs/gitea-ci-pattern.md). + if: >- + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint and build + run: npm test + + - name: Upload Firefox package + if: ${{ gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' }} + uses: actions/upload-artifact@v4 + with: + name: firefox-extension-${{ gitea.sha }} + path: web-ext-artifacts/*.zip + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index c6ef218..49bc425 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -.idea - +.idea/ +node_modules/ +web-ext-artifacts/ +npm-debug.log* diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..a7c4a98 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,170 @@ +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "search-with-chatgpt-powered-by-openai-extension" + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart deno +# elixir elm erlang fortran fsharp +# gdscript gleam go groovy haskell +# haxe hlsl html java json +# julia kotlin latex lean4 lua +# luau markdown matlab msl nextflow +# nix ocaml pascal perl php +# php_phpactor php_phpantom powershell python python_basedpyright +# python_jedi python_pyrefly python_ty qml r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# wolfram yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: +- ".worktrees/**" + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..30bc1ff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,104 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This is a Firefox WebExtension (Manifest V2) with npm-based development tooling. + +- `manifest.json` defines the extension metadata, the ChatGPT search provider, + the background script, the options panel, and the keyboard command. +- `lib/` holds pure logic with no browser dependencies: `defaults.js`, + `build-url.js`, and `settings.js`. This is the unit-tested core. +- `background/background.js` is an ES-module background script that registers + the context menu and the keyboard command. +- `options/` is the options panel rendered inline in `about:addons`. +- `test/` holds `node:test` unit tests. Excluded from the packaged ZIP. +- `icons/` contains size-specific PNG artwork named `powered-by-openai-icon-x.png`. +- `web-ext-config.mjs` controls linting, local execution, and ZIP packaging. +- `docs/superpowers/` holds specs and plans. Excluded from the packaged ZIP. +- `.gitea/workflows/firefox-extension.yml` validates pull requests and protected branches. +- `README.md`, `CHANGELOG.md`, and `LICENSE.txt` cover usage, releases, and licensing. + +## Browser Scope + +Firefox Desktop is the only current implementation and release target. Do not add Chrome, Brave, Chromium, or other browser packaging—or modify the sibling Chrome repository—unless a task explicitly requests it. Future ports remain desirable, so prefer standard WebExtension APIs where they preserve Firefox behavior and isolate unavoidable Firefox-specific code. + +## Build, Test, and Development Commands + +- `npm ci` installs the exact dependencies from `package-lock.json`; use Node.js 22 or newer. +- `npm run dev` launches a temporary Firefox profile with automatic extension reload. +- `npm run lint` runs strict Mozilla extension validation. +- `npm run test:unit` runs the `node:test` unit tests over `test/`. +- `npm run build` creates the unsigned ZIP under `web-ext-artifacts/`. +- `npm test` runs lint, unit tests, and build together; run it before every pull request. + +## Coding Style & Naming Conventions + +Use two-space indentation in JSON, YAML, and JavaScript configuration. Preserve logical key grouping in `manifest.json`. Use lowercase, hyphenated filenames and include dimensions in icon names. Keep changes focused and avoid adding runtime dependencies without a concrete need. + +## Testing Guidelines + +Unit tests cover the pure logic in `lib/` via `node:test`; automated checks +otherwise cover manifest validation and packaging, not browser behaviour. Note +`node --test` exits 0 when it discovers no test files, so confirm a non-zero +test count rather than trusting the exit code alone. + +After `npm test`, use `npm run dev` and confirm: + +- `gpt ` opens ChatGPT, the provider stays non-default, and Firefox + search settings can configure it. +- The Options/Preferences tab renders the settings plus Restore defaults, + in both light and dark themes and at a narrow window width, and values + persist across a restart. +- **Context menu → Simple** shows the direct **Ask ChatGPT** item only for + selected text. **Advanced** shows the contextual submenu; selection takes + precedence over links. **Off** hides the menu. Switch modes without restarting + and confirm Alt+Shift+G still works in every mode. Restore defaults returns + to Simple with Translate hidden, and preferences persist across a restart. +- Enable Translate and check Ask each time, preferred language, Enter, Escape, + and Cancel. Confirm long selections require consent before shortening and + link URLs are never shortened. Check concurrent dialogs retain separate data. +- `Alt+Shift+G` acts on the selection and opens a blank chat when nothing is + selected or on a restricted page such as `about:config`. Press the key to test + this — `commands.getAll()` reports a shortcut as registered even when Firefox + has silently overridden it. The injected snippet is gated on + `document.hasFocus()`, so cover all four frame cases: + - Select in the top frame → the top-frame text arrives. + - Select inside an iframe → the iframe text arrives. + - Select in the top frame, *then* select inside an iframe → the iframe text + arrives. `hasFocus()` is true for a focused frame and all its ancestors, so + if the stale top-frame text arrives instead, exclude ancestors by also + checking that `document.activeElement` is not the frame element. + - Press `Ctrl+F`, find a term, then press the shortcut without clicking back + into the page → a blank chat is expected. Firefox leaves focus in the + findbar, so no content frame reports focus. This is a deliberate trade: a + stale cross-origin frame silently seeding the prompt was the worse failure. +- Click the **Ask ChatGPT** toolbar button with no selection, a top-frame + selection, and an iframe selection after a stale top-frame selection. + Check restricted-page fallback, context menu Off, saved prompt and opening + preferences, and consent before shortening long selections. Test both the + pinned toolbar button and its entry in Firefox’s extensions menu. + +Inspect ZIP contents whenever packaging rules or assets change; `test/` and +`docs/` must never appear, not even as empty directory entries. + +Gitea Actions runs checks for every pull request and pushes to `develop` and +`main`. Only successful `main` pushes upload an unsigned build artifact; CI does +not publish to AMO. + +## Commit & Pull Request Guidelines + +Recent history uses short, lowercase prefixes such as `doc:` and `build:` (for example, `doc: fix git url`). Follow `: ` and keep each commit focused. + +Pull requests should explain the user-visible effect, list manual Firefox checks, and link the relevant issue. Include screenshots for icons or browser-visible metadata. Explicitly call out permission, search URL, privacy, and packaging changes. + +## Security & Privacy + +Do not commit credentials or API keys. New permissions, telemetry, intermediary servers, or data collection require explicit justification and matching privacy documentation. + +Permissions are deliberately limited to `storage`, `menus`, and `activeTab`, +which add no line to Firefox's install prompt. Do not add `tabs`, +`notifications`, `webNavigation`, or any host permission without re-reading +`docs/superpowers/specs/2026-07-29-extension-options-design.md` — each was +considered and rejected there, and Appendix B records a feature rejected on +security grounds that must not be revived without clearing the bar documented +with it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 20bf935..47ace30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,57 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version). -## [1.0.0]) (2024-06-09) +## [1.2.0] (2026-10-18) + +The first update on addons.mozilla.org since 1.0. It also delivers everything +in 1.1.0, which was never published there. + +- Add an Ask ChatGPT toolbar button: use selected text or open a blank chat, + honoring saved prompt and tab/window preferences. No new permissions. + Requires Firefox 112 or newer, like the other new entry points. +- Add Off / Simple / Advanced context-menu modes with migration from the old + visibility preference. Advanced provides selection presets and link/page URL + actions, with optional translation and a preferred language or language dialog. +- Preserve dedicated action instructions when shortening selections; ask before + sending shortened text and reject URLs that cannot be sent intact. +- Stay responsive with very large selections from the shortcut or toolbar: only + the part that can fit in the ChatGPT link is processed. +- Add accessible action dialogs with cancellation and retry, remove the temporary + model/effort URL probe, and document URL sharing. No new permissions. +- Show up front, in the options panel and README, that ChatGPT may require + signing in and that an unsent prompt needs Enter or Send. +- Limit the prompt template to 1,000 characters, so an oversized template can + no longer stop every other setting from saving. +- Show the configured keyboard shortcut in the options instructions, updating + when it changes and indicating when none is assigned, with a button to open + Firefox’s shortcut editor where supported. +- Shorten the context menu label to “Ask ChatGPT”. +- Add quick usage instructions and guidance for changing the search keyword + and default engine in Firefox to the options panel. +- Add a “Feedback or report a bug” link to the options panel and README. + +## [1.1.0] (2026-07-30) + +Adds an options panel and two new ways to reach ChatGPT. + + * Options panel in `about:addons` with five settings: context menu visibility, + temporary chat, web-search hint, where results open, and a prompt template. + * Right-click selected text and choose "Ask ChatGPT" to open ChatGPT with that + text filled in. + * Keyboard shortcut `Alt+Shift+G` does the same for the current selection, and + opens an empty chat when nothing is selected. Rebindable via Manage Extension + Shortcuts. + * Documented that ChatGPT no longer submits prompts passed in a URL, so a + second Enter is needed. This is an upstream change to chatgpt.com. + * `gpt ` address-bar search is unchanged. + * The options panel, right-click item, and keyboard shortcut require Firefox + 112 or newer. Older versions keep the `gpt ` search unchanged. + * Permissions added: storage, menus, activeTab. None of them adds a line to + Firefox's install prompt. + +## [1.0.0] (2024-06-09) First release of software. * favicon - Wanted to use the OpenAI ChatGPT icon but worried about copyright. Without granted permission, have to roll with this icon. (Powered by OpenAI!) * Adds ChatGPT as non-default search engine. Queryable by "gpt " in user's URL bar. Can configure through browser settings. - \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..78bc43c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +See `AGENTS.md` for all repository guidance: project structure, browser scope, +build/test/development commands, coding style, testing, commit and pull request +conventions, and security and privacy expectations. diff --git a/README.md b/README.md index 39e4564..48e80c4 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,184 @@ -Search with ChatGPT Powered by OpenAI Firefox Extension -======================================================= +# Search with ChatGPT Powered by OpenAI Firefox Extension -Extensions adds ChatGPT as a search engine configurable option -in the browser. Default prefix is "gpt " or can be set -in the settings. Does not require login, or API key, or use custom servers -or code. Simply queries your url bar into a new ChatGPT session. +This extension adds ChatGPT as a configurable Firefox search provider. Type +`gpt ` in the address bar to open the query in a new ChatGPT session. +It uses no API key, custom server, or intermediary handler. -Unofficial - Provided by Zavage Software Inc. +**ChatGPT may require you to sign in. If your prompt is not sent automatically, +press Enter or click Send in ChatGPT.** -See LICENSE.txt. +This is an unofficial extension provided by Zavage Software Inc. - * https://zavage-software.com - * https://git-repos.zavage.net/zavage-software/search-with-chatgpt-powered-by-openai-extension +- [Firefox Add-ons listing](https://addons.mozilla.org/en-US/firefox/addon/search-with-chatgpt/) +- [Project website](https://zavage-software.com/portfolio/search-with-ChatGPT) +- [Project repository](https://git-repos.zavage.net/zavage-software/search-with-chatgpt-powered-by-openai-extension) +- [Feedback or report a bug](https://zavage-software.com/contact-us) +- [Zavage Software](https://zavage-software.com) -Firefox extension page: https://addons.mozilla.org/en-US/firefox/addon/search-with-chatgpt/ - -# Privacy Policy +See `LICENSE.txt` for licensing terms. -This extension collects zero user data. +## Ask about selected text + +Highlight text on a webpage, right-click it, and choose **Ask ChatGPT**. +ChatGPT opens with your selection. Try selecting a paragraph you want explained +or translated. + +You can also press **Alt+Shift+G** to use the current selection. With nothing +selected, the shortcut opens a blank chat. Change the shortcut under +**Manage Extension Shortcuts** in the `about:addons` gear menu. + +Click the extension’s **Ask ChatGPT** toolbar button to use selected text or, +with nothing selected, open a blank chat. On pages Firefox does not allow +extensions to read, the button opens a blank chat. You can pin the button to +the toolbar from Firefox’s extensions menu. It uses your saved prompt and +tab/window preferences, and works even when the context menu is Off. + +The options panel shows your currently configured shortcut and refreshes when +it changes or you return to the panel. If you remove it, the panel shows +“No keyboard shortcut assigned”. Its **Change keyboard shortcut** button opens +Firefox’s shortcut editor when supported; otherwise follow the menu path above. +**Restore defaults** does not change your keyboard shortcut. + +To customize the prompt, set **Prompt template** to something like +`Explain simply: {query}` or `Translate into Spanish: {query}`. The Simple menu action, Ask normally, toolbar button, and shortcut use your template. +Other advanced actions use their own dedicated prompts. + +## Search keyword and default engine + +Open Firefox’s **Settings → Search**, or paste `about:preferences#search` into +the address bar. Under **Search Shortcuts**, edit ChatGPT’s keyword to replace +`gpt` with your preferred prefix. Under **Default Search Engine**, choose +ChatGPT to search without a prefix, or choose your previous engine to switch +back. See [Firefox’s search settings guide](https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox) +for illustrated instructions. + +These are Firefox settings; the extension’s **Restore defaults** button does +not change them. + +## Options + +Open `about:addons`, select this extension, and choose the **Options** tab +(**Preferences** on macOS and Linux). Available settings: + +- **Context menu** — **Simple** (default) keeps the direct selection action; + **Advanced** offers contextual presets; **Off** hides the menu. The toolbar + button and keyboard shortcut work in every mode. Existing enabled/disabled preferences migrate + to Simple/Off. Changes apply without restarting Firefox. +- **Show Translate in advanced menu** — off by default. When enabled, choose + **Ask each time** for a language dialog, or enter a **Preferred language** + to translate directly. An empty or invalid preferred language falls back to + asking. Enter continues; Escape or Cancel opens nothing. +- **Request a temporary chat** — asks ChatGPT not to save the conversation. + ChatGPT may not honour this; check its own indicator before relying on it. +- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a + guarantee. +- **Open in** — a new tab, a new background tab, or a new window. +- **Prompt template** — wraps your text, for example `Explain simply: {query}`. + Up to 1,000 characters. + +Restore defaults resets extension preferences to Simple mode with translation +hidden; it does not change Firefox's search engine or shortcut settings. + +In Advanced mode, selected text offers **Ask normally**, **Explain**, +**Summarize**, and **Debug code**. Translate appears only when enabled. The +extension does not try to detect code. A selected link uses selection actions; +without a selection, a link offers **Ask about this link** and **Review link +safety**. Page background offers **Ask about this page** and **Summarize this +page**. Link/page actions send only the URL. ChatGPT may be unable to access +its content, and a link review is not a guarantee of safety. + +Long selections require confirmation before sending shortened text. Link URLs +are never shortened: unsupported schemes, embedded login credentials, and URLs +that cannot fit are rejected. The extension does not simulate submission; ChatGPT controls whether a URL +prompt is sent automatically or waits in the composer. + +These settings apply to selecting text and choosing +**Ask ChatGPT** from the right-click menu, clicking the toolbar button, or pressing +Alt+Shift+G. Rebind the shortcut under +**Manage Extension Shortcuts** in the Add-ons gear menu. + +The `gpt ` address-bar search is unchanged and does not read these +settings. + +The options panel, toolbar button, right-click item, and keyboard shortcut require +Firefox 112 or newer. On older versions the extension still installs and the +`gpt ` address-bar search still works; only these newer features are +inactive. + +## Sending your prompt + +The extension opens ChatGPT with the prompt in the URL. ChatGPT controls what +happens next: it may send the prompt automatically or leave it in the composer. +You may need to sign in before continuing. **If your prompt is not sent +automatically, press Enter or click Send in ChatGPT.** + +If ChatGPT shows a verification error, try signing in and opening the prompt +again. Signing in has helped in testing, but does not guarantee automatic +submission. + +The extension does not click Send, simulate Enter, or call ChatGPT's internal +conversation API. This behavior can vary with changes to ChatGPT itself. + +## Browser Support + +Firefox Desktop is the current development and release target. Chrome, Brave, +other Chromium browsers, and additional WebExtension platforms may be evaluated +after the Firefox feature set is more substantial; they are not current targets. + +## Development + +Install Node.js 22 or newer, Firefox, and the locked development dependencies: + +```sh +npm ci +``` + +Available commands: + +- `npm run dev` launches the extension in a temporary Firefox profile and + reloads it when files change. +- `npm run lint` validates the extension and treats warnings as errors. +- `npm run test:unit` runs the `node:test` unit tests over `test/`. +- `npm run build` creates + `web-ext-artifacts/search-with-chatgpt-firefox-.zip`. +- `npm test` runs the same lint, unit test, and build checks used by Gitea + Actions. + +The generated ZIP is unsigned. Use `npm run dev` for local testing; signing and +submission to Mozilla Add-ons remain a separate release step. + +Before submitting a change, confirm that `gpt ` opens +`https://chatgpt.com?q=`, the provider remains non-default, and it can be +configured in Firefox search settings. + +## Privacy Policy + +This extension has no server, no API key, and no intermediary. Nothing is +reported to Zavage Software. + +- Your settings are stored in your own browser profile. If you use Firefox Sync + with **Add-ons** enabled, they sync end-to-end encrypted through your own + Mozilla account. +- Selection actions, the toolbar button, and the shortcut place your selected text in the ChatGPT + URL. Advanced link/page actions instead place the chosen link or current + page URL in the prompt. URLs may include private query parameters or fragments; + use those actions only for URLs you want to share with ChatGPT. +- The extension reads the selected text or relevant URL only when you invoke + the action. It does not extract full page text, read page titles, or open a + link itself for a safety check. +- Language and confirmation dialogs hold pending content in memory only, not + in settings sync or persistent storage. Canceling sends no prompt to ChatGPT. + Closing or restarting Firefox discards pending actions. + +## Model selection + +Model selection is not currently supported. We tested model URL hints while +signed in and signed out, but could not make them change the selected model. +Signed-out testing used Luna; signed-in testing stayed on GPT-5.6 Sol with +medium effort regardless of the requested model. We removed the model setting +rather than offer a control that did not work. Choose your model and thinking +effort in ChatGPT itself where available. Previously saved extension model +values are ignored. + +Temporary chat did work in manual testing. Check ChatGPT's temporary-chat +indicator before sending; web-search hints remain unverified. diff --git a/background/background.js b/background/background.js new file mode 100644 index 0000000..19bd834 --- /dev/null +++ b/background/background.js @@ -0,0 +1,100 @@ +import { load } from "../lib/settings.js"; +import { prepareAction } from "../lib/prepare-action.js"; +import { openChat } from "./open-chat.js"; +import { showActionDialog } from "./dialogs.js"; +import { initializeMenus } from "./menus.js"; + +const COMMAND_ID = "ask-chatgpt"; + +// Reads the user's selection for the shortcut and toolbar. The context menu +// does not need this — it receives info.selectionText directly. +// +// Toolbar clicks and command invocations confer activeTab, so executeScript +// works with no host permission. Use the toolbar event's tab ID rather than +// querying again after the user might have switched tabs. +// +// Every frame keeps its own independent Selection, and a selection made in a +// non-focused frame is not cleared when the user selects elsewhere. Without a +// filter, a stale selection in an unfocused (possibly cross-origin) iframe +// could win over — or be pulled alongside — the selection the user actually +// made, with the winner among multiple non-empty results decided by an +// executeScript ordering that isn't specified. The injected snippet is +// therefore gated on document.hasFocus(), which is true for the focused +// document and all of its ancestors: the top frame wins when the user +// selected there, and an iframe's selection is only picked up when it is +// itself the focused frame. Exclude ancestors whose active element is a frame. +// +// Failure has three measured shapes, all handled here: a thrown +// "Missing host permission for the tab", a thrown variant naming frames, and a +// SILENT resolution to [null] on parent-process about: pages. Never index into +// a result without a null check. +async function readSelection(tabId) { + if (tabId === undefined) { + try { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + tabId = tabs?.[0]?.id; + } catch { + return ""; + } + } + + // Only tab.id may be read. Reading tab.url or tab.title would require the + // "tabs" permission, which does add an install-prompt warning line. + if (typeof tabId !== "number") { + return ""; + } + + let results; + try { + results = await browser.tabs.executeScript(tabId, { + code: "document.hasFocus() && !['IFRAME', 'FRAME'].includes(document.activeElement?.tagName) ? window.getSelection().toString() : ''", + allFrames: true, + matchAboutBlank: true, + }); + } catch { + return ""; + } + + if (!Array.isArray(results)) { + return ""; + } + for (const result of results) { + if (typeof result === "string" && result.trim() !== "") { + return result; + } + } + return ""; +} + +async function runAction(request) { + let preparation = prepareAction(request); + if (preparation.kind === "ready") { + try { + await openChat(browser, preparation.url, request.settings.openIn); + return; + } catch { + // Keep the ready payload so the dialog's Continue button retries it. + } + } + try { + await showActionDialog(browser, { request, preparation }); + } catch { + console.error("Could not open the action dialog. Please try the action again."); + } +} + +void initializeMenus({ browser, load, onAction: runAction }).catch(console.error); +if (browser.browserAction) { + browser.browserAction.onClicked.addListener(async tab => { + const source = await readSelection(tab.id); + await runAction({ actionId: "ask", source, settings: await load() }); + }); +} +if (browser.commands) { + browser.commands.onCommand.addListener(async command => { + if (command !== COMMAND_ID) return; + // Read the selection before another asynchronous API loses activeTab context. + const source = await readSelection(); + await runAction({ actionId: "ask", source, settings: await load() }); + }); +} diff --git a/background/dialogs.js b/background/dialogs.js new file mode 100644 index 0000000..2ce68da --- /dev/null +++ b/background/dialogs.js @@ -0,0 +1,61 @@ +// Pending source content exists only during the initial handshake. The dialog +// owns a structured clone after receipt, independent of the event page's life. +export async function showActionDialog(browser, payload) { + const id = crypto.randomUUID(); + const url = browser.runtime.getURL('dialog/action.html') + '?request=' + id; + let tabId; + let port; + let finished = false; + let resolveOpened; + const opened = new Promise(resolve => { resolveOpened = resolve; }); + let timer; + let resolveDone, rejectDone; + const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; }); + // Attach rejection handling immediately, including when window creation stalls. + void done.catch(() => {}); + function finish(error) { + if (finished) return; + finished = true; + clearTimeout(timer); + payload = null; + browser.runtime.onConnect.removeListener(onConnect); + browser.tabs.onRemoved.removeListener(onRemoved); + if (port) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + port.disconnect(); + } + if (error) rejectDone(error); else resolveDone(); + } + function onMessage(message) { + if (message?.type === 'received') finish(); + } + function onDisconnect() { finish(); } + function onRemoved(removedId) { if (removedId === tabId) finish(); } + async function onConnect(candidate) { + if (candidate.name !== 'action-dialog:' + id) return; + await opened; + if (finished || port || candidate.sender?.id !== browser.runtime.id || + candidate.sender?.url !== url || candidate.sender?.tab?.id !== tabId) return; + port = candidate; + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + try { port.postMessage({ type: 'initialize', payload }); } + catch { finish(new Error('Could not initialize action dialog')); } + } + browser.runtime.onConnect.addListener(onConnect); + browser.tabs.onRemoved.addListener(onRemoved); + timer = setTimeout(() => finish(new Error('Action dialog initialization timed out')), 15000); + try { + try { + const win = await browser.windows.create({ url, type: 'popup', width: 480, height: 360 }); + tabId = win.tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Missing dialog tab'); + } catch { + const tab = await browser.tabs.create({ url }); + tabId = tab.id; + } + } catch (error) { finish(error); } + finally { resolveOpened(); } + return done; +} diff --git a/background/menus.js b/background/menus.js new file mode 100644 index 0000000..4b30409 --- /dev/null +++ b/background/menus.js @@ -0,0 +1,89 @@ +import { getActions, getContext } from '../lib/actions.js'; + +const ROOT = 'ask-chatgpt'; +const SIMPLE = 'ask-chatgpt-selection'; +const PREFIX = 'ask-chatgpt:'; +const CONTEXTS = ['selection', 'link', 'page']; +const MENU_KEYS = ['contextMenuMode', 'showContextMenu', 'showTranslate', 'translationMode', 'translationLanguage']; + +export function initializeMenus({ browser, load, onAction }) { + let queue = Promise.resolve(); + let mode = 'off'; + let children = []; + let menuInstance = 0; + const enqueue = task => { + queue = queue.catch(() => {}).then(task); + return queue; + }; + const create = properties => new Promise((resolve, reject) => { + browser.menus.create(properties, () => { + const error = browser.runtime.lastError; + if (error) reject(new Error(error.message)); else resolve(); + }); + }); + function rebuild() { + return enqueue(async () => { + const settings = await load(); + await browser.menus.removeAll(); + children = []; + mode = settings.contextMenuMode; + if (mode === 'off') return; + if (mode === 'simple') { + await create({ id: SIMPLE, title: 'Ask ChatGPT', contexts: ['selection'] }); + return; + } + await create({ id: ROOT, title: 'Ask ChatGPT', contexts: CONTEXTS }); + for (const kind of ['selection', 'link', 'page']) { + for (const action of getActions({ kind }, settings)) { + const id = PREFIX + action.id; + await create({ id, parentId: ROOT, title: action.title, contexts: CONTEXTS, visible: false }); + children.push({ id, kind }); + if (action.id === 'ask') { + const separator = PREFIX + 'separator'; + await create({ id: separator, parentId: ROOT, type: 'separator', contexts: CONTEXTS, visible: false }); + children.push({ id: separator, kind }); + } + } + } + }); + } + browser.storage.onChanged.addListener((changes, area) => { + if (area === 'sync' && MENU_KEYS.some(key => Object.hasOwn(changes, key))) return rebuild().catch(console.error); + }); + browser.menus.onHidden.addListener(() => { menuInstance++; }); + browser.menus.onShown.addListener(info => { + const instance = ++menuInstance; + // Context labels are available without host access; selectionText/pageUrl + // are deliberately NOT required until the user invokes an item. + const contexts = info.contexts ?? []; + const kind = contexts.includes('selection') ? 'selection' + : contexts.includes('link') ? 'link' + : contexts.includes('page') && !info.editable && !contexts.some(c => ['image', 'video', 'audio'].includes(c)) ? 'page' : null; + return enqueue(async () => { + if (instance !== menuInstance || mode !== 'advanced') return; + await Promise.all([ + browser.menus.update(ROOT, { visible: kind !== null }), + ...children.map(child => browser.menus.update(child.id, { visible: child.kind === kind })), + ]); + if (instance === menuInstance) await browser.menus.refresh(); + }).catch(console.error); + }); + browser.menus.onClicked.addListener(async info => { + try { + const settings = await load(); + const context = getContext(info); + if (!context || settings.contextMenuMode === 'off') return; + let actionId; + if (settings.contextMenuMode === 'simple') { + if (info.menuItemId !== SIMPLE || context.kind !== 'selection') return; + actionId = 'ask'; + } else { + const allowed = getActions(context, settings); + actionId = allowed.find(action => PREFIX + action.id === info.menuItemId)?.id; + if (!actionId) return; + } + await onAction({ actionId, source: context.source, settings }); + } catch { console.error('Could not run context menu action.'); } + }); + return rebuild(); +} diff --git a/background/open-chat.js b/background/open-chat.js new file mode 100644 index 0000000..e816597 --- /dev/null +++ b/background/open-chat.js @@ -0,0 +1,9 @@ +export async function openChat(browser, url, openIn) { + if (openIn === 'new-window') { + try { + await browser.windows.create({ url }); + return; + } catch { /* Fall back to a tab. */ } + } + await browser.tabs.create({ url, active: openIn !== 'background-tab' }); +} diff --git a/build-extension-zip.sh b/build-extension-zip.sh deleted file mode 100755 index 7cce1fa..0000000 --- a/build-extension-zip.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -zip -r chatgpt-search-engine-extension.zip \ - LICENSE.txt \ - manifest.json \ - icons/favicon.png - diff --git a/dialog/action.css b/dialog/action.css new file mode 100644 index 0000000..a0048e2 --- /dev/null +++ b/dialog/action.css @@ -0,0 +1,5 @@ +body { margin: 0; padding: 1.25rem; box-sizing: border-box; } +h1 { font-size: 1.25rem; margin-top: 0; } +[hidden] { display: none !important; } +button:disabled { opacity: 0.6; } +#message { overflow-wrap: anywhere; } diff --git a/dialog/action.html b/dialog/action.html new file mode 100644 index 0000000..a589d50 --- /dev/null +++ b/dialog/action.html @@ -0,0 +1,22 @@ + + + + +Ask ChatGPT + + +

Ask ChatGPT

+

Preparing your action…

+
+ +

+
+ + +
+
+ + diff --git a/dialog/action.js b/dialog/action.js new file mode 100644 index 0000000..5b9fb06 --- /dev/null +++ b/dialog/action.js @@ -0,0 +1,51 @@ +import { createDialogController } from './controller.js'; +import { openChat } from '../background/open-chat.js'; + +const field = id => document.getElementById(id); +let controller; +let initialized = false; +let previousKind; +const close = () => window.close(); +function render(state) { + const language = state.kind === 'language'; + field('title').textContent = language ? 'Translate selected text' : 'Ask ChatGPT'; + field('description').textContent = language ? 'Which language would you like to translate into?' + : state.kind === 'shortened' ? 'The selection is too long to send in full. Continue with a shortened selection, or cancel and select less text.' + : state.kind === 'error' ? state.message : 'Ready to open ChatGPT with your prompt.'; + field('language-field').hidden = !language; + field('language').disabled = state.busy; + field('continue').hidden = state.kind === 'error'; + field('continue').disabled = state.busy; + field('continue').textContent = state.kind === 'shortened' ? 'Continue with shortened text' : 'Continue'; + field('cancel').disabled = state.busy; + field('message').textContent = state.kind === 'error' ? '' : state.message ?? ''; + if (previousKind !== state.kind) { + (language ? field('language') : state.kind === 'error' ? field('cancel') : field('continue')).focus(); + previousKind = state.kind; + } +} +function failed() { + if (initialized) return; + render({ kind: 'error', message: 'Could not prepare this action. Close this window and try the menu action again.' }); +} +const id = new URL(location.href).searchParams.get('request'); +const timer = setTimeout(failed, 15000); +const port = browser.runtime.connect({ name: 'action-dialog:' + id }); +port.onMessage.addListener(message => { + if (initialized || message?.type !== 'initialize' || !message.payload?.request) return; + initialized = true; + clearTimeout(timer); + controller = createDialogController({ payload: message.payload, render, close, + open: (url, mode) => openChat(browser, url, mode) }); + port.postMessage({ type: 'received' }); +}); +port.onDisconnect.addListener(() => { clearTimeout(timer); failed(); }); +field('action-form').addEventListener('submit', event => { + event.preventDefault(); + void controller?.submit(field('language').value); +}); +function cancel() { if (controller) controller.cancel(); else close(); } +field('cancel').addEventListener('click', cancel); +document.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); cancel(); } +}); diff --git a/dialog/controller.js b/dialog/controller.js new file mode 100644 index 0000000..c4d7b98 --- /dev/null +++ b/dialog/controller.js @@ -0,0 +1,46 @@ +import { normalizeLanguage } from '../lib/settings.js'; +import { prepareAction } from '../lib/prepare-action.js'; + +export function createDialogController({ payload, open, render, close }) { + let request = structuredClone(payload.request); + let state = { ...payload.preparation, busy: false }; + let cancelled = false; + const show = () => render({ ...state }); + show(); + return { + cancel() { + if (state.busy || cancelled) return; + cancelled = true; + request = null; + close(); + }, + async submit(language) { + if (cancelled || state.busy || state.kind === 'error') return; + if (state.kind === 'language') { + const normalized = normalizeLanguage(language); + if (!normalized) { + state.message = 'Enter a language using up to 100 characters.'; + show(); return; + } + request.language = normalized; + state = { ...prepareAction(request), busy: false }; + show(); + if (state.kind !== 'ready') return; + } + if (!['ready', 'shortened'].includes(state.kind)) return; + state.busy = true; + state.message = ''; + show(); + try { + await open(state.url, request.settings.openIn); + cancelled = true; + request = null; + close(); + } catch { + state.busy = false; + state.message = 'Could not open ChatGPT. Please try again or cancel.'; + show(); + } + }, + }; +} diff --git a/docs/advanced-context-menu-verification.md b/docs/advanced-context-menu-verification.md new file mode 100644 index 0000000..6a67758 --- /dev/null +++ b/docs/advanced-context-menu-verification.md @@ -0,0 +1,65 @@ +# Advanced context menu verification + +Implemented against the approved 2026-10-05 spec and plan. Implementation was verified in the feature checkout. PR preparation commits +the extension changes separately from unrelated local notes. No release or +permission change is included. + +## Automated checks + +- `npm test`: strict lint with zero errors, notices, or warnings; 10 discovered + test files passed; unsigned ZIP built successfully. +- Direct execution of each test file: **60 individual tests passed**. +- ZIP inspection: dialog assets present; no docs, tests, or execution scratch. +- `git diff --check`: clean. +- Regression checks cover legacy preference migration, partial-save preservation, + context priority, dedicated prompts, literal substitutions, URL rejection, + Unicode budgets, truncation consent, dialogs, menu updates, and frame focus. + +## Actual Firefox 157 checks + +Temporary Firefox launched through `npm run dev`; interactions automated using +Marionette in a temporary profile. No user's browsing profile was modified. + +- Native selection, link, linked-selection, and page-background menus displayed + the intended action groups. +- Extension dialog handoff acknowledged; language input received initial focus; + Spanish translation opened the correctly constructed ChatGPT URL. +- Concurrent dialogs held separate selections. Cancel and actual Escape key + dispatch opened no ChatGPT tab. Empty language submission was blocked. +- Long selection displayed consent; Continue opened an 8,000-character URL. +- Settings reflected storage updates. Narrow light/dark settings screenshots + were inspected and showed no horizontal overflow. +- Extension reload preserved configured preferences. + +Not verified: physical shortcut presses in the user's desktop session (headless +WebDriver key attempts did not trigger the command), full browser restart, +forced background suspension/wakeup, all editable-field cases, dialog-window +creation failure on the native desktop, and the complete AGENTS.md manual matrix. +The shortcut's frame-selection logic is covered by an executable regression test, +including stale parent selection and restricted-page failure. + +## Independent review + +A fresh reviewer found no blocking defect. Its partial-save contract finding +was reclassified as important and fixed with a failing-then-passing regression. +One minor remains: if a direct launch fails but a retry dialog opens, the dialog +says it is ready rather than explaining the original failure. Continue retries. + +## Execution decisions + +- Worked in the existing feature checkout with a backup at + `/tmp/chatgpt-advanced-baseline`; this preserves prior uncommitted work but + means new work shares that checkout. +- Deferred implementation commits to avoid sweeping unrelated changes into them; + review uses the saved baseline instead of task commit ranges. +- Target languages must be single-line text of at most 100 Unicode code points; + unusual control-character labels are rejected. +- Menu-open visibility uses permission-free Firefox context labels. Source data + is read only at invocation. Whitespace-only selections may show a group but + cannot trigger a nonempty-source action. +- Partial settings updates retain their original contract so unrelated settings + cannot be reset; the extra regression test protects that contract. +- Prior shortcut-editor behavior and other unrelated dirty changes were kept; + their preexisting limitations are outside this change. +- ChatGPT honoring hints or retrieving a URL remains external behavior, described + as best effort rather than guaranteed. diff --git a/docs/future-issue-proposals.md b/docs/future-issue-proposals.md new file mode 100644 index 0000000..bd2d4a8 --- /dev/null +++ b/docs/future-issue-proposals.md @@ -0,0 +1,149 @@ +# Future issue proposals + +Drafts for consideration, not filed issues or committed release milestones. +Firefox Desktop remains the only target. Suggested order starts with named +prompt actions; research tasks can run independently. + +## 1. Add named prompt actions + +**Priority:** High. **Type:** Enhancement. + +The extension currently supports one global prompt template. Let users save +several named actions such as Explain, Summarize, Translate, and Rewrite. + +Acceptance criteria: +- Create, edit, reorder, and delete actions in settings. +- Choose an action from a compact selection context submenu. +- Preserve the existing template and shortcut behavior during migration. +- Treat templates as text, never executable code; validate limits and handle + storage sync quota failures visibly. +- Test placeholder substitution with arbitrary selected text. + +## 2. Add link and page context actions + +**Priority:** Medium. **Type:** Enhancement. + +Selection context already exists. Add explicit actions for a clicked link and +the current page, initially passing the URL rather than extracting full text. +Sending a URL does not guarantee ChatGPT can access or summarize its contents. + +Acceptance criteria: +- Clearly identify whether the action shares selected text, a link, or a page URL. +- Do not include page metadata automatically in existing selection actions. +- Handle restricted pages and unsupported URL schemes gracefully. +- Review URLs containing private query data and update privacy documentation + for the new data flow before release. +- Assess permissions against the existing design spec; full-page extraction + is a separate proposal requiring explicit scope and permission review. + +## 3. Add a toolbar quick-launch popup + +**Priority:** Medium. **Type:** Enhancement. + +Provide a prompt field, access to named actions, and a link to settings from +the toolbar. Reuse the existing URL builder and open-location preferences. + +Acceptance criteria: +- Keyboard-accessible prompt entry and launch action. +- Empty input opens a blank chat. +- Named actions appear when available; the popup can ship independently. +- Works in light/dark themes and a narrow popup. +- No automatic page capture; any selection action is explicit. + +## 4. Investigate advanced address-bar commands + +**Priority:** Medium. **Type:** Research, followed by enhancement if viable. + +Investigate an omnibox entry point that reads saved settings and recognizes +commands such as `!search` and `!temp`. The current search provider uses a +static URL and does not read extension preferences. + +Acceptance criteria: +- Verify interaction with the installed engine and its user-customized keyword. +- Document any difference between Firefox's search keyword and the extension's + manifest-declared omnibox keyword; do not promise runtime keyword editing. +- Specify escaping so users can search for literal command text. +- Preserve ordinary address-bar searches and open-location behavior. +- Keep search/temporary options labeled as hints where not guaranteed. +- Defer `!think` until model/effort research establishes a working mechanism; + adding “think harder” to a prompt is not an effort-setting implementation. +- Do not reintroduce auto-submit; review Appendix B of the existing design spec. + +Reference: [Mozilla omnibox API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox). + +## 5. Add settings export and import + +**Priority:** Medium. **Type:** Enhancement. + +Settings already use `browser.storage.sync`. Add portable JSON backup and +restore, including named actions if that feature ships. + +Acceptance criteria: +- Export a versioned settings document without account or browsing data. +- Validate file size, schema, field types, and supported versions before writing. +- Preview replacement and require an explicit import action. +- Reject invalid input without changing current settings. +- Handle old versions, unknown fields, and storage failures predictably. +- Do not imply that Firefox-owned engine keywords or shortcuts are backed up. + +## 6. Evaluate Firefox Manifest V3 migration + +**Priority:** Low until a concrete benefit or requirement is identified. +**Type:** Research/maintenance. + +Evaluate MV3 separately from user features. The existing options framework, +normal launch, temporary-chat hint, and web-search hint do not need to be +reimplemented as a bundled milestone. MV3 does not establish model control. + +Acceptance criteria: +- Document Firefox-specific benefits, costs, and minimum supported version. +- Audit background lifecycle, script injection, permissions, search provider, + options, and packaging against Mozilla's migration guide. +- If proceeding, preserve settings and verify all existing Firefox workflows. +- No Chromium packaging or sibling-repository changes. + +Reference: [Firefox MV3 migration guide](https://extensionworkshop.com/documentation/develop/manifest-v3-migration-guide/). + +## 7. Investigate optional Firefox Containers integration + +**Priority:** Low. **Type:** Research. + +Explore opening ChatGPT in a user-selected existing container for account +separation. Keep integration opt-in and justify any additional permissions. + +Acceptance criteria: +- Verify the permission requirements for listing containers and opening tabs + in them; the contextualIdentities API documents contextualIdentities and + cookies permissions. +- Review the existing permission design before implementation. +- Handle missing/deleted containers, denied permissions, disabled containers, + and private browsing without silently switching accounts. +- Determine which entry points can honor the preference; do not promise that + the static Firefox search provider can use it. +- Explain that containers separate sessions; they do not make chats temporary. + +Reference: [Mozilla contextualIdentities API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities). + +## 8. Research reliable ChatGPT model and thinking-effort selection + +**Priority:** High interest; blocked on evidence of a reliable mechanism. +**Type:** Research, not a promised feature. + +User testing did not achieve the requested behavior using the temporary URL +probe with `prompt`, `model=gpt-5-6-thinking`, `hints=search`, and +`thinking_effort=extended`. This does not independently establish which fields +were ignored. Earlier model URL tests also failed to switch the selected model. + +Acceptance criteria: +- Record a reproducible browser test and observable evidence of the actual + selected model/effort; a URL parameter or model self-report is insufficient. +- Verify identifiers and distinguish URL prefill from conversation POST data. +- Prefer a documented integration; document maintenance and permission costs + before considering page automation or internal endpoints. +- Do not introduce controls claiming success without verified behavior. +- Treat a public-API implementation as a separate product decision, including + authentication, billing, privacy, and its different user experience. +- Remove the temporary URL test UI before release once investigation is done. + +Shared release constraint: preserve user submission in ChatGPT and the current +minimal-permission approach unless a separately reviewed change justifies more. diff --git a/docs/superpowers/plans/2026-07-30-extension-options-panel.md b/docs/superpowers/plans/2026-07-30-extension-options-panel.md new file mode 100644 index 0000000..e871ef9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-extension-options-panel.md @@ -0,0 +1,1396 @@ +# Extension Options Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an options panel to this Firefox extension, reachable from `about:addons`, configuring two new entry points — a context-menu item on selected text and an `Alt+Shift+G` shortcut that acts on the selection. + +**Architecture:** The extension is currently manifest-only with no JavaScript. This adds an MV2 ES-module background script (`background.type: "module"`, supported from Firefox 112) that registers a context menu and a keyboard command, three pure-logic modules under `lib/`, and an options page rendered inline in `about:addons`. All URL-building logic is pure and unit-tested with `node:test`; the browser-facing code is a thin wiring layer around it. + +**Tech Stack:** Firefox WebExtension Manifest V2, vanilla ES modules (no bundler, no framework), `node:test` for unit tests, `web-ext` 10.5.0 for lint and packaging. + +**Spec:** `docs/superpowers/specs/2026-07-29-extension-options-design.md` (committed at `3d5ce6a`). Read it before starting. **Appendix B is a rejection record, not a backlog** — do not implement auto-submit. + +**Branch:** `feat/extension-options-panel`, already cut from `develop` @ `5edb288`. + +## Global Constraints + +Every task's requirements implicitly include this section. Values are copied verbatim from the spec. + +- **Firefox Desktop only.** Do not add Chrome/Chromium packaging or touch any sibling repository. +- **Manifest stays V2.** Do not migrate to MV3. Keep the code MV3-portable: ES-module background, `persistent: false`, never `browser_style: true`. +- **Permissions are exactly `["storage", "menus", "activeTab"]`.** Do not add host permissions, `optional_permissions`, `tabs`, `webNavigation`, `notifications`, or `contentScripts`. All three chosen permissions add no install-prompt line; `tabs` and `notifications` do. +- **Never read `tab.url`, `tab.title`, or `tab.favIconUrl`.** Reading them requires the `tabs` permission. Only `tab.id` may be read. +- **`strict_min_version` is omitted.** Corrected during execution: the plan + originally pinned `"112.0"` (the ES-module background floor), but the existing + `data_collection_permissions` key requires Firefox 140 / Android 142, so + `addons-linter` warns on any lower value and `warningsAsErrors: true` fails the + build. Measured: `112.0` → 2 warnings, `140.0` → 1, `142.0` → clean, omitted → + clean. `142.0` was rejected because it locks out ESR 140. Below Firefox 112 the + `gpt` keyword still works and only the two new entry points are inert. +- **No new runtime or dev dependencies.** `node:test` is built in. +- **`lint.warningsAsErrors: true`** — a single addons-linter warning fails the build. `npx web-ext lint` must stay at 0 errors / 0 warnings / 0 notices. +- **Two-space indentation** in JSON, YAML, and JavaScript. Lowercase, hyphenated filenames. +- **Base URL is the root path `https://chatgpt.com/`.** Never `chatgpt.com/search`, which returns 404. +- **Send the `q` parameter only.** Never `prompt`, never both. +- **`MAX_URL_CHARS = 8000`.** +- **`hints` is hard-coded to the value `search`.** No mode dropdown. +- **Copy rule:** no user-facing text may say the query is *searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled in**. ChatGPT stopped auto-submitting URL-passed prompts; this is verified, not speculative. +- **Copy rule:** the words *private*, *incognito*, and *secure* must never appear near the temporary-chat setting. +- **No user-facing error notifications.** Every failure path degrades silently to something useful. +- **Commit format:** `: `, lowercase type (`feat:`, `test:`, `doc:`, `build:`). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `lib/defaults.js` | **Create.** `DEFAULT_SETTINGS` and `OPEN_IN_VALUES` — the single source of truth for shape and defaults. No imports. | +| `lib/settings.js` | **Create.** Pure `mergeSettings()` (validation + defaulting) plus `load()`/`save()` wrapping `browser.storage.sync`. Only the pure part is unit-tested. | +| `lib/build-url.js` | **Create.** Pure `buildChatGptUrl(settings, rawQuery)`. Zero browser APIs. The tested core. | +| `background/background.js` | **Create.** Registers the context menu and command; reads the selection; opens the tab/window. Thin wiring only — no URL logic. | +| `options/options.html` | **Create.** The five controls plus Restore defaults. | +| `options/options.css` | **Create.** Hand-styled, responsive to ~400 px, light and dark via `prefers-color-scheme`. | +| `options/options.js` | **Create.** Renders settings, debounced auto-save, live re-sync on `storage.onChanged`. | +| `test/build-url.test.js` | **Create.** Unit tests for the URL builder. | +| `test/settings.test.js` | **Create.** Unit tests for `mergeSettings`. | +| `manifest.json` | **Modify.** Add `permissions`, `background`, `options_ui`, `commands`; bump version. | +| `package.json` | **Modify.** Add `"type": "module"`, a `test:unit` script; bump version. | +| `README.md` | **Modify.** Options section, the double-Enter limitation, revised privacy wording. | +| `AGENTS.md` | **Modify.** Project structure, commands, manual test checklist. | +| `CHANGELOG.md` | **Modify.** 1.1.0 entry. | + +`web-ext-config.mjs` already excludes `docs/` and `test/` (done alongside the spec commit) — no change needed. + +--- + +### Task 1: Tooling foundation, defaults, and settings validation + +Establishes the test runner and the settings contract every later task depends on. `"type": "module"` is **required**: without it `node --test` relies on Node's module-syntax detection, which emits a permanent `MODULE_TYPELESS_PACKAGE_JSON` warning and **fails outright on Node 22.0–22.6**, and CI pins the bare major `"22"`. + +**Files:** +- Modify: `package.json` +- Create: `lib/defaults.js` +- Create: `lib/settings.js` +- Test: `test/settings.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `DEFAULT_SETTINGS: { model: string, temporaryChat: boolean, webSearch: boolean, openIn: string, promptTemplate: string }` from `lib/defaults.js` + - `OPEN_IN_VALUES: string[]` from `lib/defaults.js` + - `mergeSettings(stored: unknown) => Settings` from `lib/settings.js` + - `load() => Promise` from `lib/settings.js` + - `save(partial: Partial) => Promise` from `lib/settings.js` + +- [ ] **Step 1: Add `"type": "module"` and the unit-test script to `package.json`** + +Replace the `"private"`, `"version"`, and `"scripts"` region so the file reads: + +```json +{ + "name": "search-with-chatgpt-powered-by-openai-extension", + "version": "1.1.0", + "private": true, + "type": "module", + "description": "Development tooling for the Search with ChatGPT Firefox extension.", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "web-ext run", + "lint": "web-ext lint", + "test:unit": "node --test", + "build": "web-ext build", + "test": "npm run lint && npm run test:unit && npm run build" + }, + "devDependencies": { + "web-ext": "10.5.0" + } +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `test/settings.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { mergeSettings } from "../lib/settings.js"; + +test("empty object yields the defaults", () => { + assert.deepEqual(mergeSettings({}), DEFAULT_SETTINGS); +}); + +test("null and undefined yield the defaults", () => { + assert.deepEqual(mergeSettings(null), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(undefined), DEFAULT_SETTINGS); +}); + +test("a non-object yields the defaults", () => { + assert.deepEqual(mergeSettings("nonsense"), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(42), DEFAULT_SETTINGS); +}); + +test("valid stored values are preserved", () => { + assert.deepEqual( + mergeSettings({ + model: "auto", + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }), + { + model: "auto", + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }, + ); +}); + +test("missing keys fall back individually", () => { + const merged = mergeSettings({ model: "auto" }); + assert.equal(merged.model, "auto"); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("unknown keys are dropped", () => { + const merged = mergeSettings({ model: "auto", nope: "gone", openInn: "typo" }); + assert.deepEqual(Object.keys(merged).sort(), Object.keys(DEFAULT_SETTINGS).sort()); + assert.equal("nope" in merged, false); +}); + +test("wrong types fall back to the default", () => { + const merged = mergeSettings({ + model: 123, + temporaryChat: "yes", + webSearch: 1, + promptTemplate: [], + }); + assert.equal(merged.model, DEFAULT_SETTINGS.model); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.webSearch, DEFAULT_SETTINGS.webSearch); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("an out-of-range openIn falls back to the default", () => { + assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); + assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn); +}); + +test("each documented openIn value is accepted", () => { + for (const value of ["new-tab", "background-tab", "new-window"]) { + assert.equal(mergeSettings({ openIn: value }).openIn, value); + } +}); + +test("mergeSettings does not mutate DEFAULT_SETTINGS", () => { + mergeSettings({ model: "mutated" }); + assert.equal(DEFAULT_SETTINGS.model, ""); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `npm run test:unit` + +Expected: FAIL — `Cannot find module .../lib/defaults.js`. Confirm the summary reports a non-zero `fail` count. + +- [ ] **Step 4: Create `lib/defaults.js`** + +```js +// Single source of truth for the settings shape and their defaults. +// Defaults reproduce the extension's pre-1.1 behaviour exactly: an empty model +// and both toggles off mean the only URL parameter sent is `q`. + +export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; + +export const DEFAULT_SETTINGS = { + model: "", + temporaryChat: false, + webSearch: false, + openIn: "new-tab", + promptTemplate: "{query}", +}; +``` + +- [ ] **Step 5: Create `lib/settings.js`** + +```js +import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; + +// Pure. Layers stored values over the defaults, dropping unknown keys and +// falling back on wrong types or out-of-range values. A fresh or signed-out +// profile returns {} from storage, which yields the defaults. +export function mergeSettings(stored) { + const merged = { ...DEFAULT_SETTINGS }; + if (stored === null || typeof stored !== "object") { + return merged; + } + if (typeof stored.model === "string") { + merged.model = stored.model; + } + if (typeof stored.temporaryChat === "boolean") { + merged.temporaryChat = stored.temporaryChat; + } + if (typeof stored.webSearch === "boolean") { + merged.webSearch = stored.webSearch; + } + if (OPEN_IN_VALUES.includes(stored.openIn)) { + merged.openIn = stored.openIn; + } + if (typeof stored.promptTemplate === "string") { + merged.promptTemplate = stored.promptTemplate; + } + return merged; +} + +// storage.sync works with no Mozilla account (it behaves as local storage and +// syncs later if the user signs in), so no capability probe is needed. Any +// rejection falls back to defaults rather than blocking the user's action. +// Firefox sanitises all non-quota backend errors to a single generic string, so +// there is nothing useful to branch on. +export async function load() { + try { + const stored = await browser.storage.sync.get(Object.keys(DEFAULT_SETTINGS)); + return mergeSettings(stored); + } catch { + return mergeSettings(null); + } +} + +export async function save(partial) { + await browser.storage.sync.set(partial); +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `npm run test:unit` + +Expected: PASS, all 10 tests. Confirm the output shows `# fail 0` **and a non-zero `# tests` count** — `node --test` exits 0 when it discovers zero test files, so a zero count means the runner is misconfigured, not that everything passed. + +- [ ] **Step 7: Confirm the full pipeline is green and no stderr warning appeared** + +Run: `npm test 2>&1 | grep -i 'MODULE_TYPELESS\|warning' ; npm test` + +Expected: no `MODULE_TYPELESS_PACKAGE_JSON` line; lint reports `0 errors / 0 warnings / 0 notices`; unit tests pass; build writes the ZIP; exit code 0. + +- [ ] **Step 8: Verify `test/` is excluded from the package** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.0.zip | grep -c 'test\|docs' || echo "0 matches - correct"` + +Expected: `0 matches - correct`. (The ZIP is still named `1.0` until Task 3 bumps the manifest version.) `lib/` legitimately appears once the background script imports it in Task 3. + +- [ ] **Step 9: Commit** + +```bash +git add package.json lib/defaults.js lib/settings.js test/settings.test.js +git commit -m "feat: add settings defaults and validation with unit tests" +``` + +--- + +### Task 2: The pure URL builder + +The core logic, isolated so it is fully testable without a browser. Every ChatGPT parameter here is undocumented and unreliable — the builder's job is to emit them cleanly and never depend on them being honoured. + +**Files:** +- Create: `lib/build-url.js` +- Test: `test/build-url.test.js` + +**Interfaces:** +- Consumes: `DEFAULT_SETTINGS` from `lib/defaults.js` (tests only). +- Produces: + - `buildChatGptUrl(settings: Settings, rawQuery: unknown) => string` from `lib/build-url.js` + - `MAX_URL_CHARS: number` (8000) from `lib/build-url.js` + +- [ ] **Step 1: Write the failing test** + +Create `test/build-url.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js"; + +const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); +const queryOf = (url) => new URL(url).searchParams.get("q"); +const paramsOf = (url) => [...new URL(url).searchParams.keys()]; + +test("defaults produce only the q parameter on the root path", () => { + assert.equal( + buildChatGptUrl(DEFAULT_SETTINGS, "hello"), + "https://chatgpt.com/?q=hello", + ); +}); + +test("an empty or whitespace-only query omits q entirely", () => { + for (const raw of ["", " ", "\n\t", null, undefined]) { + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`); + assert.equal(url, "https://chatgpt.com/"); + } +}); + +test("model is sent only when non-empty after trimming", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ model: "auto" }), "hi")), "hi"); + assert.equal(new URL(buildChatGptUrl(withSettings({ model: "auto" }), "hi")).searchParams.get("model"), "auto"); + assert.equal(new URL(buildChatGptUrl(withSettings({ model: " auto " }), "hi")).searchParams.get("model"), "auto"); + for (const model of ["", " "]) { + assert.equal(paramsOf(buildChatGptUrl(withSettings({ model }), "hi")).includes("model"), false); + } +}); + +test("temporaryChat and webSearch each appear only when enabled", () => { + assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false); + assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false); + + const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi")); + assert.equal(temp.searchParams.get("temporary-chat"), "true"); + + const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi")); + assert.equal(search.searchParams.get("hints"), "search"); +}); + +test("all four URL-affecting settings combine, q first", () => { + const url = buildChatGptUrl( + withSettings({ + model: "auto", + temporaryChat: true, + webSearch: true, + promptTemplate: "Explain: {query}", + }), + "entropy", + ); + assert.deepEqual(paramsOf(url), ["q", "model", "temporary-chat", "hints"]); + assert.equal(queryOf(url), "Explain: entropy"); +}); + +test("openIn never influences the URL", () => { + const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi"); + for (const openIn of ["new-tab", "background-tab", "new-window"]) { + assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base); + } +}); + +test("special characters survive encoding round-trip", () => { + const raw = 'a & b # c ? d = e + f "g" 100% 😀 日本語\nnewline'; + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); + assert.equal(url.includes(" "), false); + assert.equal(url.includes("#"), false); +}); + +test("the template replaces every {query} occurrence", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x"); + assert.equal(queryOf(url), "x / x"); +}); + +test("a template without {query} is treated as a prefix, never dropping the query", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why"); + assert.equal(queryOf(url), "Be concise: why"); +}); + +test("an empty or whitespace-only template yields the bare query", () => { + for (const promptTemplate of ["", " "]) { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why"); + } +}); + +test("a non-string template degrades to the bare query", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why"); +}); + +test("an over-long prompt is truncated to fit the URL budget", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.ok(queryOf(url).length > 0); + assert.ok(queryOf(url).length < 20000); +}); + +test("truncation accounts for the other parameters", () => { + const settings = withSettings({ model: "some-long-model-slug", temporaryChat: true, webSearch: true }); + const url = buildChatGptUrl(settings, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.equal(new URL(url).searchParams.get("model"), "some-long-model-slug"); + assert.equal(new URL(url).searchParams.get("hints"), "search"); +}); + +test("truncation never splits a surrogate pair", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀".repeat(9000)); + assert.ok(url.length <= MAX_URL_CHARS); + const lonelySurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀 ".repeat(9000)); + assert.equal(/%[0-9A-Fa-f]?$/.test(url), false); + assert.doesNotThrow(() => decodeURIComponent(new URL(url).search.slice(3))); +}); + +test("truncation prefers a whitespace boundary, so no word is cut in half", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "lorem ipsum ".repeat(2000)); + const tokens = queryOf(url).trim().split(/\s+/); + assert.ok(tokens.length > 1); + for (const token of tokens) { + assert.ok(token === "lorem" || token === "ipsum", `truncated mid-word: ${token}`); + } +}); + +test("a query needing no truncation is left byte-identical", () => { + const raw = "a modest question about thermodynamics"; + assert.equal(queryOf(buildChatGptUrl(DEFAULT_SETTINGS, raw)), raw); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --test test/build-url.test.js` + +Expected: FAIL — `Cannot find module .../lib/build-url.js`. + +- [ ] **Step 3: Create `lib/build-url.js`** + +```js +// Pure URL construction. No browser APIs, so node:test imports this directly. +// +// Every ChatGPT parameter below is undocumented by OpenAI and has broken before. +// This module's contract is only that it emits a well-formed URL; whether +// ChatGPT honours model / temporary-chat / hints is out of our control, which is +// why each is independently omittable and every label is hint-strength. +// +// The root path is mandatory: chatgpt.com/search began returning 404 in +// mid-2025, and the root form is what OpenAI's own extension uses. + +const BASE = "https://chatgpt.com/"; + +export const MAX_URL_CHARS = 8000; + +// Fraction of the kept text within which a whitespace cut is preferred. +const WHITESPACE_WINDOW = 0.9; + +function applyTemplate(template, query) { + if (query === "") { + return ""; + } + const tpl = typeof template === "string" ? template : ""; + if (tpl.includes("{query}")) { + // Corrected during execution: the replacer MUST be a function. In the + // string form, $&, $`, $' and $$ are substitution patterns in the + // replacement — and the replacement is arbitrary page-selected text. + // Measured on the default {query} template: "awk print $& here" produced + // "awk print {query} here". + return tpl.replaceAll("{query}", () => query); + } + const trimmed = tpl.trim(); + return trimmed === "" ? query : `${trimmed} ${query}`; +} + +function addOptionalParams(url, settings) { + const model = typeof settings.model === "string" ? settings.model.trim() : ""; + if (model !== "") { + url.searchParams.set("model", model); + } + if (settings.temporaryChat) { + url.searchParams.set("temporary-chat", "true"); + } + if (settings.webSearch) { + url.searchParams.set("hints", "search"); + } + return url; +} + +function lastWhitespaceIndex(text) { + for (let i = text.length - 1; i >= 0; i -= 1) { + if (/\s/.test(text[i])) { + return i; + } + } + return -1; +} + +// Returns the longest prefix of `prompt` whose finished URL fits the budget. +// Slicing happens by CODE POINT on the decoded string, so a surrogate pair can +// never be split and a percent-escape can never be severed (encoding happens +// afterwards, via URLSearchParams). +function fitToBudget(prompt, settings) { + const codePoints = Array.from(prompt); + + const urlLengthFor = (count) => { + const url = new URL(BASE); + url.searchParams.set("q", codePoints.slice(0, count).join("")); + addOptionalParams(url, settings); + return url.href.length; + }; + + if (urlLengthFor(codePoints.length) <= MAX_URL_CHARS) { + return prompt; + } + + let low = 0; + let high = codePoints.length; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + if (urlLengthFor(mid) <= MAX_URL_CHARS) { + low = mid; + } else { + high = mid - 1; + } + } + + const kept = codePoints.slice(0, low).join(""); + const boundary = Math.floor(kept.length * WHITESPACE_WINDOW); + const cut = lastWhitespaceIndex(kept); + return cut >= boundary && cut > 0 ? kept.slice(0, cut) : kept; +} + +export function buildChatGptUrl(settings, rawQuery) { + const query = String(rawQuery ?? "").trim(); + const prompt = fitToBudget(applyTemplate(settings.promptTemplate, query), settings); + + // q first, matching the most-cited working examples. Total serialized length + // is order-independent, so measuring without q above is sound. + const url = new URL(BASE); + if (prompt !== "") { + url.searchParams.set("q", prompt); + } + addOptionalParams(url, settings); + return url.href; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node --test test/build-url.test.js` + +Expected: PASS, all 17 tests, `# fail 0`. + +- [ ] **Step 5: Run the full suite** + +Run: `npm test` + +Expected: lint `0 errors / 0 warnings / 0 notices`; 27 unit tests passing across both files; build succeeds; exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add lib/build-url.js test/build-url.test.js +git commit -m "feat: add pure ChatGPT URL builder with unit tests" +``` + +--- + +### Task 3: Manifest wiring and the background script + +Turns the tested logic into a working extension. Manifest and background must land together — the manifest without the script registers nothing, and the script without the manifest is never loaded. + +**Files:** +- Modify: `manifest.json` +- Create: `background/background.js` + +**Interfaces:** +- Consumes: `load()` from `lib/settings.js`; `buildChatGptUrl()` from `lib/build-url.js`. +- Produces: the `ask-chatgpt` command name (referenced by `manifest.json` and by the `commands.onCommand` handler) and the `ask-chatgpt-selection` menu id. + +- [ ] **Step 1: Rewrite `manifest.json`** + +Replace the whole file. `browser_style: false` is set **explicitly** — omitting it defaults to `true` in MV2, and `true` is unsupported in MV3, so being explicit keeps the panel looking identical if the extension ever migrates. `is_default` stays `false`: setting it `true` triggers a second, separate consent doorhanger. + +```json +{ + "name": "Search with ChatGPT powered by OpenAI!", + "description": "Adds a ChatGPT search engine", + "manifest_version": 2, + "author": "Mathew Guest ", + "homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT", + "version": "1.1", + "icons": { + "16": "icons/powered-by-openai-icon-16x16.png", + "32": "icons/powered-by-openai-icon-32x32.png", + "48": "icons/powered-by-openai-icon-48x48.png", + "64": "icons/powered-by-openai-icon-64x64.png", + "128": "icons/powered-by-openai-icon-128x128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "{24644f0c-bfcb-4ebd-a16b-bc8a7d154288}", + "data_collection_permissions": { + "required": [ + "none" + ] + } + } + }, + "permissions": [ + "storage", + "menus", + "activeTab" + ], + "background": { + "scripts": [ + "background/background.js" + ], + "type": "module", + "persistent": false + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": false, + "browser_style": false + }, + "commands": { + "ask-chatgpt": { + "suggested_key": { + "default": "Alt+Shift+G" + }, + "description": "Ask ChatGPT about the selected text" + } + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "ChatGPT", + "search_url": "https://chatgpt.com?q={searchTerms}", + "keyword": "gpt", + "favicon_url": "https://zavage-software.com/static/ChatGPT-favicon.png", + "is_default": false, + "encoding": "UTF-8" + } + } +} +``` + +**Do not change `search_provider`.** Leaving Firefox as the transmitter of `gpt ` is a deliberate decision to minimise AMO data-collection exposure (spec Risk 6). + +- [ ] **Step 2: Create `background/background.js`** + +```js +import { load } from "../lib/settings.js"; +import { buildChatGptUrl } from "../lib/build-url.js"; + +const MENU_ID = "ask-chatgpt-selection"; +const COMMAND_ID = "ask-chatgpt"; + +// Reads the user's selection for the keyboard-shortcut path. The context menu +// does not need this — it receives info.selectionText directly. +// +// A commands invocation confers activeTab (ext-commands.js grants it before +// firing onCommand), so executeScript works with no host permission. +// +// Failure has three measured shapes, all handled here: a thrown +// "Missing host permission for the tab", a thrown variant naming frames, and a +// SILENT resolution to [null] on parent-process about: pages. Never index into +// a result without a null check. +async function readSelection() { + let tabs; + try { + tabs = await browser.tabs.query({ active: true, currentWindow: true }); + } catch { + return ""; + } + + // Only tab.id may be read. Reading tab.url or tab.title would require the + // "tabs" permission, which does add an install-prompt warning line. + const tabId = tabs?.[0]?.id; + if (typeof tabId !== "number") { + return ""; + } + + let results; + try { + results = await browser.tabs.executeScript(tabId, { + // Corrected during execution: gated on document.hasFocus(). Each frame + // owns an independent Selection that is not cleared when the user + // selects elsewhere, so an unfocused (possibly cross-origin) frame's + // stale selection could otherwise win on unspecified result ordering. + code: "document.hasFocus() ? window.getSelection().toString() : ''", + allFrames: true, + matchAboutBlank: true, + }); + } catch { + return ""; + } + + if (!Array.isArray(results)) { + return ""; + } + for (const result of results) { + if (typeof result === "string" && result.trim() !== "") { + return result; + } + } + return ""; +} + +// Corrected during execution: the whole body is wrapped, because both call +// sites invoke this without awaiting. Without the outer catch a rejection from +// the final tabs.create surfaces as an unhandled promise rejection, which the +// "degrade silently" constraint above forbids. The inner catch stays nested so +// a windows.create failure still falls through to a tab. +async function openChatGpt(query) { + try { + const settings = await load(); + const url = buildChatGptUrl(settings, query); + + if (settings.openIn === "new-window") { + try { + await browser.windows.create({ url }); + return; + } catch { + // Fall through to a tab rather than doing nothing. + } + } + + await browser.tabs.create({ url, active: settings.openIn !== "background-tab" }); + } catch { + // Nothing useful to say and nowhere useful to say it: the user expects a + // tab or nothing at all. + } +} + +// removeAll() first, so a background event page waking up and re-running this +// module cannot throw on a duplicate menu id. +async function registerMenu() { + await browser.menus.removeAll(); + browser.menus.create({ + id: MENU_ID, + title: 'Ask ChatGPT about "%s"', + contexts: ["selection"], + }); +} + +browser.menus.onClicked.addListener((info) => { + if (info.menuItemId !== MENU_ID) { + return; + } + openChatGpt(info.selectionText ?? ""); +}); + +// browser.commands does not exist on Firefox for Android. This extension is +// desktop-only, but guard rather than throwing at startup. +if (browser.commands) { + browser.commands.onCommand.addListener(async (command) => { + if (command !== COMMAND_ID) { + return; + } + openChatGpt(await readSelection()); + }); +} + +// Corrected during execution: menus.removeAll() is promise-based in Firefox, so +// an unguarded call here can log an unhandled rejection. (menus.create() is +// callback-based and cannot reject.) +registerMenu().catch(() => { + // A failed re-registration cannot be retried usefully from here. +}); +``` + +- [ ] **Step 3: Verify lint and packaging still pass** + +Run: `npm test` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; the ZIP is now named `search-with-chatgpt-firefox-1.1.zip`. + +- [ ] **Step 4: Verify the package contents** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip` + +Expected: `manifest.json`, `LICENSE.txt`, `icons/` + 5 PNGs, `background/background.js`, `lib/` + 3 modules. **No `test/`, no `docs/`, no `options/` yet** (Task 4 adds it), and no empty `test/`/`docs/` directory entries. + +- [ ] **Step 5: Manual smoke test in Firefox** + +Run: `npm run dev` + +In the temporary profile: +1. Select text on any ordinary web page, right-click → confirm an **"Ask ChatGPT about …"** item appears, showing the selected text. +2. Click it → ChatGPT opens in a new tab with the selection in the composer. +3. Right-click with **nothing** selected → confirm the item is **absent**. +4. Press `Alt+Shift+G` with text selected → same behaviour as the menu. +5. Press `Alt+Shift+G` with nothing selected → a blank ChatGPT opens (no `?q=`). +6. Navigate to `about:config`, press `Alt+Shift+G` → a blank ChatGPT opens. It must **not** fail silently with nothing happening, and must not log an unhandled rejection. +7. Select text inside an iframe (any page with an embedded frame) and press `Alt+Shift+G` → the iframe selection is used. +8. Confirm `gpt ` in the address bar still works and the provider is still non-default in `about:preferences#search`. + +**The shortcut must be verified by actually pressing the key.** `commands.getAll()` reports a `suggested_key` as registered even when Firefox has silently overridden it — `Ctrl+Shift+Y` was measured showing as registered and never firing. A green `getAll()` proves nothing. + +If `Alt+Shift+G` does not fire, check `about:addons` → gear → Manage Extension Shortcuts for a conflict before changing code. + +- [ ] **Step 6: Commit** + +```bash +git add manifest.json background/background.js +git commit -m "feat: add context menu and keyboard shortcut entry points" +``` + +--- + +### Task 4: The options panel + +The panel renders **inline inside `about:addons`**, which is a narrow, constrained surface that inherits that page's layout and follows the system theme. Because `browser_style` is off, it is hand-styled and must handle dark mode itself. + +**Files:** +- Create: `options/options.html` +- Create: `options/options.css` +- Create: `options/options.js` + +**Interfaces:** +- Consumes: `DEFAULT_SETTINGS` from `lib/defaults.js`; `load()` and `save()` from `lib/settings.js`. +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Create `options/options.html`** + +The element `id`s must match the keys in `DEFAULT_SETTINGS` exactly — `options.js` relies on that. + +```html + + + + +Search with ChatGPT — Options + + +
+
+ + +

+ ChatGPT may ignore this and use your account default. Leave blank to + always use your account default. +

+
+ +
+ +

+ ChatGPT may not honour this. Check ChatGPT's own indicator before sending + anything sensitive. +

+
+ +
+ +

A hint to ChatGPT's interface, not a guarantee.

+
+ +
+ + +
+ +
+ + +

{query} is replaced with your selected text.

+
+ +
+ + +
+
+ +

+ Selecting text and choosing Ask ChatGPT — or pressing + Alt+Shift+G — opens ChatGPT with your text + filled in. ChatGPT no longer submits prompts that arrive from a link, so press + Enter there to send. Change the shortcut under + Manage Extension Shortcuts in Add-ons. +

+ + +``` + +- [ ] **Step 2: Create `options/options.css`** + +```css +:root { + --fg: #15141a; + --fg-dim: #5b5b66; + --bg: transparent; + --border: #8f8f9d; + --field-bg: #ffffff; + --accent: #0060df; +} + +@media (prefers-color-scheme: dark) { + :root { + --fg: #fbfbfe; + --fg-dim: #b1b1bd; + --border: #8f8f9d; + --field-bg: #2b2a33; + --accent: #0df; + } +} + +body { + margin: 0; + padding: 0.5rem 0; + color: var(--fg); + background: var(--bg); + font: message-box; + font-size: 1rem; + line-height: 1.4; + max-width: 42rem; +} + +.field { + margin-bottom: 1.15rem; +} + +label { + display: block; + font-weight: 600; + margin-bottom: 0.3rem; +} + +.checkbox label { + font-weight: 400; + display: flex; + align-items: baseline; + gap: 0.45rem; +} + +input[type="text"], +select { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0.4rem 0.5rem; + color: var(--fg); + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; +} + +input[type="text"]:focus-visible, +select:focus-visible, +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.hint { + margin: 0.3rem 0 0; + color: var(--fg-dim); + font-size: 0.85rem; +} + +.tag { + font-weight: 400; + font-size: 0.75rem; + color: var(--fg-dim); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0 0.3rem; + vertical-align: 1px; +} + +.actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.5rem; +} + +button { + padding: 0.35rem 0.8rem; + color: var(--fg); + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; + cursor: pointer; +} + +#status { + color: var(--fg-dim); + font-size: 0.85rem; +} + +.note { + margin-top: 1.75rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + color: var(--fg-dim); + font-size: 0.85rem; +} + +code, +kbd { + font-family: monospace; + font-size: 0.9em; +} + +/* The inline about:addons surface can be very narrow. */ +@media (max-width: 26rem) { + .actions { + align-items: flex-start; + flex-direction: column; + } +} +``` + +- [ ] **Step 3: Create `options/options.js`** + +```js +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { load, save } from "../lib/settings.js"; + +const SAVE_DEBOUNCE_MS = 400; +const STATUS_CLEAR_MS = 1500; + +const field = (id) => document.getElementById(id); + +let saveTimer = null; +let statusTimer = null; +// Suppresses the change/input handlers while we are programmatically writing +// values into the form, so a remote sync cannot trigger a save loop. +let applying = false; + +// Corrected during execution: the activeElement guard is required because +// storage.onChanged fires for this page's OWN writes, so every debounced +// autosave re-entered the listener below and could overwrite whatever the user +// had typed since the save started. +function render(settings) { + applying = true; + for (const [id, value] of Object.entries(settings)) { + const element = field(id); + // Never overwrite the control the user is actively editing — this page's + // own saves echo back through storage.onChanged (see below). + if (element === document.activeElement) { + continue; + } + if (typeof value === "boolean") { + element.checked = value; + } else { + element.value = value; + } + } + applying = false; +} + +function collect() { + return { + model: field("model").value, + temporaryChat: field("temporaryChat").checked, + webSearch: field("webSearch").checked, + openIn: field("openIn").value, + promptTemplate: field("promptTemplate").value, + }; +} + +function showStatus(message) { + field("status").textContent = message; + clearTimeout(statusTimer); + statusTimer = setTimeout(() => { + field("status").textContent = ""; + }, STATUS_CLEAR_MS); +} + +async function persist(settings) { + try { + await save(settings); + showStatus("Saved"); + } catch { + // storage.sync sanitises its errors, so there is nothing specific to report. + showStatus("Could not save settings"); + } +} + +// Debounced so typing in a text field cannot approach any write-rate quota. +function scheduleSave() { + if (applying) { + return; + } + clearTimeout(saveTimer); + saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS); +} + +// Module scripts are deferred, so the DOM is already parsed here. +render(await load()); + +for (const id of Object.keys(DEFAULT_SETTINGS)) { + field(id).addEventListener("input", scheduleSave); + field(id).addEventListener("change", scheduleSave); +} + +field("restore").addEventListener("click", () => { + clearTimeout(saveTimer); + // Restoring defaults is an explicit user action, so it should reset every + // field even if one currently has focus. Blur first so render()'s + // activeElement guard (see below) has nothing to skip. + document.activeElement?.blur(); + render(DEFAULT_SETTINGS); + persist({ ...DEFAULT_SETTINGS }); +}); + +// browser.storage.onChanged fires for every write to the "sync" area, not just +// a remote sync landing on another device — this page's own debounced +// autosaves and its Restore-defaults save re-enter this listener too, which +// render()'s activeElement guard makes safe to react to unconditionally. +browser.storage.onChanged.addListener(async (changes, area) => { + if (area !== "sync") { + return; + } + render(await load()); +}); +``` + +- [ ] **Step 4: Verify lint and packaging** + +Run: `npm test` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; build succeeds. A top-level `await` in `options.js` is valid in an ES module; if addons-linter objects, wrap the body in an `async function init() { … } init();` instead. + +- [ ] **Step 5: Confirm `options/` is packaged** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip | grep options` + +Expected: `options/options.html`, `options/options.css`, `options/options.js`. + +- [ ] **Step 6: Manual test the panel** + +Run: `npm run dev` + +1. Open `about:addons` → Extensions → this extension → the **Options** tab (Windows) / **Preferences** tab (macOS, Linux). Firefox's label is platform-dependent; do not expect "Preferences" everywhere. +2. Confirm all five controls plus **Restore defaults** render and are readable. +3. Change each setting; confirm "Saved" appears briefly. +4. Restart Firefox (or reload the extension) and confirm every value persisted. +5. Click **Restore defaults**; confirm the fields reset and persist. +6. Narrow the Firefox window as far as it goes; confirm nothing overflows horizontally and the actions row wraps. +7. Toggle the OS/Firefox theme to dark (`about:preferences` → Language and Appearance, or set `ui.systemUsesDarkTheme`); confirm text stays legible and inputs remain visible in **both** themes. +8. Set **Prompt template** to `Explain simply: {query}`, then use the context menu; confirm the ChatGPT composer contains `Explain simply: `. +9. Set **Open in** to each of the three values and confirm each behaves correctly, including that the background-tab option does not steal focus. + +- [ ] **Step 7: Commit** + +```bash +git add options/options.html options/options.css options/options.js +git commit -m "feat: add options panel embedded in about:addons" +``` + +--- + +### Task 5: Documentation, changelog, and release metadata + +**Files:** +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. + +- [ ] **Step 1: Add an Options section and the ChatGPT limitation to `README.md`** + +Insert after the existing intro paragraphs, before `## Browser Support`: + +```markdown +## Options + +Open `about:addons`, select this extension, and choose the **Options** tab +(**Preferences** on macOS and Linux). Available settings: + +- **Model** *(experimental)* — a model slug such as `auto`. ChatGPT may ignore + it and use your account default; leave blank to always use the account + default. +- **Request a temporary chat** — asks ChatGPT not to save the conversation. + ChatGPT may not honour this; check its own indicator before relying on it. +- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a + guarantee. +- **Open in** — a new tab, a new background tab, or a new window. +- **Prompt template** — wraps your text, for example `Explain simply: {query}`. + +These settings apply to the new entry points: selecting text and choosing +**Ask ChatGPT** from the right-click menu, or pressing +Alt+Shift+G. Rebind the shortcut under +**Manage Extension Shortcuts** in the Add-ons gear menu. + +The `gpt ` address-bar search is unchanged and does not read these +settings. + +## Why you press Enter twice + +ChatGPT no longer submits prompts that are passed to it in a URL — it fills them +into the composer and waits. So `gpt ` opens ChatGPT with your query +filled in, and you press Enter there to send it. + +This is a change OpenAI made to chatgpt.com, not a fault in this extension, and +it is outside our control. It was a deliberate security measure: automatically +running a prompt that arrived in a link let malicious links execute instructions +in a signed-in ChatGPT session. The extra keypress is the checkpoint that +prevents that. No API key, server, or intermediary is involved either way. +``` + +- [ ] **Step 2: Revise the `README.md` privacy section** + +Replace the existing `## Privacy Policy` section: + +```markdown +## Privacy Policy + +This extension has no server, no API key, and no intermediary. Nothing is +reported to Zavage Software. + +- Your settings are stored in your own browser profile. If you use Firefox Sync + with **Add-ons** enabled, they sync end-to-end encrypted through your own + Mozilla account. +- When you choose **Ask ChatGPT** or press the shortcut, the text you selected + is placed in the chatgpt.com URL the extension opens for you. It goes nowhere + else. +- The extension reads a page's selected text only at the moment you invoke it, + and never reads page addresses or titles. +``` + +Do **not** write "never leaves your device" unconditionally — with Sync enabled an encrypted blob does reach Mozilla's servers. Do not restore the bare claim "This extension collects zero user data": it is true in plain English, but AMO's definition of transmission is broader, and specific wording is safer. See spec Risk 6. + +- [ ] **Step 3: Update `AGENTS.md`** + +In **Project Structure & Module Organization**, replace the opening line and add entries: + +```markdown +This is a Firefox WebExtension (Manifest V2) with npm-based development tooling. + +- `manifest.json` defines the extension metadata, the ChatGPT search provider, + the background script, the options panel, and the keyboard command. +- `lib/` holds pure logic with no browser dependencies: `defaults.js`, + `build-url.js`, and `settings.js`. This is the unit-tested core. +- `background/background.js` is an ES-module background script that registers + the context menu and the keyboard command. +- `options/` is the options panel rendered inline in `about:addons`. +- `test/` holds `node:test` unit tests. Excluded from the packaged ZIP. +- `icons/` contains size-specific PNG artwork named `powered-by-openai-icon-x.png`. +- `web-ext-config.mjs` controls linting, local execution, and ZIP packaging. +- `docs/superpowers/` holds specs and plans. Excluded from the packaged ZIP. +- `.gitea/workflows/firefox-extension.yml` validates pull requests and protected branches. +- `README.md`, `CHANGELOG.md`, and `LICENSE.txt` cover usage, releases, and licensing. +``` + +In **Build, Test, and Development Commands**, add after the `npm run lint` line: + +```markdown +- `npm run test:unit` runs the `node:test` unit tests over `test/`. +``` + +and replace the `npm test` line: + +```markdown +- `npm test` runs lint, unit tests, and build together; run it before every pull request. +``` + +Replace the first paragraph of **Testing Guidelines**: + +```markdown +Unit tests cover the pure logic in `lib/` via `node:test`; automated checks +otherwise cover manifest validation and packaging, not browser behaviour. Note +`node --test` exits 0 when it discovers no test files, so confirm a non-zero +test count rather than trusting the exit code alone. + +After `npm test`, use `npm run dev` and confirm: + +- `gpt ` opens ChatGPT, the provider stays non-default, and Firefox + search settings can configure it. +- The Options/Preferences tab renders all five settings plus Restore defaults, + in both light and dark themes and at a narrow window width, and values + persist across a restart. +- The right-click **Ask ChatGPT** item appears only when text is selected. +- `Alt+Shift+G` acts on the selection, including a selection inside an iframe, + and opens a blank chat when nothing is selected or on a restricted page such + as `about:config`. Press the key to test this — `commands.getAll()` reports a + shortcut as registered even when Firefox has silently overridden it. + +Inspect ZIP contents whenever packaging rules or assets change; `test/` and +`docs/` must never appear, not even as empty directory entries. +``` + +Add a new subsection at the end of **Security & Privacy**: + +```markdown +Permissions are deliberately limited to `storage`, `menus`, and `activeTab`, +which add no line to Firefox's install prompt. Do not add `tabs`, +`notifications`, `webNavigation`, or any host permission without re-reading +`docs/superpowers/specs/2026-07-29-extension-options-design.md` — each was +considered and rejected there, and Appendix B records a feature rejected on +security grounds that must not be revived without clearing the bar documented +with it. +``` + +- [ ] **Step 4: Add the `CHANGELOG.md` entry** + +Insert directly below the `# Changelog` intro paragraph, above the `## [1.0.0]` heading: + +```markdown +## [1.1.0] (2026-07-30) + +Adds an options panel and two new ways to reach ChatGPT. + + * Options panel in `about:addons` with five settings: model (experimental), + temporary chat, web-search hint, where results open, and a prompt template. + * Right-click selected text and choose "Ask ChatGPT" to open ChatGPT with that + text filled in. + * Keyboard shortcut `Alt+Shift+G` does the same for the current selection, and + opens an empty chat when nothing is selected. Rebindable via Manage Extension + Shortcuts. + * Documented that ChatGPT no longer submits prompts passed in a URL, so a + second Enter is needed. This is an upstream change to chatgpt.com. + * `gpt ` address-bar search is unchanged. + * Permissions added: storage, menus, activeTab. None of them adds a line to + Firefox's install prompt. +``` + +- [ ] **Step 5: Verify the whole suite one last time** + +Run: `npm test && unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; the ZIP contains exactly `manifest.json`, `LICENSE.txt`, `icons/` + 5 PNGs, `background/background.js`, `lib/` + 3 modules, `options/` + 3 files — and nothing else. + +- [ ] **Step 6: Commit** + +```bash +git add README.md AGENTS.md CHANGELOG.md +git commit -m "doc: document options panel, entry points, and the ChatGPT prefill limitation" +``` + +--- + +## Before opening the pull request + +- [ ] Re-read spec Risk 6. The PR description must flag the **open AMO + data-collection question** — whether `required: ["none"]` survives + extension code reading page text — and note it should be asked before + submission, not at submission. +- [ ] The PR description must list the manual Firefox checks actually performed, + call out the new permissions explicitly, and state that + `chrome_settings_overrides` is unchanged. +- [ ] Push the branch (`git push -u origin feat/extension-options-panel`) and + open the PR with `tea pr create`. Do not merge; hand the URL over for human + review. + +## Out of scope — do not implement + +Each was considered and rejected in the spec. Implementing any of them silently +would undo a deliberate decision: + +- Auto-submitting the prompt (spec Appendix B — rejected on security grounds). +- `browser.omnibox` (works, but increases AMO data-collection exposure). +- Any host permission, `optional_permissions`, or `webNavigation`. +- A toolbar button; multiple named prompt templates; MV3 migration; Chrome + packaging; user-facing error notifications. diff --git a/docs/superpowers/plans/2026-10-05-advanced-context-menu.md b/docs/superpowers/plans/2026-10-05-advanced-context-menu.md new file mode 100644 index 0000000..8ec98a7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-advanced-context-menu.md @@ -0,0 +1,166 @@ +# Advanced Context Menu Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add optional contextual prompt presets and opt-in translation while preserving the existing simple action. + +**Architecture:** Pure modules define settings, actions, and launch preparation. Firefox adapters register menus and open tabs/windows; a packaged dialog handles language entry, truncation consent, and errors. Direct and dialog launches share preparation and opening code. + +**Tech Stack:** Firefox Desktop MV2 WebExtensions, ES modules, vanilla HTML/CSS, node:test, web-ext; Node.js 22+. + +**Spec:** `docs/superpowers/specs/2026-10-05-advanced-context-menu-design.md` + +## Global Constraints + +- Firefox Desktop remains the only target. +- Preserve the current `storage`, `menus`, and `activeTab` permissions. +- No new host permissions, telemetry, intermediary service, runtime dependency, or API key is part of this design. +- Retain the current 8,000-character serialized URL budget. +- Users still review and submit in ChatGPT; no auto-submit or internal ChatGPT request modification. +- Options remain inline in `about:addons` and use existing visual styles. +- Preserve existing uncommitted changes; they are the working baseline, not disposable noise. Review the working tree before creating an isolated execution checkout; do not branch from HEAD alone and lose that baseline. +- Stage task-specific changes only. Many target files already contain prior work: inspect each staged diff and avoid committing unrelated hunks. + +## Review Focus + +- A selection inside a link must expose selection actions only (Task 2/5). +- A repeated `{query}` template must remain intact while budgeting repeated source text (Task 3). +- A language containing `&` or non-BMP characters must display literally and obey the 100-code-point limit (Task 1/5/6). +- Background suspension or two simultaneous translation dialogs must not mix or lose an acknowledged request (Task 4). +- Cancel, duplicate Enter, and a failed launch must never send twice or report success prematurely (Task 4). + +## File map and contracts + +- Modify `lib/defaults.js`, `lib/settings.js`: new defaults and legacy migration. +- Create `lib/actions.js`: context selection, action IDs, prompt rendering, language labels. +- Modify `lib/build-url.js`: retain URL encoding, expose untruncated prompt serialization. +- Create `lib/prepare-action.js`: pure validation and length decisions. +- Create `background/open-chat.js`: tab/window opening with fallback. +- Create `background/dialogs.js`: initial request handoff to packaged dialog. +- Create `background/menus.js`: menu registration and visibility updates. +- Modify `background/background.js`: compose modules and route entry points. +- Create `dialog/action.html`, `dialog/action.css`, `dialog/action.js`, `dialog/controller.js`: accessible dialog shell and controller. +- Modify `options/options.html`, `options/options.js`; create `options/menu-settings.js`: new controls, dependencies, validation. +- Tests mirror these responsibilities under `test/`; docs changes remain in README/CHANGELOG. + +Shared data types (plain JavaScript objects, documented with JSDoc): + +`ActionRequest = { actionId, source, settings, language? }`, where settings is a +sanitized snapshot, source is a string, and actionId is one of `ask`, `explain`, +`summarize`, `debug`, `translate`, `ask-link`, `review-link`, `ask-page`, +`summarize-page`. Only `ask` permits an empty source (blank-chat shortcut). + +`Preparation = {kind:'ready',url} | {kind:'language'} | +{kind:'shortened',url} | {kind:'error',message}`. + +## Task 1: Settings schema and migration + +**Files:** Modify `lib/defaults.js`, `lib/settings.js`, `test/settings.test.js`. + +**Interfaces:** Preserve `mergeSettings(stored)`, `load()`, `save(partial)`. +New keys: contextMenuMode (`off|simple|advanced`, default simple), showTranslate +(false), translationMode (`ask|preferred`, default ask), translationLanguage +(empty). Export `normalizeLanguage(value) -> string` from settings: trim, +return empty on invalid type or more than 100 code points. + +- [ ] Add failing assertions for `mergeSettings({showContextMenu:false}).contextMenuMode === 'off'`, absent legacy => simple, valid new mode wins over legacy, invalid new mode falls back through legacy, and unknown keys are removed. +- [ ] Add assertions that 100 emoji are accepted, 101 rejected, whitespace trims, and wrong language/mode/boolean types default. Update existing expected setting shapes without dropping their coverage. +- [ ] Run `node --test test/settings.test.js`; confirm new assertions fail for the intended missing behavior. +- [ ] Implement defaults and migration. `load()` requests both new keys and legacy `showContextMenu`; save writes normalized new keys, not a legacy boolean. Do not delete old data as an initialization side effect. +- [ ] Run `node --test test/settings.test.js`; require nonzero passing tests. Verify load against a fake storage containing only the legacy key. +- [ ] Review/stage only this task and commit `feat: migrate context menu and translation preferences`. + +## Task 2: Context and dedicated prompt definitions + +**Files:** Create `lib/actions.js`, `test/actions.test.js`. + +**Interfaces:** `getContext(info) -> {kind:'selection'|'link'|'page',source}|null`; +`getActions(context,settings) -> [{id,title}]`; `renderPrompt(request,sourceOverride=request.source) -> string`. +`getContext` accepts menu event data, prioritizes nonblank selection then link, +then page URL, and excludes password inputs and media-only contexts. Empty +editable fields get no page group. Link/page URL validation belongs to Task 3. + +- [ ] Add tests asserting linked selection chooses selection, empty selection chooses link, media-only and password contexts return null, and page background uses pageUrl. Selection order must be ask/explain/summarize/debug, with translate appended only when enabled. +- [ ] Add prompt assertions: default action applies the saved template; Explain/Debug ignore it; Translate includes the language; link-safety prompt asks for evidence/uncertainty and never claims completed checks. Preserve literal `$&`, `$$`, backticks, Unicode and braces in source text. Escape `&` in menu language labels. +- [ ] Run `node --test test/actions.test.js`; confirm intended failures. +- [ ] Implement fixed action definitions and dedicated prompts matching the spec table. Extract/reuse existing literal template replacement instead of implementing conflicting replacement semantics. Blank `ask` renders empty even with a nonempty template. +- [ ] Run `node --test test/actions.test.js test/build-url.test.js`; expect all passing. +- [ ] Commit reviewed task hunks as `feat: define contextual prompt actions`. + +## Task 3: Safe action preparation and length decisions + +**Files:** Modify `lib/build-url.js`; create `lib/prepare-action.js`, `test/prepare-action.test.js`; extend `test/build-url.test.js`. + +**Interfaces:** `serializePrompt(settings,prompt) -> string` encodes an already +rendered prompt without truncating. Keep legacy `buildChatGptUrl` compatibility +for existing tests until routing is replaced. `prepareAction(request) -> Preparation` +uses Task 2 rendering, Task 1 normalization, and `MAX_URL_CHARS`. + +- [ ] Add assertions for ready URLs <=8000, translation needing a language, invalid/credential-bearing/non-HTTP URLs producing error, and oversized URLs producing error rather than shortened. Accepted URL source must survive unchanged within decoded prompt. +- [ ] Add budget tests for repeated `{query}`, emoji, whitespace boundaries, hint overhead, oversized instructions, and the shortening marker. Every shortened result retains all fixed instructions and substitutes only a shortened source; a small source remains byte-identical after decoding. +- [ ] Run `node --test test/prepare-action.test.js`; confirm failures. +- [ ] Implement validation and binary search over source code points, measuring the fully rendered/encoded prompt each time. Reserve the marker `[Selected text shortened]` only when shortening. Prefer a late whitespace boundary as in existing builder. Reject unknown action IDs and missing required sources. +- [ ] Run `node --test test/prepare-action.test.js test/build-url.test.js test/actions.test.js`; require passing assertions and nonzero discovery. +- [ ] Commit reviewed changes as `feat: prepare actions with explicit truncation decisions`. + +## Task 4: Launch helper and reliable extension dialogs + +**Files:** Create `background/open-chat.js`, `background/dialogs.js`, `dialog/action.html`, `dialog/action.css`, `dialog/action.js`, `dialog/controller.js`, `test/open-chat.test.js`, `test/dialog.test.js`. + +**Interfaces:** `openChat(browser,url,openIn) -> Promise` rejects after +final failure. `showActionDialog(browser,payload) -> Promise` transfers +`payload={request,preparation}` to the dialog. `initializeDialog({browser,document,window})` +owns UI and launch after handoff, using Tasks 1–3 and openChat. + +- [ ] Write boundary tests: new-window fallback opens a tab, background-tab uses active:false, final failure rejects; language whitespace blocks submit, Cancel/Escape never launch, double submit launches once, rejected launch permits retry, shortening requires explicit consent. +- [ ] Write handoff tests with two opaque IDs and distinct sources: wrong sender/ID cannot claim data; after acknowledgment clearing background state does not affect dialog completion; closed/uninitialized dialogs release requests and show retry rather than processing missing data. +- [ ] Run `node --test test/open-chat.test.js test/dialog.test.js`; confirm failures. +- [ ] Implement tab/window helper and dialog shell. Use runtime extension-page messaging plus a random request ID, verify sender extension URL and associated tab ID, transfer a structured snapshot, acknowledge receipt, then release background payload. Keep launch handling active through handshake; register listeners before opening. Apply a finite 15-second initialization timeout, with clear retry copy; never time out user language entry after handoff. No selected content in URLs, logs, or storage. +- [ ] Implement dialog controller: validate language, rerun preparation, show shortening confirmation if needed, disable launch controls while pending, show errors/retry, close only after success. Use textContent for all source-derived UI. Window creation falls back to an extension tab; closing that surface cancels. Use the same shell for direct-launch error messages. +- [ ] Run boundary tests and manually probe actual Firefox event-page handoff/idle behavior before accepting this task. If lifecycle assumptions fail, correct the handoff design within the approved no-persistence/no-permission constraints before wiring menus. +- [ ] Commit task-specific files as `feat: add action dialogs and shared launch handling`. + +## Task 5: Register adaptive menus and route the shortcut + +**Files:** Create `background/menus.js`, `test/menus.test.js`; modify `background/background.js`. + +**Interfaces:** `initializeMenus({browser,load,onAction})` registers menus, +storage listener, onShown/onClicked, and serializes rebuilding. onAction receives +an ActionRequest. `runAction(request)` in background uses prepareAction then +openChat for ready or showActionDialog otherwise; errors use dialog feedback. + +- [ ] Add tests: Off has no root, Simple has one selection-only direct action, Advanced has one root with correct children; onShown reveals exactly one context group and refreshes; selection over link wins; live translation/mode changes rebuild without races. +- [ ] Add assertions that unexpected/stale item IDs or mismatched click contexts do not launch, preferred-language labels escape ampersands, and click creates one settings/source snapshot. Verify shortcut invokes `ask` independently of menu mode, including empty selection. +- [ ] Run `node --test test/menus.test.js`; confirm failures. +- [ ] Implement adapter using existing serialized removeAll/create pattern, namespaced stable IDs and visibility updates. Verify current Firefox menu APIs from Mozilla primary docs at execution time. Preserve the shortcut's focused-frame selection contract and existing fallback behavior; do not expand page-reading scope. +- [ ] Route both entry points through shared preparation, including truncation consent for Simple and shortcut. Remove obsolete menu listeners to prevent duplicates. +- [ ] Run all unit tests. Manually check AGENTS.md frame cases; if the existing ancestor-focus bug is reproduced, make the focused-frame guard correction with a regression check before claiming shortcut preservation. +- [ ] Commit reviewed changes as `feat: wire simple and advanced context menus`. + +## Task 6: Options UI, release cleanup, and complete verification + +**Files:** Modify `options/options.html`, `options/options.js`, `options/options.css` if needed; create `options/menu-settings.js`, `test/menu-settings.test.js`; modify `README.md`, `CHANGELOG.md`. + +**Interfaces:** `initializeMenuSettings({document,onChange}) -> {render(settings),collect()}` +owns context/translation controls. Existing options renderer delegates these +keys rather than assuming every preference maps to a checkbox/text input. + +- [ ] Write UI boundary tests for Off/Simple/Advanced dependent visibility, opt-in translation, preferred-language validation, retaining hidden preferences, migration display, and Restore defaults. Assert language length uses code points rather than HTML UTF-16 maxlength alone. +- [ ] Run `node --test test/menu-settings.test.js`; confirm failures. +- [ ] Add controls with approved exact labels, reuse existing styles and debounce/save behavior. Invalid preferred language displays inline guidance; launch behavior remains dialog fallback. Preserve active input against storage echoes. Update restore/shortcut copy that assumes five settings or a checkbox. +- [ ] Remove temporary URL-test HTML and handler. Update README/CHANGELOG and privacy section to describe URL sharing, menu modes, opt-in translation, length consent, and unchanged address-bar behavior. +- [ ] Run `npm test`; require lint with zero warnings, nonzero passing unit tests, and a successful ZIP build. Inspect ZIP entries for new dialog assets and absence of any `docs/` or `test/` entries; do not alter packaging exclusions unless inspection finds a defect. +- [ ] Run `npm run dev` and execute every manual check from the approved spec and AGENTS.md, including light/dark/narrow settings/dialogs, keyboard focus/cancellation, concurrent requests, idle/wakeup, long selection, URL rejection, menu precedence, restart persistence, and all shortcut frame cases. Record actual observed results; ask for user assistance if native interaction is unavailable rather than counting a headless launch as behavioral coverage. +- [ ] Run `git diff --check`, review permissions/search URL/package changes, and commit only reviewed task hunks as `feat: expose advanced menu preferences and usage guidance`. + +## Completion and review + +Execution must begin with a baseline snapshot/status review and the appropriate +isolation skill; unresolved existing work must not be overwritten. No release, +AMO upload, push, or merge is authorized by this plan. Finish with a review of +the complete change and a report of automated and actual Firefox checks, +including any checks still requiring user interaction. + +Plan self-review: spec sections map to Tasks 1–6; all five Review Focus cases +are assigned tests. Shared signatures use the ActionRequest/Preparation types +above. Lifecycle handoff is intentionally verified before entry-point wiring. diff --git a/docs/superpowers/specs/2026-07-29-extension-options-design.md b/docs/superpowers/specs/2026-07-29-extension-options-design.md new file mode 100644 index 0000000..d353253 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-extension-options-design.md @@ -0,0 +1,865 @@ +# Extension Options Panel — Design + +- **Date:** 2026-07-29 +- **Status:** Approved for planning +- **Branch:** `feat/extension-options-panel` (cut from `develop` @ `5edb288`) + +## Summary + +Add an options panel to the extension, reachable from `about:addons` → the +extension's detail card → the **Options** tab (Windows) / **Preferences** tab +(macOS, Linux). The panel configures two new entry points that this change also +introduces: a context-menu item on selected text, and a keyboard shortcut that +does the same for the current selection, falling back to a fresh empty chat when +nothing is selected. + +The existing `gpt ` address-bar search provider is **not** modified and +does not read the settings. See [Why the search provider is untouched](#why-the-search-provider-is-untouched). + +New permissions: `storage`, `menus`, and `activeTab`. All three are in Firefox's +no-prompt class, so the install dialog gains no new lines and +`data_collection_permissions: ["none"]` remains truthful. + +## Goals + +1. Ship a working options panel embedded in `about:addons`. +2. Ship two new entry points whose behaviour the panel controls. +3. Add no permission-warning lines to the install prompt. +4. Keep `data_collection_permissions: ["none"]` accurate. +5. Introduce unit testing for the pure logic, without new runtime dependencies. +6. Never promise behaviour that ChatGPT does not reliably deliver. + +## Non-goals + +- Making the `gpt ` search provider configurable. The declarative + `search_url` is fixed at install time, and the three routes around that were + each rejected: host permissions plus navigation interception (conflicts with + goals 3 and 4), a bundled extension page as `search_url` (Firefox refuses to + install), and `browser.omnibox` (works, but moves the transmitter of search + terms from Firefox to extension code — see Risk 6). `gpt ` keeps + behaving exactly as it does today. +- Auto-submitting the prompt on the user's behalf. Rejected on security grounds + after dedicated adversarial testing; see + [Appendix B](#appendix-b-why-auto-submit-was-rejected). +- Moving the `gpt` keyword to `browser.omnibox`. Deliberately declined to + minimise AMO data-collection exposure, not for lack of a working mechanism. +- A toolbar button. +- Named/multiple prompt templates with add-remove-reorder UI. One template only. +- Migrating to Manifest V3. +- Chrome or other-browser packaging (`AGENTS.md` browser scope). +- User-facing error notifications. The `notifications` permission *does* + generate an install warning, so every failure path degrades silently instead. + +## Background and constraints + +The extension is currently manifest-only: no JavaScript exists, and the +manifest declares no `background`, `content_scripts`, or +`web_accessible_resources` key. Its single feature is +`chrome_settings_overrides.search_provider` with +`search_url: "https://chatgpt.com?q={searchTerms}"`. + +### Why the search provider is untouched + +`chrome_settings_overrides.search_provider` is declarative and fixed at install +time. No WebExtension API can rewrite an installed engine's URL. Making +`gpt ` honour the settings would require intercepting the navigation it +starts, which needs host access to `chatgpt.com` plus `webRequest` or `tabs` — +all of which add install-prompt lines. Rejected in favour of goals 3 and 4. + +`is_default` stays `false`. Setting it to `true` triggers a second, separate +consent doorhanger (`webext-default-search-description`) outside any permission +list, plus an allowlist requirement. + +### ChatGPT URL parameters are undocumented + +None of `q`, `model`, `hints`, or `temporary-chat` is documented by OpenAI. +There is no contract and no support channel. The design is therefore resilient +by construction: the URL is built from a template with no runtime dependency on +any parameter being honoured, each parameter is independently removable so a +user can bisect when ChatGPT ignores one, and all user-facing copy is written at +hint strength. See [Appendix A](#appendix-a-verification-findings) for the +evidence behind each parameter's confidence rating. + +The one parameter with quasi-official standing is `q`, because OpenAI's own +"ChatGPT search" browser extension uses `https://chatgpt.com/?q=`. Build on it; +treat everything else as best-effort. + +**`q` prefills but does not submit — confirmed for this extension.** Auto-submit +was real historically but has been prefill-only since approximately November +2025, after OpenAI gated it on the `Sec-Fetch-Site` header as a prompt-injection +mitigation. + +Verified on 2026-07-29 in the maintainer's own logged-in Firefox profile, on +this extension's actual navigation path: the prompt lands in the composer and +waits. The user presses Enter twice — once to leave the address bar, once to +send. The hypothesis that address-bar navigation might still be favoured +(`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in +practice**. + +**Correction, 2026-10-18: `q` auto-submits again.** +- **Signed out:** in headless Firefox 157, `chatgpt.com/?q=` sent the prompt + and ChatGPT answered with no keypress. This happened even when an unrelated + page navigated to the URL (`Sec-Fetch-Site: cross-site`). +- **Maintainer's Firefox:** the same day, the maintainer reported that prompts + were sent without pressing Enter. +- **A week earlier:** on 2026-10-12 the signed-out path showed a verification + error and the signed-in path still needed Enter + (`docs/toolbar-verification.md`). + +So this changed within a week and may differ between accounts. Treat both +behaviours as possible. The copy rule below still applies, and the README +already says ChatGPT decides whether a prompt is sent. Appendix B covers what +this means for selection smuggling. + +No copy in the options panel, README, or AMO listing may say the query is +*searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled +in**. The two-keypress behaviour is an upstream ChatGPT change, not a defect in +this extension, and is documented as such rather than worked around; see +[Rejected alternatives](#rejected-alternatives). + +## Architecture + +Manifest stays **V2**. Mozilla has not announced any MV2 deprecation and +commits to at least 12 months' notice. The repo is MV2 today and has no Chrome +target, so migration is an orthogonal change. The code is nonetheless kept +MV3-portable: ES-module background, `persistent: false`, no `browser_style: true`. + +ES modules work in an MV2 background script via +`"background": {"scripts": [...], "type": "module"}`, supported since Firefox +112 and verified empirically in Firefox 153.0.1. No background HTML shim is +needed. The options page is a normal extension page and can use +` diff --git a/options/options.js b/options/options.js new file mode 100644 index 0000000..a162a71 --- /dev/null +++ b/options/options.js @@ -0,0 +1,97 @@ +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { load, save } from "../lib/settings.js"; +import { initializeMenuSettings, MENU_SETTING_KEYS } from "./menu-settings.js"; +import { initializeShortcut } from "./shortcut.js"; + +const SAVE_DEBOUNCE_MS = 400; +const STATUS_CLEAR_MS = 1500; + +const field = (id) => document.getElementById(id); + +let saveTimer = null; +let statusTimer = null; + +const menuSettings = initializeMenuSettings({ document, onChange: scheduleSave }); + +function render(settings) { + menuSettings.render(settings); + for (const [id, value] of Object.entries(settings)) { + if (MENU_SETTING_KEYS.includes(id)) continue; + const element = field(id); + // Never overwrite the control the user is actively editing — this page's + // own saves echo back through storage.onChanged (see below). + if (element === document.activeElement) { + continue; + } + if (typeof value === "boolean") { + element.checked = value; + } else { + element.value = value; + } + } +} + +function collect() { + return { + ...menuSettings.collect(), + temporaryChat: field("temporaryChat").checked, + webSearch: field("webSearch").checked, + openIn: field("openIn").value, + promptTemplate: field("promptTemplate").value, + }; +} + +function showStatus(message) { + field("status").textContent = message; + clearTimeout(statusTimer); + statusTimer = setTimeout(() => { + field("status").textContent = ""; + }, STATUS_CLEAR_MS); +} + +async function persist(settings) { + try { + await save(settings); + showStatus("Saved"); + } catch { + // storage.sync sanitises its errors, so there is nothing specific to report. + showStatus("Could not save settings"); + } +} + +// Debounced so typing in a text field cannot approach any write-rate quota. +function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS); +} + +// Module scripts are deferred, so the DOM is already parsed here. +void initializeShortcut({ commands: browser.commands, document, window }); +render(await load()); + +for (const id of Object.keys(DEFAULT_SETTINGS)) { + if (MENU_SETTING_KEYS.includes(id)) continue; + field(id).addEventListener("input", scheduleSave); + field(id).addEventListener("change", scheduleSave); +} + +field("restore").addEventListener("click", () => { + clearTimeout(saveTimer); + // Restoring defaults is an explicit user action, so it should reset every + // field even if one currently has focus. Blur first so render()'s + // activeElement guard (see below) has nothing to skip. + document.activeElement?.blur(); + render(DEFAULT_SETTINGS); + persist({ ...DEFAULT_SETTINGS }); +}); + +// browser.storage.onChanged fires for every write to the "sync" area, not just +// a remote sync landing on another device — this page's own debounced +// autosaves and its Restore-defaults save re-enter this listener too, which +// render()'s activeElement guard makes safe to react to unconditionally. +browser.storage.onChanged.addListener(async (changes, area) => { + if (area !== "sync") { + return; + } + render(await load()); +}); diff --git a/options/shortcut.js b/options/shortcut.js new file mode 100644 index 0000000..08a0c36 --- /dev/null +++ b/options/shortcut.js @@ -0,0 +1,48 @@ +// Firefox owns shortcut preferences; never copy them into extension storage. +export async function initializeShortcut({ commands, document, window }) { + const field = (id) => document.getElementById(id); + let refreshId = 0; + + function render(shortcut) { + field("shortcut-intro").textContent = shortcut + ? ` Or press ${shortcut} to use your selection. With nothing selected, the shortcut opens a blank chat.` + : ""; + field("shortcut-hint").textContent = shortcut + ? `${shortcut} runs your default action even when the context menu is off.` + : "The keyboard shortcut runs your default action independently of the context menu."; + field("shortcut-current").textContent = shortcut === null + ? "Could not read the keyboard shortcut. Check Manage Extension Shortcuts in Add-ons." + : shortcut ? `Current shortcut: ${shortcut}` : "No keyboard shortcut assigned."; + } + + async function refresh() { + const requestId = ++refreshId; + try { + const all = await commands.getAll(); + const command = all.find(({ name }) => name === "ask-chatgpt"); + if (requestId === refreshId) render(command?.shortcut ?? ""); + } catch { + if (requestId === refreshId) render(null); + } + } + + const button = field("change-shortcut"); + button.hidden = typeof commands?.openShortcutSettings !== "function"; + button.addEventListener("click", async () => { + try { + await commands.openShortcutSettings(); + } catch { + field("shortcut-current").textContent = + "Open Add-ons, then the gear menu → Manage Extension Shortcuts to change your shortcut."; + } + }); + + commands?.onChanged?.addListener(({ name }) => { + if (name === "ask-chatgpt") void refresh(); + }); + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") void refresh(); + }); + await refresh(); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cef58a6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4095 @@ +{ + "name": "search-with-chatgpt-powered-by-openai-extension", + "version": "1.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "search-with-chatgpt-powered-by-openai-extension", + "version": "1.2.0", + "devDependencies": { + "web-ext": "10.5.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@devicefarmer/adbkit": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit/-/adbkit-3.3.8.tgz", + "integrity": "sha512-7rBLLzWQnBwutH2WZ0EWUkQdihqrnLYCUMaB44hSol9e0/cdIhuNFcqZO0xNheAU6qqHVA8sMiLofkYTgb+lmw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@devicefarmer/adbkit-logcat": "^2.1.2", + "@devicefarmer/adbkit-monkey": "~1.2.1", + "bluebird": "~3.7", + "commander": "^9.1.0", + "debug": "~4.3.1", + "node-forge": "^1.3.1", + "split": "~1.0.1" + }, + "bin": { + "adbkit": "bin/adbkit" + }, + "engines": { + "node": ">= 0.10.4" + } + }, + "node_modules/@devicefarmer/adbkit-logcat": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit-logcat/-/adbkit-logcat-2.1.3.tgz", + "integrity": "sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@devicefarmer/adbkit-monkey": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit-monkey/-/adbkit-monkey-1.2.1.tgz", + "integrity": "sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.10.4" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", + "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@fregante/relaxed-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fregante/relaxed-json/-/relaxed-json-2.0.0.tgz", + "integrity": "sha512-PyUXQWB42s4jBli435TDiYuVsadwRHnMc27YaLouINktvTWsL3FcKrRMGawTayFk46X+n5bE23RjUTWQwrukWw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@mdn/browser-compat-data": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.0.4.tgz", + "integrity": "sha512-up6DsNsaPt3jq5d2TGx4UOXIqBKU0D86dxWwcVmmOOEYqP1rFaV1XcAcwHxMvb6bbtMfY5JpjUCszzy/aVc4yA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/addons-linter": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-10.8.0.tgz", + "integrity": "sha512-AotpGWlp5FrmUIH8kjyYjQvCmthnSqPLpw7kOEl7DfoAWEndOCV/ONuekAVM8QObiei802RyyIClEaekA51n5g==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@fluent/syntax": "0.19.0", + "@fregante/relaxed-json": "2.0.0", + "@mdn/browser-compat-data": "8.0.4", + "addons-moz-compare": "1.3.0", + "addons-scanner-utils": "15.4.0", + "ajv": "8.20.0", + "cheerio": "1.2.0", + "columnify": "1.6.0", + "common-tags": "1.8.2", + "css-tree": "3.2.1", + "deepmerge": "4.3.1", + "eslint": "9.39.4", + "eslint-plugin-no-unsanitized": "4.1.5", + "eslint-visitor-keys": "5.0.1", + "espree": "11.2.0", + "esprima": "4.0.1", + "fast-json-patch": "3.1.1", + "image-size": "2.0.2", + "json-merge-patch": "1.0.2", + "pino": "10.3.1", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "upath": "3.0.7", + "yargs": "17.7.2", + "yauzl": "3.4.0" + }, + "bin": { + "addons-linter": "bin/addons-linter" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/addons-moz-compare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/addons-moz-compare/-/addons-moz-compare-1.3.0.tgz", + "integrity": "sha512-/rXpQeaY0nOKhNx00pmZXdk5Mu+KhVlL3/pSBuAYwrxRrNiTvI/9xfQI8Lmm7DMMl+PDhtfAHY/0ibTpdeoQQQ==", + "dev": true, + "license": "MPL-2.0" + }, + "node_modules/addons-scanner-utils": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/addons-scanner-utils/-/addons-scanner-utils-15.4.0.tgz", + "integrity": "sha512-i3PnJx9YLZi96o4t4JWArP6JimC01949YVAcMJKHfyQ5qmUBaH21qNS8f12/RsloejW8IcCEljhh3Fq3gxFNsg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "common-tags": "1.8.2", + "first-chunk-stream": "3.0.0", + "jsonwebtoken": "^9.0.3", + "strip-bom-stream": "4.0.0", + "upath": "3.0.7", + "yauzl": "3.4.0" + }, + "peerDependencies": { + "express": "5.2.1", + "safe-compare": "1.1.4" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + }, + "safe-compare": { + "optional": true + } + } + }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-differ": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-4.0.0.tgz", + "integrity": "sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", + "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chrome-launcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.0.tgz", + "integrity": "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/columnify": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/columnify/-/columnify-1.6.0.tgz", + "integrity": "sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "strip-ansi": "^6.0.1", + "wcwidth": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/configstore": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz", + "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-no-unsanitized": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-4.1.5.tgz", + "integrity": "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg==", + "dev": true, + "license": "MPL-2.0", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/firefox-profile": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/firefox-profile/-/firefox-profile-4.7.0.tgz", + "integrity": "sha512-aGApEu5bfCNbA4PGUZiRJAIU6jKmghV2UVdklXAofnNtiDjqYw0czLS46W7IfFqVKgKhFB8Ao2YoNGHY4BoIMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "adm-zip": "~0.5.x", + "fs-extra": "^11.2.0", + "ini": "^4.1.3", + "minimist": "^1.2.8", + "xml2js": "^0.6.2" + }, + "bin": { + "firefox-profile": "lib/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/first-chunk-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-3.0.0.tgz", + "integrity": "sha512-LNRvR4hr/S8cXXkIY5pTgVP7L3tq6LlYWcg9nWBuW7o1NMxKZo6oOVa/6GIekMGI0Iw7uC+HWimMe9u/VAeKqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fx-runner": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/fx-runner/-/fx-runner-1.5.0.tgz", + "integrity": "sha512-EstfdRQu04tM+SXR3pBmc0+GYKZj9FMHAvA8XAbKPzTvUeHP0v1/F3aQ10bG7EluR6phvFqsvn5Z7SvYJsEgNw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "commander": "^12.1.0", + "shell-quote": "1.8.4", + "which": "^4.0.0", + "winreg": "0.0.12" + }, + "bin": { + "fx-runner": "bin/fx-runner" + } + }, + "node_modules/fx-runner/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/fx-runner/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/fx-runner/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "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": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "dev": true, + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-npm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-merge-patch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-merge-patch/-/json-merge-patch-1.0.2.tgz", + "integrity": "sha512-M6Vp2GN9L7cfuMXiWOmHj9bEFbeC250iVtcKQbqVgEsDVYnIsrNsbU+h/Y/PkbBQCtEa4Bez+Ebv0zfbC8ObLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/ky": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/latest-version": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-json": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lighthouse-logger": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", + "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/multimatch": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-6.0.0.tgz", + "integrity": "sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.5", + "array-differ": "^4.0.0", + "array-union": "^3.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "dev": true, + "license": "MIT" + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/promise-toolbox": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/promise-toolbox/-/promise-toolbox-0.21.0.tgz", + "integrity": "sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg==", + "dev": true, + "license": "ISC", + "dependencies": { + "make-error": "^1.3.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pupa": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "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": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "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/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-5.0.0.tgz", + "integrity": "sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-bom-buf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-buf/-/strip-bom-buf-2.0.0.tgz", + "integrity": "sha512-gLFNHucd6gzb8jMsl5QmZ3QgnUJmp7qn4uUSHNwEXumAp7YizoGYw19ZUVfuq4aBOQUtyn2k8X/CwzWB73W2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-utf8": "^0.2.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-4.0.0.tgz", + "integrity": "sha512-0ApK3iAkHv6WbgLICw/J4nhwHeDZsBxIIsOD+gHgZICL6SeJ0S9f/WZqemka9cjkTyMN5geId6e8U5WGFAn3cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "first-chunk-stream": "^3.0.0", + "strip-bom-buf": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/upath/-/upath-3.0.7.tgz", + "integrity": "sha512-VjBBquch25nUGMuVBpOb2Cj3gc8Kb7lJBqbsXR/0anZ/5uJsL14Kpth9JKfnBsckxCfgIp6hPvcvvmZ97R9X7g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/anodynos" + }, + { + "type": "polar", + "url": "https://polar.sh/anodynos" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-upath" + } + ], + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/update-notifier": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-ext": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/web-ext/-/web-ext-10.5.0.tgz", + "integrity": "sha512-tveUHhZHu5A2Qrs7u+hbh2S+7ZPuKVH/ILzqkRwpdivFOnVmgPhDLtmtGNKT3a8N9PZbW5uP6INe6Byj5qePVQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@babel/runtime": "8.0.0", + "@devicefarmer/adbkit": "3.3.8", + "addons-linter": "10.8.0", + "camelcase": "8.0.0", + "chrome-launcher": "1.2.0", + "debounce": "1.2.1", + "decamelize": "6.0.1", + "es6-error": "4.1.1", + "firefox-profile": "4.7.0", + "fx-runner": "1.5.0", + "https-proxy-agent": "^7.0.0", + "jose": "5.9.6", + "jszip": "3.10.1", + "multimatch": "6.0.0", + "open": "11.0.0", + "parse-json": "8.3.0", + "pino": "10.3.1", + "promise-toolbox": "0.21.0", + "source-map-support": "0.5.21", + "strip-bom": "5.0.0", + "strip-json-comments": "5.0.3", + "tmp": "0.2.7", + "update-notifier": "7.3.1", + "watchpack": "2.5.2", + "yargs": "17.7.2", + "zip-dir": "2.0.0" + }, + "bin": { + "web-ext": "bin/web-ext.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=8.0.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/winreg": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/winreg/-/winreg-0.0.12.tgz", + "integrity": "sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ==", + "dev": true, + "license": "BSD" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-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/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/zip-dir/-/zip-dir-2.0.0.tgz", + "integrity": "sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.0", + "jszip": "^3.2.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ad6b9f --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "search-with-chatgpt-powered-by-openai-extension", + "version": "1.2.0", + "private": true, + "type": "module", + "description": "Development tooling for the Search with ChatGPT Firefox extension.", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "web-ext run", + "lint": "web-ext lint", + "test:unit": "node --test", + "build": "web-ext build", + "test": "npm run lint && npm run test:unit && npm run build" + }, + "devDependencies": { + "web-ext": "10.5.0" + } +} diff --git a/test/actions.test.js b/test/actions.test.js new file mode 100644 index 0000000..fef2432 --- /dev/null +++ b/test/actions.test.js @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS as defaults } from '../lib/defaults.js'; +const actions = await import('../lib/actions.js').catch(() => ({})); + +test('context prioritizes selection over link and excludes unrelated targets', () => { + assert.equal(typeof actions.getContext, 'function'); + assert.deepEqual(actions.getContext({selectionText:'code',linkUrl:'https://example.com',contexts:['selection','link']}), {kind:'selection',source:'code'}); + assert.deepEqual(actions.getContext({selectionText:' ',linkUrl:'https://example.com'}), {kind:'link',source:'https://example.com'}); + assert.equal(actions.getContext({mediaType:'image',pageUrl:'https://example.com'}), null); + assert.equal(actions.getContext({editable:true,pageUrl:'https://example.com'}), null); + assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page'); +}); +test('selection actions and optional translation reflect settings', () => { + assert.equal(typeof actions.getActions, 'function'); + assert.deepEqual(actions.getActions({kind:'selection'}, defaults).map(a=>a.id), ['ask','explain','summarize','debug']); + const list = actions.getActions({kind:'selection'}, {...defaults,showTranslate:true,translationMode:'preferred',translationLanguage:'A & B'}); + assert.equal(list.at(-1).title,'Translate to A && B'); +}); +test('dedicated prompts ignore personal template and preserve literal source', () => { + assert.equal(typeof actions.renderPrompt, 'function'); + const request = {actionId:'ask',source:'$& $$ `code` 😀',settings:{...defaults,promptTemplate:'French: {query}'}}; + assert.equal(actions.renderPrompt(request), 'French: $& $$ `code` 😀'); + assert.equal(actions.renderPrompt({...request,source:''}), ''); + for (const actionId of ['explain','summarize','debug']) { + const prompt = actions.renderPrompt({...request,actionId}); + assert.ok(prompt.includes(request.source)); + assert.ok(!prompt.includes('French:')); + } + assert.match(actions.renderPrompt({...request,actionId:'translate',language:'Spanish'}), /Spanish/); + assert.match(actions.renderPrompt({...request,actionId:'review-link'}), /uncertainty/); +}); diff --git a/test/build-url.test.js b/test/build-url.test.js new file mode 100644 index 0000000..cf513ac --- /dev/null +++ b/test/build-url.test.js @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { applyTemplate, serializePrompt } from "../lib/build-url.js"; + +const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); +const queryOf = (url) => new URL(url).searchParams.get("q"); +const paramsOf = (url) => [...new URL(url).searchParams.keys()]; + +test("defaults produce only the q parameter on the root path", () => { + assert.equal(serializePrompt(DEFAULT_SETTINGS, "hello"), "https://chatgpt.com/?q=hello"); +}); + +test("an empty prompt omits q entirely", () => { + assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/"); + for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) { + assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`); + } +}); + +test("legacy model values never add a model URL parameter", () => { + assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"), + serializePrompt(DEFAULT_SETTINGS, "hi")); +}); + +test("temporaryChat and webSearch each appear only when enabled", () => { + assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]); + + const temp = new URL(serializePrompt(withSettings({ temporaryChat: true }), "hi")); + assert.equal(temp.searchParams.get("temporary-chat"), "true"); + + const search = new URL(serializePrompt(withSettings({ webSearch: true }), "hi")); + assert.equal(search.searchParams.get("hints"), "search"); +}); + +test("all URL-affecting settings combine, q first", () => { + const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" }); + const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy")); + assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]); + assert.equal(queryOf(url), "Explain: entropy"); +}); + +test("openIn never influences the URL", () => { + const base = serializePrompt(DEFAULT_SETTINGS, "hi"); + for (const openIn of ["new-tab", "background-tab", "new-window"]) { + assert.equal(serializePrompt(withSettings({ openIn }), "hi"), base); + } +}); + +test("special characters survive encoding round-trip", () => { + const raw = 'a & b # c ? d = e + f "g" 100% 😀 日本語\nnewline'; + const url = serializePrompt(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); + assert.equal(url.includes(" "), false); + assert.equal(url.includes("#"), false); +}); + +test("the template replaces every {query} occurrence", () => { + assert.equal(applyTemplate("{query} / {query}", "x"), "x / x"); +}); + +test("a template without {query} is treated as a prefix, never dropping the query", () => { + assert.equal(applyTemplate("Be concise:", "why"), "Be concise: why"); +}); + +test("an empty or whitespace-only template yields the bare query", () => { + for (const template of ["", " "]) { + assert.equal(applyTemplate(template, "why"), "why"); + } +}); + +test("a non-string template degrades to the bare query", () => { + assert.equal(applyTemplate(null, "why"), "why"); +}); + +// String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the +// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly +// that argument. These selections are ordinary shell one-liners, not +// adversarial input, and must round-trip byte-identically into `q`. +for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) { + test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => { + assert.equal(applyTemplate("{query}", raw), raw); + assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${raw}`); + assert.equal(queryOf(serializePrompt(DEFAULT_SETTINGS, applyTemplate("{query}", raw))), raw); + }); +} diff --git a/test/dialog.test.js b/test/dialog.test.js new file mode 100644 index 0000000..6889b0b --- /dev/null +++ b/test/dialog.test.js @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +const { createDialogController } = await import('../dialog/controller.js').catch(()=>({})); +const { showActionDialog } = await import('../background/dialogs.js').catch(()=>({})); +const payload = source=>({request:{actionId:'translate',source,settings:{...DEFAULT_SETTINGS}},preparation:{kind:'language'}}); +function fixture(data=payload('hello')) { + const opened=[];let closed=0;let state; + const controller=createDialogController({payload:data,open:async(url)=>opened.push(url),render:s=>{state=s;},close:()=>closed++}); + return {controller,opened,get state(){return state;},get closed(){return closed;}}; +} +test('dialog validates language, cancel never launches, source snapshots are independent',async()=>{ + assert.equal(typeof createDialogController,'function'); + const a=fixture(),b=fixture(payload('second')); + await a.controller.submit(' ');assert.equal(a.opened.length,0);assert.match(a.state.message,/language/i); + a.controller.cancel();await a.controller.submit('Spanish');assert.equal(a.opened.length,0);assert.equal(a.closed,1); + await b.controller.submit('French');assert.equal(b.opened.length,1);assert.ok(new URL(b.opened[0]).searchParams.get('q').includes('second'));assert.equal(b.closed,1); +}); +test('long translation requires a separate explicit shortening confirmation',async()=>{ + assert.equal(typeof createDialogController,'function'); + const f=fixture(payload('x'.repeat(30000))); + await f.controller.submit('Spanish');assert.equal(f.opened.length,0);assert.equal(f.state.kind,'shortened'); + await f.controller.submit();assert.equal(f.opened.length,1);assert.ok(f.opened[0].length<=8000); +}); +test('pending launch blocks double submit and failure permits retry',async()=>{ + assert.equal(typeof createDialogController,'function'); + let count=0,release,state,closed=0; + const controller=createDialogController({payload:payload('hello'),render:s=>state=s,close:()=>closed++,open:()=>{count++;return new Promise((resolve,reject)=>release={resolve,reject});}}); + const pending=controller.submit('Spanish');await controller.submit('Spanish');assert.equal(count,1); + release.reject(Error('fail'));await pending;assert.equal(closed,0);assert.match(state.message,/try again/i); + const retry=controller.submit();release.resolve();await retry;assert.equal(count,2);assert.equal(closed,1); +}); +function event() { const listeners=new Set();return {addListener:f=>listeners.add(f),removeListener:f=>listeners.delete(f),emit:(...args)=>{for(const f of [...listeners])f(...args);},get size(){return listeners.size;}}; } +function browserFixture() { + let id=0;const tabs=[]; + return {tabs,runtime:{id:'extension',getURL:p=>'moz-extension://test/'+p,onConnect:event()},windows:{create:async({url})=>{const tab={id:++id};tabs.push({url,...tab});return {tabs:[tab]};}},tabsApi:{onRemoved:event()}}; +} +function connect(b,tab,index=0) { + const messages=[]; + const port={name:'action-dialog:'+new URL(tab.url).searchParams.get('request'),sender:{id:'extension',url:tab.url,tab:{id:tab.id+index}},onMessage:event(),onDisconnect:event(),postMessage:v=>messages.push(v),disconnect(){this.onDisconnect.emit();}}; + b.runtime.onConnect.emit(port);return {port,messages}; +} +test('handoff scopes data to matching dialog tab and releases background after acknowledgment',async()=>{ + assert.equal(typeof showActionDialog,'function'); + const b=browserFixture();b.tabs=b.tabsApi; + // Capture opened tabs outside the browser API object. + const opened=[];let id=0;b.windows.create=async({url})=>{const tab={id:++id,url};opened.push(tab);return {tabs:[tab]};}; + const first=showActionDialog(b,payload('first')),second=showActionDialog(b,payload('second')); + await new Promise(r=>setImmediate(r)); + const bad=connect(b,opened[0],100);await new Promise(r=>setImmediate(r));assert.equal(bad.messages.length,0); + const a=connect(b,opened[0]),c=connect(b,opened[1]);await new Promise(r=>setImmediate(r)); + assert.equal(a.messages[0].payload.request.source,'first');assert.equal(c.messages[0].payload.request.source,'second'); + a.port.onMessage.emit({type:'received'});c.port.onMessage.emit({type:'received'});await Promise.all([first,second]); + assert.equal(b.runtime.onConnect.size,0);assert.equal(b.tabs.onRemoved.size,0); + assert.equal(a.messages[0].payload.request.source,'first'); +}); diff --git a/test/menu-settings.test.js b/test/menu-settings.test.js new file mode 100644 index 0000000..1535452 --- /dev/null +++ b/test/menu-settings.test.js @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +const { initializeMenuSettings }=await import('../options/menu-settings.js').catch(()=>({})); +function fixture() { + const fields=new Map();const document={activeElement:null,getElementById:id=>{ + if(!fields.has(id))fields.set(id,{value:'',checked:false,hidden:false,textContent:'',addEventListener(type,fn){this[type]=fn;},setAttribute(name,value){this[name]=value;}}); + return fields.get(id); + }}; + let changed=0; + const controls=initializeMenuSettings({document,onChange:()=>changed++}); + return {controls,document,field:document.getElementById,get changed(){return changed;}}; +} +test('dependent controls preserve hidden preferences and restore defaults',()=>{ + assert.equal(typeof initializeMenuSettings,'function'); + const f=fixture();f.controls.render(DEFAULT_SETTINGS); + assert.equal(f.field('advanced-settings').hidden,true); + f.field('contextMenuMode').value='advanced';f.field('contextMenuMode').input(); + assert.equal(f.field('advanced-settings').hidden,false);assert.equal(f.field('translation-settings').hidden,true); + f.field('showTranslate').checked=true;f.field('showTranslate').input(); + assert.equal(f.field('translation-settings').hidden,false); + f.field('translationMode').value='preferred';f.field('translationMode').input(); + assert.equal(f.field('preferred-language-field').hidden,false); + f.field('translationLanguage').value=' Spanish ';f.field('translationLanguage').input(); + assert.equal(f.controls.collect().translationLanguage,'Spanish'); + f.field('contextMenuMode').value='off';f.field('contextMenuMode').input(); + assert.equal(f.controls.collect().translationLanguage,'Spanish'); + f.controls.render(DEFAULT_SETTINGS);assert.equal(f.field('showTranslate').checked,false);assert.equal(f.field('translationLanguage').value,''); +}); +test('invalid language gives guidance and active edits survive storage echoes',()=>{ + assert.equal(typeof initializeMenuSettings,'function'); + const f=fixture();f.controls.render({...DEFAULT_SETTINGS,contextMenuMode:'advanced',showTranslate:true,translationMode:'preferred'}); + assert.match(f.field('language-validation').textContent,/language/i); + f.field('translationLanguage').value='😀'.repeat(100);f.field('translationLanguage').input();assert.equal(f.field('language-validation').textContent,''); + f.field('translationLanguage').value='😀'.repeat(101);f.field('translationLanguage').input();assert.notEqual(f.field('language-validation').textContent,'');assert.equal(f.controls.collect().translationLanguage,''); + f.document.activeElement=f.field('translationLanguage');f.controls.render(DEFAULT_SETTINGS);assert.equal([...f.field('translationLanguage').value].length,101); +}); diff --git a/test/menus.test.js b/test/menus.test.js new file mode 100644 index 0000000..2ded7c1 --- /dev/null +++ b/test/menus.test.js @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +const { initializeMenus } = await import('../background/menus.js').catch(()=>({})); +function event(){const listeners=[];return {addListener:f=>listeners.push(f),emit:async(...args)=>{for(const f of listeners)await f(...args);}};} +function fixture(mode='advanced') { + let settings={...DEFAULT_SETTINGS,contextMenuMode:mode}; + const items=new Map(), launches=[];let refreshes=0; + const browser={runtime:{},storage:{onChanged:event()},menus:{onShown:event(),onHidden:event(),onClicked:event(), + removeAll:async()=>items.clear(),create:(item,callback)=>{items.set(item.id,{...item});callback();}, + update:async(id,patch)=>{assert.ok(items.has(id));Object.assign(items.get(id),patch);},refresh:async()=>{refreshes++;}}}; + const ready=initializeMenus({browser,load:async()=>({...settings}),onAction:async request=>launches.push(request)}); + return {browser,items,launches,ready,get refreshes(){return refreshes;},set settings(value){settings={...settings,...value};}}; +} +test('mode controls roots, live preferences rebuild, context-only onShown needs no host permission',async()=>{ + assert.equal(typeof initializeMenus,'function'); + const f=fixture('off');await f.ready;assert.equal(f.items.size,0); + f.settings={contextMenuMode:'simple'};await f.browser.storage.onChanged.emit({contextMenuMode:{}},'sync'); + assert.equal(f.items.size,1);assert.equal([...f.items.values()][0].parentId,undefined); + f.settings={contextMenuMode:'advanced'};await f.browser.storage.onChanged.emit({contextMenuMode:{}},'sync'); + assert.equal([...f.items.values()].filter(i=>!i.parentId).length,1); + await f.browser.menus.onShown.emit({contexts:['selection','link'],editable:false}); + assert.deepEqual([...f.items.values()].filter(i=>i.parentId&&i.visible&&i.type!=='separator').map(i=>i.title),['Ask normally','Explain','Summarize','Debug code']); + await f.browser.menus.onShown.emit({contexts:['link'],editable:false}); + assert.deepEqual([...f.items.values()].filter(i=>i.parentId&&i.visible).map(i=>i.title),['Ask about this link','Review link safety']); + assert.ok(f.refreshes>=2); +}); +test('click revalidates context and action, preserves source/settings snapshot',async()=>{ + assert.equal(typeof initializeMenus,'function'); + const f=fixture();await f.ready; + await f.browser.menus.onClicked.emit({menuItemId:'ask-chatgpt:debug',selectionText:'code',linkUrl:'https://example.com'}); + assert.equal(f.launches.length,1);assert.equal(f.launches[0].actionId,'debug');assert.equal(f.launches[0].source,'code'); + f.settings={promptTemplate:'changed'};assert.equal(f.launches[0].settings.promptTemplate,'{query}'); + await f.browser.menus.onClicked.emit({menuItemId:'ask-chatgpt:debug',linkUrl:'https://example.com'}); + await f.browser.menus.onClicked.emit({menuItemId:'ask-chatgpt:translate',selectionText:'code'}); + await f.browser.menus.onClicked.emit({menuItemId:'unknown',selectionText:'code'}); + assert.equal(f.launches.length,1); +}); +test('translation rebuilds with literal label and rapid preference updates settle',async()=>{ + assert.equal(typeof initializeMenus,'function'); + const f=fixture();await f.ready; + f.settings={showTranslate:true,translationMode:'preferred',translationLanguage:'A & B'}; + await f.browser.storage.onChanged.emit({showTranslate:{}},'sync'); + assert.equal(f.items.get('ask-chatgpt:translate').title,'Translate to A && B'); + f.settings={contextMenuMode:'off'}; + const a=f.browser.storage.onChanged.emit({contextMenuMode:{}},'sync'); + f.settings={contextMenuMode:'simple'}; + await Promise.all([a,f.browser.storage.onChanged.emit({contextMenuMode:{}},'sync')]); + assert.equal(f.items.size,1); +}); diff --git a/test/open-chat.test.js b/test/open-chat.test.js new file mode 100644 index 0000000..245d50e --- /dev/null +++ b/test/open-chat.test.js @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +const { openChat } = await import('../background/open-chat.js').catch(()=>({})); +test('window failure falls back to tab, background mode stays inactive, final failure rejects', async()=>{ + assert.equal(typeof openChat,'function'); + const opened=[]; + const browser={windows:{create:async()=>{throw Error('window failed');}},tabs:{create:async v=>opened.push(v)}}; + await openChat(browser,'https://chatgpt.com/','new-window'); + assert.deepEqual(opened,[{url:'https://chatgpt.com/',active:true}]); + await openChat(browser,'https://chatgpt.com/','background-tab'); + assert.equal(opened[1].active,false); + browser.tabs.create=async()=>{throw Error('tab failed');}; + await assert.rejects(openChat(browser,'https://chatgpt.com/','new-window')); +}); diff --git a/test/prepare-action.test.js b/test/prepare-action.test.js new file mode 100644 index 0000000..bd496a9 --- /dev/null +++ b/test/prepare-action.test.js @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +import { MAX_URL_CHARS } from '../lib/build-url.js'; +const { prepareAction } = await import('../lib/prepare-action.js').catch(()=>({})); +const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides}); +test('ready actions preserve source and hints, translation asks only when needed', () => { + assert.equal(typeof prepareAction, 'function'); + assert.equal(prepareAction(req()).url, 'https://chatgpt.com/?q=Hello'); + assert.equal(prepareAction(req({actionId:'translate'})).kind,'language'); + assert.match(new URL(prepareAction(req({actionId:'translate',language:'Spanish'})).url).searchParams.get('q'),/Spanish/); + assert.equal(prepareAction(req({actionId:'translate',settings:{...DEFAULT_SETTINGS,translationMode:'preferred',translationLanguage:'Spanish'}})).kind,'ready'); + assert.equal(prepareAction(req({source:''})).url,'https://chatgpt.com/'); + assert.equal(prepareAction(req({actionId:'debug',source:''})).kind,'error'); + assert.equal(prepareAction(req({actionId:'bogus'})).kind,'error'); +}); +test('URLs are validated and preserved whole, never shortened', () => { + assert.equal(typeof prepareAction, 'function'); + for (const source of ['javascript:alert(1)','about:config','ftp://example.com','https://user:pass@example.com','not a url','https://example.com/?q='+'x'.repeat(9000)]) { + assert.equal(prepareAction(req({actionId:'ask-link',source})).kind,'error',source.slice(0,40)); + } + const source='https://example.com/?q=a%20b&x=1#part'; + const out=prepareAction(req({actionId:'review-link',source})); + assert.equal(out.kind,'ready'); + assert.ok(new URL(out.url).searchParams.get('q').endsWith(source)); +}); +test('shortened selections end on a whole word and decode cleanly', () => { + assert.equal(typeof prepareAction, 'function'); + const out=prepareAction(req({source:'lorem ipsum '.repeat(2000)})); + assert.equal(out.kind,'shortened'); + const words=new URL(out.url).searchParams.get('q').replace('[Selected text shortened]','').trim().split(/\s+/); + assert.ok(words.length>1); + for (const word of words) assert.ok(word==='lorem'||word==='ipsum',`cut mid-word: ${word}`); + const emoji=prepareAction(req({source:'😀 '.repeat(9000)})); + assert.equal(emoji.kind,'shortened'); + assert.doesNotThrow(()=>decodeURIComponent(new URL(emoji.url).search.slice(1))); +}); +test('long selections preserve instructions, repetition and Unicode with explicit consent', () => { + assert.equal(typeof prepareAction, 'function'); + const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true}; + const out=prepareAction(req({source:'😀 word '.repeat(4000),settings})); + assert.equal(out.kind,'shortened'); + assert.ok(out.url.length<=8000); + const q=new URL(out.url).searchParams.get('q'); + assert.ok(q.startsWith('START '));assert.ok(q.includes(' END'));assert.ok(q.includes('[Selected text shortened]')); + assert.equal(q.match(/START (.*?) AGAIN/s)[1],q.match(/AGAIN (.*?) END/s)[1]); + assert.equal(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + assert.equal(typeof prepareAction, 'function'); + // Select-all on a very large page; shortening this took over a second uncapped. + const source='😀 word '.repeat(625000); + const started=performance.now(); + const out=prepareAction(req({source})); + const elapsed=performance.now()-started; + assert.equal(out.kind,'shortened'); + assert.ok(elapsed<250,`took ${elapsed.toFixed(0)} ms`); + const capped=Array.from(source).slice(0,MAX_URL_CHARS).join(''); + assert.deepEqual(prepareAction(req({source:capped})),out); +}); +test('a selection too long to send whole always needs consent, even if its start would fit', () => { + assert.equal(typeof prepareAction, 'function'); + // The first MAX_URL_CHARS code points trim down to "a"; sending just that would drop the rest unasked. + const out=prepareAction(req({source:'a'+' '.repeat(MAX_URL_CHARS)+'b'})); + assert.equal(out.kind,'shortened'); + assert.ok(new URL(out.url).searchParams.get('q').includes('[Selected text shortened]')); +}); diff --git a/test/settings.test.js b/test/settings.test.js new file mode 100644 index 0000000..0f7fb34 --- /dev/null +++ b/test/settings.test.js @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { readFileSync } from "node:fs"; + +import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS } from "../lib/defaults.js"; +import { mergeSettings } from "../lib/settings.js"; + +test("empty object yields the defaults", () => { + assert.deepEqual(mergeSettings({}), DEFAULT_SETTINGS); +}); + +test("null and undefined yield the defaults", () => { + assert.deepEqual(mergeSettings(null), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(undefined), DEFAULT_SETTINGS); +}); + +test("a non-object yields the defaults", () => { + assert.deepEqual(mergeSettings("nonsense"), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(42), DEFAULT_SETTINGS); +}); + +test("valid stored values are preserved", () => { + assert.deepEqual( + mergeSettings({ + showContextMenu: false, + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }), + { + ...DEFAULT_SETTINGS, + contextMenuMode: "off", + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }, + ); +}); + +test("missing keys fall back individually", () => { + const merged = mergeSettings({ openIn: "new-window" }); + assert.equal(merged.openIn, "new-window"); + assert.equal(merged.contextMenuMode, "simple"); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("unknown keys are dropped", () => { + const merged = mergeSettings({ model: "auto", nope: "gone", openInn: "typo" }); + assert.deepEqual(Object.keys(merged).sort(), Object.keys(DEFAULT_SETTINGS).sort()); + assert.equal("nope" in merged, false); +}); + +test("wrong types fall back to the default", () => { + const merged = mergeSettings({ + showContextMenu: "false", + model: 123, + temporaryChat: "yes", + webSearch: 1, + promptTemplate: [], + }); + assert.equal(merged.contextMenuMode, "simple"); + assert.equal("model" in merged, false); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.webSearch, DEFAULT_SETTINGS.webSearch); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("a prompt template over the length limit falls back to the default", () => { + assert.equal(typeof MAX_TEMPLATE_CHARS, "number"); + const atLimit = "x".repeat(MAX_TEMPLATE_CHARS); + assert.equal(mergeSettings({ promptTemplate: atLimit }).promptTemplate, atLimit); + assert.equal(mergeSettings({ promptTemplate: `${atLimit}x` }).promptTemplate, + DEFAULT_SETTINGS.promptTemplate); +}); + +// storage.sync rejects any item over 8192 bytes (key plus JSON value), and the +// options page writes every field in one set(), so one oversized template would +// make every later save fail. The worst character is a pasted control +// character, which JSON escapes to six bytes. +test("a template at the limit fits storage.sync's per-item quota in the worst case", () => { + const worst = "\u0001".repeat(MAX_TEMPLATE_CHARS); + const bytes = new TextEncoder().encode("promptTemplate" + JSON.stringify(worst)).length; + assert.ok(bytes <= 8192, `${bytes} bytes`); +}); + +test("the options page enforces the same template limit", () => { + const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8"); + const input = html.match(/]*id="promptTemplate"[^>]*>/)?.[0] ?? ""; + assert.equal(input.match(/maxlength="(\d+)"/)?.[1], String(MAX_TEMPLATE_CHARS)); +}); + +test("an out-of-range openIn falls back to the default", () => { + assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); + assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn); +}); + +test("each documented openIn value is accepted", () => { + for (const value of ["new-tab", "background-tab", "new-window"]) { + assert.equal(mergeSettings({ openIn: value }).openIn, value); + } +}); + +test("mergeSettings does not mutate DEFAULT_SETTINGS", () => { + mergeSettings({ openIn: "new-window" }); + assert.equal(DEFAULT_SETTINGS.openIn, "new-tab"); +}); + +test("previously saved model selections are ignored", () => { + assert.deepEqual(mergeSettings({ model: "gpt-5.6-sol" }), DEFAULT_SETTINGS); +}); + + +test("legacy mode migrates and a valid new mode wins", () => { + assert.equal(mergeSettings({ showContextMenu: false }).contextMenuMode, "off"); + assert.equal(mergeSettings({ showContextMenu: true }).contextMenuMode, "simple"); + assert.equal(mergeSettings({ showContextMenu: false, contextMenuMode: "advanced" }).contextMenuMode, "advanced"); + assert.equal(mergeSettings({ showContextMenu: false, contextMenuMode: "bad" }).contextMenuMode, "off"); +}); + +test("translation defaults and Unicode language limits are safe", () => { + assert.equal(mergeSettings({}).showTranslate, false); + assert.equal(mergeSettings({}).translationMode, "ask"); + assert.equal(mergeSettings({ translationLanguage: " Spanish " }).translationLanguage, "Spanish"); + assert.equal(mergeSettings({ translationLanguage: "😀".repeat(100) }).translationLanguage, "😀".repeat(100)); + assert.equal(mergeSettings({ translationLanguage: "😀".repeat(101) }).translationLanguage, ""); + assert.equal(mergeSettings({ translationLanguage: 42, translationMode: "bad", showTranslate: "yes" }).translationMode, "ask"); +}); + +test("load includes legacy-only preferences and save filters unknown keys", async () => { + const { load, save } = await import("../lib/settings.js"); + let saved; + globalThis.browser = { storage: { sync: { + get: async (keys) => Object.fromEntries(keys.filter(k => k === "showContextMenu").map(k => [k, false])), + set: async (value) => { saved = value; }, + } } }; + try { + assert.equal((await load()).contextMenuMode, "off"); + await save({ ...DEFAULT_SETTINGS, contextMenuMode: "advanced", unknown: true }); + assert.equal(saved.contextMenuMode, "advanced"); + assert.equal("unknown" in saved, false); + } finally { delete globalThis.browser; } +}); + +test("partial saves preserve unrelated stored preferences", async () => { + const { save } = await import("../lib/settings.js"); + const stored = { contextMenuMode: "advanced", translationLanguage: "Spanish", promptTemplate: "Explain {query}" }; + globalThis.browser = { storage: { sync: { set: async patch => Object.assign(stored, patch) } } }; + try { + await save({ webSearch: true }); + assert.equal(stored.contextMenuMode, "advanced"); + assert.equal(stored.translationLanguage, "Spanish"); + assert.equal(stored.promptTemplate, "Explain {query}"); + assert.equal(stored.webSearch, true); + } finally { delete globalThis.browser; } +}); diff --git a/test/shortcut-selection.test.js b/test/shortcut-selection.test.js new file mode 100644 index 0000000..58bc528 --- /dev/null +++ b/test/shortcut-selection.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; + +test('shortcut uses focused leaf frame, ignores stale ancestors and opens blank without content focus',async()=>{ + let command; + let frames=[]; + const opened=[]; + const noop={addListener(){},removeListener(){}}; + globalThis.browser={ + storage:{sync:{get:async()=>({contextMenuMode:'off'})},onChanged:noop}, + runtime:{onConnect:noop}, + menus:{removeAll:async()=>{},onShown:noop,onHidden:noop,onClicked:noop}, + commands:{onCommand:{addListener:f=>command=f}}, + tabs:{query:async()=>[{id:1}],executeScript:async(id,{code})=>frames.map(frame=>vm.runInNewContext(code,{document:{hasFocus:()=>frame.focus,activeElement:{tagName:frame.active??'BODY'}},window:{getSelection:()=>({toString:()=>frame.text})}})),create:async value=>opened.push(value)}, + }; + try { + await import('../background/background.js'); + for (const [value,expected] of [ + [[{focus:true,text:'top'}],'top'], + [[{focus:true,active:'IFRAME',text:'stale top'},{focus:true,text:'frame'}],'frame'], + [[{focus:false,text:'stale top'},{focus:false,text:'stale frame'}],null], + ]) { + frames=value;await command('ask-chatgpt'); + assert.equal(new URL(opened.at(-1).url).searchParams.get('q'),expected); + } + browser.tabs.executeScript=async()=>{throw Error('restricted');}; + await command('ask-chatgpt');assert.equal(opened.at(-1).url,'https://chatgpt.com/'); + } finally {delete globalThis.browser;} +}); diff --git a/test/shortcut.test.js b/test/shortcut.test.js new file mode 100644 index 0000000..9628543 --- /dev/null +++ b/test/shortcut.test.js @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { initializeShortcut } from "../options/shortcut.js"; + +function setup(commands) { + const elements = Object.fromEntries([ + "shortcut-intro", "shortcut-hint", "shortcut-current", "change-shortcut", + ].map((id) => [id, Object.assign(new EventTarget(), { textContent: "", hidden: true })])); + const document = Object.assign(new EventTarget(), { + visibilityState: "visible", + getElementById: (id) => elements[id], + }); + const window = new EventTarget(); + const ready = initializeShortcut({ commands, document, window }); + return { elements, document, window, ready }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); +const command = (shortcut) => ({ name: "ask-chatgpt", description: "Ask ChatGPT", shortcut }); + +test("shows the configured command rather than another command or the default", async () => { + const { elements, ready } = setup({ getAll: async () => [ + { name: "other", description: "Other", shortcut: "Alt+O" }, command("Ctrl+Shift+Y"), + ] }); + await ready; + for (const id of ["shortcut-intro", "shortcut-hint", "shortcut-current"]) { + assert.match(elements[id].textContent, /Ctrl\+Shift\+Y/); + assert.doesNotMatch(elements[id].textContent, /Alt\+Shift\+G|Alt\+O/); + } +}); + +test("updates an open page when the shortcut is removed", async () => { + let changed; + let shortcut = "Alt+Shift+G"; + const { elements, ready } = setup({ + getAll: async () => [command(shortcut)], + onChanged: { addListener: (listener) => { changed = listener; } }, + }); + await ready; + shortcut = ""; + changed({ name: "ask-chatgpt", newShortcut: "", oldShortcut: "Alt+Shift+G" }); + await flush(); + assert.match(elements["shortcut-current"].textContent, /No keyboard shortcut assigned/); + assert.doesNotMatch(elements["shortcut-intro"].textContent, /press|Alt\+Shift\+G/i); +}); + +test("refreshes on focus and visibility without the change event API", async () => { + let shortcut = "Alt+Shift+G"; + const { elements, window, document, ready } = setup({ getAll: async () => [command(shortcut)] }); + await ready; + shortcut = "Ctrl+Shift+Y"; + window.dispatchEvent(new Event("focus")); + await flush(); + assert.match(elements["shortcut-current"].textContent, /Ctrl\+Shift\+Y/); + shortcut = "Alt+J"; + document.dispatchEvent(new Event("visibilitychange")); + await flush(); + assert.match(elements["shortcut-current"].textContent, /Alt\+J/); +}); + +test("missing commands and failed reads never claim the default is assigned", async () => { + for (const commands of [undefined, { getAll: async () => [] }, { + getAll: async () => { throw new Error("Unavailable"); }, + }]) { + const { elements, ready } = setup(commands); + await ready; + assert.doesNotMatch(elements["shortcut-current"].textContent, /Alt\+Shift\+G/); + assert.equal(elements["change-shortcut"].hidden, true); + } +}); + +test("shortcut editor failure displays manual recovery instructions", async () => { + const { elements, ready } = setup({ + getAll: async () => [command("Alt+J")], + openShortcutSettings: async () => { throw new Error("Unavailable"); }, + }); + await ready; + assert.equal(elements["change-shortcut"].hidden, false); + elements["change-shortcut"].dispatchEvent(new Event("click")); + await flush(); + assert.match(elements["shortcut-current"].textContent, /Manage Extension Shortcuts/); +}); diff --git a/test/toolbar.test.js b/test/toolbar.test.js new file mode 100644 index 0000000..3e6acb1 --- /dev/null +++ b/test/toolbar.test.js @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; + +test('toolbar asks about the clicked tab selection and opens blank when unavailable', async () => { + let click; + let frames = []; + let restricted = false; + let settings = { contextMenuMode: 'off' }; + const opened = []; + const noop = { addListener() {}, removeListener() {} }; + globalThis.browser = { + storage: { sync: { get: async () => settings }, onChanged: noop }, + runtime: { onConnect: noop }, + menus: { removeAll: async () => {}, onShown: noop, onHidden: noop, onClicked: noop }, + commands: { onCommand: noop }, + browserAction: { onClicked: { addListener: listener => { click = listener; } } }, + tabs: { + // The clicked tab must be used even if another tab becomes active. + query: async () => [{ id: 99 }], + executeScript: async (id, { code }) => { + assert.equal(id, 7); + if (restricted) throw new Error('Restricted page'); + return frames.map(frame => vm.runInNewContext(code, { + document: { + hasFocus: () => frame.focus, + activeElement: { tagName: frame.active ?? 'BODY' }, + }, + window: { getSelection: () => ({ toString: () => frame.text }) }, + })); + }, + create: async options => { opened.push(options); }, + }, + windows: { create: async options => { opened.push({ ...options, window: true }); } }, + }; + try { + await import('../background/background.js'); + assert.equal(typeof click, 'function', 'toolbar click handler must be registered'); + for (const [selection, expected] of [ + [[{ focus: true, text: 'selected $& text' }], 'selected $& text'], + [[{ focus: true, active: 'IFRAME', text: 'stale parent' }, + { focus: false, text: 'stale sibling' }, + { focus: true, text: 'iframe selection' }], 'iframe selection'], + [[{ focus: true, text: '' }], null], + [[{ focus: false, text: 'stale selection' }], null], + ]) { + frames = selection; + await click({ id: 7 }); + assert.equal(new URL(opened.at(-1).url).searchParams.get('q'), expected); + } + + settings = { contextMenuMode: 'off', promptTemplate: 'Explain: {query}', openIn: 'background-tab' }; + frames = [{ focus: true, text: 'hello' }]; + await click({ id: 7 }); + assert.deepEqual(opened.at(-1), { url: 'https://chatgpt.com/?q=Explain%3A+hello', active: false }); + + settings = { openIn: 'new-window' }; + restricted = true; + await click({ id: 7 }); + assert.deepEqual(opened.at(-1), { url: 'https://chatgpt.com/', window: true }); + } finally { + delete globalThis.browser; + } +}); diff --git a/web-ext-config.mjs b/web-ext-config.mjs new file mode 100644 index 0000000..7be1694 --- /dev/null +++ b/web-ext-config.mjs @@ -0,0 +1,27 @@ +export default { + sourceDir: ".", + artifactsDir: "web-ext-artifacts", + ignoreFiles: [ + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "README.md", + "package.json", + "package-lock.json", + "web-ext-config.mjs", + // Directories need both the bare name and the glob: the glob alone removes + // the files but leaves an empty directory entry in the ZIP. + "docs", + "docs/**", + "test", + "test/**", + "web-ext-artifacts/**", + ], + build: { + filename: "search-with-chatgpt-firefox-{version}.zip", + overwriteDest: true, + }, + lint: { + warningsAsErrors: true, + }, +};