diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c6e54d..515552d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile @@ -79,11 +79,13 @@ jobs: # The release body is the whole changelog: there is no CHANGELOG.md, so # this is the only place a release is described. # - # Empty output means every commit this cycle was an internal type. That - # is worth stopping for rather than publishing a blank body: an update - # reaches every installed copy, and one that says nothing about what - # changed is worse than not releasing. It runs before the tests because - # it is the cheapest check here. + # Empty output means every commit this cycle was an internal type, which + # a cycle spent on tests, docs or a refactor legitimately is. It does not + # stop the release: the step says why the body is blank and publishing + # continues. That call belongs to whoever dispatched the workflow, who + # read `pnpm changelog` first, rather than to a job already told to + # publish - and the warnings are what make an empty body legible in the + # log afterwards. - name: Build the release notes run: | pnpm exec git-cliff --config cliff.toml \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df2c14e..110a16b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,11 +22,29 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + # Caches the schema zips the linter downloads, which is the slow part of + # `pnpm run lint`. They come from branch heads rather than tags, so a + # cache that never expired would freeze the schema train at whenever it + # was first written; the key therefore carries the ISO week. Inside a week + # the fetch is free, and the first run of each week picks up whatever the + # annotated-schemas branches say now. Deliberately no restore-keys - a + # prefix match would hand back last week's entry and undo the rotation. + # The hash of lint.sh is in the key because the pinned linter commit and + # the cache layout are both decided in there. + - name: This week's cache key + id: linter-cache + run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v6 + with: + path: .webext-linter-cache + key: webext-linter-${{ hashFiles('scripts/lint.sh') }}-${{ steps.linter-cache.outputs.week }} + - run: pnpm test # The release job reads the archive path off this script's stdout and @@ -40,3 +58,13 @@ jobs: unzip -Z1 "$xpi" | grep -qx 'manifest.json' json="$(node scripts/build-updates-json.mjs "$xpi")" test -f "$json" + + # Fails the build on an error-severity finding - that is the whole point + # of the linter knowing Thunderbird rather than Firefox, and it is just + # the exit code, with nothing to parse. It runs after the archive check + # because it builds the archive too, so a packaging break should be + # reported as one. The release workflow does not repeat it: this job runs + # on every push to main, so no commit reaches a tag unlinted, and a + # release should not be able to fail on a tarball download. + - name: Lint the archive against Thunderbird's schemas + run: pnpm run lint diff --git a/.gitignore b/.gitignore index db50608..f95bc5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,25 @@ node_modules/ -# Packaged extension artifacts (see issue 12) +# Packaged extension artifacts. Built by scripts/package.sh, which prints the +# path it wrote; nothing here is committed. *.xpi dist/ +# Thunderbird's linter, fetched by scripts/lint.sh, and the schemas and library +# hashes it downloads. The tool is pinned to a commit in that script and is not +# on npm yet, so it is not a dependency this repo can declare; the cache is +# separate so that bumping the pin does not discard it. +.webext-linter/ +.webext-linter-cache/ + +# What the Thunderbird tier downloads and generates: the pinned build, the +# driver, and one profile per run. Nothing here is reproducible from the repo +# on purpose - it is reproducible from `pnpm test:thunderbird`, which fetches +# it, and every byte of it is pinned in tests/thunderbird/harness/pins.js. +.thunderbird/ + +# Coverage reports. Written by `pnpm coverage`, read once, never committed - +# nothing is gated on them, so there is nothing here worth keeping. +coverage/ + .DS_Store diff --git a/README.md b/README.md index 7bdf00f..5a143be 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,52 @@ zipped, minus tests, docs and tooling. pnpm run lint ``` -Builds the archive and runs [addons-linter](https://github.com/mozilla/addons-linter) -over it - the engine behind `web-ext lint`, and the nearest thing to a review -Thunderbird add-ons have. It lints the built `.xpi` rather than the checkout, so -what it reads is what ships. - -Zero errors is the bar. Warnings are not, and cannot be: the linter knows -Firefox, so the MailExtension APIs this add-on exists to call - the `compose` -permission, `compose.{get,set}ComposeDetails`, `composeAction.openPopup` - all -read to it as unsupported. Skim the list rather than trusting the exit code; it -is short enough to know by heart, and a new entry is worth a look. +Builds the archive and runs Thunderbird's own +[webext-linter](https://github.com/thunderbird/webext-linter) over it. It +matches every `browser.*` call against Thunderbird's annotated API schemas and +applies the addons.thunderbird.net review policies, so it knows the surface +this add-on is built on: the `compose` permission and every `compose`, +`composeAction`, `menus` and `scripting` call pass. It lints the built `.xpi` +rather than the checkout, so what it reads is what ships. + +The exit code is the bar, and CI fails the build on it. Nothing is skimmed: +`0` means no error-severity finding, and the info-severity findings that are +printed alongside are few and all real. This replaced addons-linter, which +knows Firefox and reported this add-on's entire reason for existing as an +unsupported API, which is why its warnings could never be made to fail +anything. + +The script fetches the linter into `.webext-linter/` the first time it runs, +pinned to a commit in `scripts/lint.sh` and bootstrapped with its own `npm`, +because the tool publishes no tags and is not on npm yet. It and its schema +cache are both ignored and never packaged; nothing else here uses npm. + +Three things about the output that will look wrong the first time: + +- **It is written as a reviewer's reply to a submission.** This add-on is + submitted nowhere, so the manual-review sections at the end are addressed to + a reviewer who does not exist. The Issues section is the part to read. +- **It lints against the current release, not the floor.** The channel comes + from `strict_max_version`, and the manifest deliberately names none so that + updates keep reaching newer Thunderbirds, so every run says + `schema release-mv3`. The `128.0` floor is checked separately and better, by + the `strict-min-version-api` check: a call newer than the declared minimum + is an error. Do not add a `strict_max_version` to move the channel. +- **Two checks are skipped and one lookup is off, and that is all.** + `update-url`, because serving its own updates is why this add-on is + unlisted, and `unused-files`, because an upstream path-parsing bug makes it + report the vendored highlight.js licence as dead weight. The lookup is + `--cdn-lib-lookup`, which identifies a bundled library by asking third-party + CDNs for its content hash: the only bundled library here is a hand-modified + highlight.js, so no hash can match it by construction and leaving it on only + makes the run depend on four hosts being up. All three reasons are written + out in `scripts/lint.sh`. + +One info finding is standing rather than new: both `src/compose/insert-into-body.js` +and the vendored highlight.js insert markup through `.innerHTML`, which +Thunderbird stops permitting after ESR 153. The supported replacement, +`Element.setHTML()`, needs Thunderbird 148, which is above this add-on's floor +of 128 - so this waits on the floor moving rather than on someone noticing it. ## Developing @@ -79,9 +115,96 @@ popup. Compose scripts are injected per compose window, so changes under `src/compose/` need the compose window reopened as well - reloading the add-on does not reach one that is already open. -Run the tests with `pnpm test`. They cover the manifest, the update manifest, -the version arithmetic and the HTML builder; everything that needs a running -compose window is checked by hand against `docs/release-checklist.md`. +### Running the tests + +```sh +pnpm test # both automated tiers +pnpm test:node # the pure tier alone, for a fast edit loop +pnpm test:watch # both automated tiers, rerunning as files change +pnpm test:thunderbird # the real-Thunderbird tier; see below +pnpm coverage # a report; nothing is gated on it +``` + +The suite is split into tiers, and which one a test belongs in is decided by +where it can be written rather than by what it is about: + +| Tier | Directory | Environment | +| --- | --- | --- | +| `node` | `tests/node/` | no DOM at all | +| `dom` | `tests/dom/` | a simulated document, via jsdom | +| `thunderbird` | `tests/thunderbird/` | a real Thunderbird, driven headless | + +`pnpm test` runs the first two. The third is a local command run while working +the checklist, not part of the default run and not part of CI. + +The `node` tier has no document on purpose: a test that reaches for one there +fails rather than passing, which is what has kept the code-block pipeline from +quietly growing a dependency on a DOM. Wanting a document means moving the file +into `tests/dom/`, which is a change someone can see. Anything dropped straight +into `tests/` without picking a tier runs in `node`, so the strict tier is the +default rather than something to remember. + +Coverage is reported and never gated - there is no threshold and there will not +be one. The reasoning behind all of this, including the alternatives that were +turned down, is in +[docs/adr/0001-three-test-tiers.md](docs/adr/0001-three-test-tiers.md). + +### The real-Thunderbird tier + +```sh +pnpm test:thunderbird +``` + +**Thunderbird does not support this and does not document it.** Driving the +application over WebDriver, switching into its privileged context and +temp-installing an unsigned build are all things that happen to work rather +than things anyone has promised to keep working, and a Thunderbird update can +break the tier with no warning. When that happens it is this project's cost to +absorb, which is affordable exactly because the tier runs in no pipeline and +can block nothing. It is the only tier that can exercise the editor command +path that runs in production. + +Nothing needs to be installed first. The command fetches the pinned Thunderbird +and a matching geckodriver into `.thunderbird/`, verifies both against +published checksums, and starts the application headless on a profile it +creates for the run and deletes afterwards. That is about 90 MiB and a couple +of minutes the first time and nothing on every run after it; the directory is +ignored and disposable, so deleting it starts over. Linux x86_64 only as it +stands - the archive names and the driver asset are picked for that platform. + +The pinned version is the floor `strict_min_version` promises, which means the +tier drives a build that is frozen and past end of life. That is the trade the +promise implies rather than a reason to move the floor, and it is why the +override below exists. + +| Variable | Effect | +| --- | --- | +| `THUNDERBIRD_BINARY` | Drive an installed Thunderbird instead of the pin, and skip the download. Needs 128 or newer: a Manifest V3 MailExtension will not load at all below that, so pointing this at an older build fails for a real reason. | +| `THUNDERBIRD_HEADLESS=0` | Give the application a display. Run the command under `xvfb-run` and it stays unattended; this is the fallback for the things headless Thunderbird has been known to get wrong. | +| `THUNDERBIRD_TIER_DEBUG=1` | geckodriver's trace log, on the terminal. | + +What it asserts is what the add-on does: a snippet typed into the popup and a +block coming out in the message body, through the toolbar button, through the +shortcut, through a right-click and into a plain-text composer, with the +insertion function's own report of which path it took read back off the +console. That is `tests/thunderbird/insertion.test.js`, and every assertion in +it used to be a line on the release checklist. + +The harness itself is `tests/thunderbird/harness/`, and its interface is +documented in `tests/thunderbird/harness/index.js` - including four limits +found while building it, which are worth reading before writing a test that +runs into them. The popup's document cannot be read from outside; the popup has +to be handed the keyboard before it hears anything, and a test that forgets can +pass while asserting nothing; a letter-key shortcut cannot be delivered to +Thunderbird 128 by synthesised input; and the popup cannot be opened in a +plain-text composer at all, which is a defect in the add-on rather than a limit +of the harness. + +What is still checked by hand is anything that is a claim about Thunderbird +rather than about this project's own logic; that list is +`docs/release-checklist.md`, which now opens with three commands - `pnpm test`, +this one, and this one again with `THUNDERBIRD_BINARY` pointed at an installed +Thunderbird - and only then reaches the items a person has to look at. ## Commit messages @@ -97,11 +220,16 @@ Two types reach the notes: - `feat` - an **Added** entry. - `fix` - a **Fixed** entry. -`refactor` and `perf` become **Changed**, `revert` becomes **Removed**, and +`perf` becomes **Changed** and `revert` becomes **Removed**. `refactor`, `docs`, `test`, `chore`, `ci`, `build` and `style` are required on the commit but deliberately absent from the notes: someone reading them wants to know what the add-on now does, not how the repo is maintained. +`refactor` is on that list rather than beside `perf` for the same reason. A +refactor changes nothing anyone using the add-on can observe, so an entry for +one tells a reader waiting to hear what the add-on now does about a file move +instead. `perf` stays because a faster add-on is something a user experiences. + Scopes in use: `compose`, `code-block`, `popup`, `options`, `ui`, `release`. A commit with no type is dropped entirely rather than guessed at. That is @@ -109,9 +237,11 @@ meant to be caught in review - silently listing it under the wrong heading would be worse. Merge commits are skipped for the same reason and keep their default subjects. -Run `pnpm changelog` at any point to see what the next release will say. If a -cycle produces nothing, the release is refused rather than published with an -empty body; see below. +Run `pnpm changelog` at any point to see what the next release will say. A +cycle whose every commit was an internal type produces nothing at all, which +is an ordinary outcome rather than a rare one - a cycle spent on tests and an +extraction committed as `refactor` is exactly that. What happens next is +under Releasing. Because the notes are written at publish time from the commits themselves, there is nothing to prepare and nothing that can go stale. Fixing a bad @@ -129,17 +259,18 @@ chooses it. Releasing is running an action, not pushing a tag. 3. **Actions ▸ Release ▸ Run workflow**, on `main`. Leave the bump at `minor` unless the next cycle is a patch or a major. -The workflow refuses to start unless it is on `main`, the version is not -already tagged, and the generated notes are not empty. It then runs the tests, -builds the archive, generates `updates.json` from the manifest and the +The workflow refuses to start unless it is on `main` and the version is not +already tagged. It then runs the tests, builds the archive, generates `updates.json` from the manifest and the archive's digest, publishes both under a tag it creates itself with the notes as the release body, and finally raises `manifest.json` to the next version and pushes that to `main`. -Empty notes mean every commit in the cycle was an internal type, so the -release is refused. An update reaches every installed copy, and one that says -nothing about what changed is worse than not releasing at all. If something -user-facing did land, it was committed under the wrong type. +Empty notes do not stop it. They mean every commit in the cycle was an +internal type, and the workflow prints why the body is blank and publishes +anyway. That call is step 1's, not the job's: read `pnpm changelog` and decide +there, because a release with nothing to say about it is usually one worth +skipping, and if something user-facing did land it was committed under the +wrong type. So `main` always sits on an unreleased version, and every tag names a commit where the manifest agreed with it. The bump comes last on purpose: if anything diff --git a/cliff.toml b/cliff.toml index b978ea0..4ea7097 100644 --- a/cliff.toml +++ b/cliff.toml @@ -44,12 +44,15 @@ commit_parsers = [ { message = "^Merge", skip = true }, { message = "^feat", group = "Added" }, { message = "^fix", group = "Fixed" }, - { message = "^refactor", group = "Changed" }, + # `perf` alone owns Changed. A faster add-on is something someone using it + # experiences, so it belongs in the notes; a refactor by definition changes + # nothing observable, so it is listed with the internal-only types below. { message = "^perf", group = "Changed" }, { message = "^revert", group = "Removed" }, # Internal-only types. They are required on the commit and absent from the # release notes: someone reading them wants to know what the add-on now # does, not how the repo is maintained. + { message = "^refactor", skip = true }, { message = "^docs", skip = true }, { message = "^test", skip = true }, { message = "^chore", skip = true }, diff --git a/docs/adr/0001-three-test-tiers.md b/docs/adr/0001-three-test-tiers.md new file mode 100644 index 0000000..d7f2954 --- /dev/null +++ b/docs/adr/0001-three-test-tiers.md @@ -0,0 +1,116 @@ +# Three test tiers, split by directory, with coverage never gated + +## Status + +Accepted. + +## Context + +The suite began as one run with no DOM, and that was a decision rather than a +default: the code-block pipeline must stay pure, so a test that reaches for a +document should fail loudly instead of quietly working in tests and nowhere +else. The constraint held. It also meant that four modules - the background, +the compose-sandbox insertion function, the popup and the options page - could +not be tested at all, because the only way to reach any of them is through a +document. Three of those four are where the add-on's observable behaviour +actually lives, and the release checklist was covering all of it by hand. + +So the constraint was global when it only needed to be local. It protected one +module at the cost of four. + +## Decision + +Three tiers, declared as named runner projects in `vitest.config.js`. + +**`node`** - no DOM. The pure pipeline, the settings coercion, the snippet +measurement, and the static files: the manifest, the update manifest, the +stylesheets, the version arithmetic. Lives in `tests/node/`, and also claims +any test file dropped straight into `tests/` that no other tier has taken, so +the strict tier is what an unfiled test gets. + +**`dom`** - a simulated document, for modules that need one to do anything but +do not need Thunderbird. Lives in `tests/dom/`. jsdom, chosen on ecosystem +grounds rather than capability: the candidates are equivalent on the thing that +matters here, since both implement Range and Selection well enough to drive +caret insertion and neither implements the editor command at all. + +**`thunderbird`** - a real Thunderbird, driven headless over WebDriver, in +`tests/thunderbird/`. It is the only tier that can exercise the editor command +path that actually runs in production, and the only one that can retire a +checklist item honestly. It provisions the Thunderbird it drives, so it is +green on a machine that has none; it is kept out of the default test command +anyway, because that run must not need the network, ninety megabytes of disk or +a couple of minutes, and because an unsupported harness should never be the +reason a change cannot be tested. It is run locally while working the checklist +rather than in CI. + +`pnpm test` runs `node` and `dom`. `pnpm test:node` runs the strict tier alone, +because the cost of running tests while editing should never be the reason not +to run them. + +Which tier a test belongs in is answered by where it can be written: no +document, a simulated one, or a real Thunderbird. Nothing else. + +## Considered options + +**One DOM environment for everything.** Rejected. It costs exactly the property +that has kept the pipeline pure. Nothing would ever have reported that the +pipeline had started reading a document, because in the suite there would +always have been one. + +**Per-file environment pragmas.** Rejected, and this is the closer call, so it +is the one worth recording. The original constraint was never "no DOM +anywhere"; it was "reaching for a DOM should fail loudly". A pragma at the top +of a pipeline test satisfies the letter of the configuration while losing +precisely that: the test still passes, the file still looks ordinary, and the +line that gave the pipeline a document is one line in a header nobody reads +twice. Putting the boundary in the directory layout means asking for a document +is moving a file, which is visible in a diff and visible in a listing, and +means the tier a test runs in is a fact about the repository rather than a fact +about that file's first line. + +## Coverage + +Reported, never gated. There is no threshold configured and there is not meant +to be one. + +The report's value here is narrow and real: after logic is pulled out of a +large module, it is the cheapest way to see whether the new module took that +logic or only holds a copy of it. What it is not is a summary of how well this +project is tested, because this project leaves whole modules uncovered on +purpose - the theme reduction because a simulated CSS object model is least +faithful exactly where that module's claim lives, and everything the third tier +covers because CI does not run it. A threshold over a codebase like that is a +number somebody tunes down until it agrees with whatever the last commit did, +and a number that always agrees is not a check. + +The vendored highlight.js is excluded. It is a third party's code at a pinned +revision, and the pipeline's tests import it directly, so it would otherwise +bury this project's own numbers under several hundred files nobody here is +going to write a test for. + +## Consequences + +The third tier is undocumented and unsupported by Thunderbird. WebDriver +accepting the Thunderbird binary, switching into a privileged context and +temp-installing an unsigned checkout is all behaviour nobody has promised to +keep working, and a Thunderbird update may break it with no warning and no +recourse. That breakage is a cost this project owns and accepts: it is one +afternoon when it happens, against a harness that can block nothing because it +runs in no pipeline. It was verified end to end before being committed to, and +it is worth the exposure because it is the only place the production insertion +path can be exercised at all. + +What the tier turned out to own, beyond the code: a Thunderbird version, a +geckodriver version, the archive's compression suffix, a list of preferences, +an enterprise policy file and a dummy local account, because a compose window +with no identity does not open. Every one of those is a claim about the outside +world that can rot while nothing here changes, which is the same exposure as +the paragraph above and the reason they are pinned in one file with their +reasons written next to them rather than spread through the harness. + +The checklist keeps every item that is a claim about Thunderbird rather than +about this project's own logic - the button on a dark appearance, the icon +rather than a puzzle piece, where cloud attachment links land, the absence of +spell-check underlines, installing the built archive into a clean profile. None +of those should ever be retired because a fake agreed with them. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 2975981..cf565fa 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,8 +1,13 @@ # Release checklist -`pnpm test` covers the manifest, the update manifest, the HTML builder and the -settings. It cannot open a compose window, so everything the add-on actually -*does* is unverified until someone does it. This file is that someone's list. +The suite can open a compose window now. What it cannot do is look at one, so +this file is what is left: claims about Thunderbird, claims about what +something looks like, and one section of claims about the release itself that +only exist after it has been published. Everything that was a claim about this +add-on's own logic has moved into the tests, and the first section lists what +that took with it - not as items to work through, but so that a failure there +is recognisable as a checklist item failing rather than as a test being +fussy. Run it before every tag, on **both** supported Thunderbird versions: @@ -14,14 +19,62 @@ Run it before every tag, on **both** supported Thunderbird versions: Record the result in the GitHub release description, or in the pull request if the release is being prepared on a branch. +## What the tests cover + +- [ ] `pnpm test` passes. +- [ ] `pnpm test:thunderbird` passes. It drives the pinned 128 ESR, which it + fetches itself, so this is the floor version of the two runs above. +- [ ] `THUNDERBIRD_BINARY=/path/to/thunderbird pnpm test:thunderbird` passes + against the current release. Same suite, the maintainer's own install; + the README says what the variable does. + +Three commands, and they stand in for the following, each of which was an item +on this list and is now an assertion in `tests/thunderbird/insertion.test.js` +unless another file is named: + +- A block landing at the caret in an empty HTML composer, through the toolbar + button. +- The same insert with the caret mid-paragraph, leaving the text on both sides + of it intact - and source with nothing to highlight not throwing on the way. +- A selection right-clicked, arriving in the popup, and replaced rather than + duplicated. +- The insert going through the editor command rather than a DOM fallback, which + is the path that runs in production, and one `Ctrl+Z` taking it out again. +- `&`, `<`, `>` and `"` in the source reaching the message as those characters. +- The popup closing when the insert lands. +- A plain-text composer receiving the source as text with no markup in it. + The test unhides the format toolbar to get there, because as things stand the + popup cannot be opened in a plain-text composer at all - issue #12. What is + covered is the insert; what is broken is reaching it. +- The shortcut inserting exactly what the button inserts, and the manifest's + `Ctrl+Shift+C` having become the key element Thunderbird derives from it. + **Delivering that key press is not covered** - see the first item under + Insertion. +- `pnpm run package` producing `dist/thundercode-.xpi` with the + manifest's version in its name, in `compose-window.test.js`: the tier + installs that archive, so every run builds it. +- The live preview updating as the source changes, and the large-snippet + warning appearing past the threshold and not below it, in + `tests/dom/popup.test.js` and `tests/node/snippet-size.test.js`. Both are + claims about this add-on's own arithmetic rather than about Thunderbird, + which is what made them safe to stop looking at. +- Correcting the detected language and the preview following it, in + `tests/dom/popup.test.js`. +- The update manifest being keyed by the id this add-on's manifest declares, + in `tests/node/updates.test.js`. That was the "Update manifest did not + contain an entry for …" line to look for in the Error Console after an + update check, which is the only symptom a mismatch has - and it is a claim + about a file this repo generates rather than about Thunderbird reading it. + ## Insertion -- [ ] Insert a block at the caret in an empty HTML compose window. -- [ ] Insert a block with the caret mid-paragraph; surrounding text is intact. -- [ ] Select existing text in the compose window, right-click, insert as a code - block; the selection is replaced, not duplicated. -- [ ] `Ctrl+Shift+C` opens the popup. -- [ ] Undo (`Ctrl+Z`) reverses the insert in one step. +- [ ] `Ctrl+Shift+C` opens the popup, pressed on a real keyboard in an HTML + composer. The tier drives the `key` element Thunderbird built from the + manifest and asserts that opening the popup that way inserts identically + to the button, but it cannot press the key: a letter-key shortcut is + matched on keypress, and synthesised input produces none. So what is left + here is exactly the delivery, which is Thunderbird's half of that + shortcut. - [ ] No red spell-check underlines anywhere in an inserted block, and prose typed above and below it is still checked. The block relies on the `moz-forward-container` wrapper for this, which is Thunderbird's own @@ -30,55 +83,45 @@ the release is being prepared on a branch. - [ ] Attaching a file with Filelink while a block sits above a forwarded message still puts the cloud links in a sensible place. This is the known cost of that wrapper; it is a nuisance, not a failure. -- [ ] The message is marked modified after an insert (closing prompts to save). - -## Highlighting - -- [ ] Paste source in a language with a distinctive shape (Python, SQL); the - detected language is right and the block is coloured. -- [ ] Override the detected language in the popup; the preview follows. -- [ ] Paste plain prose; the plaintext fallback does not throw. -- [ ] Source containing `&`, `<`, `>` and `"` renders as those characters - rather than as entities or markup. - -## Popup - -- [ ] The live preview updates as the source changes. -- [ ] The large-snippet warning appears above the threshold and not below it. -- [ ] Inserting closes the popup. - -## Options - -- [ ] Open the options pane from the Add-ons Manager; it is embedded, not a tab. -- [ ] Change the theme; a newly inserted block uses it. -- [ ] Settings survive a Thunderbird restart. +- [ ] The message is marked modified after an insert, so closing the composer + prompts to save. The tier reads the editor's modification count; that + Thunderbird then puts up the prompt is the part with a dialog in it. ## Appearance - [ ] The toolbar button is visible in the format toolbar on a light theme. - [ ] The toolbar button is visible on a dark theme (not dark ink on dark). - [ ] An inserted block reads correctly in both themes. +- [ ] Paste source in a language with a distinctive shape (Python, SQL), then + paste plain prose: the code is coloured in the composer and the prose is + not. That the right language is detected, and that the colours are in the + markup at all, is `tests/node/code-block.test.js`. That they survive into + a message body and read as code is this. -## Plain-text composers +## Options -- [ ] Open a plain-text compose window; the add-on degrades as intended rather - than inserting broken markup. +- [ ] Open the options pane from the Add-ons Manager; it is embedded, not a tab. +- [ ] Change the theme; a newly inserted block uses it. The theme is read out of + the stylesheet through Thunderbird's own CSS parser, which is the one + thing a simulated document is least faithful about - see the comment at + the top of `src/popup/theme-map.js`. +- [ ] Settings survive a Thunderbird restart. ## Packaging -- [ ] `pnpm run package` succeeds and `dist/thundercode-.xpi` has the - version from `manifest.json` in its name. -- [ ] Install that archive from file into a clean profile and repeat one - insertion - this is the path users take, and it is not the path - `about:debugging` exercises. +- [ ] Install `dist/thundercode-.xpi` from file into a clean profile + and repeat one insertion - this is the path users take, and it is not the + path a temporary install exercises. - [ ] The Add-ons Manager shows the ThunderCode icon, not a puzzle piece. -- [ ] Run Thunderbird's reviewer linter once against the archive: - clone and - `node verify.js dist/thundercode-.xpi`. Warnings about unknown - `messenger.*` APIs and mail permissions are expected noise - the linter - does not know Thunderbird's own surface. Anything else is worth reading. -## Updates +## After publishing + +The one section here that is not about Thunderbird, said out loud rather than +filed as though it were. These are claims about GitHub and about this repo's +own release workflow, and the reason they survive the split is not that a test +could not make them - it is that there is nothing to make them against until +the workflow has run and published something. A person looking at the release +that just went out is the only thing that can see them. - [ ] The published release is **not** a draft and **not** a prerelease. The workflow sets both false, so this is a check that nobody edited the @@ -88,15 +131,20 @@ the release is being prepared on a branch. - [ ] `main` now holds the *next* version, pushed by the workflow's bump commit. If it still holds the released one, the bump step failed and the next release will refuse to start. -- [ ] `updates.json` is attached to the release alongside the `.xpi`, and - - returns it. +- [ ] + returns the new version. What that URL *says* is pinned by + `tests/node/updates.test.js`, and that the file builds at all is checked + on every push by the test workflow; what neither can see is whether the + release carries it. That URL is baked into every installed copy, so a + release published without the asset leaves all of them polling a 404 and + never hearing about the update. + +## Updating an installed copy -The rest is only meaningful once a previous release exists. +Thunderbird's half of an update - the daily check, the download and the +install - against a real one. Only meaningful once a previous release exists. - [ ] Set `extensions.logging.enabled` to `true` in the config editor first; update failures are otherwise completely silent. - [ ] With the previous version installed, force a check from the Add-ons Manager gear menu and confirm it upgrades to the new one. -- [ ] The Error Console shows no "Update manifest did not contain an entry for - …" line. diff --git a/package.json b/package.json index fb886d5..342b93a 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,25 @@ "description": "Thunderbird MailExtension for inserting syntax-highlighted code blocks into HTML mail", "type": "module", "packageManager": "pnpm@12.3.4", + "engines": { + "node": ">=24" + }, "scripts": { - "test": "vitest run", - "test:watch": "vitest", + "test": "vitest run --project node --project dom", + "test:node": "vitest run --project node", + "test:thunderbird": "vitest run --project thunderbird", + "test:watch": "vitest --project node --project dom", + "coverage": "vitest run --project node --project dom --coverage", "package": "bash scripts/package.sh", "lint": "bash scripts/lint.sh", "changelog": "git-cliff --unreleased --strip all" }, "devDependencies": { - "addons-linter": "^10.10.0", + "@vitest/coverage-v8": "^5.0.0", "git-cliff": "^2.13.1", "highlight.js": "11.12.0", + "jsdom": "^30.0.1", + "selenium-webdriver": "^4.48.0", "vitest": "^5.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3f2afc..544b30a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,86 +109,107 @@ importers: .: devDependencies: - addons-linter: - specifier: ^10.10.0 - version: 10.10.0(supports-color@7.2.0) + '@vitest/coverage-v8': + specifier: ^5.0.0 + version: 5.0.0(vitest@5.0.0) git-cliff: specifier: ^2.13.1 version: 2.13.1 highlight.js: specifier: 11.12.0 version: 11.12.0 + jsdom: + specifier: ^30.0.1 + version: 30.0.1 + selenium-webdriver: + specifier: ^4.48.0 + version: 4.48.0 vitest: specifier: ^5.0.0 - version: 5.0.0(vite@8.2.2) + version: 5.0.0(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2) packages: - '@eslint-community/eslint-utils@4.10.1': - resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true - '@eslint/eslintrc@3.3.7': - resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@bazel/runfiles@6.5.0': + resolution: {integrity: sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true - '@fluent/syntax@0.19.0': - resolution: {integrity: sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==} - engines: {node: '>=14.0.0', npm: '>=7.0.0'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} - '@fregante/relaxed-json@2.0.0': - resolution: {integrity: sha512-PyUXQWB42s4jBli435TDiYuVsadwRHnMc27YaLouINktvTWsL3FcKrRMGawTayFk46X+n5bE23RjUTWQwrukWw==} - engines: {node: '>= 0.10.0'} + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} @@ -200,15 +221,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mdn/browser-compat-data@8.0.8': - resolution: {integrity: sha512-Rutrrc3FYOc+um4/QC4WFETNk2fV8NKWoqQl3tQfcVTQ1WFaoJRbsJBasSF34ltitmnYYD6ZsKW1cOQtSj1jbQ==} - '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rolldown/binding-android-arm-eabi@1.2.7': resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -324,8 +339,22 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@vitest/coverage-v8@5.0.0': + resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + peerDependencies: + '@vitest/browser': 5.0.0 + vitest: 5.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.1': + resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.1': + resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} + engines: {node: '>=22'} '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} @@ -341,267 +370,52 @@ packages: '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} - engines: {node: '>=0.4.0'} - hasBin: true - - addons-linter@10.10.0: - resolution: {integrity: sha512-1n5Xvn4DyHMsulchNDl4ucWnXXQhHwLif6eK80KjieI9KIK3xD+3OYXk5ZcA6a2J8y+Nrlvq9/vq6w7KOqT2MQ==} - engines: {node: '>=20.0.0'} - hasBin: true - - addons-moz-compare@1.3.0: - resolution: {integrity: sha512-/rXpQeaY0nOKhNx00pmZXdk5Mu+KhVlL3/pSBuAYwrxRrNiTvI/9xfQI8Lmm7DMMl+PDhtfAHY/0ibTpdeoQQQ==} - - addons-scanner-utils@15.4.0: - resolution: {integrity: sha512-i3PnJx9YLZi96o4t4JWArP6JimC01949YVAcMJKHfyQ5qmUBaH21qNS8f12/RsloejW8IcCEljhh3Fq3gxFNsg==} - peerDependencies: - express: 5.2.1 - safe-compare: 1.1.4 - peerDependenciesMeta: - express: - optional: true - safe-compare: - optional: true - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + ast-v8-to-istanbul@1.0.6: + resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - cheerio-select@2.1.0: - resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - - cheerio@1.2.0: - resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} - engines: {node: '>=20.18.1'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - columnify@1.6.0: - resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==} - engines: {node: '>=8.0.0'} - - common-tags@1.8.2: - resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} - engines: {node: '>=4.0.0'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - encoding-sniffer@0.2.1: - resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-no-unsanitized@4.1.5: - resolution: {integrity: sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg==} - peerDependencies: - eslint: ^9 || ^10 - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} @@ -610,21 +424,6 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-patch@3.1.1: - resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-uri@3.1.7: - resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -638,34 +437,11 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - first-chunk-stream@3.0.0: - resolution: {integrity: sha512-LNRvR4hr/S8cXXkIY5pTgVP7L3tq6LlYWcg9nWBuW7o1NMxKZo6oOVa/6GIekMGI0Iw7uC+HWimMe9u/VAeKqw==} - engines: {node: '>=8'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.4: - resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -705,66 +481,31 @@ packages: engines: {node: ^18.19 || >=20.6} hasBin: true - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - highlight.js@11.12.0: resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - image-size@2.0.2: - resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} - engines: {node: '>=16.x'} - hasBin: true - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -773,47 +514,29 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} - is-utf8@0.2.1: - resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-yaml@4.3.2: - resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-merge-patch@1.0.2: - resolution: {integrity: sha512-M6Vp2GN9L7cfuMXiWOmHj9bEFbeC250iVtcKQbqVgEsDVYnIsrNsbU+h/Y/PkbBQCtEa4Bez+Ebv0zfbC8ObLg==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - jsonwebtoken@9.0.3: - resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} - engines: {node: '>=12', npm: '>=6'} - - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} @@ -889,101 +612,41 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} magic-string@1.2.3: resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} - parse5-htmlparser2-tree-adapter@7.1.0: - resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} - - parse5-parser-stream@7.1.2: - resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} @@ -993,9 +656,6 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1003,76 +663,46 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} - hasBin: true - postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} - process-warning@5.1.0: - resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - real-require@1.0.0: - resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - rolldown@1.2.7: resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + selenium-webdriver@4.48.0: + resolution: {integrity: sha512-rKM9uXFRWcF9aThrZQDNQH2/9Et/WvMZbg3/x1rnSYWoXiwJuShYeH0IAli8Cuw+c3lEV0UWPfUz88H+fvW9Hg==} + engines: {node: '>= 22.0.0'} - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -1089,61 +719,25 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom-buf@2.0.0: - resolution: {integrity: sha512-gLFNHucd6gzb8jMsl5QmZ3QgnUJmp7qn4uUSHNwEXumAp7YizoGYw19ZUVfuq4aBOQUtyn2k8X/CwzWB73W2lQ==} - engines: {node: '>=8'} - - strip-bom-stream@4.0.0: - resolution: {integrity: sha512-0ApK3iAkHv6WbgLICw/J4nhwHeDZsBxIIsOD+gHgZICL6SeJ0S9f/WZqemka9cjkTyMN5geId6e8U5WGFAn3cQ==} - engines: {node: '>=8'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - thread-stream@4.2.0: - resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} - engines: {node: '>=20'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} tinybench@6.1.4: resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} @@ -1157,28 +751,39 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} - undici@7.29.1: - resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} - engines: {node: '>=20.18.1'} + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} - upath@3.0.7: - resolution: {integrity: sha512-VjBBquch25nUGMuVBpOb2Cj3gc8Kb7lJBqbsXR/0anZ/5uJsL14Kpth9JKfnBsckxCfgIp6hPvcvvmZ97R9X7g==} - engines: {node: '>=20'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} - upath@3.0.8: - resolution: {integrity: sha512-YAsrLMIlhfSCm9rga5TZsJ1mXgahs7N0qOTokzU8mFz35hUrYMvVkaEPefI3rEb/vkAWAOj1Km/qdije9RC1kQ==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} vite@8.2.2: resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} @@ -1264,17 +869,25 @@ packages: jsdom: optional: true - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} @@ -1286,33 +899,24 @@ packages: engines: {node: '>=8'} hasBin: true - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true - yauzl@3.4.0: - resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} - engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} yoctocolors@2.2.0: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} @@ -1320,71 +924,67 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4(supports-color@7.2.0))': + '@asamuzakjp/css-color@6.0.7': dependencies: - eslint: 9.39.4(supports-color@7.2.0) - eslint-visitor-keys: 3.4.3 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2(supports-color@7.2.0)': + '@asamuzakjp/dom-selector@8.3.2': dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@7.2.0) - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 + '@babel/helper-string-parser@7.29.7': {} - '@eslint/core@0.17.0': + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': dependencies: - '@types/json-schema': 7.0.15 + '@babel/types': 7.29.8 - '@eslint/eslintrc@3.3.7(supports-color@7.2.0)': + '@babel/types@7.29.8': dependencies: - ajv: 6.15.0 - debug: 4.4.3(supports-color@7.2.0) - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.3.2 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 - '@eslint/js@9.39.4': {} + '@bazel/runfiles@6.5.0': {} - '@eslint/object-schema@2.1.7': {} + '@bcoe/v8-coverage@1.0.2': {} - '@eslint/plugin-kit@0.4.1': + '@bramus/specificity@2.4.2': dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 + css-tree: 3.2.1 - '@fluent/syntax@0.19.0': {} + '@csstools/color-helpers@6.1.1': {} - '@fregante/relaxed-json@2.0.0': {} + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - '@humanfs/core@0.19.2': + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@humanfs/types': 0.15.0 + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - '@humanfs/node@0.16.8': + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 + '@csstools/css-tokenizer': 4.0.0 - '@humanfs/types@0.15.0': {} + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 - '@humanwhocodes/module-importer@1.0.1': {} + '@csstools/css-tokenizer@4.0.0': {} - '@humanwhocodes/retry@0.4.3': {} + '@exodus/bytes@1.15.1': {} '@jridgewell/resolve-uri@3.1.2': {} @@ -1395,12 +995,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@mdn/browser-compat-data@8.0.8': {} - '@oxc-project/types@0.148.0': {} - '@pinojs/redact@0.4.0': {} - '@rolldown/binding-android-arm-eabi@1.2.7': optional: true @@ -1461,7 +1057,23 @@ snapshots: '@types/estree@1.0.9': {} - '@types/json-schema@7.0.15': {} + '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/istanbul-lib-report': 1.0.1 + ast-v8-to-istanbul: 1.0.6 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2) + + '@vitest/istanbul-lib-coverage@1.0.1': {} + + '@vitest/istanbul-lib-report@1.0.1': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.1 '@vitest/mocker@5.0.0(vite@8.2.2)': dependencies: @@ -1474,149 +1086,21 @@ snapshots: '@vitest/spy@5.0.0': {} - acorn-jsx@5.3.2(acorn@8.18.0): - dependencies: - acorn: 8.18.0 - - acorn@8.18.0: {} - - addons-linter@10.10.0(supports-color@7.2.0): - dependencies: - '@fluent/syntax': 0.19.0 - '@fregante/relaxed-json': 2.0.0 - '@mdn/browser-compat-data': 8.0.8 - 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(supports-color@7.2.0) - eslint-plugin-no-unsanitized: 4.1.5(eslint@9.39.4(supports-color@7.2.0)) - 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.8 - yargs: 17.7.2 - yauzl: 3.4.0 - transitivePeerDependencies: - - express - - jiti - - safe-compare - - supports-color - - addons-moz-compare@1.3.0: {} + assertion-error@2.0.1: {} - addons-scanner-utils@15.4.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 - - ajv@6.15.0: + ast-v8-to-istanbul@1.0.6: dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 - ajv@8.20.0: + bidi-js@1.1.0: dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.7 - json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - assertion-error@2.0.1: {} - - atomic-sleep@1.0.0: {} - - balanced-match@1.0.2: {} - - boolbase@1.0.0: {} - - brace-expansion@1.1.18: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - buffer-equal-constant-time@1.0.1: {} - - buffer-from@1.1.2: {} - - callsites@3.1.0: {} - chai@6.2.2: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - cheerio-select@2.1.0: - dependencies: - boolbase: 1.0.0 - css-select: 5.2.2 - css-what: 6.2.2 - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - - cheerio@1.2.0: - 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.29.1 - whatwg-mimetype: 4.0.0 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clone@1.0.4: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - columnify@1.6.0: - dependencies: - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - - common-tags@1.8.2: {} - - concat-map@0.0.1: {} + core-util-is@1.0.3: {} cross-spawn@7.0.6: dependencies: @@ -1624,162 +1108,30 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 - css-what@6.2.2: {} - - debug@4.4.3(supports-color@7.2.0): + data-urls@7.0.0: dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 - - deep-is@0.1.4: {} - - deepmerge@4.3.1: {} + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' - defaults@1.0.4: - dependencies: - clone: 1.0.4 + decimal.js@10.6.0: {} detect-libc@2.1.2: {} - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - emoji-regex@8.0.0: {} - - encoding-sniffer@0.2.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-encoding: 3.1.1 - - entities@4.5.0: {} - - entities@6.0.1: {} - - entities@7.0.1: {} + entities@8.1.0: {} es-module-lexer@2.3.2: {} - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-plugin-no-unsanitized@4.1.5(eslint@9.39.4(supports-color@7.2.0)): - dependencies: - eslint: 9.39.4(supports-color@7.2.0) - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4(supports-color@7.2.0): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4(supports-color@7.2.0)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@7.2.0) - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.7(supports-color@7.2.0) - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@7.2.0) - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - 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.4 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) - eslint-visitor-keys: 4.2.1 - - espree@11.2.0: - dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) - eslint-visitor-keys: 5.0.1 - - esprima@4.0.1: {} - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 - esutils@2.0.3: {} - execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -1797,16 +1149,6 @@ snapshots: expect-type@1.4.0: {} - fast-deep-equal@3.1.3: {} - - fast-json-patch@3.1.1: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-uri@3.1.7: {} - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -1815,29 +1157,9 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - first-chunk-stream@3.0.0: {} - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.4 - keyv: 4.5.4 - - flatted@3.4.4: {} - fsevents@2.3.3: optional: true - get-caller-file@2.0.5: {} - get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -1872,106 +1194,70 @@ snapshots: git-cliff-windows-arm64: 2.13.1 git-cliff-windows-x64: 2.13.1 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - has-flag@4.0.0: {} - highlight.js@11.12.0: {} - htmlparser2@10.1.0: + html-encoding-sniffer@6.0.0: dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' human-signals@8.0.1: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - ignore@5.3.2: {} + immediate@3.0.6: {} - image-size@2.0.2: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 + inherits@2.0.4: {} is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@4.0.1: {} is-unicode-supported@2.1.0: {} - is-utf8@0.2.1: {} + isarray@1.0.0: {} isexe@2.0.0: {} - js-yaml@4.3.2: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-merge-patch@1.0.2: - dependencies: - fast-deep-equal: 3.1.3 - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} + js-tokens@10.0.0: {} - jsonwebtoken@9.0.3: + jsdom@30.0.1: 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.1.1 - ms: 2.1.3 - semver: 7.8.5 - - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' - keyv@4.5.4: + jszip@3.10.1: dependencies: - json-buffer: 3.0.1 + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 - levn@0.4.1: + lie@3.3.0: dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 + immediate: 3.0.6 lightningcss-android-arm64@1.33.0: optional: true @@ -2022,151 +1308,71 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - 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.merge@4.6.2: {} - - lodash.once@4.1.1: {} + lru-cache@11.5.2: {} magic-string@1.2.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - mdn-data@2.27.1: {} - - minimatch@3.1.5: + magicast@0.5.4: dependencies: - brace-expansion: 1.1.18 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 - ms@2.1.3: {} + mdn-data@2.27.1: {} nanoid@3.3.18: {} - natural-compare@1.4.0: {} - npm-run-path@6.0.0: dependencies: path-key: 4.0.0 unicorn-magic: 0.3.0 - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - obug@2.1.4: {} - on-exit-leak-free@2.1.2: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 + pako@1.0.11: {} parse-ms@4.0.0: {} - parse5-htmlparser2-tree-adapter@7.1.0: + parse5@8.0.1: dependencies: - domhandler: 5.0.3 - parse5: 7.3.0 - - parse5-parser-stream@7.1.2: - dependencies: - parse5: 7.3.0 - - parse5@7.3.0: - dependencies: - entities: 6.0.1 - - path-exists@4.0.0: {} + entities: 8.1.0 path-key@3.1.1: {} path-key@4.0.0: {} - pend@1.2.0: {} - picocolors@1.1.1: {} picomatch@4.0.7: {} - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.1.0: {} - - pino@10.3.1: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.1.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.1 - thread-stream: 4.2.0 - postcss@8.5.28: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 - prelude-ls@1.2.1: {} - pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 - process-warning@5.1.0: {} + process-nextick-args@2.0.1: {} punycode@2.3.1: {} - quick-format-unescaped@4.0.4: {} - - real-require@0.2.0: {} - - real-require@1.0.0: {} - - require-directory@2.1.1: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 require-from-string@2.0.2: {} - resolve-from@4.0.0: {} - rolldown@1.2.7: dependencies: '@oxc-project/types': 0.148.0 @@ -2188,13 +1394,23 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 - safe-buffer@5.2.1: {} + safe-buffer@5.1.2: {} - safe-stable-stringify@2.5.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 - safer-buffer@2.1.2: {} + selenium-webdriver@4.48.0: + dependencies: + '@bazel/runfiles': 6.5.0 + jszip: 3.10.1 + tmp: 0.2.7 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - semver@7.8.5: {} + setimmediate@1.0.5: {} shebang-command@2.0.0: dependencies: @@ -2206,55 +1422,19 @@ snapshots: signal-exit@4.1.0: {} - sonic-boom@4.2.1: - dependencies: - atomic-sleep: 1.0.0 - source-map-js@1.2.1: {} - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - split2@4.2.0: {} - stackback@0.0.2: {} std-env@4.2.0: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: + string_decoder@1.1.1: dependencies: - ansi-regex: 5.0.1 - - strip-bom-buf@2.0.0: - dependencies: - is-utf8: 0.2.1 - - strip-bom-stream@4.0.0: - dependencies: - first-chunk-stream: 3.0.0 - strip-bom-buf: 2.0.0 + safe-buffer: 5.1.2 strip-final-newline@4.0.0: {} - strip-json-comments@3.1.1: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - thread-stream@4.2.0: - dependencies: - real-require: 1.0.0 + symbol-tree@3.2.4: {} tinybench@6.1.4: {} @@ -2265,22 +1445,30 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 + tinyrainbow@3.1.1: {} - undici@7.29.1: {} + tldts-core@7.4.12: {} - unicorn-magic@0.3.0: {} + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 - upath@3.0.7: {} + tmp@0.2.7: {} - upath@3.0.8: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 - uri-js@4.4.1: + tr46@6.0.0: dependencies: punycode: 2.3.1 + undici@8.10.2: {} + + unicorn-magic@0.3.0: {} + + util-deprecate@1.0.2: {} + vite@8.2.2: dependencies: lightningcss: 1.33.0 @@ -2291,7 +1479,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vitest@5.0.0(vite@8.2.2): + vitest@5.0.0(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.2.2) @@ -2307,18 +1495,35 @@ snapshots: tinyglobby: 0.2.17 vite: 8.2.2 why-is-node-running: 2.3.0 + optionalDependencies: + '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + jsdom: 30.0.1 transitivePeerDependencies: - msw - wcwidth@1.0.1: + w3c-xmlserializer@5.0.0: dependencies: - defaults: 1.0.4 + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} - whatwg-encoding@3.1.1: + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: dependencies: - iconv-lite: 0.6.3 + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' - whatwg-mimetype@4.0.0: {} + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' which@2.0.2: dependencies: @@ -2329,32 +1534,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - word-wrap@1.2.5: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - y18n@5.0.8: {} + ws@8.21.3: {} - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yauzl@3.4.0: - dependencies: - pend: 1.2.0 + xml-name-validator@5.0.0: {} - yocto-queue@0.1.0: {} + xmlchars@2.2.0: {} yoctocolors@2.2.0: {} diff --git a/scripts/lint.sh b/scripts/lint.sh index 1729948..8fd74aa 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -2,31 +2,106 @@ # # Builds the archive and lints it: pnpm run lint # -# The linter is addons-linter, the engine behind `web-ext lint` and the same -# one Mozilla runs on submissions. Thunderbird has no linter of its own, so -# this is as close as an add-on here can get to a machine-checked review. +# The linter is Thunderbird's own webext-linter. It matches every `browser.*` +# call against Thunderbird's annotated API schemas and applies the +# addons.thunderbird.net review policies. # -# It lints the built .xpi rather than the working tree. web-ext's --source-dir -# mode would need its own ignore list, which is scripts/package.sh's exclusion -# list written a second time and drifting from it; running the archive instead -# checks the bytes that actually ship, tests and docs already absent. +# It replaced addons-linter, which is Mozilla's and knows Firefox. To that one +# the `compose` permission and every compose, composeAction, menus and +# scripting call read as an unsupported API, so its warning list was this +# add-on's entire reason for existing and could never be made fatal. This one +# recognises all of it, which is what makes an exit code worth failing a build +# on. +# +# Exit codes are the linter's own: 0 = no error-severity findings, 1 = one or +# more, 2 = the tool itself failed. Info-severity findings are printed and do +# not fail. Read them anyway; there are few and they are all real. +# +# It lints the built .xpi rather than the working tree, unchanged from before: +# a source-folder run would need its own ignore list, which is +# scripts/package.sh's exclusion list written a second time and drifting from +# it, while the archive is the bytes that actually ship. set -euo pipefail cd "$(dirname "$0")/.." +# Pinned to a commit, not a tag, because upstream has none: `git tag` and the +# releases list on the repository are both empty, and the project versions by +# commit message and package.json instead. This is the commit whose +# package.json reads 1.9.0. It is not on npm either - the @thunderbirdops +# scope exists but this package is not in it yet - which is why the tool is +# fetched here at all. The day it publishes, everything below collapses into +# an ordinary devDependency and a version range. +linter_repo="thunderbird/webext-linter" +linter_commit="fb6bc3f387d99d693a9c15dd618b29de3dd2289d" + +# Both are gitignored and excluded from the archive. The caches sit outside the +# tool directory so that bumping the pin above does not throw the fetched +# schemas away with it, and so CI can cache the expensive one without the one +# that is immutable anyway. +linter_dir=".webext-linter" +cache_dir=".webext-linter-cache" + +# The stamp is written last on purpose: an interrupted fetch then refetches +# rather than leaving a half-installed tool that looks present. +stamp="$linter_dir/.pinned-commit" +if [ "$(cat "$stamp" 2>/dev/null || true)" != "$linter_commit" ]; then + echo "Fetching $linter_repo@${linter_commit:0:12}" >&2 + rm -rf "$linter_dir" + mkdir -p "$linter_dir" + + # A codeload tarball rather than a clone: `git clone --depth 1` cannot be + # given a commit, and a full clone to reach one is the entire history for a + # single tree. + curl --fail --silent --show-error --location \ + "https://codeload.github.com/$linter_repo/tar.gz/$linter_commit" | + tar --extract --gzip --strip-components=1 --directory "$linter_dir" + + # pnpm is this repo's package manager and this is not this repo's dependency + # tree. The linter has sixteen runtime dependencies and ships its own + # package-lock.json, so it bootstraps with its own npm inside this ignored + # directory; nothing here reaches pnpm-lock.yaml or node_modules/. --omit=dev + # skips its prettier, which only its own contributors need. + (cd "$linter_dir" && npm ci --omit=dev --no-audit --no-fund >&2) + + echo "$linter_commit" >"$stamp" +fi + xpi="$(bash scripts/package.sh)" echo "Linting $xpi" >&2 -# --self-hosted turns off the checks that only apply to add-ons distributed -# through addons.mozilla.org. Without it the manifest's `update_url` is a hard -# error ("not allowed for Mozilla-hosted add-ons") - but self-serving updates -# is precisely why this add-on is not listed. See "Installing" in README.md. -# -# Warnings are not failures, and cannot be: addons-linter knows Firefox, so -# every MailExtension point this add-on exists to use - the `compose` -# permission, `compose.{get,set}ComposeDetails`, `composeAction.openPopup` - -# reads to it as an unsupported API. Making warnings fatal would mean silencing -# them one by one and losing the ones worth reading. Read the list; it should -# stay short. -exec pnpm exec addons-linter --self-hosted "$xpi" +# --checks-skip carries the two findings this add-on answers for deliberately, +# and nothing else is suppressed. The list is meant to stay this short. +# +# update-url is the direct replacement for addons-linter's --self-hosted. The +# check is right that an add-on serving its own updates cannot be listed on +# ATN, and staying off ATN is precisely why this one serves its own updates. +# See "Installing" in README.md. +# +# unused-files is an upstream bug rather than a finding. The check exempts +# licence and readme files, but it decides whether a file is documentation from +# the last dot in the whole path instead of in the file name, so for +# `vendor/highlight.js/LICENSE` it reads the extension as `.js/license`, misses +# the exemption and reports the vendored BSD-3-Clause notice as dead weight. +# That notice has to ship and the directory is named after the library, so +# there is nothing here to fix. The cost is real: this check is what found +# cliff.toml sitting unreferenced in the archive, so scripts/package.sh's +# exclusion list is once again the only thing keeping the archive clean. +# +# --cdn-lib-lookup false turns off identifying an unrecognised bundled library +# by content-hash lookup against jsDelivr and friends. The only bundled library +# here is the vendored highlight.js, which is hand-modified (see +# vendor/highlight.js/PROVENANCE.md), so a content hash cannot match it by +# construction. Checked both ways: with the lookup on, four third-party hosts +# are asked and nothing is found. Off, the run learns the same thing without +# depending on them being up. +# +# The cache directories are named rather than left to default because the +# default is relative to the working directory, and CI caches a fixed path. +exec node "$linter_dir/verify.js" "$xpi" \ + --cache-schema-dir "$cache_dir/schema" \ + --cache-hash-db-dir "$cache_dir/lib-hash-db" \ + --cache-experiments-dir "$cache_dir/experiments" \ + --cdn-lib-lookup false \ + --checks-skip update-url,unused-files diff --git a/scripts/package.sh b/scripts/package.sh index e337d19..6331ccb 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -39,14 +39,29 @@ exclusions=( 'node_modules/*' 'pnpm-lock.yaml' 'package.json' - # The test suite and its runner config are dev-only. + # The test suite, its runner config and any coverage report it left behind + # are dev-only. The report matters here because it is written into the + # working tree, which is what this script zips. 'tests/*' 'vitest.config.js' - # This script and anything else that builds rather than ships. + 'coverage/*' + # Release tooling. git-cliff renders the release notes at publish time and + # nothing at runtime reads its config; it shipped in the archive until + # Thunderbird's linter noticed it sitting there unreferenced. + 'cliff.toml' + # This script and anything else that builds rather than ships, including the + # linter scripts/lint.sh fetches and the caches it fills. 'scripts/*' + '.webext-linter/*' + '.webext-linter-cache/*' # Its own output, and any archive left at the root by an earlier convention. 'dist/*' '*.xpi' + # The Thunderbird tier's cache: an extracted Thunderbird, a geckodriver and a + # profile per run. Excluded for the obvious reason and one less obvious one - + # this script is what the tier installs, so an unexcluded build would zip the + # 84 MiB Thunderbird it is about to be installed into. + '.thunderbird/*' # Issue tracker, specs and repo documentation. `.git` is matched both as a # directory (main checkout) and as a plain file (git worktrees). '.scratch/*' diff --git a/src/background/background.js b/src/background/background.js index 6964160..d222a15 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -1,3 +1,5 @@ +import { TAKE_PENDING_SELECTION } from "../messaging/take-pending-selection.js"; + /** * The extension's background, and the first one it has had: ticket 01 left it * out deliberately because a `compose_action` with a `default_popup` opens @@ -22,8 +24,6 @@ */ const MENU_ID = "thundercode-insert-code-block"; -const TAKE_PENDING_SELECTION = "thundercode:take-pending-selection"; - /** * Text a right-click parked for the popup that is about to open, keyed by the * compose tab it came from. diff --git a/src/messaging/take-pending-selection.js b/src/messaging/take-pending-selection.js new file mode 100644 index 0000000..9e30c98 --- /dev/null +++ b/src/messaging/take-pending-selection.js @@ -0,0 +1,14 @@ +/** + * The one message this add-on sends: the popup asking the background for the + * text a right-click parked against its compose tab. + * + * A module of its own because both ends need the same string and neither end + * owns it - the background answers the message and the popup asks it, and a + * literal at each end is two things to keep in step. It was two, and a test + * that restated it made three. + * + * Namespaced with the add-on's own prefix because `runtime.onMessage` is a bus: + * every listener in this extension hears every message sent to it, so the type + * has to be recognisable rather than merely descriptive. + */ +export const TAKE_PENDING_SELECTION = "thundercode:take-pending-selection"; diff --git a/src/popup/language-latch.js b/src/popup/language-latch.js new file mode 100644 index 0000000..136a5f5 --- /dev/null +++ b/src/popup/language-latch.js @@ -0,0 +1,120 @@ +/** + * The override rule: what the pipeline should be told about the language. + * + * Two pieces of state and three rules. They were two plain module variables in + * the popup, read and written by four places that each knew one of the rules, + * so the rule as a whole was only ever stated in a comment. Here it is one + * question with one answer, and the question is the only thing anything outside + * ever needed to ask. + * + * A closure rather than a reducer. What the popup does with the answer is two + * DOM writes and a timer rather than data, so an action-and-effect vocabulary + * would be a second thing to keep correct for no gain. This follows the + * precedent of ./snippet-size.js, which is a pure module for the same reason + * and stops at the same edge: the wording of the warning is deliberately not in + * there, and the dropdown is deliberately not in here. + * + * The dropdown's value is passed in rather than read, which is the whole of why + * this file needs no document. What is on screen is the caller's business; this + * only decides whether it is what the pipeline gets told. + * + * @returns {{ + * takeOver: () => void, + * sourceChanged: (change: { wholesale: boolean }) => void, + * requestedLanguage: (shown: string) => string | undefined, + * honourRequest: (shown: string) => string | undefined, + * }} + */ +export function createLanguageLatch() { + /** + * Whether the user has taken the language over. + * + * Once they have, detection stops for the life of this latch: an override is + * an instruction, and a dropdown that re-guesses over the top of a deliberate + * choice is worse than one that never guessed. + * + * One latch per popup is what keeps "for the life of this latch" from meaning + * for ever. The popup document is built fresh every time the button is + * clicked, so a latch built with it resets itself, and there is deliberately + * nothing anywhere that writes the chosen language to storage - remembering it + * across opens is exactly how auto-detection stops working without anyone + * noticing. + */ + let overridden = false; + + /** + * Whether a fresh guess is owed. + * + * Detection and the preview are one pipeline call - a source change costs one + * highlight pass, not two - so this flag is the whole of the difference + * between the two kinds of edit: every change re-renders, and only a + * wholesale one asks for a fresh guess. + * + * It starts `true` so that the popup's load-time render derives the + * dropdown's opening value from the (empty) textarea like every other value + * it takes, rather than leaving it on the first entry of an alphabetical + * list. + */ + let detectionDue = true; + + const requestedLanguage = (shown) => + detectionDue && !overridden ? undefined : shown; + + return { + /** + * The user chose a language. The first rule, and it is permanent: nothing + * here ever puts this back, because there is no gesture that means "go back + * to guessing" - reopening the popup is that gesture. + */ + takeOver() { + overridden = true; + }, + + /** + * The second rule: content that arrived wholesale asks for a fresh guess, + * and editing content that is already there does not. + * + * The trigger is the arrival of new content and not every edit of it, which + * is the honest reading of the story - a snippet being tweaked afterwards + * has already got a language - and is what keeps the dropdown from + * re-guessing under someone's fingers while they fix a typo. Which edits + * count as wholesale is the caller's judgement, because it is the caller + * that can see the event. + */ + sourceChanged({ wholesale }) { + if (wholesale) { + detectionDue = true; + } + }, + + /** + * What the pipeline should be told about the language, given what the + * dropdown shows: nothing at all - which is how any caller asks it to + * detect - while a wholesale change is still waiting to be rendered, and + * the dropdown's value otherwise. + * + * Asked rather than worked out by each caller, because the insert needs the + * same answer as the render. Paste and Ctrl+Enter inside the debounce window + * is a real path - it is close to the fastest way to use the popup - and + * reading the dropdown there would insert the block under whatever language + * was last shown. Both callers ask here and both detect over the same + * source, so they cannot arrive at different answers. + */ + requestedLanguage, + + /** + * The same answer, for the render that is about to act on it, and the + * request is spent by the asking. + * + * The third rule: the render that honours a guess is what clears it, so two + * pastes in quick succession still detect once, and asking without + * honouring - which is what an insert does - leaves the guess the render + * still owes. + */ + honourRequest(shown) { + const language = requestedLanguage(shown); + detectionDue = false; + return language; + }, + }; +} diff --git a/src/popup/main.js b/src/popup/main.js new file mode 100644 index 0000000..58af7cb --- /dev/null +++ b/src/popup/main.js @@ -0,0 +1,14 @@ +import { startPopup } from "./popup.js"; + +/** + * The popup page's entry point, and the whole of the difference between + * importing popup.js and running it. + * + * It is a file of its own so that popup.js can be imported without wiring + * anything up. The wiring has to be startable more than once and against a + * clock somebody else holds - that is what a test of the popup needs, and a + * module that wires itself up on import can offer neither - so the one call + * that does start it lives here, where the document is the page's own and the + * clock is the window's. + */ +startPopup(); diff --git a/src/popup/popup.html b/src/popup/popup.html index ae151b4..152a841 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -77,6 +77,10 @@ opening this popup, never the one inside it. --> - + + diff --git a/src/popup/popup.js b/src/popup/popup.js index e34a7f3..01c664f 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -1,233 +1,23 @@ import hljs from "../../vendor/highlight.js/common.js"; import { buildCodeBlockHtml } from "../code-block/build-code-block-html.js"; import { insertIntoBody } from "../compose/insert-into-body.js"; +import { TAKE_PENDING_SELECTION } from "../messaging/take-pending-selection.js"; import { readSettings } from "../settings/settings.js"; +import { createLanguageLatch } from "./language-latch.js"; import { measureSnippet } from "./snippet-size.js"; import { loadThemeMap } from "./theme-map.js"; -const sourceField = document.getElementById("source"); -const languageField = document.getElementById("language"); -const insertButton = document.getElementById("insert"); -const errorLine = document.getElementById("error"); -const warningLine = document.getElementById("warning"); -const previewPane = document.getElementById("preview"); - -/** - * Tab width and font size, read once as the popup opens. - * - * Kept as the promise rather than awaited into a variable: the read starts - * immediately, so it is long finished by the time anyone has pasted anything, - * and awaiting it inside the insert removes the window where a fast Insert - * would find it not yet loaded. Re-reading per insert would buy freshness - * nobody can use - the popup is closed while the options page is open. - */ -const settings = readSettings(); - -/** - * Started at load, awaited at insert. Reading the theme is asynchronous - the - * stylesheet has to have finished parsing - but it does not depend on anything - * the user does, so kicking it off now means the wait has almost always - * already elapsed by the time Insert is pressed. - * - * Held as the promise rather than resolved into a variable so there is no - * moment where the map is "not ready yet" and something has to decide what to - * do about it. - */ -const themeMap = loadThemeMap(document.getElementById("theme")); - -/** - * The dropdown is the bundle's own language list, read back from it rather - * than written out here. A hand-kept list would drift from what is actually - * registered the first time the vendored bundle is bumped, and the failure - * would be an entry that throws or a language quietly missing from the menu. - * - * Labels come from the same place. `getLanguage(id).name` is the display name - * upstream ships for each language, so "cpp" reads as "C++" without this file - * owning a translation table. - */ -function fillLanguageDropdown() { - const options = hljs - .listLanguages() - .map((id) => ({ id, label: hljs.getLanguage(id).name ?? id })) - .sort((a, b) => a.label.localeCompare(b.label)); - - for (const { id, label } of options) { - languageField.add(new Option(label, id)); - } -} - -fillLanguageDropdown(); - -/** - * Whether the user has taken the language over. - * - * Once they have, detection stops for the rest of this popup: an override is - * an instruction, and a dropdown that re-guesses over the top of a deliberate - * choice is worse than one that never guessed. It is a plain module variable - * on purpose - the popup document is built fresh every time the button is - * clicked, so this resets itself, and there is deliberately nothing anywhere - * that writes the chosen language to `storage`. Remembering it across opens is - * exactly how auto-detection stops working without anyone noticing. - */ -let languageOverridden = false; - -/** - * Whether the language the next render applies should be detected rather than - * taken from the dropdown. - * - * Detection and the preview are one seam call - a source change costs one - * highlight pass, not two - so this flag is the whole of the difference - * between the two kinds of edit: every change re-renders, and only a wholesale - * one asks for a fresh guess. It is set by the change and cleared by the - * render that honours it, so two pastes in quick succession still detect once. - * - * It starts `true` so that the load-time render derives the dropdown's opening - * value from the (empty) textarea like every other value it takes, rather than - * leaving it on the first entry of an alphabetical list. - */ -let detectionDue = true; - -languageField.addEventListener("change", () => { - languageOverridden = true; - // `change` is what a dropdown fires, and re-rendering on it is what makes a - // corrected language confirmable by eye without touching the source again. - schedulePreview(); -}); - -/** - * What the seam should be told about the language: nothing at all - which is - * how any caller asks it to detect - while a wholesale change is still waiting - * to be rendered, and the dropdown's value otherwise. - * - * A function rather than a branch inside the render, because the insert needs - * the same answer. Paste and Ctrl+Enter inside the debounce window is a real - * path - it is close to the fastest way to use this popup - and reading the - * dropdown there would insert the block under whatever language was last - * shown. Both callers detect through the same pure seam over the same source, - * so they cannot arrive at different answers. - */ -function requestedLanguage() { - return detectionDue && !languageOverridden ? undefined : languageField.value; -} - -/** - * The compose window this popup was opened from. - * - * A popup anchored in a compose window resolves `currentWindow` to that - * window, so the active tab is the composer the button was clicked in - which - * is what keeps a snippet out of the wrong email when several composers are - * open. If the resolved tab is not a composer, we refuse rather than guess at - * another one. - */ -async function findComposeTab() { - const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); - if (!tab || tab.type !== "messageCompose") { - throw new Error("No compose window found for this popup."); - } - return tab; -} - -async function insert() { - const tab = await findComposeTab(); - // Which of the two blocks this composer can take is a property of the - // window, not a choice: the compose format of an open window cannot be - // changed, and `setComposeDetails` ignores `isPlainText`. So we ask and - // adapt rather than offering to switch, and the button works either way. - const { isPlainText } = await browser.compose.getComposeDetails(tab.id); - - // Both settings arrive resolved - `readSettings` falls back to the seam's - // defaults for anything unset or unusable - so there is nothing to check - // here, and no branch for "settings never configured". - const { tabWidth, fontSize } = await settings; - - // The theme is passed in as data, always, even for the plain-text composer - // that will not use it. Branching on `isPlainText` here would put a second - // reason to know about the composer's format into the one call that should - // not care: the seam already renders both and the caller picks. - const { html, text } = buildCodeBlockHtml({ - source: sourceField.value, - // Not the dropdown directly: an insert can outrun the debounced render - // that would have filled it in, and this asks for detection in that window - // rather than shipping a block under a language nobody chose. - language: requestedLanguage(), - themeMap: await themeMap, - tabWidth, - fontSize, - }); - - if (!isPlainText) { - // Set before the block goes in, never after. The default `"auto"` sends an - // HTML message as plain text when it sees no formatting, which would drop - // the block entirely; `"both"` also guarantees the plain-text alternative - // part. Doing it first means a failure here costs an insert rather than - // leaving an already-inserted block on a message that will downgrade it. - // - // Which is only safe because a body-less call leaves the document alone, - // and that is worth citing rather than assuming: the spec's blanket "every - // call replaces the whole document, moves the caret to the top and - // destroys the undo history" is true only of a call that carries a body. - // `ext-compose.js` hands the details to `SetComposeDetails` in - // `MsgComposeCommands.js`, where the `innerHTML` assignment, - // `editor.beginningOfDocument()` and `editor.clearUndoRedo()` all sit - // inside `if (typeof newValues.body == "string")`. `deliveryFormat` is - // handled separately, and only sets `compFields.deliveryFormat` and - // refreshes the send-format menu. So passing `deliveryFormat` alone cannot - // touch the caret this insert is about to read - the only marks it leaves - // are `gContentChanged = true`, on a message we are about to change - // anyway, and a `focus()` back onto whatever was focused. - await browser.compose.setComposeDetails(tab.id, { deliveryFormat: "both" }); - } - // A plain-text message is skipped deliberately: `deliveryFormat` describes - // how an HTML message is put on the wire, and there is no HTML part here to - // downgrade. Ticket 02 predicted this call would be rejected on a plain-text - // composer, which the popup would then surface as an error while inserting - // nothing - the button looking broken in exactly the window this ticket is - // about. Not making the call is both the fix and the honest description. - - const [injection] = await browser.scripting.executeScript({ - target: { tabId: tab.id }, - func: insertIntoBody, - args: [{ content: isPlainText ? text : html, isPlainText }], - }); - if (injection.error) { - throw injection.error; - } -} - -/** - * Advisory, and structurally so: this function writes to the warning line and - * to nothing else. It never touches `insertButton.disabled`, and neither does - * the insert path read the warning - emailing three thousand lines of code is a - * mistake worth mentioning and not one worth preventing. Ticket 02 removed the - * last thing that gated Insert on the textarea's contents; this is not quietly - * putting one back, and there is no size at which it starts to. - * - * Recomputed from scratch on every source change, which covers paste, typing, - * cut and undo alike. That is also what clears the warning again when the - * content drops back under the threshold: there is no separate hide path to - * forget to call. - */ -function refreshSizeWarning() { - const { lineCount, isLarge } = measureSnippet(sourceField.value); - warningLine.textContent = isLarge - ? `${lineCount} lines. The block carries all its formatting inline, so ` + - `the inserted HTML will be several times the size of the source. ` + - `This is a heads-up, not a limit.` - : ""; - warningLine.hidden = !isLarge; -} - /** * How long the popup waits for typing to stop before re-rendering, in * milliseconds. * * The seam is not free: highlighting is a scan over the whole snippet, and * detection scores it against all 36 grammars - around 100ms for a 500-line - * paste and half a second for the 3000-line one ticket 11's warning exists - * for. The snippet may be hundreds of lines, and rendering on every keystroke - * would do all of that once per character and throw all but the last result - * away. This is the same debounce the detection rides on, which is the point: - * one source change, one pass. + * paste and half a second for the 3000-line one ticket 11's warning exists for. + * The snippet may be hundreds of lines, and rendering on every keystroke would + * do all of that once per character and throw all but the last result away. + * This is the same debounce the detection rides on, which is the point: one + * source change, one pass. * * A trailing debounce is the simplest thing that fixes it, and the only thing * tried. `requestIdleCallback` would schedule better and would also mean a @@ -240,277 +30,509 @@ function refreshSizeWarning() { */ const PREVIEW_DEBOUNCE_MS = 150; -let previewTimer; - -/** - * Guards against an older render finishing after a newer one. Incremented by - * every call to `renderFromSource` and compared across its awaits. - */ -let previewGeneration = 0; - /** - * Renders the preview, and settles the language, from one call to the seam. + * Wires up a popup document and starts the work that opening it implies: the + * language dropdown, the settings and theme reads, the load-time render, the + * right-click prefill, and the four listeners below. * - * One call rather than two is not an optimisation bolted on afterwards: it is - * what makes the dropdown and the preview incapable of disagreeing. Ticket 04 - * detected on its own pass and ticket 06 rendered on another, and neither - * could see the other; a snippet that detects as `x` cannot now be previewed - * as `y`, because there is one `detectedLanguage` and one `html` and they came - * out of the same call over the same source. + * All of it used to be this module's side effects, which made an import the + * only way in - so it could not be run twice, and it took the clock from + * whatever global happened to be around it. A test of this file needs both, and + * neither was a property worth keeping: the document and the debounce's two + * timer functions are arguments now, each defaulting to the page's own, and the + * order of everything below is otherwise untouched. * - * The popup asks for detection the way any caller does - by naming no - * language - and reads back `detectedLanguage`, which is the language that was - * applied and not the one that was requested. Detection itself lives behind - * the seam, and this file neither knows nor can tell that `hljs.highlightAuto` - * is involved. + * Deliberately one function rather than a handful of exported handlers. The + * class of bug this file has actually had is a wiring bug - three `input` + * listeners registered by work that could not see itself, and a prefill that + * replayed each of them by hand - and handlers exported one at a time would let + * a test assert every one of them while the wiring between them, which is the + * part that broke, stayed unasserted. * - * The rest is the preview: `html` here is not a rendering *like* the - * one that gets inserted, it is the string that will be. The seam is pure, so - * the same source, language, theme map and settings cannot produce two - * different blocks - which is why the preview can be trusted, and why there is - * deliberately no preview stylesheet and no simplified preview markup anywhere - * in this popup. A second rendering path would be a second thing to keep - * correct, and its drift would show up as a preview that was accurate right up - * until the day it mattered. - * - * The obvious tension is that this puts a built HTML string into a live - * document, which is the shape of an injection bug. Three things make it not - * one, and it is worth saying which of them is the real defence: - * - * - The string is not user HTML. It is the seam's output, and the seam escapes - * every `&`, `<` and `>` in the source before it becomes markup - a test - * pins that - so pasted markup arrives as text. This is the guarantee that - * matters, and it is the same one the message body already relies on. - * - It is parsed inertly, by `DOMParser` into a detached document, and only - * the resulting block element is adopted. A parse is not an execution: no script - * runs, no `src` is fetched, no handler attribute is honoured, and that holds - * whatever the string turns out to contain. `innerHTML` on the live document - * would be one line shorter and would also fetch an `` if the seam - * ever emitted one. - * - The popup is an extension page under the default MV3 CSP, so inline script - * could not run here even if something managed to write it in. + * @param {object} [host] + * @param {Document} [host.document] The document to wire up. + * @param {typeof globalThis.setTimeout} [host.setTimeout] The debounce's clock, + * taken as an argument so that a test can hold it still rather than wait out + * a real 150ms per render. + * @param {typeof globalThis.clearTimeout} [host.clearTimeout] */ -async function renderFromSource() { - // Both promises were started at load and are long resolved by the time - // anyone has pasted anything. They are awaited here rather than kept in a - // variable for the same reason the insert awaits them: there is then no - // state where this has to decide what a not-yet-loaded theme means. - const generation = ++previewGeneration; - const { tabWidth, fontSize } = await settings; - const resolvedThemeMap = await themeMap; - // A newer render was scheduled while this one waited. Dropping the stale one - // keeps an older render from being the one left on screen: with a debounce in - // front this is close to unreachable, but "close to" is not a property worth - // relying on for the element whose whole job is to be accurate. - if (generation !== previewGeneration) { - return; +export function startPopup({ + document = globalThis.document, + setTimeout = globalThis.setTimeout, + clearTimeout = globalThis.clearTimeout, +} = {}) { + // The window the popup is closed through, read off the document rather than + // taken from the global for the same reason as everything else here. + const view = document.defaultView; + + const sourceField = document.getElementById("source"); + const languageField = document.getElementById("language"); + const insertButton = document.getElementById("insert"); + const errorLine = document.getElementById("error"); + const warningLine = document.getElementById("warning"); + const previewPane = document.getElementById("preview"); + + /** + * Tab width and font size, read once as the popup opens. + * + * Kept as the promise rather than awaited into a variable: the read starts + * immediately, so it is long finished by the time anyone has pasted anything, + * and awaiting it inside the insert removes the window where a fast Insert + * would find it not yet loaded. Re-reading per insert would buy freshness + * nobody can use - the popup is closed while the options page is open. + */ + const settings = readSettings(); + + /** + * Started at load, awaited at insert. Reading the theme is asynchronous - the + * stylesheet has to have finished parsing - but it does not depend on + * anything the user does, so kicking it off now means the wait has almost + * always already elapsed by the time Insert is pressed. + * + * Held as the promise rather than resolved into a variable so there is no + * moment where the map is "not ready yet" and something has to decide what to + * do about it. + */ + const themeMap = loadThemeMap(document.getElementById("theme")); + + /** + * The dropdown is the bundle's own language list, read back from it rather + * than written out here. A hand-kept list would drift from what is actually + * registered the first time the vendored bundle is bumped, and the failure + * would be an entry that throws or a language quietly missing from the menu. + * + * Labels come from the same place. `getLanguage(id).name` is the display name + * upstream ships for each language, so "cpp" reads as "C++" without this file + * owning a translation table. + */ + function fillLanguageDropdown() { + const options = hljs + .listLanguages() + .map((id) => ({ id, label: hljs.getLanguage(id).name ?? id })) + .sort((a, b) => a.label.localeCompare(b.label)); + + for (const { id, label } of options) { + // Built through the supplied document rather than the `Option` + // constructor, which would always take the page's own. + const option = document.createElement("option"); + option.value = id; + option.textContent = label; + languageField.add(option); + } } - const source = sourceField.value; - // Read and cleared after the staleness check, so a render that turns out to - // be stale cannot swallow a detection the newer one still owes. - const language = requestedLanguage(); - detectionDue = false; - - const { html, detectedLanguage } = buildCodeBlockHtml({ - source, - language, - themeMap: resolvedThemeMap, - tabWidth, - fontSize, + fillLanguageDropdown(); + + /** + * The override rule - whether the language has been taken over, and whether a + * fresh guess is owed - lives in ./language-latch.js, where it can be driven + * without a document. One latch per popup, built here rather than imported as + * state, because the document is built fresh every time the button is clicked + * and the rule resets with it. + * + * What is left in this file is the two halves the latch deliberately does not + * know about: which edits count as wholesale, which needs the event, and what + * the dropdown currently shows, which is passed in on every question. + */ + const latch = createLanguageLatch(); + + languageField.addEventListener("change", () => { + latch.takeOver(); + // `change` is what a dropdown fires, and re-rendering on it is what makes a + // corrected language confirmable by eye without touching the source again. + schedulePreview(); }); - // Assigning `value` is safe for any result: detection can only return a name - // `hljs.listLanguages()` carries, and that is the same list the dropdown was - // filled from. It fires no `change`, so writing it here cannot be mistaken - // for the user taking the language over. - if (language === undefined) { - languageField.value = detectedLanguage; + /** + * The compose window this popup was opened from. + * + * A popup anchored in a compose window resolves `currentWindow` to that + * window, so the active tab is the composer the button was clicked in - which + * is what keeps a snippet out of the wrong email when several composers are + * open. If the resolved tab is not a composer, we refuse rather than guess at + * another one. + */ + async function findComposeTab() { + const [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + if (!tab || tab.type !== "messageCompose") { + throw new Error("No compose window found for this popup."); + } + return tab; } - // An empty textarea shows nothing - not the bordered empty box the seam - // returns for empty source, and not an error either. There is nothing to - // preview before anything has been pasted, and a box appearing the moment - // the popup opens would read as the block already existing. The call above - // still happened, and cost nothing: it is what puts the dropdown on Plain - // text for an empty document. - // - // Literally empty, not whitespace-only. Source that is all spaces *does* - // insert an empty bordered box, and a preview that hid it would be lying - // about the one thing this element exists to tell the truth about. - if (source === "") { - hidePreview(); - return; + async function insert() { + const tab = await findComposeTab(); + // Which of the two blocks this composer can take is a property of the + // window, not a choice: the compose format of an open window cannot be + // changed, and `setComposeDetails` ignores `isPlainText`. So we ask and + // adapt rather than offering to switch, and the button works either way. + const { isPlainText } = await browser.compose.getComposeDetails(tab.id); + + // Both settings arrive resolved - `readSettings` falls back to the seam's + // defaults for anything unset or unusable - so there is nothing to check + // here, and no branch for "settings never configured". + const { tabWidth, fontSize } = await settings; + + // The theme is passed in as data, always, even for the plain-text composer + // that will not use it. Branching on `isPlainText` here would put a second + // reason to know about the composer's format into the one call that should + // not care: the seam already renders both and the caller picks. + const { html, text } = buildCodeBlockHtml({ + source: sourceField.value, + // Not the dropdown directly: an insert can outrun the debounced render + // that would have filled it in, and this asks for detection in that + // window rather than shipping a block under a language nobody chose. + // Asking without honouring leaves the request standing, so the render + // that was already due still detects. + language: latch.requestedLanguage(languageField.value), + themeMap: await themeMap, + tabWidth, + fontSize, + }); + + if (!isPlainText) { + // Set before the block goes in, never after. The default `"auto"` sends + // an HTML message as plain text when it sees no formatting, which would + // drop the block entirely; `"both"` also guarantees the plain-text + // alternative part. Doing it first means a failure here costs an insert + // rather than leaving an already-inserted block on a message that will + // downgrade it. + // + // Which is only safe because a body-less call leaves the document alone, + // and that is worth citing rather than assuming: the spec's blanket + // "every call replaces the whole document, moves the caret to the top and + // destroys the undo history" is true only of a call that carries a body. + // `ext-compose.js` hands the details to `SetComposeDetails` in + // `MsgComposeCommands.js`, where the `innerHTML` assignment, + // `editor.beginningOfDocument()` and `editor.clearUndoRedo()` all sit + // inside `if (typeof newValues.body == "string")`. `deliveryFormat` is + // handled separately, and only sets `compFields.deliveryFormat` and + // refreshes the send-format menu. So passing `deliveryFormat` alone + // cannot touch the caret this insert is about to read - the only marks it + // leaves are `gContentChanged = true`, on a message we are about to + // change anyway, and a `focus()` back onto whatever was focused. + await browser.compose.setComposeDetails(tab.id, { + deliveryFormat: "both", + }); + } + // A plain-text message is skipped deliberately: `deliveryFormat` describes + // how an HTML message is put on the wire, and there is no HTML part here to + // downgrade. Ticket 02 predicted this call would be rejected on a + // plain-text composer, which the popup would then surface as an error while + // inserting nothing - the button looking broken in exactly the window this + // ticket is about. Not making the call is both the fix and the honest + // description. + + const [injection] = await browser.scripting.executeScript({ + target: { tabId: tab.id }, + func: insertIntoBody, + args: [{ content: isPlainText ? text : html, isPlainText }], + }); + if (injection.error) { + throw injection.error; + } } - const parsed = new DOMParser().parseFromString(html, "text/html"); - previewPane.replaceChildren( - document.importNode(parsed.body.firstElementChild, true), - ); - previewPane.hidden = false; -} - -function hidePreview() { - previewPane.replaceChildren(); - previewPane.hidden = true; -} - -function schedulePreview() { - clearTimeout(previewTimer); - previewTimer = setTimeout(renderNow, PREVIEW_DEBOUNCE_MS); -} + /** + * Advisory, and structurally so: this function writes to the warning line and + * to nothing else. It never touches `insertButton.disabled`, and neither does + * the insert path read the warning - emailing three thousand lines of code is + * a mistake worth mentioning and not one worth preventing. Ticket 02 removed + * the last thing that gated Insert on the textarea's contents; this is not + * quietly putting one back, and there is no size at which it starts to. + * + * Recomputed from scratch on every source change, which covers paste, typing, + * cut and undo alike. That is also what clears the warning again when the + * content drops back under the threshold: there is no separate hide path to + * forget to call. + */ + function refreshSizeWarning() { + const { lineCount, isLarge } = measureSnippet(sourceField.value); + warningLine.textContent = isLarge + ? `${lineCount} lines. The block carries all its formatting inline, so ` + + `the inserted HTML will be several times the size of the source. ` + + `This is a heads-up, not a limit.` + : ""; + warningLine.hidden = !isLarge; + } -/** - * Renders without waiting, and cancels any render that was waiting. - * - * Used for the two ways content arrives that are not typing - the popup - * opening, and the right-click prefill - where a debounce would only mean the - * dropdown visibly correcting itself a moment after the popup appeared. - */ -function renderNow() { - clearTimeout(previewTimer); - // A render that fails clears the preview rather than leaving the last good - // one up. Stale is the one failure mode this element must not have: a - // preview showing the previous language beside a dropdown showing the new - // one is worse than no preview at all. The error itself is not surfaced - // here - the insert makes the identical call and reports it properly on the - // error line, and a preview failure is not an insert failure until someone - // presses Insert. - renderFromSource().catch(hidePreview); -} + let previewTimer; + + /** + * Guards against an older render finishing after a newer one. Incremented by + * every call to `renderFromSource` and compared across its awaits. + */ + let previewGeneration = 0; + + /** + * Renders the preview, and settles the language, from one call to the seam. + * + * One call rather than two is not an optimisation bolted on afterwards: it is + * what makes the dropdown and the preview incapable of disagreeing. Ticket 04 + * detected on its own pass and ticket 06 rendered on another, and neither + * could see the other; a snippet that detects as `x` cannot now be previewed + * as `y`, because there is one `detectedLanguage` and one `html` and they + * came out of the same call over the same source. + * + * The popup asks for detection the way any caller does - by naming no + * language - and reads back `detectedLanguage`, which is the language that + * was applied and not the one that was requested. Detection itself lives + * behind the seam, and this file neither knows nor can tell that + * `hljs.highlightAuto` is involved. + * + * The rest is the preview: `html` here is not a rendering *like* the one that + * gets inserted, it is the string that will be. The seam is pure, so the same + * source, language, theme map and settings cannot produce two different + * blocks - which is why the preview can be trusted, and why there is + * deliberately no preview stylesheet and no simplified preview markup + * anywhere in this popup. A second rendering path would be a second thing to + * keep correct, and its drift would show up as a preview that was accurate + * right up until the day it mattered. + * + * The obvious tension is that this puts a built HTML string into a live + * document, which is the shape of an injection bug. Three things make it not + * one, and it is worth saying which of them is the real defence: + * + * - The string is not user HTML. It is the seam's output, and the seam + * escapes every `&`, `<` and `>` in the source before it becomes markup - a + * test pins that - so pasted markup arrives as text. This is the guarantee + * that matters, and it is the same one the message body already relies on. + * - It is parsed inertly, by `DOMParser` into a detached document, and only + * the resulting block element is adopted. A parse is not an execution: no + * script runs, no `src` is fetched, no handler attribute is honoured, and + * that holds whatever the string turns out to contain. `innerHTML` on the + * live document would be one line shorter and would also fetch an + * `` if the seam ever emitted one. + * - The popup is an extension page under the default MV3 CSP, so inline + * script could not run here even if something managed to write it in. + */ + async function renderFromSource() { + // Both promises were started at load and are long resolved by the time + // anyone has pasted anything. They are awaited here rather than kept in a + // variable for the same reason the insert awaits them: there is then no + // state where this has to decide what a not-yet-loaded theme means. + const generation = ++previewGeneration; + const { tabWidth, fontSize } = await settings; + const resolvedThemeMap = await themeMap; + // A newer render was scheduled while this one waited. Dropping the stale + // one keeps an older render from being the one left on screen: with a + // debounce in front this is close to unreachable, but "close to" is not a + // property worth relying on for the element whose whole job is to be + // accurate. + if (generation !== previewGeneration) { + return; + } + + const source = sourceField.value; + // Asked after the staleness check, because asking this way spends the + // request: a render that turns out to be stale must not swallow a detection + // the newer one still owes. + const language = latch.honourRequest(languageField.value); + + const { html, detectedLanguage } = buildCodeBlockHtml({ + source, + language, + themeMap: resolvedThemeMap, + tabWidth, + fontSize, + }); + + // Assigning `value` is safe for any result: detection can only return a + // name `hljs.listLanguages()` carries, and that is the same list the + // dropdown was filled from. It fires no `change`, so writing it here cannot + // be mistaken for the user taking the language over. + if (language === undefined) { + languageField.value = detectedLanguage; + } + + // An empty textarea shows nothing - not the bordered empty box the seam + // returns for empty source, and not an error either. There is nothing to + // preview before anything has been pasted, and a box appearing the moment + // the popup opens would read as the block already existing. The call above + // still happened, and cost nothing: it is what puts the dropdown on Plain + // text for an empty document. + // + // Literally empty, not whitespace-only. Source that is all spaces *does* + // insert an empty bordered box, and a preview that hid it would be lying + // about the one thing this element exists to tell the truth about. + if (source === "") { + hidePreview(); + return; + } + + const parsed = new DOMParser().parseFromString(html, "text/html"); + previewPane.replaceChildren( + document.importNode(parsed.body.firstElementChild, true), + ); + previewPane.hidden = false; + } -/** - * Everything that happens when the source changes, in one place and in one - * order. - * - * There were three `input` listeners here - detection, the size warning, the - * preview - registered by three tickets that could not see each other, and the - * prefill below had to replay each of them by hand. One entry point means the - * prefill announces a change instead of re-enacting one, and means the - * difference between the paths is stated as data rather than as which - * listeners a caller remembered to call. - * - * @param {object} change - * @param {boolean} change.wholesale Whether the content was replaced rather - * than edited, which is the only thing detection keys on. - * @param {boolean} [change.immediate] Render now rather than after the - * debounce. Typing is the debounced case and everything else is not: content - * that arrives all at once has no burst to collapse. - */ -function handleSourceChanged({ wholesale, immediate = false }) { - // Not debounced, and cheap enough not to be: counting lines is a scan, not a - // highlight, and a warning that appeared a fifth of a second after the paste - // would read as a reaction to whatever the user did next. - refreshSizeWarning(); - if (wholesale) detectionDue = true; - - if (immediate) { - renderNow(); - return; + function hidePreview() { + previewPane.replaceChildren(); + previewPane.hidden = true; } - schedulePreview(); -} -sourceField.addEventListener("input", (event) => { - handleSourceChanged({ wholesale: isWholesaleChange(event) }); -}); + function schedulePreview() { + clearTimeout(previewTimer); + previewTimer = setTimeout(renderNow, PREVIEW_DEBOUNCE_MS); + } -/** - * Whether this edit replaced the content wholesale - a paste, a drop, a - * middle-click yank - rather than moving it along by a character. - * - * Detection is not cheap, and it is not wanted per keystroke even if it were: - * the trigger is the arrival of new content and not every edit of it. That is - * also the honest reading of the story - the language is detected when code is - * pasted, and a snippet being tweaked afterwards has already got one - and it - * is what keeps the dropdown from re-guessing under someone's fingers while - * they fix a typo. - * - * An event with no `inputType` at all counts as wholesale. A browser that will - * not say what happened should cost a redundant detection, not a dropdown that - * silently never updates again. - */ -function isWholesaleChange(event) { - return !event.inputType || event.inputType.startsWith("insertFrom"); -} + /** + * Renders without waiting, and cancels any render that was waiting. + * + * Used for the two ways content arrives that are not typing - the popup + * opening, and the right-click prefill - where a debounce would only mean the + * dropdown visibly correcting itself a moment after the popup appeared. + */ + function renderNow() { + clearTimeout(previewTimer); + // A render that fails clears the preview rather than leaving the last good + // one up. Stale is the one failure mode this element must not have: a + // preview showing the previous language beside a dropdown showing the new + // one is worse than no preview at all. The error itself is not surfaced + // here - the insert makes the identical call and reports it properly on the + // error line, and a preview failure is not an insert failure until someone + // presses Insert. + renderFromSource().catch(hidePreview); + } -// Once at load, so the warning line and the dropdown start in a state this -// file owns rather than one the markup guessed at. It comes out at no warning -// and Plain text, which is what an empty document should say. -handleSourceChanged({ wholesale: true, immediate: true }); + /** + * Everything that happens when the source changes, in one place and in one + * order. + * + * There were three `input` listeners here - detection, the size warning, the + * preview - registered by three tickets that could not see each other, and + * the prefill below had to replay each of them by hand. One entry point means + * the prefill announces a change instead of re-enacting one, and means the + * difference between the paths is stated as data rather than as which + * listeners a caller remembered to call. + * + * @param {object} change + * @param {boolean} change.wholesale Whether the content was replaced rather + * than edited, which is the only thing detection keys on. + * @param {boolean} [change.immediate] Render now rather than after the + * debounce. Typing is the debounced case and everything else is not: + * content that arrives all at once has no burst to collapse. + */ + function handleSourceChanged({ wholesale, immediate = false }) { + // Not debounced, and cheap enough not to be: counting lines is a scan, not + // a highlight, and a warning that appeared a fifth of a second after the + // paste would read as a reaction to whatever the user did next. + refreshSizeWarning(); + latch.sourceChanged({ wholesale }); + + if (immediate) { + renderNow(); + return; + } + schedulePreview(); + } -/** - * Right-click path: the menu handler parks the selected text against the - * compose tab and opens this popup, which claims it here. The background drops - * the text as it hands it over, so a toolbar or shortcut open - which parks - * nothing - gets an empty string and the popup opens empty. - * - * The prefill is the convenient path, not the reliable one. `selectionText` is - * plain text extracted from HTML, so whatever indentation it arrives with is - * whatever survived that extraction. Pasting over it is still the path that - * gives the block its indentation back. - */ -async function claimSelectionPrefill() { - const tab = await findComposeTab(); - const selectionText = await browser.runtime.sendMessage({ - type: "thundercode:take-pending-selection", - tabId: tab.id, + sourceField.addEventListener("input", (event) => { + handleSourceChanged({ wholesale: isWholesaleChange(event) }); }); - if (typeof selectionText !== "string" || selectionText === "") { - return; + + /** + * Whether this edit replaced the content wholesale - a paste, a drop, a + * middle-click yank - rather than moving it along by a character. + * + * Detection is not cheap, and it is not wanted per keystroke even if it were: + * the trigger is the arrival of new content and not every edit of it. That is + * also the honest reading of the story - the language is detected when code + * is pasted, and a snippet being tweaked afterwards has already got one - and + * it is what keeps the dropdown from re-guessing under someone's fingers + * while they fix a typo. + * + * An event with no `inputType` at all counts as wholesale. A browser that + * will not say what happened should cost a redundant detection, not a + * dropdown that silently never updates again. + */ + function isWholesaleChange(event) { + return !event.inputType || event.inputType.startsWith("insertFrom"); } - sourceField.value = selectionText; - // Assigning `value` from script fires no `input` event, so the change has to - // be announced by hand - once, to the one thing that watches the textarea. - // Content that arrived from outside is as wholesale as a paste, and it is - // already here rather than being typed, so there is no burst to wait out. - handleSourceChanged({ wholesale: true, immediate: true }); -} -// Deliberately silent on failure. A prefill that does not arrive leaves an -// empty textarea, which is exactly what the toolbar button opens anyway; an -// error line here would report a broken convenience as a broken popup. -claimSelectionPrefill().catch(() => {}); + // Once at load, so the warning line and the dropdown start in a state this + // file owns rather than one the markup guessed at. It comes out at no warning + // and Plain text, which is what an empty document should say. + handleSourceChanged({ wholesale: true, immediate: true }); -/** - * The one path from "confirm" to a closed popup, shared by the button and the - * keyboard. Both entry points have to behave identically, including the error - * branch - a shortcut that silently does nothing is worse than one that does - * not exist. - */ -async function confirmInsert() { - if (insertButton.disabled) { - return; // An insert is already in flight; a second Ctrl+Enter is a no-op. - } - insertButton.disabled = true; - errorLine.hidden = true; - try { - await insert(); - window.close(); - } catch (error) { - errorLine.textContent = String(error?.message ?? error); - errorLine.hidden = false; - insertButton.disabled = false; + /** + * Right-click path: the menu handler parks the selected text against the + * compose tab and opens this popup, which claims it here. The background + * drops the text as it hands it over, so a toolbar or shortcut open - which + * parks nothing - gets an empty string and the popup opens empty. + * + * The prefill is the convenient path, not the reliable one. `selectionText` + * is plain text extracted from HTML, so whatever indentation it arrives with + * is whatever survived that extraction. Pasting over it is still the path + * that gives the block its indentation back. + */ + async function claimSelectionPrefill() { + const tab = await findComposeTab(); + const selectionText = await browser.runtime.sendMessage({ + type: TAKE_PENDING_SELECTION, + tabId: tab.id, + }); + if (typeof selectionText !== "string" || selectionText === "") { + return; + } + sourceField.value = selectionText; + // Assigning `value` from script fires no `input` event, so the change has + // to be announced by hand - once, to the one thing that watches the + // textarea. Content that arrived from outside is as wholesale as a paste, + // and it is already here rather than being typed, so there is no burst to + // wait out. + handleSourceChanged({ wholesale: true, immediate: true }); } -} -insertButton.addEventListener("click", confirmInsert); - -// Ctrl+Enter confirms, so paste-and-insert never needs the mouse. Bound on the -// document rather than the textarea so it also works once focus has moved to -// the button. -// -// `preventDefault` is load-bearing, not tidiness: the compose window binds -// Ctrl+Enter to Send, and a chrome `` still fires for a key press that -// started inside an extension popup unless the popup consumes the event. Miss -// this and the shortcut sends the message. -// -// `metaKey` is accepted alongside `ctrlKey` because on macOS the same gesture -// is Cmd+Enter - the manifest's `Ctrl` is likewise read as Command there. -document.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || !(event.ctrlKey || event.metaKey)) { - return; + // Deliberately silent on failure. A prefill that does not arrive leaves an + // empty textarea, which is exactly what the toolbar button opens anyway; an + // error line here would report a broken convenience as a broken popup. + claimSelectionPrefill().catch(() => {}); + + /** + * The one path from "confirm" to a closed popup, shared by the button and the + * keyboard. Both entry points have to behave identically, including the error + * branch - a shortcut that silently does nothing is worse than one that does + * not exist. + */ + async function confirmInsert() { + if (insertButton.disabled) { + return; // An insert is already in flight; a second Ctrl+Enter is a no-op. + } + insertButton.disabled = true; + errorLine.hidden = true; + try { + await insert(); + view.close(); + } catch (error) { + errorLine.textContent = String(error?.message ?? error); + errorLine.hidden = false; + insertButton.disabled = false; + } } - event.preventDefault(); - void confirmInsert(); -}); + + insertButton.addEventListener("click", confirmInsert); + + // Ctrl+Enter confirms, so paste-and-insert never needs the mouse. Bound on + // the document rather than the textarea so it also works once focus has moved + // to the button. + // + // `preventDefault` is load-bearing, not tidiness: the compose window binds + // Ctrl+Enter to Send, and a chrome `` still fires for a key press that + // started inside an extension popup unless the popup consumes the event. Miss + // this and the shortcut sends the message. + // + // `metaKey` is accepted alongside `ctrlKey` because on macOS the same gesture + // is Cmd+Enter - the manifest's `Ctrl` is likewise read as Command there. + document.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !(event.ctrlKey || event.metaKey)) { + return; + } + event.preventDefault(); + void confirmInsert(); + }); +} diff --git a/src/popup/snippet-size.js b/src/popup/snippet-size.js index 025daf5..9b5661f 100644 --- a/src/popup/snippet-size.js +++ b/src/popup/snippet-size.js @@ -18,11 +18,11 @@ export const LARGE_SNIPPET_LINES = 500; * How many lines the pasted source has, and whether that is enough to warn * about. * - * Pure and DOM-free so the threshold decision can be driven from a Node test - - * the popup around it cannot be, since the runner has no DOM. The wording of - * the warning is deliberately *not* here: pinning a sentence in a test makes - * rephrasing it a test failure, and the sentence is the part of this most - * likely to be reworded. + * Pure and DOM-free so the threshold decision can be driven without a document + * at all, one line count at a time, rather than through the popup that shows + * it. The wording of the warning is deliberately *not* here: pinning a sentence + * in a test makes rephrasing it a test failure, and the sentence is the part of + * this most likely to be reworded. * * @param {string} source * @returns {{ lineCount: number, isLarge: boolean }} diff --git a/src/popup/theme-map.js b/src/popup/theme-map.js index ec4e931..ef0b7b3 100644 --- a/src/popup/theme-map.js +++ b/src/popup/theme-map.js @@ -10,11 +10,20 @@ import { CONTAINER_CLASS } from "../code-block/build-code-block-html.js"; * the browser's own CSS parser: this module only walks the already-parsed * CSSOM. No regex over the file, no colour table. * - * This is popup-side code and is deliberately not unit tested. It needs a - * browser to do anything at all, the test runner has no DOM by design, and its - * failure mode is visible the instant a block comes out monochrome. That - * division is the point of the seam: the pipeline is pure and tested, and the - * one thing that cannot be is this file. + * Deliberately not unit tested, and for a narrower reason than this comment + * used to give. There is a simulated document in the suite now, so "the runner + * has no DOM" no longer says anything about this file. What is still true is + * that the claim the module is built around - that the CSS parser expands a + * shorthand into its longhands while parsing into the object model, so the + * accessors below see a value the theme's author never wrote out - is exactly + * where a simulated object model is least faithful. Reading one property back + * would carry across; the expansion would not, and a test that passed on it + * would be pinning the fake's behaviour and calling it the platform's. + * + * The gap is affordable rather than merely admitted. The pipeline that consumes + * this map is pure and pinned, a stylesheet that yields nothing costs the + * colour and nothing else, and the failure mode is visible the instant a block + * comes out monochrome. */ /** diff --git a/tests/dom/insert-into-body.test.js b/tests/dom/insert-into-body.test.js new file mode 100644 index 0000000..2574a02 --- /dev/null +++ b/tests/dom/insert-into-body.test.js @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildCodeBlockHtml } from "../../src/code-block/build-code-block-html.js"; +import { insertIntoBody } from "../../src/compose/insert-into-body.js"; + +/** + * The insertion function's two DOM paths, driven against a simulated compose + * body. + * + * The function is handed to `scripting.executeScript({ func })` and re-evaluated + * inside the compose editor's sandbox, so it is self-contained by requirement + * and takes only its argument object. That is what makes it callable from here + * with no seam to build: a document and a selection are the whole of its + * environment. What it reports back - which of its three paths ran - is the + * interface these tests assert through, and until this file existed nothing + * read it. + * + * **The gap here is deliberate.** Its preferred path is an editor command, and + * no simulated DOM implements one: `tests/dom/tier.test.js` pins that + * `document.execCommand` is undefined in this tier, and the ADR records that + * neither jsdom nor its alternative has it. So every test below says out loud + * what the command answered, and the two fallbacks are covered with it + * reporting failure. The claim the preferred path is chosen for - that a real + * editor action joins the undo stack and marks the message modified - is a + * claim about Gecko's editor and cannot be made here at all. It belongs to the + * real-Thunderbird tier, and the one thing this tier can say about that path is + * the one asserted below: when the command answers yes, neither fallback + * touches the document. + * + * Nothing in here writes out markup. The content is what the popup would hand + * over - `buildCodeBlockHtml`'s `html` for an HTML composer and its `text` for + * a plain-text one - so retuning the block cannot fail these tests, and the + * document is read back as visible text rather than as a shape. + */ + +/** + * The source a user pasted. Indented, and carrying angle brackets that look + * like markup, because both are what the plain-text assertions are about: the + * indentation is what this whole feature exists to protect, and a `` that + * arrives as an element rather than as two characters is the bug a plain-text + * composer is a different editor in order to avoid. + */ +const snippet = 'function shout(word) {\n\treturn "" + word + "";\n}\n'; + +/** + * A real block from the pipeline rather than a hand-written string, because + * this is exactly what the popup passes: `html` for an HTML composer, and + * `text` - the normalised source, tabs expanded - for a plain-text one. The + * language is named so nothing here depends on what detection makes of the + * fixture. + */ +const block = buildCodeBlockHtml({ source: snippet, language: "plaintext" }); + +/** + * The editor command this tier does not have, taught to the document for the + * length of one test, answering what the test tells it to and recording what + * it was asked for. + * + * Stated per test rather than left to jsdom's absence. An absent command + * throws and the function falls through to the DOM path, so the fallbacks + * would be reached either way - but then the test would be silent about which + * of "the command said no" and "there was no command" it was covering, and the + * day a simulated DOM grows an `execCommand` that answers `true` the whole + * file would go green while asserting nothing. + */ +const editorCommand = (answer) => { + const asked = []; + document.execCommand = (command, showUi, value) => { + asked.push({ command, showUi, value }); + return answer; + }; + return asked; +}; + +/** + * Drops a caret into a text node, the way clicking into a message does. + */ +const caretAt = (node, offset) => { + const range = document.createRange(); + range.setStart(node, offset); + range.collapse(true); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +}; + +/** + * What the user types next, put in wherever the caret now is. + * + * This is how "the caret is left after the block" is asserted: the property + * that matters is that carrying on typing continues after the block rather + * than inside or before it, and reading it back this way says that without + * pinning which node and offset the implementation chose to express it as. + */ +const typeAtCaret = (text) => { + document + .getSelection() + .getRangeAt(0) + .insertNode(document.createTextNode(text)); +}; + +/** + * Whether these read in this order in the body's visible text. + * + * The body is read as text and not as markup on purpose: the block wraps + * itself and the composer's own paragraphs are the composer's business, so + * asserting on either would fail the day something gains an attribute without + * changing what anyone reads. Every assertion below passes the text itself as + * the label, so a failure reports what the body actually said. + */ +const readsInOrder = (...parts) => { + const text = document.body.textContent; + let from = 0; + return parts.every((part) => { + const at = text.indexOf(part, from); + from = at + part.length; + return at !== -1; + }); +}; + +/** Elements in the body, for asserting that content did or did not parse. */ +const elementCount = () => document.body.querySelectorAll("*").length; + +describe("insertIntoBody", () => { + beforeEach(() => { + document.body.innerHTML = ""; + document.getSelection().removeAllRanges(); + }); + + afterEach(() => { + // Put back the way this tier found it - absent - so that no test can pass + // because an earlier one taught the document a command of its own. + delete document.execCommand; + }); + + describe("with the editor command reporting failure", () => { + /** + * The caret is where the user put it, so the block goes in there and not + * at either end, and what was on both sides of it stays on both sides of + * it. Splicing into the middle of a paragraph is the case that would + * quietly lose the tail of it. + */ + it("splices the block in at the caret and leaves the caret after it", () => { + document.body.innerHTML = "

Here it is: and that is all.

"; + const paragraph = document.querySelector("p").firstChild; + caretAt(paragraph, "Here it is: ".length); + editorCommand(false); + + expect( + insertIntoBody({ content: block.html, isPlainText: false }), + ).toEqual({ mechanism: "range" }); + + typeAtCaret("Thanks!"); + expect( + readsInOrder("Here it is: ", block.text, "Thanks!", "and that is all."), + document.body.textContent, + ).toBe(true); + }); + + /** + * The commonest way to reach this: a composer whose body has never been + * clicked into has no selection at all. Appending rather than failing is + * what keeps the button from appearing to do nothing, and the reported + * mechanism is how the popup - which closes on insert and takes its + * console with it - could ever tell the two apart. + */ + it("appends when the body has no caret in it, and reports that it did", () => { + document.body.innerHTML = "

Morning,

"; + editorCommand(false); + + expect( + insertIntoBody({ content: block.html, isPlainText: false }), + ).toEqual({ mechanism: "append" }); + + expect( + readsInOrder("Morning,", block.text), + document.body.textContent, + ).toBe(true); + }); + + /** + * A caret that is somewhere other than the message body is not a caret + * this function may insert at, so it appends as if there were none. The + * failure this rules out is a block spliced into whatever else on the page + * happened to hold the selection. + */ + it("appends when the caret is outside the message body", () => { + document.body.innerHTML = "

Morning,

"; + const elsewhere = document.createElement("title"); + elsewhere.textContent = "not the message"; + document.head.append(elsewhere); + caretAt(elsewhere.firstChild, 0); + editorCommand(false); + + const inserted = insertIntoBody({ + content: block.html, + isPlainText: false, + }); + elsewhere.remove(); + + expect(inserted).toEqual({ mechanism: "append" }); + expect( + readsInOrder("Morning,", block.text), + document.body.textContent, + ).toBe(true); + }); + + /** + * A plain-text composer is a different editor rather than the same one + * with the styling switched off, so what goes in is the source itself: + * text, with the angle brackets in it staying two characters rather than + * becoming an element, and with the indentation the block exists to + * preserve arriving as it left. + * + * The same claim is made against a real plain-text composer by the + * real-Thunderbird tier, where the editor doing the accepting is Gecko's + * plaintext editor. What is asserted here is the fallback path: the source + * reaching the body as a text node and nothing being parsed out of it. + */ + it("puts a plain-text composer's source in as text, indentation and all", () => { + document.body.innerHTML = "
Morning,\n
"; + const body = document.querySelector("pre").firstChild; + caretAt(body, body.length); + editorCommand(false); + + const before = elementCount(); + expect( + insertIntoBody({ content: block.text, isPlainText: true }), + ).toEqual({ mechanism: "range" }); + + // The fixture has to be indented for this test to mean anything, so it + // says so rather than trusting itself. + expect(block.text).toMatch(/\n +return/); + expect(document.body.textContent).toContain(block.text); + expect(document.body.textContent).toContain('""'); + expect(elementCount()).toBe(before); + }); + + /** + * The two properties are independent - which path ran, and what that path + * puts in the document - so the plain-text case is covered on both. A + * composer never clicked into is where the append path is commonest, and + * it is the one where markup arriving instead of text would be least + * likely to be noticed before the message went out. + */ + it("appends a plain-text composer's source as text as well", () => { + document.body.innerHTML = "
Morning,\n
"; + editorCommand(false); + + const before = elementCount(); + expect( + insertIntoBody({ content: block.text, isPlainText: true }), + ).toEqual({ mechanism: "append" }); + + expect(document.body.textContent).toContain(block.text); + expect(elementCount()).toBe(before); + }); + + /** + * Content that parses to nothing is inserted, and then there is nothing to + * put the caret after: a fragment that turned out empty was never inserted + * and so has no parent, and repositioning relative to it would throw. The + * user-visible cost of that throw would be an insert reported as failed + * for a document that is exactly as they left it. + */ + it("does not throw when the content turns out to produce an empty fragment", () => { + document.body.innerHTML = "

Morning,

"; + caretAt(document.querySelector("p").firstChild, "Morning".length); + editorCommand(false); + + let inserted; + expect(() => { + inserted = insertIntoBody({ content: "", isPlainText: false }); + }).not.toThrow(); + + expect(inserted).toEqual({ mechanism: "range" }); + expect(document.body.textContent).toBe("Morning,"); + + // And the caret is still somewhere usable rather than dropped, which is + // the other half of "the document is as the user left it". + typeAtCaret("!"); + expect(document.body.textContent).toBe("Morning!,"); + }); + }); + + describe("when the editor command succeeds", () => { + /** + * The one thing this tier can say about the path it cannot run: that a + * command which answers yes is the end of it. Neither fallback may touch + * the document afterwards, or an insert that the editor already made would + * be made a second time by hand - two blocks, and the second one outside + * the undo step the first one created. + * + * The command here inserts nothing, so a document that is unchanged is the + * whole assertion. What it actually does to a real body, and whether that + * lands in the undo stack, is the real-Thunderbird tier's to say. + */ + it("reports the editor command and leaves the document to it", () => { + document.body.innerHTML = "

Here it is: and that is all.

"; + caretAt(document.querySelector("p").firstChild, "Here it is: ".length); + const asked = editorCommand(true); + const untouched = document.body.innerHTML; + const caret = document.getSelection().getRangeAt(0); + + expect( + insertIntoBody({ content: block.html, isPlainText: false }), + ).toEqual({ mechanism: "execCommand" }); + + expect(document.body.innerHTML).toBe(untouched); + expect(asked).toEqual([ + { command: "insertHTML", showUi: false, value: block.html }, + ]); + + // The caret is the editor's to move on this path, so the function leaves + // it exactly where it found it rather than repositioning a block it did + // not place. + const after = document.getSelection().getRangeAt(0); + expect(after.startContainer).toBe(caret.startContainer); + expect(after.startOffset).toBe(caret.startOffset); + }); + + /** + * Which command is asked for is the whole of the difference between the + * two composers on this path: the plaintext editor rejects an HTML insert, + * and its own insert is what maps the source's newlines onto whatever that + * editor represents a line break with instead of this function guessing. + */ + it("asks the plain-text editor for its own insert, not an HTML one", () => { + document.body.innerHTML = "
Morning,\n
"; + const body = document.querySelector("pre").firstChild; + caretAt(body, body.length); + const asked = editorCommand(true); + + expect( + insertIntoBody({ content: block.text, isPlainText: true }), + ).toEqual({ mechanism: "execCommand" }); + + expect(asked).toEqual([ + { command: "insertText", showUi: false, value: block.text }, + ]); + }); + }); +}); diff --git a/tests/dom/options.test.js b/tests/dom/options.test.js new file mode 100644 index 0000000..bce9a7a --- /dev/null +++ b/tests/dom/options.test.js @@ -0,0 +1,182 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SETTING_FIELDS } from "../../src/settings/settings.js"; +import { installBrowserFake } from "../helpers/browser-fake.js"; + +/** + * The options page's correction notice. + * + * The page has no exported entry point and is not getting one: four + * assertions do not justify a second initialiser, and what it does at module + * scope is what it does when Thunderbird opens it. So the shipped HTML is + * loaded into this tier's document and the module is imported on top of it, + * which is as close to how it runs as anything short of a real Thunderbird. + * Loading the real file rather than a fixture is also what makes a renamed + * field fail here instead of in someone's settings page. + * + * What is worth pinning is only the correction notice. A value the block + * cannot use is not saved as typed, and the page says so out loud rather than + * letting the field change quietly under a "Saved." - a correction nobody is + * told about is one they find out about in an email they have already sent. + * The rest of the page is attribute plumbing, which the hand-run checklist + * covers better than a fake will. + * + * The numbers come from `SETTING_FIELDS`, and the wording is read back off the + * page itself rather than written out here: the bounds and the two sentences + * both belong to modules that own them, and retuning either should stay a + * one-line change rather than turning this file red. + */ + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const optionsPage = readFileSync( + resolve(repoRoot, "src/options/options.html"), + "utf8", +); + +let fake; + +/** + * Puts the shipped page in the document and runs its script over it, with + * `stored` as what `storage.local` already holds. + * + * The module does its work at module scope and ends in a top-level `await` on + * the settings read, so the import has to happen with the fake already + * installed, and the registry has to be reset per test or the second test gets + * the first test's page. The `