diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1cc3ad --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +insert_final_newline = false +trim_trailing_whitespace = false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ca094bc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +Issue URL: + + +## What is the current behavior? + + + +## What is the new behavior? + + + +## Does this introduce a breaking change? + +- [ ] Yes +- [ ] No + + +## Other information diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..dcbeadc --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,75 @@ +name: CI + +on: [pull_request] + +# A new push supersedes the run before it. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 24 + +jobs: + checks: + name: Checks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: βš™οΈ Use Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - name: πŸ•ΈοΈ Install Dependencies + run: npm ci + - name: πŸ–ŒοΈ Lint + run: npm run lint + - name: 🧩 Typecheck + run: npm run typecheck + - name: πŸ§ͺ Spec + run: npm run test.spec + # Lint formats files in place, so a diff means the branch was pushed unformatted. + - name: πŸ” Check Diff + run: git diff --exit-code + shell: bash + + e2e: + name: E2E + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: βš™οΈ Use Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - name: πŸ•ΈοΈ Install Dependencies + run: npm ci + - name: πŸ“¦ Install Playwright Browsers + run: npx playwright install chromium + - name: πŸ§ͺ E2E + run: npm run test.e2e + # The HTML report is the useful part of a failure, so it is kept for download. + - name: πŸ“¦ Archive Playwright Report + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + verify: + # The one required check, so the jobs above can change without touching the + # protection rules. Renaming this blocks merges until those rules match. + name: Verify + if: ${{ always() }} + needs: [checks, e2e] + runs-on: ubuntu-latest + + steps: + - name: Check job status + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: exit 1 diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml new file mode 100644 index 0000000..4442a20 --- /dev/null +++ b/.github/workflows/conventional-commit.yml @@ -0,0 +1,36 @@ +name: PR Conventional Commit Validation + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + validate-pr-title: + runs-on: ubuntu-latest + steps: + - name: Validate PR title + if: | + !contains(github.event.pull_request.title, 'release') && + !contains(github.event.pull_request.title, 'chore') + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + requireScope: true + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + revert + release + chore + # The subject must not start with an uppercase character. + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" didn't match the configured pattern. Please ensure that the subject doesn't start with an uppercase character. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbd64df --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..5ffa098 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,7 @@ +import ionicConfig from '@ionic/prettier-config'; + +export default { + ...ionicConfig, + /* No trailing commas in function arguments. */ + trailingComma: 'es5', +}; diff --git a/.stylelintrc.yml b/.stylelintrc.yml new file mode 100644 index 0000000..eae4388 --- /dev/null +++ b/.stylelintrc.yml @@ -0,0 +1,194 @@ +# https://stylelint.io/ + +plugins: +- stylelint-order +rules: + at-rule-empty-line-before: + - always + - except: + - blockless-after-blockless + - first-nested + ignore: + - after-comment + block-no-empty: + - true + custom-property-empty-line-before: + - always + - except: + - after-comment + - after-custom-property + - first-nested + declaration-no-important: + - true + order/order: + - custom-properties + - dollar-variables + - at-rules + - declarations + - rules + order/properties-order: + - emptyLineBefore: always + properties: + - display + - position + - top + - right + - bottom + - left + - emptyLineBefore: always + properties: + - flex + - flex-basis + - flex-direction + - flex-flow + - flex-grow + - flex-shrink + - flex-wrap + - align-content + - align-items + - align-self + - justify-content + - order + - emptyLineBefore: always + properties: + - width + - min-width + - max-width + - height + - min-height + - max-height + - emptyLineBefore: always + properties: + - margin + - margin-top + - margin-right + - margin-bottom + - margin-left + - emptyLineBefore: always + properties: + - padding + - padding-top + - padding-right + - padding-bottom + - padding-left + - emptyLineBefore: always + properties: + - float + - clear + - emptyLineBefore: always + properties: + - columns + - column-gap + - column-fill + - column-rule + - column-span + - column-count + - column-width + - emptyLineBefore: always + properties: + - transform + - transform-box + - transform-origin + - transform-style + - emptyLineBefore: always + properties: + - transition + - transition-delay + - transition-duration + - transition-property + - transition-timing-function + - emptyLineBefore: always + properties: + - border + - border-top + - border-right + - border-bottom + - border-left + - border-width + - border-top-width + - border-right-width + - border-bottom-width + - border-left-width + - border-style + - border-top-style + - border-right-style + - border-bottom-style + - border-left-style + - border-radius + - border-top-left-radius + - border-top-right-radius + - border-bottom-left-radius + - border-bottom-right-radius + - border-color + - border-top-color + - border-right-color + - border-bottom-color + - border-left-color + - emptyLineBefore: always + properties: + - outline + - outline-color + - outline-offset + - outline-style + - outline-width + - emptyLineBefore: always + properties: + - background + - background-attachment + - background-clip + - background-color + - background-image + - background-repeat + - background-position + - background-size + - color + - emptyLineBefore: always + properties: + - font + - font-family + - font-size + - font-smoothing + - font-style + - font-variant + - font-weight + - emptyLineBefore: always + properties: + - letter-spacing + - line-height + - list-style + - emptyLineBefore: always + properties: + - text-align + - text-decoration + - text-indent + - text-overflow + - text-rendering + - text-shadow + - text-transform + - text-wrap + - emptyLineBefore: always + properties: + - white-space + - word-spacing + - emptyLineBefore: always + properties: + - border-collapse + - border-spacing + - box-shadow + - caption-side + - contain + - content + - cursor + - direction + - empty-cells + - object-fit + - opacity + - overflow + - quotes + - speak + - table-layout + - touch-action + - user-select + - vertical-align + - visibility + - z-index diff --git a/README.md b/README.md index a103b0a..a7eb176 100644 --- a/README.md +++ b/README.md @@ -1 +1,71 @@ # ionic-visual-review + +Review Playwright snapshot changes for Ionic Framework pull requests. GitHub's Files tab won't render hundreds of binary files, so this serves every before and after pair straight from git, with four comparison views and viewed state that syncs to the pull request. + +## Setup + +Requires Node 24 or later and a local clone of [ionic-framework](https://github.com/ionic-team/ionic-framework). + +The [GitHub CLI](https://cli.github.com), signed in with `gh auth login`, is optional. It adds teammates' comments, posting comments and syncing viewed marks to the pull request. Without it, pull requests still open read-only and viewed marks stay local. + +```sh +npm install +``` + +## Usage + +The reviewer looks for ionic-framework next to this repository first. If your clone is somewhere else, pass `--repo`, or start without it and type the path when asked. + +```sh +npm start -- --pr 31321 +npm start -- --base origin/main --head my-branch +npm start -- --repo ~/code/ionic-framework --pr 31321 +``` + +| Option | | +| ------------------------------ | ------------------------------------------------------------------------- | +| `--repo ` | The ionic-framework checkout to review. Defaults to `../ionic-framework`. | +| `--pr ` | Review a pull request. | +| `--base `, `--head ` | Review a range instead of a pull request. | +| `--path ` | Narrow the set with a git pathspec. | +| `--port ` | Defaults to 4300. | +| `--no-open` | Don't open a browser. | + +Press ? in the app for what each marker means and the keyboard shortcuts. + +### Why it needs a local clone + +The clone is where everything is read from: + +- **Images** come out of git's object store, about 4,000 for a large sync in a second, with no checkout and no network. GitHub's API would mean thousands of downloads, and allows 60 an hour without an account. +- **The changes** come from one `git diff`, renames included. GitHub's pull request file list stops at 3,000 files. +- **Test links and orphans** come from reading the specs at the commit under review. +- **Review progress and the diff cache** are kept in the clone's `.git/snapshot-review/`. + +## Linting + +```sh +npm run lint # ESLint and stylelint, then Prettier formats in place +npm run lint.fix # the same, with ESLint and stylelint fixing what they can +npm run typecheck # TypeScript checks the JavaScript through its JSDoc types +``` + +CI fails if `npm run lint` leaves a diff, so run it before pushing. + +## Testing + +```sh +npm test # everything: test.spec, then test.e2e +npm run test.spec # unit and integration, about a second +npm run test.e2e # the app in a browser, about three seconds +``` + +The end-to-end tests need Playwright's Chromium once: `npx playwright install chromium`. + +| Folder | What it covers | +| ------------------ | ----------------------------------------------------------------------- | +| `test/unit` | Pure functions: screenshot name parsing, formatting, browser sets. | +| `test/integration` | The manifest and the viewed-state store, against a real git repository. | +| `test/e2e` | The running app: listing, filters, marking viewed. | + +Integration and end-to-end tests build a throwaway repository with one of every kind of change in `test/fixtures/repo.js`. A fake `gh` that always fails is first on `PATH`, so no test can reach GitHub. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ace0816 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,61 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier/flat'; +import importPlugin from 'eslint-plugin-import'; +import playwright from 'eslint-plugin-playwright'; +import globals from 'globals'; + +export default [ + { ignores: ['node_modules/**', 'playwright-report/**', 'test-results/**'] }, + + js.configs.recommended, + importPlugin.flatConfigs.recommended, + prettier, + + { + languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + rules: { + 'no-fallthrough': 'off', + 'no-constant-condition': 'off', + + 'import/first': 'error', + 'import/order': [ + 'error', + { + alphabetize: { order: 'asc', caseInsensitive: false }, + groups: [['builtin', 'external'], 'parent', ['sibling', 'index']], + 'newlines-between': 'always', + }, + ], + 'import/newline-after-import': 'error', + 'import/no-duplicates': 'error', + 'import/no-mutable-exports': 'error', + + 'no-useless-catch': 'off', + 'no-case-declarations': 'off', + 'no-unused-vars': 'warn', + 'no-unused-expressions': ['error', { allowShortCircuit: true, allowTernary: true }], + + /* Listed after prettier, which turns it off. */ + curly: ['error', 'all'], + }, + }, + + { + files: ['**/*.e2e.js'], + plugins: { playwright }, + rules: { + 'playwright/missing-playwright-await': 'error', + 'playwright/no-restricted-matchers': [ + 'error', + { + toMatchSnapshot: + '"toHaveScreenshot" assertions should be used in favor of "toMatchSnapshot". "toHaveScreenshot" brings file size reductions and anti-flake behaviors such as disabling animations by default.', + }, + ], + }, + }, + + /* The client runs in the browser; the server, tests and config run in Node. */ + { files: ['src/client/**/*.js'], languageOptions: { globals: globals.browser } }, + { files: ['src/*.mjs', 'test/**/*.js', '*.config.js'], languageOptions: { globals: globals.node } }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7cc8c28 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5007 @@ +{ + "name": "ionic-visual-review", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ionic-visual-review", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "img-comparison-slider": "^8.0.7", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@ionic/prettier-config": "^4.0.0", + "@playwright/test": "1.63.0", + "@types/node": "^24.19.1", + "eslint": "^9.39.5", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-playwright": "^2.12.0", + "globals": "^17.13.0", + "prettier": "^3.9.9", + "stylelint": "^16.26.1", + "stylelint-order": "^7.0.1", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@dual-bundle/import-meta-resolve": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", + "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/JounQin" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@ionic/prettier-config": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ionic/prettier-config/-/prettier-config-4.0.0.tgz", + "integrity": "sha512-0DqL6CggVdgeJAWOLPUT73rF1VD5p0tVlCpC5GXz5vTIUBxNwsJ5085Q7wXjKiE5Odx3aOHGTcuRWCawFsLFag==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^2.4.0 || ^3.0.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-playwright": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.12.0.tgz", + "integrity": "sha512-7HpSKR/D2oCH4RKRauFskcrnDrHvLQKopW7Nen8lqrFZtx/SB+FgqVaH+zyQd32noZSZy9Lrsa+DsYrVUpn6hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globals": "^17.3.0" + }, + "engines": { + "node": ">=16.9.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/img-comparison-slider": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/img-comparison-slider/-/img-comparison-slider-8.0.7.tgz", + "integrity": "sha512-BALViPtL2FgcCa9K8KkrmhWuyeDkVu6XVwBoRm9pU8Y6P1rjw31zujuEMBWTz2w6W30zzdQZNTccvg7J9Gf/LQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixelmatch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz", + "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==", + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-resolve-nested-selector": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-sorting": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-9.1.0.tgz", + "integrity": "sha512-Mn8KJ45HNNG6JBpBizXcyf6LqY/qyqetGcou/nprDnFwBFBLGj0j/sNKV2lj2KMOVOwdXu14aEzqJv8CIV6e8g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.4.20" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylelint": { + "version": "16.26.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz", + "integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-syntax-patches-for-csstree": "^1.0.19", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3", + "@csstools/selector-specificity": "^5.0.0", + "@dual-bundle/import-meta-resolve": "^4.2.1", + "balanced-match": "^2.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^9.0.0", + "css-functions-list": "^3.2.3", + "css-tree": "^3.1.0", + "debug": "^4.4.3", + "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^11.1.1", + "global-modules": "^2.0.0", + "globby": "^11.1.0", + "globjoin": "^0.1.4", + "html-tags": "^3.3.1", + "ignore": "^7.0.5", + "imurmurhash": "^0.1.4", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.37.0", + "mathml-tag-names": "^2.1.3", + "meow": "^13.2.0", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.5.6", + "postcss-resolve-nested-selector": "^0.1.6", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.0", + "postcss-value-parser": "^4.2.0", + "resolve-from": "^5.0.0", + "string-width": "^4.2.3", + "supports-hyperlinks": "^3.2.0", + "svg-tags": "^1.0.0", + "table": "^6.9.0", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/stylelint-order": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-7.0.1.tgz", + "integrity": "sha512-GWPei1zBVDDjxM+/BmcSCiOcHNd8rSqW6FUZtqQGlTRpD0Z5nSzspzWD8rtKif5KPdzUG68DApKEV/y/I9VbTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss": "^8.5.6", + "postcss-sorting": "^9.1.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^16.18.0 || ^17.0.0" + } + }, + "node_modules/stylelint/node_modules/balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/stylelint/node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/stylelint/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/stylelint/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..325a3c1 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "ionic-visual-review", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Review Playwright snapshot changes for Ionic Framework pull requests, side by side, with pixel diffs and viewed state that syncs to GitHub.", + "license": "Apache-2.0", + "repository": "github:ionic-team/ionic-visual-review", + "engines": { + "node": ">=24" + }, + "scripts": { + "start": "node src/cli.mjs", + "lint": "eslint . && stylelint \"src/**/*.css\" && prettier \"./**/*.{html,js,mjs,css}\" --write --cache", + "lint.fix": "eslint . --fix && stylelint \"src/**/*.css\" --fix && prettier \"./**/*.{html,js,mjs,css}\" --write --cache", + "test": "npm run test.spec && npm run test.e2e", + "test.spec": "node --test 'test/unit/**/*.test.js' 'test/integration/**/*.test.js'", + "test.e2e": "npx playwright test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "img-comparison-slider": "^8.0.7", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@ionic/prettier-config": "^4.0.0", + "@playwright/test": "1.63.0", + "@types/node": "^24.19.1", + "eslint": "^9.39.5", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-playwright": "^2.12.0", + "globals": "^17.13.0", + "prettier": "^3.9.9", + "stylelint": "^16.26.1", + "stylelint-order": "^7.0.1", + "typescript": "^7.0.2" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..3ada2fe --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,14 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'test/e2e', + testMatch: '*.e2e.js', + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [['html'], ['github']], + use: { + ...devices['Desktop Chrome'], + trace: 'retain-on-failure', + }, +}); diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..80b39ea --- /dev/null +++ b/renovate.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"], + "description": "Renovate may only open or update PRs on weekdays, 6-10am ET.", + "timezone": "America/New_York", + "minimumReleaseAge": "3 days", + "schedule": ["* 6-10 * * 1-5"], + "semanticCommits": "enabled", + "rebaseWhen": "never", + "includePaths": ["package.json", ".github/workflows/**"], + "packageRules": [ + { + "description": "Playwright pins its browser builds, so the package and the browsers in CI move together.", + "matchPackageNames": ["@playwright/test", "eslint-plugin-playwright"], + "groupName": "playwright" + }, + { + "matchPackageNames": ["eslint", "@eslint/**", "eslint-plugin-*", "eslint-config-*", "globals"], + "groupName": "eslint" + } + ] +} diff --git a/src/cli.mjs b/src/cli.mjs new file mode 100644 index 0000000..f4fbb18 --- /dev/null +++ b/src/cli.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +/* + * Entry point for npm start. Kept apart from index.mjs so tests can import the + * server's pieces without starting it. + * + * - Any startup failure, such as no clone found or no screenshots in the range, + * prints as one line and exits with code 1. + */ +import { main } from './index.mjs'; + +main().catch((error) => { + process.stderr.write(`\n ${error.message}\n\n`); + process.exit(1); +}); diff --git a/src/client/app.js b/src/client/app.js new file mode 100644 index 0000000..0d5353e --- /dev/null +++ b/src/client/app.js @@ -0,0 +1,124 @@ +/* + * Entry point for the page: wires the controls, loads the review and renders it. + * + * - The comment box, teammates' comments and the c shortcut appear only for a pull + * request with gh signed in. Viewed marks work in every case. + * - If a push changed screenshots since they were marked viewed, the sidebar says how + * many lost their mark. + * - The orphans only and encoding only checkboxes appear only when there is one. + * - A newer push is checked for on load and whenever the tab regains focus. + * - Every image is prefetched after the first render, so stepping through stays + * instant. + */ +import { renderPublish, wireComments } from './scripts/comments.js'; +import { wireControls } from './scripts/controls.js'; +import { dom } from './scripts/dom.js'; +import { indexGroups, reencodedOnly } from './scripts/entries.js'; +import { applyFilter, renderFacets, restoreGrouping } from './scripts/filters.js'; +import { wireKeyboard } from './scripts/keyboard.js'; +import { blobUrl } from './scripts/links.js'; +import { renderList, renderProgress } from './scripts/list.js'; +import { select } from './scripts/stage.js'; +import { state } from './scripts/state.js'; +import { checkFreshness } from './scripts/sync.js'; +import { restoreTheme } from './scripts/theme.js'; + +/** + * Restores the reviewer's preferences, loads the manifest and renders the first screen. + * @returns {Promise} + */ +const init = async () => { + /* Before the manifest, so the first paint is already in the right theme. */ + restoreTheme(); + restoreGrouping(); + + window.addEventListener('focus', checkFreshness); + + const manifest = await fetch('/api/manifest').then((response) => response.json()); + + state.head = manifest.head ?? null; + state.blobBase = manifest.blobBase ?? null; + state.entries = manifest.entries; + state.viewed = new Set(Object.keys(manifest.state.viewed ?? {})); + state.comments = new Map(Object.entries(manifest.state.comments ?? {})); + state.stale = new Set(manifest.state.stale ?? []); + state.unsent = new Set(manifest.state.unsent ?? []); + state.posted = new Set(manifest.state.posted ?? []); + state.threads = manifest.threads ?? {}; + state.pr = manifest.pr ?? null; + state.prState = manifest.prState ?? null; + state.viewer = manifest.viewer ?? null; + + /* A comment exists to be posted on a pull request. Without one, or without an account + to post as, there is nowhere for it to go, so the box would only collect notes that + never leave this machine. Viewed stays either way: it is how a range is worked + through, pull request or not. */ + dom.commentSection.hidden = !state.pr || !state.viewer; + renderPublish(); + + /* Only the screenshots that lost their tick are worth saying out loud. Carrying + progress over is the expected outcome and needs no announcement; ticks vanishing + without explanation does. */ + const { reset = 0 } = manifest.carriedOver ?? {}; + if (reset) { + dom.carried.hidden = false; + dom.carried.textContent = `${reset} ${ + reset === 1 ? 'screenshot has' : 'screenshots have' + } changed since you reviewed ${reset === 1 ? 'it' : 'them'} and need another look.`; + } + indexGroups(); + + dom.rangeLabel.textContent = manifest.range.label; + if (manifest.range.url) { + dom.rangeLabel.href = manifest.range.url; + } + dom.rangeRefs.textContent = manifest.range.mergeBase.slice(0, 10); + dom.rangeRefs.title = 'Merge base: the commit every expected screenshot is read from'; + + /* Offered only when there is something to find, the same way a filter with one value + is left out of the facet bar. */ + dom.orphansOnlyLabel.hidden = !state.entries.some((entry) => entry.orphaned); + dom.reencodedOnlyLabel.hidden = !state.entries.some(reencodedOnly); + + renderFacets(); + + document.documentElement.style.setProperty('--onion', String(dom.onion.valueAsNumber / 100)); + + applyFilter(); + renderList(); + renderProgress(); + + if (state.visible[0] ?? state.entries[0]) { + select((state.visible[0] ?? state.entries[0]).path); + } + + /* Asked once the list is up, so a push that landed before this session opened is + reported without waiting for the reviewer to mark something. */ + checkFreshness(); + + /* Warm the cache so stepping through hundreds of screenshots stays instant. */ + const queue = state.entries + .flatMap((entry) => [ + entry.status === 'A' ? null : blobUrl('expected', entry.path), + entry.status === 'D' ? null : blobUrl('actual', entry.path), + entry.comparable ? blobUrl('diff', entry.path) : null, + ]) + .filter(Boolean); + + await Promise.all( + Array.from({ length: 6 }, async () => { + for (;;) { + const url = queue.shift(); + if (!url) { + return; + } + await fetch(url, { cache: 'force-cache' }).catch(() => {}); + } + }) + ); +}; + +wireComments(); +wireControls(); +wireKeyboard(); +init(); diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 0000000..420ccc4 --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,269 @@ + + + + + + Snapshot review + + + + + + + + + + + + + +
+
Snapshot review
+
+ + +
+
+ +
+
+ + + +
+ +
+ + +
+
+ + + + + + + + + + + + + +
+ +
+
+ + + + +
+ +
+ + +
+ + + +
+ + + +
+
+ +

+ +
+
+
+
+ Expected +
+
Expected screenshot
+
+
+
+ Actual +
+
Actual screenshot
+
+
+ +
+ + Expected screenshot + Actual screenshot + +
+ +
+
+ + Expected screenshot + Actual screenshot +
+
+ +
+
+
Changed pixels highlighted
+
Pixel difference
+
+
+
+ +
+
+ Comment + + +
+ + +
+
+
+ + +
+

What the list is telling you

+
+
+
Modified. Both versions exist and the pixels differ.
+
+
Added. No baseline to compare against.
+
+
Removed. Showing the image being deleted.
+
+
Renamed. Compared against its old path.
+
βœ“
+
Viewed. The row dims once it is.
+
+
Has a comment.
+
5%
+
Share of pixels that changed.
+
+

Directories

+
    +
  • The checkbox marks everything beneath it that the current filters admit.
  • +
  • A dash means only part of the directory is viewed.
  • +
  • Filter to the browsers you review before ticking, and only those get marked.
  • +
+

One row per test

+
    +
  • + The same test is captured in up to three browsers. Turn this on and those collapse into a single row, + showing Chrome where there is one. +
  • +
  • The number beside a row is how many browsers it stands for. Hover it to see which.
  • +
  • + Marking that row viewed marks every browser behind it, so a test you are happy with in Chrome is settled in + one press. +
  • +
  • + A browser filter still applies: filter to Chrome and a row marks only Chrome, the same way a directory does. +
  • +
+

Filters

+
    +
  • + The filters sit above the snapshot list: status, browser, mode, theme, + direction and palette. Each one shows its values and how many there are. +
  • +
  • + Click a value to narrow to it. Picking more than one value in the same filter widens it, so ltr plus rtl + shows both. +
  • +
  • + Picking values in different filters stacks them, so rtl plus dark shows only the screenshots that are both. +
  • +
  • + A filter is hidden when there is nothing to choose, either because the range holds only one of its values or + because it would repeat another filter. Most pull requests are entirely ltr and entirely light, so those two + rarely appear. +
  • +
+

What needs GitHub

+
    +
  • + Marking viewed always works. It is kept in a local file, so the directory ticks, the progress bar, + Unviewed only, v and V behave the same however the review was started. +
  • +
  • + Reviewing a pull request while signed in also writes that state to the pull request, so its Files tab agrees + with what was ticked here. +
  • +
  • + Reviewing a range with --base and --head has no pull request to write to. The comment box is + not shown and c does nothing. +
  • +
  • + Signed out, a pull request still opens and every screenshot is there, read anonymously from GitHub. What + needs an account is switched off: comments, and the copy of the viewed state that reaches the Files tab. + Comments left by teammates do not appear either. +
  • +
  • + Signed out, a new push is noticed more slowly, because GitHub allows far fewer anonymous requests and the + check is spaced out to stay within them. +
  • +
+

Keyboard

+
+
jk↑↓
+
Next and previous
+
v
+
Mark viewed and advance
+
V
+
Mark the whole directory viewed
+
n
+
Next unviewed
+
1to4
+
Side by side, slider, onion skin, diff
+
z
+
Cycle zoom
+
c
+
Focus the comment box, when there is a pull request to post to
+
/
+
Focus the filter
+
u
+
Toggle unviewed only
+
?
+
This panel
+
Esc
+
Close this panel, or leave a text field
+
+

+ Percentages come from pixelmatch. Viewed state and comments are kept locally, and are also written to the pull + request when one is being reviewed and gh is signed in. +

+
+
+ + + + diff --git a/src/client/scripts/comments.js b/src/client/scripts/comments.js new file mode 100644 index 0000000..446224f --- /dev/null +++ b/src/client/scripts/comments.js @@ -0,0 +1,325 @@ +/* + * Comment box, teammates' comments, posting and deleting. + * + * - Shown only for a pull request with gh signed in. Without either, there is + * nowhere for a comment to go, so the whole section is hidden. + * - A comment saves locally 400 ms after typing stops. Nothing reaches GitHub until + * Post. + * - Post sends every unsent comment as one review, after confirming. On a merged or + * closed pull request it is disabled, with the reason as its tooltip. If posting + * fails, the button says so and every comment stays unsent. + * - Editing a posted comment makes it unsent again. It posts as a new comment rather + * than editing the old one. + * - A comment written before its screenshot changed in a newer push is flagged until + * it is rewritten. + * - Delete clears a local comment. A posted one is deleted from the pull request too, + * after confirming, and stays here if GitHub refuses. + * - Teammates' comments are read only, with resolved and outdated threads marked. + * Their HTML is rebuilt from an allowlist: no images, and only http(s) links. + */ +import { dom } from './dom.js'; +import { refreshRow } from './list.js'; +import { state } from './state.js'; +import { persist } from './sync.js'; + +/** + * A screenshot's file name without the extension. + * @param {string} path + * @returns {string} + */ +export const fileOf = (path) => + path + .split('/') + .pop() + .replace(/\.png$/, ''); + +/* + * A comment arrives as GitHub's rendered markdown, which is somebody else's HTML, so + * it is rebuilt here from an allowlist rather than trusted. Anything not on the list + * is unwrapped, keeping its words and dropping the element, and every attribute is + * discarded except an http(s) href. + * + * Images are deliberately absent: a comment that embeds one would otherwise have this + * page fetch it, and the alt text is not worth the request. + */ +const ALLOWED_TAGS = new Set([ + 'P', + 'BR', + 'CODE', + 'PRE', + 'A', + 'STRONG', + 'EM', + 'B', + 'I', + 'DEL', + 'UL', + 'OL', + 'LI', + 'BLOCKQUOTE', + 'HR', +]); + +/** + * Rebuilds GitHub's rendered comment HTML from the allowlist. Other tags are unwrapped, + * and only http(s) links survive. + * @param {string | null} html + * @returns {DocumentFragment} + */ +const sanitize = (html) => { + const parsed = new DOMParser().parseFromString(html ?? '', 'text/html'); + const fragment = document.createDocumentFragment(); + + const walk = (source, target) => { + for (const node of source.childNodes) { + if (node.nodeType === Node.TEXT_NODE) { + target.append(node.textContent); + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + if (!ALLOWED_TAGS.has(node.tagName)) { + walk(node, target); + continue; + } + + const element = document.createElement(node.tagName.toLowerCase()); + + if (node.tagName === 'A') { + const href = node.getAttribute('href') ?? ''; + if (/^https?:\/\//i.test(href)) { + element.href = href; + element.target = '_blank'; + element.rel = 'noreferrer'; + } + } + + walk(node, element); + target.append(element); + } + }; + + walk(parsed.body, fragment); + return fragment; +}; + +/** + * Shows teammates' comments on the selected screenshot, one box per thread. Read only, + * since replies belong on GitHub. + */ +export const renderThreads = () => { + const threads = state.threads[state.current] ?? []; + dom.threads.hidden = threads.length === 0; + + if (threads.length === 0) { + return; + } + + const nodes = []; + + /* More than one conversation about the same screenshot is worth counting, since + otherwise a second thread just looks like a late reply to the first. */ + if (threads.length > 1) { + const heading = document.createElement('p'); + heading.className = 'threads-count'; + heading.textContent = `${threads.length} conversations on this screenshot`; + nodes.push(heading); + } + + for (const thread of threads) { + const box = document.createElement('div'); + box.className = 'thread'; + box.classList.toggle('is-resolved', thread.resolved); + + /* Stated once for the thread, not repeated against every reply in it. */ + const tags = [thread.resolved ? 'resolved' : '', thread.outdated ? 'outdated' : ''].filter(Boolean); + if (tags.length) { + const tag = document.createElement('span'); + tag.className = 'thread-tag'; + tag.textContent = tags.join(', '); + box.append(tag); + } + + thread.comments.forEach((comment, index) => { + const item = document.createElement('div'); + item.className = 'thread-comment'; + /* Replies sit under the comment they answer, so a conversation reads as one. */ + item.classList.toggle('is-reply', index > 0); + + const who = document.createElement('a'); + who.className = 'thread-author'; + who.href = comment.url; + who.target = '_blank'; + who.rel = 'noreferrer'; + who.textContent = comment.mine ? `${comment.author} (you)` : comment.author; + + const body = document.createElement('div'); + body.className = 'thread-body'; + body.append(sanitize(comment.body)); + + item.append(who, body); + box.append(item); + }); + + nodes.push(box); + } + + dom.threads.replaceChildren(...nodes); +}; + +/** + * Shows Delete when there is a comment, labelled for the pull request when it is posted + * there. + */ +export const renderDelete = () => { + const path = state.current; + const published = state.posted.has(path); + const has = Boolean(state.comments.get(path)) || published; + + dom.deleteComment.hidden = !has; + dom.deleteComment.textContent = published ? 'Delete from PR' : 'Delete'; + dom.deleteComment.title = published + ? `Removes this comment from #${state.pr} as well as here` + : 'Clears this comment'; +}; + +/** + * Clears the selected screenshot's comment. One already posted is deleted from the pull + * request too, after confirming. + * @returns {Promise} + */ +export const deleteCurrentComment = async () => { + const path = state.current; + const published = state.posted.has(path); + + if ( + published && + !window.confirm(`Delete this comment from pull request #${state.pr}?\n\nIt is removed for everyone.`) + ) { + return; + } + + dom.deleteComment.disabled = true; + + try { + const result = await fetch('/api/comments/delete', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path }), + }).then((response) => response.json()); + + if (!result?.ok) { + throw new Error(result?.error ?? 'failed'); + } + + state.comments.delete(path); + state.unsent.delete(path); + state.posted.delete(path); + state.stale.delete(path); + + dom.comment.value = ''; + dom.comment.classList.remove('stale'); + dom.commentState.textContent = ''; + refreshRow(path); + renderPublish(); + } catch (error) { + /* Reported on the status line, not the button's title: renderDelete rewrites that + title on the way out, so a failure put there would vanish silently. */ + dom.commentState.textContent = `Could not delete: ${error.message ?? error}`; + } finally { + dom.deleteComment.disabled = false; + renderDelete(); + } +}; + +/** + * Shows the publish button with the number of unsent comments, when there is a pull + * request and an account to post as. + */ +export const renderPublish = () => { + const count = state.unsent.size; + /* Comments still save here; posting them would land on a pull request nobody is + reviewing any more. */ + const settled = state.prState === 'MERGED' || state.prState === 'CLOSED'; + dom.publish.hidden = !state.pr || !state.viewer || count === 0; + dom.publish.disabled = settled; + dom.publish.title = settled + ? `#${state.pr} is ${state.prState.toLowerCase()}, so comments can't be posted to it` + : ''; + dom.publish.textContent = `Post ${count} comment${count === 1 ? '' : 's'} to #${state.pr}`; +}; + +/** + * Posts every unsent comment as one review, after confirming. + * @returns {Promise} + */ +export const publishComments = async () => { + const count = state.unsent.size; + const confirmed = window.confirm( + `Post ${count} comment${count === 1 ? '' : 's'} to pull request #${state.pr}?\n\n` + + 'They publish together as one review, visible to everyone who can see the pull request.' + ); + + if (!confirmed) { + return; + } + + dom.publish.disabled = true; + dom.publish.textContent = 'Posting…'; + + try { + const result = await fetch('/api/comments/publish', { method: 'POST' }).then((response) => response.json()); + if (!result?.ok) { + throw new Error(result?.error ?? 'failed'); + } + + state.unsent.clear(); + renderPublish(); + + if (result.url) { + window.open(result.url, '_blank', 'noreferrer'); + } + } catch (error) { + dom.publish.disabled = false; + dom.publish.textContent = 'Posting failed, try again'; + dom.publish.title = String(error.message ?? error); + } +}; + +let commentTimer = null; + +/** Saves the comment as it is typed, once typing pauses for 400 ms. */ +export const wireComments = () => { + dom.comment.addEventListener('input', () => { + const path = state.current; + const value = dom.comment.value; + + if (value.trim()) { + state.comments.set(path, value); + } else { + state.comments.delete(path); + } + + /* Rewriting settles it against what is on screen now. */ + state.stale.delete(path); + dom.comment.classList.remove('stale'); + + if (value.trim()) { + state.unsent.add(path); + } else { + state.unsent.delete(path); + } + renderPublish(); + renderDelete(); + + dom.commentState.textContent = 'Saving…'; + clearTimeout(commentTimer); + commentTimer = setTimeout(async () => { + await persist({ path, comment: value.trim() ? value : '' }); + dom.commentState.textContent = value.trim() ? 'Saved locally' : ''; + refreshRow(path); + }, 400); + }); +}; diff --git a/src/client/scripts/controls.js b/src/client/scripts/controls.js new file mode 100644 index 0000000..4a25343 --- /dev/null +++ b/src/client/scripts/controls.js @@ -0,0 +1,145 @@ +/* + * Click and change handlers for the toolbar, list, filters and dialogs. + * + * - A disabled comparison mode ignores clicks, so a screenshot with nothing to + * compare stays side by side. + * - A directory checkbox marks what the filters admit in that directory, not every + * screenshot in it. + * - One row per test is remembered in this browser. Toggling it keeps the open + * screenshot selected, on whichever row now carries it. + * - Unviewed only and the theme picker give up focus after a change, so the keyboard + * shortcuts keep working. + * - Clicking outside the help dialog closes it. Fit zoom recalculates on resize. + */ +import { deleteCurrentComment, publishComments } from './comments.js'; +import { dom } from './dom.js'; +import { entryFor, setKeyOf } from './entries.js'; +import { GROUP_KEY, applyFilter, refilter, renderFacets } from './filters.js'; +import { renderList } from './list.js'; +import { setViewed, toggleGroup } from './navigation.js'; +import { applyMode, applyZoom, select } from './stage.js'; +import { state } from './state.js'; +import { applyTheme } from './theme.js'; + +/** + * Connects the toolbar, list, filters, checkboxes, theme picker and help dialog to their + * handlers. + */ +export const wireControls = () => { + dom.modes.addEventListener('click', (event) => { + const target = /** @type {HTMLButtonElement} */ (event.target); + const mode = target.dataset?.mode; + if (mode && !target.disabled) { + state.mode = mode; + applyMode(); + applyZoom(); + } + }); + + dom.zoom.addEventListener('click', (event) => { + const zoom = /** @type {HTMLElement} */ (event.target).dataset?.zoom; + if (zoom) { + state.zoom = zoom; + applyZoom(); + } + }); + + dom.onion.addEventListener('input', () => + document.documentElement.style.setProperty('--onion', String(dom.onion.valueAsNumber / 100)) + ); + + dom.list.addEventListener('click', (event) => { + /** @type {HTMLElement} */ + const row = /** @type {Element} */ (event.target).closest('.row'); + if (row) { + select(row.dataset.path, { scroll: false }); + } + }); + + dom.list.addEventListener('change', (event) => { + const target = /** @type {HTMLInputElement} */ (event.target); + if (target.classList.contains('group-box')) { + toggleGroup(/** @type {HTMLElement} */ (target.closest('.group-head')).dataset.group, target.checked); + } + }); + + dom.viewed.addEventListener('change', () => setViewed(dom.viewed.checked)); + + dom.groupBrowsers.addEventListener('change', () => { + state.grouped = dom.groupBrowsers.checked; + try { + localStorage.setItem(GROUP_KEY, state.grouped ? 'on' : 'off'); + } catch { + /* A browser refusing storage is not a reason to refuse the setting. */ + } + + const anchor = entryFor(state.current); + applyFilter(); + renderList(); + + /* The screenshot being looked at survives the regrouping, landing on whichever row + now carries it. */ + const carrier = anchor && state.visible.find((entry) => setKeyOf(entry) === setKeyOf(anchor)); + if (carrier) { + select(carrier.path); + } else if (state.visible.length) { + select(state.visible[0].path); + } + }); + + dom.facets.addEventListener('click', (event) => { + /** @type {HTMLElement} */ + const button = /** @type {Element} */ (event.target).closest('.count'); + if (!button) { + return; + } + + const selected = state.facets[/** @type {HTMLElement} */ (button.closest('.facet')).dataset.facet]; + const value = button.dataset.value; + + if (selected.has(value)) { + selected.delete(value); + } else { + selected.add(value); + } + + renderFacets(); + refilter(); + }); + + dom.filter.addEventListener('input', refilter); + + dom.orphansOnly.addEventListener('change', refilter); + + dom.reencodedOnly.addEventListener('change', refilter); + + /* Focus would otherwise stay on the checkbox, where the review shortcuts are treated + as typing and swallowed. */ + dom.unviewedOnly.addEventListener('change', () => { + dom.unviewedOnly.blur(); + refilter(); + }); + + dom.theme.addEventListener('change', () => { + applyTheme(/** @type {import('./theme.js').Theme} */ (dom.theme.value)); + dom.theme.blur(); + }); + + dom.deleteComment.addEventListener('click', deleteCurrentComment); + + dom.publish.addEventListener('click', publishComments); + + dom.helpButton.addEventListener('click', () => dom.help.showModal()); + + dom.help.addEventListener('click', (event) => { + if (event.target === dom.help) { + dom.help.close(); + } + }); + + window.addEventListener('resize', () => { + if (state.zoom === 'fit') { + applyZoom(); + } + }); +}; diff --git a/src/client/scripts/dom.js b/src/client/scripts/dom.js new file mode 100644 index 0000000..1254837 --- /dev/null +++ b/src/client/scripts/dom.js @@ -0,0 +1,97 @@ +/* + * Every element the scripts use, looked up once by id, and the constants they share. + * + * - dom is typed from the id list, so a misspelt element name is a type error. + * - An id missing from index.html comes back null, and the first script to use it + * throws. + */ + +const IDS = /** @type {const} */ ([ + 'stage', + 'rangeLabel', + 'rangeRefs', + 'progressLabel', + 'progressFill', + 'helpButton', + 'help', + 'groupBrowsers', + 'filter', + 'unviewedOnly', + 'orphansOnly', + 'orphansOnlyLabel', + 'reencodedOnly', + 'reencodedOnlyLabel', + 'facets', + 'carried', + 'pushed', + 'publish', + 'list', + 'crumbStatus', + 'crumbGroup', + 'crumbTest', + 'crumbStem', + 'crumbBrowser', + 'crumbMeta', + 'crumbRatio', + 'crumbResize', + 'crumbRenamed', + 'crumbOutdated', + 'crumbOrphan', + 'viewed', + 'modes', + 'zoom', + 'onionControl', + 'onion', + 'theme', + 'viewSxs', + 'viewSlider', + 'viewOnion', + 'viewDiff', + 'figExpected', + 'figActual', + 'sxsExpected', + 'sxsActual', + 'bytesExpected', + 'bytesActual', + 'labelExpected', + 'labelActual', + 'sliderExpected', + 'sliderActual', + 'onionSizer', + 'onionExpected', + 'onionActual', + 'diffImage', + 'diffCount', + 'notice', + 'comment', + 'commentSection', + 'commentState', + 'deleteComment', + 'threads', +]); + +/** + * Every id as an HTMLElement, narrowed where scripts use more than that offers. + * @typedef {Record<(typeof IDS)[number], HTMLElement> + * & Record<'rangeLabel' | 'crumbTest', HTMLAnchorElement> + * & Record<'helpButton' | 'publish' | 'deleteComment', HTMLButtonElement> + * & Record<'groupBrowsers' | 'filter' | 'unviewedOnly' | 'orphansOnly' | 'reencodedOnly' | 'viewed' | 'onion', HTMLInputElement> + * & Record<'sxsExpected' | 'sxsActual' | 'sliderExpected' | 'sliderActual' | 'onionSizer' | 'onionExpected' | 'onionActual' | 'diffImage', HTMLImageElement> + * & { help: HTMLDialogElement, theme: HTMLSelectElement, comment: HTMLTextAreaElement }} Dom + */ + +export const dom = /** @type {Dom} */ (Object.fromEntries(IDS.map((id) => [id, document.getElementById(id)]))); + +export const VIEWS = { sxs: dom.viewSxs, slider: dom.viewSlider, onion: dom.viewOnion, diff: dom.viewDiff }; + +export const COMPARING = new Set(['slider', 'onion', 'diff']); + +export const ZOOM_ORDER = ['fit', '1', '2']; + +export const STATUS_LABEL = { M: 'Modified', A: 'Added', D: 'Removed', R: 'Renamed' }; + +/* Lower case for the facet bar, which reads as a sentence rather than a badge. */ +export const STATUS_COUNT_LABEL = { M: 'modified', A: 'added', D: 'removed', R: 'renamed' }; + +/* Listed rather than discovered so a range missing one still knows it exists. */ +export const BROWSER_ORDER = ['Mobile Chrome', 'Mobile Firefox', 'Mobile Safari']; diff --git a/src/client/scripts/entries.js b/src/client/scripts/entries.js new file mode 100644 index 0000000..6557b1c --- /dev/null +++ b/src/client/scripts/entries.js @@ -0,0 +1,122 @@ +/* + * Lookups over the screenshot list: by path, by directory and by browser set. + * + * - A browser set is one test's screenshot across browsers. Chrome stands for the set + * when present, otherwise the first one. + * - Directory and set scopes hold only what the filters admit, so a screenshot the + * filters hide is never marked. + * - The comparison views are skipped when one side is missing or no pixel changed. + * - Encoding only means different bytes with identical pixels. A rename that kept the + * same bytes doesn't count. + */ +import { state } from './state.js'; + +/** @typedef {import('./state.js').Entry} Entry */ + +/** + * The entry for a path. + * @param {string} path + * @returns {Entry | undefined} + */ +export const entryFor = (path) => state.entries.find((candidate) => candidate.path === path); + +/** + * Whether the comparison views have anything to show. A pure move or a re-encode has + * two sides but not one changed pixel. + * @param {Entry | undefined} entry + * @returns {boolean} + */ +export const worthComparing = (entry) => Boolean(entry?.comparable) && entry.ratio !== 0; + +/** + * Whether only the encoding changed: different files, identical pixels. A rename that + * kept the same bytes is not counted. + * @param {Entry | undefined} entry + * @returns {boolean} + */ +export const reencodedOnly = (entry) => + Boolean(entry?.comparable) && entry.ratio === 0 && entry.expectedSha !== entry.actualSha; + +/** Indexes every entry by directory and by browser set. */ +export const indexGroups = () => { + state.groups = new Map(); + state.sets = new Map(); + + for (const entry of state.entries) { + if (!state.groups.has(entry.group)) { + state.groups.set(entry.group, []); + } + state.groups.get(entry.group).push(entry); + + const key = setKeyOf(entry); + if (!state.sets.has(key)) { + state.sets.set(key, []); + } + state.sets.get(key).push(entry); + } +}; + +/** + * The key shared by one test's screenshots across browsers. + * @param {Entry} entry + * @returns {string} + */ +export const setKeyOf = (entry) => `${entry.dir}/${entry.stem}`; + +/** + * Every browser's screenshot of one test. + * @param {string} key From `setKeyOf`. + * @returns {Entry[]} + */ +const setEntries = (key) => state.sets.get(key) ?? []; + +/** + * The screenshots in a set that the filters admit, so a browser the filters hide is + * never marked. + * @param {string} key From `setKeyOf`. + * @returns {Entry[]} + */ +export const setScope = (key) => setEntries(key).filter((entry) => state.inScope.has(entry.path)); + +/* Chrome is the browser the team actually reads, so it speaks for the set where it is + present. */ +const PRIMARY_BROWSER = 'Mobile Chrome'; + +/** + * The screenshot that stands for a set: Chrome when present, since that is the browser + * the team reads. + * @template {Pick} T + * @param {T[]} entries + * @returns {T} + */ +export const setLead = (entries) => entries.find((entry) => entry.browser === PRIMARY_BROWSER) ?? entries[0]; + +/** + * Drops the Mobile prefix from a Playwright project name. + * @param {string} browser + * @returns {string} + */ +export const shortBrowser = (browser) => browser.replace(/^Mobile /, ''); + +/** + * Every screenshot in a directory. + * @param {string} group + * @returns {Entry[]} + */ +const groupEntries = (group) => state.groups.get(group) ?? []; + +/** + * The screenshots in a directory that the filters admit, which is what its checkbox + * acts on. + * @param {string} group + * @returns {Entry[]} + */ +export const groupScope = (group) => groupEntries(group).filter((entry) => state.inScope.has(entry.path)); + +/** + * How many of these are marked viewed. + * @param {Entry[]} entries + * @returns {number} + */ +export const countViewed = (entries) => + entries.reduce((total, entry) => total + (state.viewed.has(entry.path) ? 1 : 0), 0); diff --git a/src/client/scripts/filters.js b/src/client/scripts/filters.js new file mode 100644 index 0000000..3149405 --- /dev/null +++ b/src/client/scripts/filters.js @@ -0,0 +1,194 @@ +/* + * The facet rows, the checkbox filters and one row per test. + * + * - Each facet row (status, browser, mode, theme, direction, palette) is a filter. + * Values combine within a row, and a screenshot must match every row in use. + * - A row with only one value is left out, except status. Theme is left out when it + * repeats mode, which is always the case on main. + * - Text, facets, orphans only and encoding only decide what a directory covers. + * Unviewed only just hides rows, so directory counts stay whole. + * - One row per test collapses each browser set to one row, led by Chrome, and is + * remembered in this browser. + * - When the open screenshot is filtered out, the first visible one opens instead. + */ +import { BROWSER_ORDER, STATUS_COUNT_LABEL, dom } from './dom.js'; +import { reencodedOnly, setKeyOf, setLead, shortBrowser } from './entries.js'; +import { renderList } from './list.js'; +import { select } from './stage.js'; +import { state } from './state.js'; + +/** + * The facets the list can be narrowed by. Values are listed rather than discovered, so a + * facet knows which of its values a range is missing. + */ +const FACETS = [ + { key: 'status', values: ['M', 'A', 'D', 'R'], label: (value) => STATUS_COUNT_LABEL[value], always: true }, + { key: 'browser', values: BROWSER_ORDER, label: (value) => shortBrowser(value) }, + { key: 'mode', values: ['ios', 'md'], label: (value) => value }, + /* Mode is behaviour and theme is appearance, so the ionic theme runs on both modes. + On main there is no ionic theme and the two axes always agree, which is what + redundantWith suppresses. */ + { key: 'theme', values: ['ios', 'md', 'ionic'], label: (value) => value, redundantWith: 'mode' }, + { key: 'direction', values: ['ltr', 'rtl'], label: (value) => value }, + { key: 'palette', values: ['light', 'dark', 'high-contrast', 'high-contrast-dark'], label: (value) => value }, +]; + +/** + * Renders each facet as a row of counts that doubles as its filter. A facet with one + * value, or one repeating another, is left out. + */ +export const renderFacets = () => { + const tallies = new Map(); + + for (const facet of FACETS) { + const counts = new Map(); + for (const entry of state.entries) { + const value = entry[facet.key]; + if (value !== null && value !== undefined) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + } + /* Sorted by what is shown, not by the raw value: status is stored as M, A, D and + R but reads as added, modified, removed, renamed. */ + const present = facet.values + .filter((value) => counts.has(value)) + .sort((a, b) => facet.label(a).localeCompare(facet.label(b))); + + tallies.set(facet.key, { counts, present }); + } + + const identical = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]); + const rows = []; + + for (const facet of FACETS) { + const { counts, present } = tallies.get(facet.key); + + /* One possible value is nothing to narrow, so the row is left out entirely. Most + ranges are wholly ltr and wholly light, and those rows would never be usable. */ + if (present.length < 2 && !facet.always) { + continue; + } + + /* Two rows offering the same choice are worse than one. */ + if (facet.redundantWith && identical(present, tallies.get(facet.redundantWith).present)) { + continue; + } + + const selected = state.facets[facet.key]; + const row = document.createElement('div'); + row.className = 'facet'; + row.dataset.facet = facet.key; + row.classList.toggle('filtering', selected.size > 0); + + /* Named, because mode and theme share their values and adjacent rows of bare + "ios md" chips would be impossible to tell apart. */ + const name = document.createElement('span'); + name.className = 'facet-name'; + name.textContent = facet.key; + row.append(name); + + const chips = document.createElement('span'); + chips.className = 'facet-chips'; + + for (const value of present) { + const button = document.createElement('button'); + button.className = 'count'; + button.dataset.value = value; + button.classList.toggle('active', selected.has(value)); + button.innerHTML = `${counts.get(value)} ${facet.label(value)}`; + button.title = selected.has(value) + ? `Showing only ${facet.label(value)}. Click to stop filtering by it.` + : `Show only ${facet.label(value)}`; + chips.append(button); + } + + row.append(chips); + rows.push(row); + } + + dom.facets.replaceChildren(...rows); +}; + +/** + * Works out which entries the filters admit and which rows are visible, collapsing + * browser sets when grouped. Unviewed only hides rows without narrowing a directory. + */ +export const applyFilter = () => { + const needle = dom.filter.value.trim().toLowerCase(); + + /* Content filters decide what a directory means. "Unviewed only" is deliberately + not one of them: it hides rows by progress rather than by content, and folding it + in here would make every directory read 0 out of however many are left. */ + state.inScope = new Set( + state.entries + .filter((entry) => { + if (dom.orphansOnly.checked && !entry.orphaned) { + return false; + } + + if (dom.reencodedOnly.checked && !reencodedOnly(entry)) { + return false; + } + + for (const facet of FACETS) { + const selected = state.facets[facet.key]; + if (selected.size && !selected.has(entry[facet.key])) { + return false; + } + } + return !needle || `${entry.group}/${entry.file}`.toLowerCase().includes(needle); + }) + .map((entry) => entry.path) + ); + + state.visible = state.entries.filter( + (entry) => state.inScope.has(entry.path) && !(dom.unviewedOnly.checked && state.viewed.has(entry.path)) + ); + + /* Collapsed here rather than at render, so that stepping, selecting and jumping to + the next unviewed all move between rows instead of through browsers the list is + no longer showing. */ + if (state.grouped) { + const members = new Map(); + for (const entry of state.visible) { + const key = setKeyOf(entry); + if (!members.has(key)) { + members.set(key, []); + } + members.get(key).push(entry); + } + + const seen = new Set(); + state.visible = state.visible.reduce((rows, entry) => { + const key = setKeyOf(entry); + if (!seen.has(key)) { + seen.add(key); + rows.push(setLead(members.get(key))); + } + return rows; + }, []); + } +}; + +export const GROUP_KEY = 'snapshot-review-group-browsers'; + +/** Applies the remembered One row per test setting. */ +export const restoreGrouping = () => { + let saved = null; + try { + saved = localStorage.getItem(GROUP_KEY); + } catch { + saved = null; + } + state.grouped = saved === 'on'; + dom.groupBrowsers.checked = state.grouped; +}; + +/** Reapplies the filters and redraws, moving the selection if it was filtered out. */ +export const refilter = () => { + applyFilter(); + renderList(); + if (state.visible.length && !state.visible.some((entry) => entry.path === state.current)) { + select(state.visible[0].path); + } +}; diff --git a/src/client/scripts/format.js b/src/client/scripts/format.js new file mode 100644 index 0000000..6d72ce7 --- /dev/null +++ b/src/client/scripts/format.js @@ -0,0 +1,23 @@ +/* + * Number formatting for sizes and change figures. No DOM, so it loads under Node. + * + * - A missing side's size is blank rather than 0 KB. + * - No change reads 0%, and a change under 0.01% reads <0.01%, so the two never look + * the same. + */ + +/** + * Formats a size in kilobytes. + * @param {number | null} bytes + * @returns {string} Empty for a side that does not exist. + */ +export const kb = (bytes) => (bytes === null ? '' : `${(bytes / 1024).toFixed(1)} KB`); + +/** + * Formats a change ratio. Zero reads as 0% and a trace as <0.01%, so no change and + * almost no change stay distinct. + * @param {number | null} ratio + * @returns {string} + */ +export const percent = (ratio) => + ratio === null ? '' : ratio === 0 ? '0%' : ratio < 0.0001 ? '<0.01%' : `${(ratio * 100).toFixed(2)}%`; diff --git a/src/client/scripts/keyboard.js b/src/client/scripts/keyboard.js new file mode 100644 index 0000000..5ce03dd --- /dev/null +++ b/src/client/scripts/keyboard.js @@ -0,0 +1,80 @@ +/* + * Keyboard shortcuts, as listed in the help dialog. + * + * - Ignored while typing in a field, while a modifier key is held, or while help is + * open. Escape leaves a field. + * - v toggles the open screenshot's mark and moves on. n jumps to the next unviewed, + * wrapping to the top. + * - Shift+V marks the open screenshot's directory, or unmarks it when all of it is + * viewed, covering only what the filters admit. + * - c focuses the comment box only when it is shown: a pull request with gh signed in. + */ +import { ZOOM_ORDER, dom } from './dom.js'; +import { countViewed, entryFor, groupScope } from './entries.js'; +import { refilter } from './filters.js'; +import { nextUnviewed, setViewed, step, toggleGroup } from './navigation.js'; +import { applyZoom } from './stage.js'; +import { state } from './state.js'; + +/** + * Handles the shortcuts listed in the help dialog, except while typing or while help is + * open. + */ +export const wireKeyboard = () => { + document.addEventListener('keydown', (event) => { + const target = /** @type {HTMLElement} */ (event.target); + const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName); + + if (event.key === 'Escape') { + if (typing) { + target.blur(); + } + return; + } + + if (typing || event.metaKey || event.ctrlKey || event.altKey || dom.help.open) { + return; + } + + const actions = { + j: () => step(1), + ArrowDown: () => step(1), + k: () => step(-1), + ArrowUp: () => step(-1), + v: () => setViewed(!state.viewed.has(state.current), { advance: true }), + V: () => { + const entry = entryFor(state.current); + if (entry) { + const entries = groupScope(entry.group); + toggleGroup(entry.group, countViewed(entries) < entries.length); + } + }, + n: nextUnviewed, + 1: () => dom.modes.querySelectorAll('button')[0].click(), + 2: () => dom.modes.querySelectorAll('button')[1].click(), + 3: () => dom.modes.querySelectorAll('button')[2].click(), + 4: () => dom.modes.querySelectorAll('button')[3].click(), + z: () => { + state.zoom = ZOOM_ORDER[(ZOOM_ORDER.indexOf(state.zoom) + 1) % ZOOM_ORDER.length]; + applyZoom(); + }, + c: () => { + if (state.pr && state.viewer) { + dom.comment.focus(); + } + }, + '/': () => dom.filter.focus(), + u: () => { + dom.unviewedOnly.checked = !dom.unviewedOnly.checked; + refilter(); + }, + '?': () => dom.help.showModal(), + }; + + const action = actions[event.key]; + if (action) { + event.preventDefault(); + action(); + } + }); +}; diff --git a/src/client/scripts/links.js b/src/client/scripts/links.js new file mode 100644 index 0000000..cc71dd1 --- /dev/null +++ b/src/client/scripts/links.js @@ -0,0 +1,86 @@ +/* + * Links to images on this server and to the same files on GitHub. + * + * - GitHub links appear only for commits on origin. A branch that was never pushed + * gets plain captions and no test link. + * - The test link opens the spec at the line that took the screenshot, or the whole + * spec when the line can't be found. It is hidden when the spec is gone. + * - Image URLs carry the head commit, so after Refresh the browser never shows an + * image cached from the previous push. + */ +import { dom } from './dom.js'; +import { state } from './state.js'; + +/** @typedef {import('./state.js').Entry} Entry */ + +/** + * The file on github.com at the commit for `side`, when that commit has been pushed. + * @param {'expected' | 'actual'} side + * @param {string} path + * @returns {string | null} + */ +const githubBlob = (side, path) => { + const base = state.blobBase; + const sha = base?.[side]; + return sha ? `https://github.com/${base.repo}/blob/${sha}/${path}` : null; +}; + +/** + * Points the crumb at the spec that took the screenshot, on its line when that can be + * placed, and hides it when the spec is gone. + * @param {Entry} entry + */ +export const setTestLink = (entry) => { + const href = entry.test ? githubBlob('actual', entry.test.path) : null; + + if (!href) { + dom.crumbTest.hidden = true; + return; + } + + const file = entry.test.path.split('/').pop(); + dom.crumbTest.hidden = false; + dom.crumbTest.href = entry.test.line ? `${href}#L${entry.test.line}` : href; + dom.crumbTest.textContent = entry.test.line ? `${file}:${entry.test.line}` : file; + dom.crumbTest.title = entry.test.line + ? 'Open the line that took this screenshot' + : 'Open the spec that took this screenshot. The exact line could not be placed.'; +}; + +const CAPTION = { M: ['Expected', 'Actual'], R: ['Expected', 'Actual'], A: [null, 'Added'], D: ['Removed', null] }; + +/** + * Sets a caption to Expected, Actual, Added or Removed, linked to that version on GitHub + * when there is one. + * @param {HTMLElement} label + * @param {'expected' | 'actual'} side + * @param {string | null} path Null when this side does not exist. + * @param {Entry['status']} status + */ +export const setCaptionLink = (label, side, path, status) => { + const href = path ? githubBlob(side, path) : null; + const [expected, actual] = CAPTION[status] ?? CAPTION.M; + const text = (side === 'expected' ? expected : actual) ?? ''; + + if (!href) { + label.replaceChildren(document.createTextNode(text)); + return; + } + + const link = document.createElement('a'); + link.href = href; + link.target = '_blank'; + link.rel = 'noreferrer'; + link.textContent = text; + link.title = `Open this version on GitHub`; + label.replaceChildren(link); +}; + +/** + * This server's URL for one side of a screenshot. The commit is in the query, so a + * refresh never serves an image cached from the previous push. + * @param {'expected' | 'actual' | 'diff'} side + * @param {string} path + * @returns {string} + */ +export const blobUrl = (side, path) => `/blob?side=${side}&path=${encodeURIComponent(path)}&v=${state.head ?? ''}`; diff --git a/src/client/scripts/list.js b/src/client/scripts/list.js new file mode 100644 index 0000000..8022763 --- /dev/null +++ b/src/client/scripts/list.js @@ -0,0 +1,213 @@ +/* + * The screenshot list, its directory headers and the viewed count in the top bar. + * + * - Each row shows status, name, browser, change, a viewed tick and a comment marker. + * The marker looks different when only a teammate commented. + * - A grouped row speaks for its whole set: partly viewed when some browsers are, the + * largest change of any, and the missing-test warning if any member has no test. + * - A directory header's checkbox and count cover what the filters admit, and its + * tooltip says how many it will mark. + * - When nothing matches the filters, the list says so. + */ +import { STATUS_LABEL, dom } from './dom.js'; +import { countViewed, entryFor, groupScope, setKeyOf, setScope, shortBrowser } from './entries.js'; +import { percent } from './format.js'; +import { state } from './state.js'; + +/** @typedef {import('./state.js').Entry} Entry */ + +/** + * A row's label: the stem without the component name its directory already shows. + * @param {Entry} entry + * @returns {string} + */ +const rowLabel = (entry) => { + const prefix = `${entry.group.split('/')[0]}-`; + return entry.stem.startsWith(prefix) ? entry.stem.slice(prefix.length) : entry.stem; +}; + +/** + * Draws a row's status, label, browser, change and flags. A grouped row describes its + * whole set. + * @param {HTMLElement} row + * @param {Entry} entry The screenshot the row shows. + */ +const paintRow = (row, entry) => { + /* A grouped row answers for every browser behind it, so its tick, its comment + marker and its change figure all have to describe the set, not just the lead. */ + const kin = row.dataset.set ? setScope(row.dataset.set) : [entry]; + const seen = countViewed(kin); + + row.classList.toggle('selected', entry.path === state.current); + row.classList.toggle('is-viewed', seen === kin.length); + row.classList.toggle('is-part', seen > 0 && seen < kin.length); + + /* Both slots are always emitted and hidden when empty, so a tick keeps the same + column whether or not the row also carries a comment. */ + /* One marker for any note on this screenshot, mine or a teammate's, since what a + reviewer wants to spot in the list is that something was said about it. */ + const mine = kin.some((member) => state.comments.get(member.path)); + const theirs = kin.some((member) => (state.threads[member.path] ?? []).length > 0); + + const tickTitle = + kin.length === 1 + ? 'Viewed' + : seen === kin.length + ? `Viewed in all ${kin.length}` + : `Viewed in ${seen} of ${kin.length}`; + + const flags = [ + `βœ“`, + ``, + ].join(''); + + const ratios = kin.map((member) => member.ratio).filter((ratio) => ratio !== null && ratio !== undefined); + const ratio = ratios.length ? Math.max(...ratios) : null; + const change = + ratio === null ? '' : `${percent(ratio)}`; + + /* Grouped, any browser missing its test makes the whole row worth flagging. */ + const orphaned = kin.some((member) => member.orphaned); + + row.innerHTML = ` + + ${ + orphaned ? '' : '' + }${rowLabel(entry)} + ${ + kin.length > 1 + ? `${kin.length}` + : entry.browser + ? shortBrowser(entry.browser) + : '' + } + ${change} + ${flags} + `; +}; + +/** + * Repaints the row showing a screenshot, found through its set when grouped. + * @param {string} path + */ +export const refreshRow = (path) => { + /* Grouped, the screenshot that changed may not be the one its row is showing, so + the row is found by the set it belongs to. */ + const member = entryFor(path); + /** @type {HTMLElement | null} */ + const row = + dom.list.querySelector(`.row[data-path="${CSS.escape(path)}"]`) ?? + (state.grouped && member ? dom.list.querySelector(`.row[data-set="${CSS.escape(setKeyOf(member))}"]`) : null); + + const entry = row && entryFor(row.dataset.path); + if (row && entry) { + paintRow(row, entry); + } +}; + +/** + * Updates a directory head's checkbox, count and tooltip for what the filters admit. + * @param {HTMLElement} head + * @param {string} group + */ +const paintGroupHead = (head, group) => { + const entries = groupScope(group); + const viewed = countViewed(entries); + + /** @type {HTMLInputElement} */ + const box = head.querySelector('.group-box'); + box.checked = viewed === entries.length; + box.indeterminate = viewed > 0 && viewed < entries.length; + + head.classList.toggle('is-done', viewed === entries.length); + head.querySelector('.group-count').textContent = `${viewed}/${entries.length}`; + + /* Counts what it will actually touch rather than claiming the whole directory: a + filter narrows this, and a tooltip promising "every screenshot" would be wrong + exactly when it matters. */ + const noun = entries.length === 1 ? 'screenshot' : 'screenshots'; + /** @type {HTMLElement} */ (head.querySelector('.group-check')).title = + viewed === entries.length + ? `Unmark ${entries.length} ${noun} listed here` + : `Mark ${entries.length} ${noun} listed here viewed`; +}; + +/** + * Creates a directory head. + * @param {string} group + * @returns {HTMLElement} + */ +const buildGroupHead = (group) => { + const head = document.createElement('div'); + head.className = 'group-head'; + head.dataset.group = group; + + head.innerHTML = ` + + ${group} + + `; + + paintGroupHead(head, group); + return head; +}; + +/** Repaints every directory head. */ +export const refreshGroupHeads = () => { + for (const head of /** @type {NodeListOf} */ (dom.list.querySelectorAll('.group-head'))) { + paintGroupHead(head, head.dataset.group); + } +}; + +/** + * Repaints one directory head, rather than walking all of them when a single + * screenshot changed. + * @param {string} group + */ +export const refreshGroupHead = (group) => { + /** @type {HTMLElement | null} */ + const head = dom.list.querySelector(`.group-head[data-group="${CSS.escape(group)}"]`); + if (head) { + paintGroupHead(head, group); + } +}; + +/** Rebuilds the list from the visible entries. */ +export const renderList = () => { + if (state.visible.length === 0) { + dom.list.innerHTML = '

Nothing matches.

'; + return; + } + + const fragment = document.createDocumentFragment(); + let group = null; + + for (const entry of state.visible) { + if (entry.group !== group) { + group = entry.group; + fragment.append(buildGroupHead(group)); + } + + const row = document.createElement('button'); + row.className = 'row'; + row.dataset.path = entry.path; + if (state.grouped) { + row.dataset.set = setKeyOf(entry); + } + paintRow(row, entry); + fragment.append(row); + } + + dom.list.replaceChildren(fragment); +}; + +/** Updates the viewed count and progress bar in the top bar. */ +export const renderProgress = () => { + const total = state.entries.length; + dom.progressLabel.textContent = `${state.viewed.size} / ${total} viewed`; + dom.progressFill.style.width = total ? `${(state.viewed.size / total) * 100}%` : '0'; +}; diff --git a/src/client/scripts/navigation.js b/src/client/scripts/navigation.js new file mode 100644 index 0000000..3e328d5 --- /dev/null +++ b/src/client/scripts/navigation.js @@ -0,0 +1,156 @@ +/* + * Moving through the list and marking screenshots viewed. + * + * - j and k stop at either end. n wraps round to the top. + * - Marking a grouped row marks every browser in its set that the filters admit. + * - With unviewed only on, a marked row disappears and the selection moves to the + * row that took its place. + * - A directory checkbox, or Shift+V, marks the whole directory in one write. + * - Marks show immediately. Saving them, and syncing them to GitHub, happens in + * sync.js and on the server. + */ +import { dom } from './dom.js'; +import { entryFor, groupScope, setKeyOf, setScope } from './entries.js'; +import { applyFilter } from './filters.js'; +import { refreshGroupHead, refreshGroupHeads, refreshRow, renderList, renderProgress } from './list.js'; +import { select } from './stage.js'; +import { state } from './state.js'; +import { persist } from './sync.js'; + +/** + * Where the selection is among the visible rows. + * @returns {number} -1 when nothing visible is selected. + */ +const visibleIndex = () => state.visible.findIndex((entry) => entry.path === state.current); + +/** + * Moves the selection by `delta` rows, stopping at either end. + * @param {number} delta + */ +export const step = (delta) => { + if (!state.visible.length) { + return; + } + const index = visibleIndex(); + const next = index === -1 ? 0 : Math.min(state.visible.length - 1, Math.max(0, index + delta)); + select(state.visible[next].path); +}; + +/** Selects the next unviewed row, wrapping round to the top. */ +export const nextUnviewed = () => { + const index = visibleIndex(); + const ordered = [...state.visible.slice(index + 1), ...state.visible.slice(0, Math.max(index, 0))]; + const target = ordered.find((entry) => !state.viewed.has(entry.path)); + if (target) { + select(target.path); + } +}; + +/** + * Marks the selected screenshot viewed or not, and its whole set when grouped. + * @param {boolean} value + * @param {{ advance?: boolean }} [options] `advance` moves to the next row afterwards. + */ +export const setViewed = (value, { advance = false } = {}) => { + const path = state.current; + if (!path) { + return; + } + + const index = visibleIndex(); + const entry = entryFor(path); + + /* Grouped, the row stands for every browser behind it, so judging the one on screen + judges the set. */ + const targets = state.grouped && entry ? setScope(setKeyOf(entry)).map((member) => member.path) : [path]; + + for (const target of targets) { + if (value) { + state.viewed.add(target); + } else { + state.viewed.delete(target); + } + } + persist({ paths: targets, viewed: value }); + dom.viewed.checked = value; + renderProgress(); + + const hidden = dom.unviewedOnly.checked && value; + if (hidden) { + applyFilter(); + renderList(); + } else { + refreshRow(path); + /* The directory header carries its own tally, so marking one screenshot has to + move it too, or the header disagrees with the rows beneath it. */ + if (entry) { + refreshGroupHead(entry.group); + } + } + + if (advance) { + if (hidden) { + const target = state.visible[Math.min(index, state.visible.length - 1)]; + if (target) { + select(target.path); + } + } else { + step(1); + } + } +}; + +/** + * Marks many screenshots in a single write. + * @param {string[]} paths + * @param {boolean} value + */ +const setViewedMany = (paths, value) => { + if (paths.length === 0) { + return; + } + + for (const path of paths) { + if (value) { + state.viewed.add(path); + } else { + state.viewed.delete(path); + } + } + persist({ paths, viewed: value }); + renderProgress(); + + if (state.current && paths.includes(state.current)) { + dom.viewed.checked = value; + } + + /* Marking viewed removes rows while that filter is on, so the list has to be rebuilt + rather than repainted in place. */ + if (dom.unviewedOnly.checked) { + const anchor = state.current; + applyFilter(); + renderList(); + if (anchor && state.visible.some((entry) => entry.path === anchor)) { + select(anchor); + } else if (state.visible.length) { + select(state.visible[0].path); + } + return; + } + + for (const path of paths) { + refreshRow(path); + } + refreshGroupHeads(); +}; + +/** + * Marks the screenshots a directory's checkbox covers. + * @param {string} group + * @param {boolean} value + */ +export const toggleGroup = (group, value) => + setViewedMany( + groupScope(group).map((entry) => entry.path), + value + ); diff --git a/src/client/scripts/stage.js b/src/client/scripts/stage.js new file mode 100644 index 0000000..94031ac --- /dev/null +++ b/src/client/scripts/stage.js @@ -0,0 +1,196 @@ +/* + * The stage: the open screenshot, its comparison views, zoom and the crumbs above it. + * + * - Added and removed screenshots show their one side. Slider, onion skin and diff + * are disabled when there is nothing to compare, and a notice says why: one side + * is missing, the bytes are identical, or only the encoding changed. + * - When no pixel changed, the baseline is hidden rather than shown twice. + * - A renamed screenshot's baseline is read from its old path, and the crumbs say + * where it moved from. + * - The crumbs also show the size, the change, a resize, a newer push having changed + * it, and a missing test. + * - Fit zoom scales up to 3x. 1:1 and 2x use the screenshot's own size. + * - Opening a screenshot also loads its viewed tick, comment and teammates' comments. + */ +import { renderDelete, renderThreads } from './comments.js'; +import { COMPARING, STATUS_LABEL, VIEWS, dom } from './dom.js'; +import { entryFor, worthComparing } from './entries.js'; +import { kb, percent } from './format.js'; +import { blobUrl, setCaptionLink, setTestLink } from './links.js'; +import { state } from './state.js'; + +/** @typedef {import('./state.js').Entry} Entry */ + +const STAGE_PADDING = 40; + +const SXS_GAP = 18; + +const FIT_MAX_SCALE = 3; + +/** + * The scale that fits a screenshot on the stage, capped at 3x. Side by side gets half + * the width each. + * @param {Entry} entry + * @param {string} mode + * @returns {number} + */ +const fitScale = (entry, mode) => { + const sxs = mode === 'sxs' && entry.comparable; + const width = dom.stage.clientWidth - STAGE_PADDING; + const height = dom.stage.clientHeight - STAGE_PADDING - 24; + return Math.min((sxs ? (width - SXS_GAP) / 2 : width) / entry.size.width, height / entry.size.height, FIT_MAX_SCALE); +}; + +/** Applies the zoom to all four views through one CSS variable. */ +export const applyZoom = () => { + const entry = entryFor(state.current); + + if (entry?.size) { + const mode = entry.comparable ? state.mode : 'sxs'; + const scale = state.zoom === 'fit' ? fitScale(entry, mode) : Number(state.zoom); + document.documentElement.style.setProperty('--shot-width', `${Math.round(entry.size.width * scale)}px`); + dom.stage.classList.toggle('upscaled', scale > 1.05); + } + + for (const button of dom.zoom.querySelectorAll('button')) { + button.classList.toggle('active', button.dataset.zoom === state.zoom); + } +}; + +/** + * Shows the chosen comparison view, falling back to side by side when there is nothing + * to compare. + */ +export const applyMode = () => { + const entry = entryFor(state.current); + const comparable = worthComparing(entry); + const mode = comparable ? state.mode : 'sxs'; + + for (const button of dom.modes.querySelectorAll('button')) { + button.disabled = COMPARING.has(button.dataset.mode) && !comparable; + button.classList.toggle('active', button.dataset.mode === mode); + } + + for (const [name, view] of Object.entries(VIEWS)) { + view.classList.toggle('show', name === mode); + } + + dom.onionControl.classList.toggle('show', mode === 'onion'); +}; + +/** + * Shows a screenshot on the stage and brings the crumbs, notice, comment box and + * controls in line with it. + * @param {string} path + * @param {{ scroll?: boolean }} [options] `scroll` brings its row into view. + */ +export const select = (path, { scroll = true } = {}) => { + const entry = entryFor(path); + if (!entry) { + return; + } + + state.current = path; + + for (const row of /** @type {NodeListOf} */ (dom.list.querySelectorAll('.row'))) { + row.classList.toggle('selected', row.dataset.path === path); + } + if (scroll) { + dom.list.querySelector('.row.selected')?.scrollIntoView({ block: 'nearest' }); + } + + dom.crumbStatus.className = `badge ${entry.status}`; + dom.crumbStatus.textContent = STATUS_LABEL[entry.status]; + dom.crumbGroup.textContent = entry.group; + setTestLink(entry); + dom.crumbStem.textContent = entry.stem; + dom.crumbBrowser.textContent = entry.browser ?? ''; + dom.crumbMeta.textContent = entry.size ? `${entry.size.width} Γ— ${entry.size.height}` : ''; + dom.crumbRatio.textContent = entry.ratio === null ? '' : `${percent(entry.ratio)} changed`; + dom.crumbResize.textContent = + entry.resized && entry.expectedSize && entry.actualSize + ? `resized ${entry.expectedSize.width}Γ—${entry.expectedSize.height} β†’ ${entry.actualSize.width}Γ—${entry.actualSize.height}` + : ''; + /* + * Where it moved from, naming only the part that actually changed. These usually + * keep their filename and change directory, so repeating an identical stem would + * bury the one difference that matters. + */ + const movedDir = Boolean(entry.oldGroup) && entry.oldGroup !== entry.group; + const movedName = Boolean(entry.oldStem) && entry.oldStem !== entry.stem; + + dom.crumbRenamed.hidden = !movedDir && !movedName; + dom.crumbRenamed.textContent = + movedDir && movedName + ? `from ${entry.oldGroup} / ${entry.oldStem}` + : movedDir + ? `from ${entry.oldGroup}` + : `was ${entry.oldStem}`; + dom.crumbRenamed.title = entry.oldPath ?? ''; + + const expected = entry.status === 'A' ? null : blobUrl('expected', entry.path); + const actual = entry.status === 'D' ? null : blobUrl('actual', entry.path); + + /* The baseline is hidden when it would be the same picture twice. */ + dom.figExpected.hidden = !expected || (entry.comparable && entry.ratio === 0); + dom.figActual.hidden = !actual; + dom.sxsExpected.src = expected ?? ''; + dom.sxsActual.src = actual ?? ''; + dom.bytesExpected.textContent = kb(entry.bytes.expected); + dom.bytesActual.textContent = kb(entry.bytes.actual); + + /* A renamed screenshot's baseline lives at its old path. */ + setCaptionLink(dom.labelExpected, 'expected', expected ? (entry.oldPath ?? entry.path) : null, entry.status); + setCaptionLink(dom.labelActual, 'actual', actual ? entry.path : null, entry.status); + + if (worthComparing(entry)) { + dom.sliderExpected.src = expected; + dom.sliderActual.src = actual; + /* The frame must be sized by whichever version is taller, or the shorter one + would clip the taller. */ + dom.onionSizer.src = (entry.actualSize?.height ?? 0) >= (entry.expectedSize?.height ?? 0) ? actual : expected; + dom.onionExpected.src = expected; + dom.onionActual.src = actual; + dom.diffImage.src = blobUrl('diff', entry.path); + dom.diffCount.textContent = entry.changed === null ? '' : `${entry.changed.toLocaleString()} pixels`; + dom.notice.classList.remove('show'); + } else { + dom.notice.classList.add('show'); + + /* Three ways to end up here, and they are worth telling apart: one side is + missing, the bytes are the same, or only the encoding changed. The caption + already says added or removed, so this explains what it does not. */ + if (!entry.comparable) { + dom.notice.textContent = 'Only one version of this screenshot exists, so there is nothing to compare.'; + } else if (entry.expectedSha === entry.actualSha) { + /* The badge and the crumbs already say it was renamed and where from, and they + say it for every rename, including the ones that also changed and so never + reach this notice. All that is left to explain is the empty stage. */ + dom.notice.textContent = 'Byte for byte identical to the original, so there is nothing to compare.'; + } else { + dom.notice.textContent = 'No pixel changed. Only the encoding differs, so there is nothing to see.'; + } + } + + /* One hook for the stage, so the removed treatment lives in CSS rather than being + assembled here. */ + dom.stage.dataset.status = entry.status; + + /* Flagged in the crumbs rather than explained again. The sidebar already carries + the sentence and the button; repeating both next to the image said the same + thing twice with two identical buttons. */ + dom.crumbOutdated.hidden = !state.changedByPush.has(entry.path); + + dom.crumbOrphan.hidden = !entry.orphaned; + dom.crumbOrphan.title = 'It will keep being regenerated until the file is deleted.'; + + dom.viewed.checked = state.viewed.has(entry.path); + dom.comment.value = state.comments.get(entry.path) ?? ''; + renderThreads(); + renderDelete(); + dom.commentState.textContent = state.stale.has(entry.path) ? 'Written before this screenshot changed' : ''; + dom.comment.classList.toggle('stale', state.stale.has(entry.path)); + + applyMode(); + applyZoom(); +}; diff --git a/src/client/scripts/state.js b/src/client/scripts/state.js new file mode 100644 index 0000000..a78783e --- /dev/null +++ b/src/client/scripts/state.js @@ -0,0 +1,87 @@ +/* + * The one mutable state object every script reads and writes. + * + * - Filled from the manifest on load, and again after Refresh reloads the page. + * - Nothing here is saved by itself. Viewed marks and comments persist through the + * server, and preferences through localStorage in their own scripts. + */ + +/** + * One screenshot in the review, as the server sends it. + * @typedef {object} Entry + * @property {string} path + * @property {string} file + * @property {string} dir + * @property {string} group The directory as shown, such as "textarea/test/slot". + * @property {string} stem The file name without browser, platform or extension. + * @property {string | null} browser + * @property {'A' | 'M' | 'D' | 'R'} status + * @property {string | null} mode + * @property {string | null} theme + * @property {string | null} direction + * @property {string | null} palette + * @property {string | null} oldPath Where a renamed screenshot's baseline lives. + * @property {string | null} oldGroup + * @property {string | null} oldStem + * @property {string | null} expectedSha + * @property {string | null} actualSha + * @property {boolean} comparable Both sides exist. + * @property {number | null} ratio Changed pixels as a share of the canvas. + * @property {number | null} changed Changed pixels. + * @property {boolean} resized + * @property {{ width: number, height: number } | null} size + * @property {{ width: number, height: number } | null} expectedSize + * @property {{ width: number, height: number } | null} actualSize + * @property {{ expected: number | null, actual: number | null }} bytes + * @property {{ path: string, line: number | null } | null} test The spec that took it. + * @property {boolean} orphaned Still in the range, but no spec writes it. + */ + +export const state = { + entries: [], + visible: [], + viewed: new Set(), + comments: new Map(), + /* Comments written against a version of the screenshot that has since changed. */ + stale: new Set(), + /* Screenshots a push has rewritten since this server started. */ + changedByPush: new Set(), + pushedHead: null, + /* Review threads already on the pull request, keyed by path. */ + threads: {}, + /* Comments not yet on the pull request, and the pull request to post them to. */ + unsent: new Set(), + /* Comments that are on the pull request right now. */ + posted: new Set(), + pr: null, + /* OPEN, CLOSED or MERGED. Only an open pull request takes new comments. */ + prState: null, + /* The GitHub account gh is logged in as, or null when it has none. */ + viewer: null, + /* Every screenshot in a directory, keyed by directory. */ + groups: new Map(), + /* The same screenshot across browsers, keyed by directory and name. */ + sets: new Map(), + /* Whether the list shows one row per test rather than one per browser. */ + grouped: false, + /* The paths the content filters currently admit, which is what a directory means + while a filter is on. */ + inScope: new Set(), + /* Per facet, the values the list is restricted to. An empty set means no + restriction on that facet. */ + facets: { + status: new Set(), + browser: new Set(), + mode: new Set(), + theme: new Set(), + direction: new Set(), + palette: new Set(), + }, + current: null, + /* The commit this page is showing, used to version image URLs. */ + head: null, + /* Repo and the two commits, when they are pushed and can be linked to. */ + blobBase: null, + mode: 'slider', + zoom: 'fit', +}; diff --git a/src/client/scripts/sync.js b/src/client/scripts/sync.js new file mode 100644 index 0000000..4213bf5 --- /dev/null +++ b/src/client/scripts/sync.js @@ -0,0 +1,126 @@ +/* + * Writes to the server, and noticing a newer push. + * + * - Every viewed or comment change is saved through the server. A save that fails is + * dropped silently. + * - When a viewed mark doesn't reach GitHub, the viewed count is marked out of sync, + * and its tooltip names the files GitHub refused or gives the error. + * - A newer push shows a banner with Refresh. It is urgent when the push changed + * something just marked, naming the file if it was one, and quiet otherwise. + * - Refresh rebuilds the review on the newer push and reloads. If that fails, the + * button says so and the current review stays. + */ +import { fileOf } from './comments.js'; +import { dom } from './dom.js'; +import { select } from './stage.js'; +import { state } from './state.js'; + +/** + * Saves a viewed or comment change, then reports a newer push or a failed GitHub sync + * from the reply. + * @param {{ path?: string, paths?: string[], viewed?: boolean, comment?: string }} patch + * @returns {Promise} + */ +export const persist = (patch) => + fetch('/api/state', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), + }) + .then((response) => response.json()) + .then((result) => { + if (result?.freshness?.moved) { + notePush(result.freshness); + } + /* A tick that did not reach GitHub has to say so, or the two tallies drift + apart silently and the Files tab quietly disagrees. Named, because "some did + not sync" is not something a reviewer can act on. */ + const mirrored = result?.mirrored; + + if (mirrored && !mirrored.ok) { + const refused = mirrored.refused ?? []; + dom.progressLabel.title = refused.length + ? `GitHub would not accept ${refused.length} of these:\n${refused + .map((path) => path.split('/').pop()) + .join('\n')}` + : `Not synced to GitHub: ${mirrored.error}`; + dom.progressLabel.classList.add('out-of-sync'); + if (refused.length) { + console.warn('Not marked viewed on the pull request:', refused); + } + } else if (mirrored?.ok) { + dom.progressLabel.title = ''; + dom.progressLabel.classList.remove('out-of-sync'); + } + }) + .catch(() => {}); + +/** + * Asks whether a newer push has landed and shows the banner if it has. Called on load + * and on tab focus, so a finished review still notices. + * @returns {Promise} + */ +export const checkFreshness = () => + fetch('/api/freshness') + .then((response) => response.json()) + .then((result) => { + if (result?.moved) { + notePush(result); + } + }) + .catch(() => {}); + +/** + * Shows the push banner: urgent when the push changed something just marked, quiet + * when it did not. + * @param {{ head: string, changed?: string[] }} freshness + */ +const notePush = ({ head, changed = [] }) => { + state.pushedHead = head; + state.changedByPush = new Set([...state.changedByPush, ...changed]); + + dom.pushed.hidden = false; + dom.pushed.classList.toggle('urgent', changed.length > 0); + + const message = + changed.length === 1 + ? `${fileOf(changed[0])} changed in a newer push.` + : changed.length > 1 + ? `${changed.length} of the screenshots you just marked changed in a newer push.` + : `A newer push is available (${head.slice(0, 7)}). Nothing you have marked has changed.`; + + dom.pushed.replaceChildren(document.createTextNode(`${message} `), refreshButton()); + + /* Repaint, so the warning lands on the screenshot currently open as well. */ + if (state.current) { + select(state.current, { scroll: false }); + } +}; + +/** + * A button that rebuilds the review on the newer push, then reloads. + * @returns {HTMLButtonElement} + */ +const refreshButton = () => { + const button = document.createElement('button'); + button.className = 'refresh'; + button.textContent = 'Refresh'; + + button.addEventListener('click', async () => { + button.disabled = true; + button.textContent = 'Refreshing…'; + + try { + const result = await fetch('/api/refresh', { method: 'POST' }).then((response) => response.json()); + if (!result?.ok) { + throw new Error('refresh failed'); + } + window.location.reload(); + } catch { + button.disabled = false; + button.textContent = 'Refresh failed, try again'; + } + }); + + return button; +}; diff --git a/src/client/scripts/theme.js b/src/client/scripts/theme.js new file mode 100644 index 0000000..1a492e0 --- /dev/null +++ b/src/client/scripts/theme.js @@ -0,0 +1,45 @@ +/* + * Light, dark and system theme. + * + * - System follows the operating system, and is the default. + * - The choice is remembered in this browser. Where storage is blocked, as in a + * private window, it still applies until the page closes. + */ +import { dom } from './dom.js'; + +const THEME_KEY = 'snapshot-review-theme'; + +/** @typedef {'system' | 'light' | 'dark'} Theme */ + +/** + * Applies a theme and remembers it in this browser. System removes the attribute, so the + * stylesheet follows the operating system. + * @param {Theme} choice + */ +export const applyTheme = (choice) => { + if (choice === 'system') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.dataset.theme = choice; + } + + try { + localStorage.setItem(THEME_KEY, choice); + } catch { + /* Private windows and blocked storage. The choice still applies for this page. */ + } +}; + +/** Applies the remembered theme, or system when there is none. */ +export const restoreTheme = () => { + let stored = 'system'; + try { + stored = localStorage.getItem(THEME_KEY) ?? 'system'; + } catch { + /* Unreadable storage reads as no preference. */ + } + + const choice = /** @type {Theme} */ (['system', 'light', 'dark'].includes(stored) ? stored : 'system'); + dom.theme.value = choice; + applyTheme(choice); +}; diff --git a/src/client/styles/base.css b/src/client/styles/base.css new file mode 100644 index 0000000..1644751 --- /dev/null +++ b/src/client/styles/base.css @@ -0,0 +1,122 @@ +/* Reset, shared utilities, form controls and the page skeleton. */ + +* { + box-sizing: border-box; +} + +html, +/* + * No font smoothing override. Antialiased is the reflex on a dark theme and it is + * the wrong one: it thins light text on a dark background, which is already the + * direction the eye errs. The default rendering is what keeps this legible at 11px. + */ +body { + height: 100%; + + margin: 0; + + background: var(--bg); + color: var(--text); + + font: + 13px/1.5 -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + system-ui, + sans-serif; + + overflow: hidden; +} + +button, +input, +select, +textarea { + color: inherit; + + font: inherit; +} + +.mono { + font-family: var(--mono); + font-size: 11px; +} + +.dim { + color: var(--dim); +} + +.strong { + font-weight: 500; +} + +.spacer { + flex: 1; +} + +.layout { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + + height: calc(100% - 48px); +} + +.field { + width: 100%; + + padding: 6px 9px; + + border: 1px solid var(--line); + border-radius: 6px; + + outline: none; + + background: var(--bg); +} + +.field:focus { + border-color: var(--accent); +} + +.field-select { + width: auto; + + margin-left: auto; + + padding: 4px 6px; + + font-size: 11px; +} + +/* Beats the class rule below, which would otherwise keep a hidden checkbox visible. */ +.check[hidden] { + display: none; +} + +.check { + display: inline-flex; + + align-items: center; + + color: var(--muted); + + white-space: nowrap; + + cursor: pointer; + user-select: none; + gap: 6px; +} + +.check input { + margin: 0; + accent-color: var(--accent); +} + +.main { + display: flex; + + flex-direction: column; + + min-width: 0; + min-height: 0; +} diff --git a/src/client/styles/comment.css b/src/client/styles/comment.css new file mode 100644 index 0000000..5d55f15 --- /dev/null +++ b/src/client/styles/comment.css @@ -0,0 +1,249 @@ +/* Comment box, teammate threads, and the publish and delete buttons. */ + +.delete-comment { + margin-left: auto; + + padding: 1px 8px; + + border: 1px solid var(--line); + border-radius: 4px; + + background: transparent; + color: var(--muted); + + font: inherit; + font-size: 11px; + + cursor: pointer; +} + +.delete-comment:hover:not(:disabled) { + border-color: var(--removed); + + color: var(--removed); +} + +.delete-comment[hidden] { + display: none; +} + +.publish { + padding: 3px 10px; + + border: 1px solid var(--accent); + border-radius: 5px; + + background: var(--accent-soft); + color: var(--accent); + + font: inherit; + font-size: 11px; + + cursor: pointer; +} + +.publish:hover:not(:disabled) { + background: var(--accent); + color: var(--bg); +} + +.publish:disabled { + cursor: default; + opacity: 0.6; +} + +.publish[hidden] { + display: none; +} + +.comment { + padding: 10px 16px 12px; + + border-top: 1px solid var(--line); + + background: var(--panel); +} + +.comment-head { + display: flex; + + align-items: baseline; + justify-content: space-between; + + margin-bottom: 6px; + + color: var(--muted); + + font-size: 11px; + font-weight: 500; + /* An explicit gap, because the delete button's auto margin swallows the free space + that space-between would otherwise distribute, leaving the label and the status + touching the moment that button appears. */ + gap: 10px; +} + +.threads { + display: flex; + + flex-direction: column; + /* Room for a question and its answer without scrolling, which is the common shape. + Anything longer scrolls rather than pushing the comment box off screen. */ + max-height: 230px; + + margin-bottom: 8px; + gap: 8px; + overflow-y: auto; +} + +.threads[hidden] { + display: none; +} + +.thread { + padding: 6px 9px; + + border-left: 2px solid var(--accent); + border-radius: 0 5px 5px 0; + + background: var(--panel); +} + +.threads-count { + margin: 0; + + color: var(--dim); + + font-size: 11px; +} + +.thread-comment + .thread-comment { + margin-top: 6px; + + padding-top: 6px; + + border-top: 1px solid var(--line-soft); +} + +/* A reply is indented so a two-sided exchange reads as one conversation rather than + two opinions of equal standing. */ +.thread-comment.is-reply { + padding-left: 10px; +} + +.thread-tag { + color: var(--dim); + + font-size: 11px; +} + +.thread.is-resolved { + border-left-color: var(--line); + + opacity: 0.6; +} + +.thread-author { + color: var(--accent); + + font-size: 11px; + font-weight: 600; + + text-decoration: none; +} + +.thread-author:hover { + text-decoration: underline; +} + +.thread-body { + margin: 3px 0 0; + + color: var(--muted); + + font-size: 11px; +} + +.thread-body p { + margin: 0 0 4px; +} + +.thread-body p:last-child { + margin-bottom: 0; +} + +.thread-body code { + padding: 1px 4px; + + border-radius: 4px; + + background: var(--line-soft); + color: var(--text); + + font-family: var(--mono); + font-size: 11px; +} + +.thread-body pre { + margin: 4px 0; + + padding: 7px 9px; + + border-radius: 5px; + + background: var(--line-soft); + overflow-x: auto; +} + +.thread-body pre code { + padding: 0; + + background: none; +} + +.thread-body a { + color: var(--accent); + /* Comment links are often long commit or preview URLs, and one of them should not + push the panel wider than the screenshot it is about. */ + overflow-wrap: anywhere; +} + +.thread-body ul, +.thread-body ol { + margin: 4px 0; + + padding-left: 18px; +} + +.thread-body blockquote { + margin: 4px 0; + + padding-left: 8px; + + border-left: 2px solid var(--line); + + color: var(--dim); +} + +.comment textarea { + width: 100%; + + padding: 8px 10px; + + border: 1px solid var(--line); + border-radius: 6px; + + outline: none; + + background: var(--bg); + + font-family: inherit; + resize: vertical; +} + +.comment textarea.stale { + border-color: var(--modified); +} + +.comment textarea:focus { + border-color: var(--accent); +} diff --git a/src/client/styles/crumbs.css b/src/client/styles/crumbs.css new file mode 100644 index 0000000..a6c58ac --- /dev/null +++ b/src/client/styles/crumbs.css @@ -0,0 +1,239 @@ +/* Above the stage: crumbs, flags, and the view and zoom controls. */ + +.crumbs, +.toolbar { + display: flex; + + align-items: center; + gap: 12px; + + padding: 9px 16px; + + border-bottom: 1px solid var(--line-soft); +} + +.toolbar { + background: var(--panel); +} + +.badge { + padding: 2px 7px; + + border-radius: 20px; + + font-size: 11px; + font-weight: 600; + + letter-spacing: 0.03em; + + text-transform: uppercase; +} + +.badge.M { + background: var(--modified-soft); + color: var(--modified); +} + +.badge.A { + background: var(--added-soft); + color: var(--added); +} + +.badge.D { + background: var(--removed-soft); + color: var(--removed); +} + +.badge.R { + background: var(--renamed-soft); + color: var(--renamed); +} + +.chip { + padding: 1px 6px; + + border: 1px solid var(--line); + border-radius: 4px; + + color: var(--muted); + + font-size: 11px; +} + +.chip:empty { + display: none; +} + +.ratio { + padding: 1px 7px; + + border-radius: 4px; + + background: var(--modified-soft); + color: var(--modified); + + font-family: var(--mono); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.ratio:empty { + display: none; +} + +/* Flags a pair whose dimensions changed, which is information neither GitHub nor a + plain overlay surfaces. */ +/* Reads as part of the path it follows, not as a button. */ +.test-link { + color: var(--dim); + + font-family: var(--mono); + font-size: 12px; + + text-decoration: none; +} + +.test-link:hover { + color: var(--accent); + + text-decoration: underline; +} + +.test-link[hidden] { + display: none; +} + +.renamed-from { + padding: 1px 6px; + + border-radius: 4px; + + background: var(--renamed-soft); + color: var(--renamed); + + font-family: var(--mono); + font-size: 11px; +} + +.renamed-from[hidden] { + display: none; +} + +.resize { + padding: 1px 7px; + + border-radius: 4px; + + background: var(--accent-tint); + color: var(--accent); + + font-family: var(--mono); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.resize:empty { + display: none; +} + +.segmented { + display: inline-flex; + + border: 1px solid var(--line); + border-radius: 7px; + + overflow: hidden; +} + +.segmented button { + padding: 5px 11px; + + border: none; + border-right: 1px solid var(--line); + + background: none; + color: var(--muted); + + white-space: nowrap; + + cursor: pointer; +} + +.segmented button:last-child { + border-right: none; +} + +.segmented button:hover:not(:disabled) { + color: var(--text); +} + +.segmented button.active { + background: var(--accent); + color: var(--on-accent); +} + +.segmented button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.segmented.small button { + padding: 4px 9px; + + font-size: 11px; +} + +.range-control { + display: none; + + align-items: center; + gap: 8px; + + color: var(--muted); + + font-size: 11px; +} + +.range-control.show { + display: flex; +} + +.range-control input { + width: 110px; + accent-color: var(--accent); +} + +.outdated { + padding: 1px 6px; + + border-radius: 4px; + + background: var(--modified-soft); + color: var(--modified); + + font-size: 11px; +} + +.outdated[hidden] { + display: none; +} + +/* A screenshot with no spec behind it. Stated in words rather than as a badge, because + a badge among other badges was read as just more metadata and skipped. */ +.orphan { + display: inline-flex; + gap: 5px; + + align-items: center; + + color: var(--removed); + + font-size: 11px; + font-weight: 600; + /* Kept on one line: wrapped, it read as two fragments rather than one warning. */ + white-space: nowrap; +} + +.orphan[hidden] { + display: none; +} diff --git a/src/client/styles/help.css b/src/client/styles/help.css new file mode 100644 index 0000000..b309d40 --- /dev/null +++ b/src/client/styles/help.css @@ -0,0 +1,156 @@ +/* Help dialog and key caps. */ + +.help { + /* Two columns of readable measure. At 340px in one column almost every line + wrapped, which is what made a short reference feel like a wall. */ + width: min(560px, calc(100vw - 48px)); + max-height: calc(100vh - 64px); + overflow-y: auto; + + padding: 20px 24px 18px; + + border: 1px solid var(--line); + border-radius: 10px; + + background: var(--raised); + color: var(--text); +} + +.help::backdrop { + background: var(--backdrop); +} + +/* Each heading opens a section, so it needs room above it. The first does not, since + the dialog's own padding already provides that. */ +/* One column, wide enough that a bullet is a line rather than a paragraph. Splitting + into two at this width gave each side barely more measure than the old 340px, so + the text wrapped just as hard and only the shape changed. + flow-root stops a section swallowing its first heading's top margin. */ +.help-columns > h2:first-child { + margin-top: 0; +} + +.help h2 { + margin: 26px 0 12px; + + font-size: 13px; +} + +.help dl { + display: grid; + /* Wide enough for four key caps side by side. */ + grid-template-columns: 104px 1fr; + gap: 6px 12px; + + margin: 0; +} + +.help dt { + display: flex; + gap: 3px; + + align-items: center; + + color: var(--accent); + + font-family: var(--mono); + font-size: 11px; +} + +/* Key caps. The heavier bottom border is what reads as depth without a shadow. */ +.help kbd { + min-width: 20px; + + padding: 2px 5px 1px; + + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 4px; + + background: var(--panel); + color: var(--text); + + font-family: var(--mono); + font-size: 10px; + + line-height: 1.4; + + text-align: center; +} + +.help .key-sep { + color: var(--dim); + + font-size: 10px; +} + +.help dd { + margin: 0; + + color: var(--muted); +} + +/* + * The legend shows the real markers rather than naming their colors. A swatch is an + * inline span, so it needs blockifying here; in the list it is a grid item and gets + * that for free. + */ +.help .legend dt { + display: flex; + + align-items: center; + + min-height: 18px; +} + +/* The keyboard column is sized for "1 - 4" and "Esc". A swatch needs far less, and + at the wider width the markers drifted away from what they describe. */ +.help .legend { + grid-template-columns: 32px 1fr; +} + +.help .legend .dot { + display: inline-block; +} + +.help .legend .tick { + color: var(--added); +} + +.help .legend .note { + color: var(--accent); +} + +/* Body copy in the dialog is all one color. The bullet sections were carrying the + dim class, which is a good deal darker than the definition lists beside them. */ +.help ul { + margin: 14px 0 0; + + padding-left: 16px; + + color: var(--muted); +} + +.help li { + margin-bottom: 4px; +} + +.help li b { + color: var(--muted); + + font-family: var(--mono); + font-weight: 400; +} + +.help p { + margin: 14px 0 0; + + padding-top: 12px; + /* The soft line is nearly the same value as this dialog's background. The dialog's + own border is what a rule on top of it has to match. */ + border-top: 1px solid var(--line); + + color: var(--muted); + + font-size: 11px; +} diff --git a/src/client/styles/list.css b/src/client/styles/list.css new file mode 100644 index 0000000..a31abc5 --- /dev/null +++ b/src/client/styles/list.css @@ -0,0 +1,290 @@ +/* Snapshot list: directory heads, rows and their markers. */ + +.list { + flex: 1; + + min-height: 0; + overflow-y: auto; + + padding-bottom: 40px; +} + +.group-head { + display: grid; + position: sticky; + top: 0; + + align-items: center; + + padding: 7px 12px 7px 10px; + + border-bottom: 1px solid var(--line-soft); + + background: var(--raised); + color: var(--muted); + + font-family: var(--mono); + font-size: 11px; + + z-index: 1; + grid-template-columns: auto 1fr auto; + grid-template-areas: 'check name count'; + gap: 2px 8px; +} + +.group-head.is-done { + color: var(--dim); +} + +.group-check { + grid-area: check; + + display: flex; + + align-items: center; + + padding: 2px; + + cursor: pointer; +} + +.group-box { + margin: 0; + accent-color: var(--accent); + + cursor: pointer; +} + +.group-name { + color: var(--text); + + text-overflow: ellipsis; + + white-space: nowrap; + + overflow: hidden; + grid-area: name; +} + +.group-head.is-done .group-name { + color: var(--muted); +} + +.group-count { + grid-area: count; + + color: var(--dim); + font-variant-numeric: tabular-nums; +} + +.row { + display: grid; + + align-items: center; + + width: 100%; + + padding: 5px 12px; + + border: none; + border-left: 2px solid transparent; + + background: none; + + text-align: left; + + cursor: pointer; + /* + * Every track is fixed except the name, because each row is its own grid: an auto + * track sizes to that row's own content, so "Chrome" and "Firefox" landed at + * different x positions down the list. The name takes what is left and truncates. + */ + grid-template-columns: 8px 1fr 44px 40px 26px; + gap: 8px; +} + +.row:hover { + background: var(--hover); +} + +.row.selected { + border-left-color: var(--accent); + + background: var(--accent-soft); +} + +.row.is-viewed .row-stem { + color: var(--dim); +} + +.dot { + width: 6px; + height: 6px; + + border-radius: 50%; +} + +.dot.M { + background: var(--modified); +} + +.dot.A { + background: var(--added); +} + +.dot.D { + background: var(--removed); +} + +.dot.R { + background: var(--renamed); +} + +.row-stem { + font-family: var(--mono); + font-size: 11px; + + text-overflow: ellipsis; + + white-space: nowrap; + + overflow: hidden; +} + +/* The browser column becomes a count when one row answers for several screenshots. */ +.row-kin { + display: inline-block; + + min-width: 17px; + + padding: 0 4px; + + border-radius: 9px; + + background: var(--line); + color: var(--muted); + + font-size: 11px; + + line-height: 16px; + + text-align: center; +} + +.row.is-part .tick { + opacity: 0.45; +} + +.row-browser { + color: var(--dim); + + font-size: 11px; + + text-overflow: ellipsis; + + white-space: nowrap; + + overflow: hidden; +} + +/* Change magnitude, which only exists because pixelmatch gives us a pixel count. */ +.row-change { + display: flex; + + align-items: center; + /* Right aligned so the percent signs line up down the column. */ + justify-content: flex-end; + + color: var(--muted); + + font-family: var(--mono); + font-size: 11px; + gap: 5px; + font-variant-numeric: tabular-nums; +} + +.row-flags { + display: flex; + gap: 3px; + /* Centred against the tick, which is a text glyph sitting on its own baseline. */ + align-items: center; + justify-content: flex-end; + + color: var(--dim); + + font-size: 11px; +} + +.row-flags .off { + visibility: hidden; +} + +.row-flags .tick { + flex: 0 0 auto; + + color: var(--added); +} + +/* + * A speech bubble, drawn as a mask so the row and the legend share one definition and + * the shape still takes its color from whatever it sits in. + */ +.note { + display: inline-block; + /* Must not shrink: it shares a narrow column with the viewed tick, and a flex item + with a fixed width will still be squashed without this. */ + flex: 0 0 auto; + + width: 11px; + height: 11px; + + background: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 2h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7.5L4 14.8V12H3a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z'/%3E%3C/svg%3E") + center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 2h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7.5L4 14.8V12H3a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.row-flags .note { + color: var(--accent); +} + +.empty { + padding: 24px 14px; + + color: var(--dim); + + text-align: center; +} + +/* Shared by the crumb and the list row, so the same shape means the same thing in + both places. */ +.warn { + display: inline-block; + + flex: 0 0 auto; + + width: 12px; + height: 12px; + + background: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8.9 1.5a1 1 0 0 0-1.8 0l-6.8 12A1 1 0 0 0 1.2 15h13.6a1 1 0 0 0 .9-1.5ZM8 5.2c.5 0 .8.4.8.9l-.2 3.4a.6.6 0 0 1-1.2 0l-.2-3.4c0-.5.3-.9.8-.9Zm0 6a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8Z'/%3E%3C/svg%3E") + center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8.9 1.5a1 1 0 0 0-1.8 0l-6.8 12A1 1 0 0 0 1.2 15h13.6a1 1 0 0 0 .9-1.5ZM8 5.2c.5 0 .8.4.8.9l-.2 3.4a.6.6 0 0 1-1.2 0l-.2-3.4c0-.5.3-.9.8-.9Zm0 6a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8Z'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.row .warn { + width: 11px; + height: 11px; + margin-inline-end: 5px; + + color: var(--removed); + + vertical-align: -1px; +} + +/* A teammate's note, when you have not written one yourself. */ +.row-flags .note.theirs { + color: var(--muted); +} diff --git a/src/client/styles/sidebar.css b/src/client/styles/sidebar.css new file mode 100644 index 0000000..3a9ac40 --- /dev/null +++ b/src/client/styles/sidebar.css @@ -0,0 +1,193 @@ +/* Sidebar: filter, checkboxes, facets, and the push and carried-over banners. */ + +.sidebar { + display: flex; + + flex-direction: column; + + min-height: 0; + + border-right: 1px solid var(--line); + + background: var(--panel); +} + +.controls { + display: flex; + + flex-direction: column; + gap: 8px; + + padding: 10px; + + border-bottom: 1px solid var(--line-soft); +} + +.control-row { + display: flex; + /* Wraps rather than overflowing: the row gains a checkbox when the range holds + screenshots with no test, and three will not fit the sidebar on one line. */ + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; +} + +.facets { + display: flex; + + flex-direction: column; + gap: 1px; + + padding: 5px 8px 6px; + + border-bottom: 1px solid var(--line-soft); + + color: var(--muted); +} + +/* One row per facet, named on the left. A facet with only one possible value is not + rendered at all, so a light-only, ltr-only range shows just the status row. */ +.facet { + display: grid; + /* Wide enough for "direction", the longest facet name. */ + grid-template-columns: 64px 1fr; + gap: 4px; + + align-items: start; +} + +.facet-name { + padding-top: 4px; + + color: var(--dim); + + text-overflow: ellipsis; + + overflow: hidden; +} + +.facet-chips { + display: flex; + + flex-wrap: wrap; + gap: 4px; +} + +.count { + padding: 3px 7px; + + border: 1px solid transparent; + border-radius: 5px; + + background: transparent; + color: inherit; + + font: inherit; + + cursor: pointer; +} + +.count b { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.count:hover { + border-color: var(--line); + + color: var(--text); +} + +.count.active { + border-color: transparent; + + background: var(--accent-soft); + color: var(--accent); +} + +.count.active b { + color: var(--accent); +} + +/* While a facet is filtering, its unselected values are dimmed rather than hidden, so + the row still reads as the whole range and not as a shrinking list. */ +.facet.filtering .count:not(.active) { + opacity: 0.45; +} + +.carried { + margin: 0; + + padding: 7px 12px; + + border-bottom: 1px solid var(--line-soft); + + background: var(--accent-soft); + color: var(--accent); + + font-size: 11px; +} + +.carried[hidden] { + display: none; +} + +.pushed { + margin: 0; + + padding: 7px 12px; + + border-bottom: 1px solid var(--line-soft); + + background: var(--accent-soft); + color: var(--accent); + + font-size: 11px; +} + +/* A push that rewrote something already marked is a correctness problem, not news. */ +.pushed.urgent { + background: var(--modified-soft); + color: var(--modified); +} + +.refresh { + padding: 1px 8px; + + border: 1px solid currentColor; + border-radius: 4px; + + background: transparent; + color: inherit; + + font: inherit; + + cursor: pointer; +} + +/* + * Filled with the banner's own color, not inverted. invert(1) flipped the painted + * pixels to whatever the mathematical inverse happened to be, which lands outside the + * palette: the amber urgent banner turned blue and the blue one turned orange. + */ +.refresh:hover:not(:disabled) { + border-color: var(--accent); + + background: var(--accent); + color: var(--bg); +} + +.pushed.urgent .refresh:hover:not(:disabled) { + border-color: var(--modified); + + background: var(--modified); +} + +.refresh:disabled { + cursor: default; + opacity: 0.6; +} + +.pushed[hidden] { + display: none; +} diff --git a/src/client/styles/stage.css b/src/client/styles/stage.css new file mode 100644 index 0000000..591407c --- /dev/null +++ b/src/client/styles/stage.css @@ -0,0 +1,218 @@ +/* Stage: the panes, the notice strip, onion skin and the slider. */ + +/* + * A column, so the notice explaining a one-sided screenshot sits under the image + * rather than beside it. Centring is left to the children's auto margins, which + * unlike align-items do not clip an image larger than the stage at 1:1 or 2x. + */ +.stage { + display: flex; + + flex: 1; + flex-direction: column; + /* The safe keyword centres while content fits and falls back to start when it does + not, which is what keeps a 2x screenshot from having its top and left cut off. */ + align-items: safe center; + justify-content: safe center; + + min-height: 0; + + padding: 20px; + + background: repeating-conic-gradient(var(--checker-a) 0% 25%, var(--checker-b) 0% 50%) 50% / 18px 18px; + + overflow: auto; + gap: 14px; +} + +.stage.upscaled img { + image-rendering: pixelated; +} + +/* + * A removed screenshot is still shown, because a filename cannot tell you which + * visual state stopped being covered. It is muted so it reads as gone rather than as + * something to review, and comes back to full strength on hover for the times it does + * need a closer look. + */ +.stage[data-status='D'] .pane { + transition: opacity 120ms ease; + + opacity: 0.45; +} + +.stage[data-status='D'] .pane:hover { + opacity: 1; +} + +.stage[data-status='D'] #labelExpected { + color: var(--removed); +} + +.view { + display: none; + /* Centring belongs to the stage now; an auto margin here would eat the free space + and push the notice away from the image it describes. */ + flex: 0 0 auto; +} + +.view.show { + display: flex; + gap: 18px; + + align-items: flex-start; + justify-content: center; +} + +figure { + display: flex; + + flex-direction: column; + + min-width: 0; + + margin: 0; + gap: 7px; +} + +figure[hidden] { + display: none; +} + +figcaption a { + color: inherit; + text-decoration-color: var(--line); + text-underline-offset: 2px; +} + +figcaption a:hover { + color: var(--accent); + text-decoration-color: currentColor; +} + +figcaption { + display: flex; + gap: 8px; + + align-items: baseline; + + color: var(--muted); + + font-size: 11px; + font-weight: 500; +} + +.pane { + border: 1px solid var(--line); + /* Square, deliberately. A radius here clipped the screenshot's own corners, which + made a component look rounded when it was not. Nothing may alter the pixels. */ + /* Literal white in both themes: the browser rendered these screenshots on white, + so anything else would misrepresent them. */ + background: #fff; + + line-height: 0; + + overflow: hidden; +} + +.pane img { + display: block; + + width: var(--shot-width); + max-width: none; + height: auto; +} + +/* Onion skin is two stacked layers, which no package is needed for. */ +.frame { + position: relative; +} + +.sizer { + visibility: hidden; +} + +/* Height stays automatic. Stretching a layer to the frame's height would squash one + version onto the other's dimensions, and most pairs in a regeneration differ in + height, which is exactly the change under review. */ +.layer { + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: auto; + pointer-events: none; +} + +.layer-top { + opacity: var(--onion, 0.5); +} + +/* The slider is a web component. It only needs sizing and handle colors. */ +img-comparison-slider { + --divider-width: 2px; + --divider-color: var(--accent); + --default-handle-color: var(--accent); + --default-handle-opacity: 1; + --default-handle-width: 40px; + display: block; + + width: var(--shot-width); +} + +img-comparison-slider img { + width: 100%; +} + +/* Sits above the stage rather than inside it: the stage scrolls, and a note saying + there is nothing to look at is no use once a tall screenshot has pushed it off. */ +.notice { + display: none; + + flex: 0 0 auto; + gap: 8px; + + align-items: center; + + margin: 0; + + padding: 8px 16px; + + border-bottom: 1px solid var(--line-soft); + /* Tinted rather than panel-colored, because sharing the toolbar's background made + it read as another strip of chrome instead of as something to notice. */ + background: var(--accent-soft); + /* Body text on the tint, not accent on tint: the accent pair only reaches 4.3:1 in + dark mode. The icon carries the color instead. */ + color: var(--text); + + font-size: 12px; +} + +/* A circled i, drawn rather than fetched, so the strip announces itself as a note. */ +.notice::before { + flex: 0 0 auto; + + width: 14px; + height: 14px; + + border: 1px solid var(--accent); + border-radius: 50%; + + color: var(--accent); + + font-size: 10px; + font-style: italic; + font-weight: 700; + + line-height: 13px; + + text-align: center; + + content: 'i'; +} + +.notice.show { + display: flex; +} diff --git a/src/client/styles/tokens.css b/src/client/styles/tokens.css new file mode 100644 index 0000000..58c4682 --- /dev/null +++ b/src/client/styles/tokens.css @@ -0,0 +1,102 @@ +/* Color tokens: dark by default, light from the system preference or the theme picker. */ + +/* + * The slider handle and its interaction come from img-comparison-slider, and the help + * modal is a native , so neither needs styling here beyond a few tokens. + */ +/* + * Dark is the default. Light arrives either by system preference or by explicit + * choice, and every color the interface uses is a token here so a theme is two + * lists rather than a hunt through the stylesheet. + * + * The soft variants are the tinted panels behind status text. They are listed rather + * than derived, because a tint that reads against near-black is not the tint that + * reads against white. + */ +:root { + --bg: #0b0e14; + --panel: #10141c; + --raised: #161b25; + --hover: #151a24; + --line: #212936; + --line-soft: #1a202b; + --text: #e6eaf2; + --muted: #8a94a7; + --dim: #78849a; + --on-accent: #ffffff; + --accent: #4c8dff; + --accent-soft: #1c2c4d; + --accent-tint: rgb(76 141 255 / 0.15); + --added: #3fb950; + --added-soft: rgb(63 185 80 / 0.16); + --removed: #f85149; + --removed-soft: rgb(248 81 73 / 0.16); + --modified: #d29922; + --modified-soft: rgb(210 153 34 / 0.16); + --renamed: #a371f7; + --renamed-soft: rgb(163 113 247 / 0.16); + --backdrop: rgb(0 0 0 / 0.6); + /* Behind a screenshot, so a transparent PNG still reads as transparent. */ + --checker-a: #0d1118; + --checker-b: #0a0d13; + --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; + --shot-width: auto; +} + +@media (prefers-color-scheme: light) { + /* Preference applies unless a choice has been made. */ + :root:not([data-theme='dark']) { + --bg: #ffffff; + --panel: #f6f8fa; + --raised: #ffffff; + --hover: #eef1f4; + --line: #d0d7de; + --line-soft: #e4e8ed; + --text: #1f2328; + --muted: #59636e; + --dim: #656d76; + --on-accent: #ffffff; + --accent: #0969da; + --accent-soft: #ddf4ff; + --accent-tint: rgb(9 105 218 / 0.1); + --added: #1a7f37; + --added-soft: rgb(26 127 55 / 0.12); + --removed: #cf222e; + --removed-soft: rgb(207 34 46 / 0.1); + --modified: #9a6700; + --modified-soft: rgb(154 103 0 / 0.12); + --renamed: #8250df; + --renamed-soft: rgb(130 80 223 / 0.12); + --backdrop: rgb(31 35 40 / 0.4); + --checker-a: #eef1f4; + --checker-b: #e4e8ed; + } +} + +/* And again for the choice, so it wins whatever the system says. */ +:root[data-theme='light'] { + --bg: #ffffff; + --panel: #f6f8fa; + --raised: #ffffff; + --hover: #eef1f4; + --line: #d0d7de; + --line-soft: #e4e8ed; + --text: #1f2328; + --muted: #59636e; + --dim: #656d76; + --on-accent: #ffffff; + --accent: #0969da; + --accent-soft: #ddf4ff; + --accent-tint: rgb(9 105 218 / 0.1); + --added: #1a7f37; + --added-soft: rgb(26 127 55 / 0.12); + --removed: #cf222e; + --removed-soft: rgb(207 34 46 / 0.1); + --modified: #9a6700; + --modified-soft: rgb(154 103 0 / 0.12); + --renamed: #8250df; + --renamed-soft: rgb(130 80 223 / 0.12); + --backdrop: rgb(31 35 40 / 0.4); + --checker-a: #eef1f4; + --checker-b: #e4e8ed; +} diff --git a/src/client/styles/topbar.css b/src/client/styles/topbar.css new file mode 100644 index 0000000..1c31b63 --- /dev/null +++ b/src/client/styles/topbar.css @@ -0,0 +1,117 @@ +/* Top bar: brand, range, progress and the theme picker. */ + +.topbar { + display: flex; + + align-items: center; + gap: 20px; + + height: 48px; + + padding: 0 14px; + + border-bottom: 1px solid var(--line); + + background: var(--panel); +} + +.brand { + display: flex; + + align-items: center; + gap: 9px; + + font-weight: 600; + + white-space: nowrap; +} + +.mark { + width: 14px; + height: 14px; + + border-radius: 3px; + + background: linear-gradient(135deg, var(--accent), #38d6b0); +} + +.range { + display: flex; + + align-items: baseline; + gap: 10px; + + min-width: 0; +} + +.range a { + color: var(--text); + + font-weight: 500; + + text-decoration: none; + text-overflow: ellipsis; + + white-space: nowrap; + + overflow: hidden; +} + +.range a:hover { + color: var(--accent); +} + +.progress { + display: flex; + + align-items: center; + gap: 10px; + + margin-left: auto; + + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.out-of-sync { + color: var(--modified); + + text-decoration: underline dotted; +} + +.progress-track { + width: 140px; + height: 4px; + + border-radius: 2px; + + background: var(--line); + + overflow: hidden; +} + +.progress-fill { + width: 0; + height: 100%; + + transition: width 120ms ease-out; + + background: var(--accent); +} + +.ghost { + width: 26px; + height: 26px; + + border: 1px solid var(--line); + border-radius: 6px; + + background: none; + color: var(--muted); + + cursor: pointer; +} + +.ghost:hover { + color: var(--text); +} diff --git a/src/diff.mjs b/src/diff.mjs new file mode 100644 index 0000000..e2373f2 --- /dev/null +++ b/src/diff.mjs @@ -0,0 +1,105 @@ +/* + * Pixel comparison of two screenshots. + * + * - Compared with pixelmatch at a 0.1 threshold. Antialiasing differences are + * ignored and painted yellow in the diff image; real changes are painted red over + * a dimmed copy of the screenshot. + * - When the sizes differ, both are padded top left onto the larger canvas, so added + * or removed rows count as changed rather than being scaled away. + * - The ratio is changed pixels over that canvas. Zero means identical pixels, even + * when the files' bytes differ. + */ +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; + +/** @typedef {{ width: number, height: number }} Size */ + +/** + * @typedef {object} Comparison + * @property {boolean} resized Whether the two sizes differ. + * @property {number} changed Changed pixels. + * @property {number} ratio Changed pixels as a share of the canvas. + * @property {Buffer} png The diff image. + * @property {Size} size The common canvas both were padded to. + * @property {Size} expectedSize + * @property {Size} actualSize + */ + +/* + * A CSS blend mode is enough to show where something moved, but not how much moved. + * pixelmatch gives a real diff image and a changed-pixel count, which is what makes + * sorting several hundred screenshots by magnitude possible. + */ + +const THRESHOLD = 0.1; + +/** + * Copies a PNG onto a larger transparent canvas, anchored top left, so extra rows + * count as changed rather than being scaled away. + * @param {PNG} png Decoded image. + * @param {number} width Target width. + * @param {number} height Target height. + * @returns {PNG} The same image when it is already that size. + */ +const padTo = (png, width, height) => { + if (png.width === width && png.height === height) { + return png; + } + + const canvas = new PNG({ width, height }); + PNG.bitblt(png, canvas, 0, 0, png.width, png.height, 0, 0); + return canvas; +}; + +/** + * Diffs two PNGs, padding both to a common canvas when their sizes differ. + * @param {Buffer} expectedBuffer The baseline image. + * @param {Buffer} actualBuffer The image from the branch. + * @returns {Comparison} + */ +export const comparePng = (expectedBuffer, actualBuffer) => { + const expectedPng = PNG.sync.read(expectedBuffer); + const actualPng = PNG.sync.read(actualBuffer); + + const width = Math.max(expectedPng.width, actualPng.width); + const height = Math.max(expectedPng.height, actualPng.height); + const resized = expectedPng.width !== actualPng.width || expectedPng.height !== actualPng.height; + + const expected = padTo(expectedPng, width, height); + const actual = padTo(actualPng, width, height); + + const diff = new PNG({ width, height }); + const changed = pixelmatch(expected.data, actual.data, diff.data, width, height, { + threshold: THRESHOLD, + includeAA: false, + /* Dim the unchanged pixels rather than dropping them, so the diff still reads as + the component instead of as disconnected specks. */ + alpha: 0.25, + diffColor: [255, 61, 87], + aaColor: [255, 195, 0], + }); + + return { + resized, + changed, + ratio: changed / (width * height), + png: PNG.sync.write(diff), + /* Both natural sizes, so the client can lay the overlay views out without + scaling either image to fit the other. */ + size: { width, height }, + expectedSize: { width: expectedPng.width, height: expectedPng.height }, + actualSize: { width: actualPng.width, height: actualPng.height }, + }; +}; + +/** + * Reads width and height from the PNG header without decoding the image. + * @param {Buffer} buffer + * @returns {Size | null} Null when the buffer is too short to be a PNG. + */ +export const pngSize = (buffer) => { + if (!buffer || buffer.length < 24) { + return null; + } + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +}; diff --git a/src/git.mjs b/src/git.mjs new file mode 100644 index 0000000..0bf05b2 --- /dev/null +++ b/src/git.mjs @@ -0,0 +1,999 @@ +/* + * Everything read from git or GitHub. + * + * - Images are read through one long-lived git cat-file process. Nothing is checked + * out, and the working tree is never touched. + * - The changed list is a three-dot diff from the merge base over every -snapshots + * folder under core/src, with renames detected. + * - File names are split into browser, mode, theme, direction and palette. A name + * outside the convention gets none of them. + * - Each screenshot is linked to the spec that took it, at the line when its + * screenshot() call can be matched. One whose spec is gone is orphaned, unless it + * is being removed. + * - Signed in to gh: pull request details, teammates' comments, viewed marks and + * posting all go through gh. + * - Signed out: pull request details come from GitHub's public API, which allows 60 + * requests an hour. Teammates' comments, viewed sync and posting are unavailable. + * - origin not on GitHub: a pull request can't be read and no GitHub links are made. + * Links are also left out for commits that aren't on origin yet. + * - Viewed marks go 50 to a request, one request at a time, as GitHub asks of a single + * user. A path GitHub refuses is reported rather than thrown. + * - Comments post as one review of type COMMENT, never an approval or a change + * request. If one fails partway, the half-built review is deleted. + */ +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +/** + * @typedef {object} PullRequest + * @property {string} baseRefName + * @property {string} headRefOid + * @property {string} headRefName + * @property {string} title + * @property {string} url + * @property {boolean} isCrossRepository + * @property {'OPEN' | 'CLOSED' | 'MERGED'} state + */ + +/** + * @typedef {object} ThreadComment + * @property {string} author + * @property {string} body Rendered HTML from GitHub. + * @property {string} url + * @property {string} createdAt + * @property {boolean} mine Whether the signed-in account wrote it. + */ + +/** + * @typedef {object} Thread + * @property {boolean} resolved + * @property {boolean} outdated + * @property {ThreadComment[]} comments + */ + +/** + * @typedef {object} Published + * @property {string | null} url The review, or null for a dry run. + * @property {number} count + * @property {{ path: string, id: string | null }[]} posted Each comment's node id, for deleting it later. + * @property {boolean} [dryRun] + */ + +/** + * @typedef {object} Axes + * @property {string | null} mode ios or md. + * @property {string | null} theme ios, md or ionic. + * @property {string | null} direction ltr or rtl. + * @property {string | null} palette light when the name leaves it out. + * @property {string | null} name What the test passed to `screenshot()`. + */ + +/** + * @typedef {Axes & { file: string, dir: string, group: string, stem: string, browser: string | null }} Described + */ + +/** + * @typedef {object} ManifestFields + * @property {'A' | 'M' | 'D' | 'R'} status + * @property {string} path + * @property {string | null} oldPath Where a renamed screenshot's baseline lives. + * @property {string | null} oldGroup + * @property {string | null} oldStem + * @property {number | null} similarity Git's similarity index for a rename. + * @property {string | null} expectedSha Null for an addition. + * @property {string | null} actualSha Null for a deletion. + * @property {{ path: string, line: number | null } | null} [test] Set by `attachTests`. + * @property {boolean} [orphaned] Set by `attachTests`. + */ + +/** @typedef {Described & ManifestFields} ManifestEntry */ + +const execFileAsync = promisify(execFile); + +const MAX_BUFFER = 256 * 1024 * 1024; + +/** + * Screenshots live next to the spec that produced them, in a folder Playwright + * names after the spec file. Anchored with :(top) so the pathspec resolves from + * the repository root no matter which directory the command is invoked from. + */ +/* All of core/src, not just components: typography's screenshots live under + core/src/css, and scoping to components left them out of every review. */ +export const DEFAULT_PATHSPEC = ':(top)core/src/**/*-snapshots/*.png'; + +/** + * Runs git and returns what it printed. + * @param {string[]} args + * @param {string} cwd The repository to run in. + * @returns {Promise} + */ +export const git = async (args, cwd) => { + const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: MAX_BUFFER }); + return stdout; +}; + +/** + * The top level of the repository containing `cwd`. + * @param {string} cwd + * @returns {Promise} Rejects when `cwd` is not inside a repository. + */ +export const repoRoot = async (cwd) => (await git(['rev-parse', '--show-toplevel'], cwd)).trim(); + +/** + * Where review state and the diff cache live. Inside the git directory, so they are + * never committed and are shared by every worktree. + * @param {string} cwd + * @returns {Promise} + */ +export const stateDir = async (cwd) => { + const dir = (await git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd)).trim(); + return `${dir}/snapshot-review`; +}; + +/** + * Resolves a ref to a commit id. + * @param {string} ref + * @param {string} cwd + * @returns {Promise} Null when the ref does not resolve. + */ +export const resolveRef = async (ref, cwd) => { + try { + return (await git(['rev-parse', '--verify', `${ref}^{commit}`], cwd)).trim(); + } catch { + return null; + } +}; + +/** + * Reads a pull request from GitHub's REST API without credentials, for reviewing + * signed out. Anonymous reads are limited to 60 an hour, so this never polls. + * @param {number | string} number + * @param {string} cwd + * @returns {Promise} + */ +const restPullRequest = async (number, cwd) => { + const slug = await remoteSlug(cwd); + + if (!slug) { + throw new Error('origin is not a GitHub remote, so the pull request cannot be read.'); + } + + const response = await fetch(`https://api.github.com/repos/${slug}/pulls/${number}`, { + headers: { accept: 'application/vnd.github+json', 'user-agent': 'snapshot-review' }, + }); + + if (!response.ok) { + throw new Error( + response.status === 403 + ? `GitHub is rate limiting anonymous requests. Run gh auth login, or wait and retry.` + : `Could not read pull request #${number} from GitHub (${response.status}).` + ); + } + + const body = await response.json(); + + return { + baseRefName: body.base.ref, + headRefOid: body.head.sha, + headRefName: body.head.ref, + title: body.title, + url: body.html_url, + isCrossRepository: body.head.repo?.full_name !== body.base.repo?.full_name, + state: body.merged_at ? 'MERGED' : body.state === 'closed' ? 'CLOSED' : 'OPEN', + }; +}; + +/** + * Resolves a pull request to the range to review. Fetches the head if it is missing + * and the base every time, since a stale base puts the baseline at the wrong commit. + * @param {number | string} number + * @param {string} cwd + * @returns {Promise<{ base: string, head: string, pr: PullRequest }>} + */ +export const resolvePullRequest = async (number, cwd) => { + const pr = (await authenticatedUser(cwd)) + ? JSON.parse( + ( + await execFileAsync( + 'gh', + [ + 'pr', + 'view', + String(number), + '--json', + 'baseRefName,headRefOid,headRefName,title,url,isCrossRepository,state', + ], + { cwd, maxBuffer: MAX_BUFFER } + ) + ).stdout + ) + : await restPullRequest(number, cwd); + + if (!(await resolveRef(pr.headRefOid, cwd))) { + await git(['fetch', 'origin', `pull/${number}/head`], cwd); + } + + /* + * The base branch is fetched, not merely read. + * + * A head is a commit id, so having it locally is enough. A base is a branch that + * moves, and a local copy days out of date puts the baseline at the wrong commit: + * every screenshot the base gained since then is reported as this branch changing + * it. Forced, because a base that was rewritten does not fast forward. + * + * Failure is ignored so an offline review still opens on whatever is already here. + */ + await git(['fetch', 'origin', `+refs/heads/${pr.baseRefName}:refs/remotes/origin/${pr.baseRefName}`], cwd).catch( + () => {} + ); + + const base = (await resolveRef(`origin/${pr.baseRefName}`, cwd)) ? `origin/${pr.baseRefName}` : pr.baseRefName; + + return { base, head: pr.headRefOid, pr }; +}; + +/** + * The commit a pull request points at now, without fetching it. Used to notice a push. + * @param {number | string} number + * @param {string} cwd + * @returns {Promise} + */ +export const headOfPullRequest = async (number, cwd) => { + if (!(await authenticatedUser(cwd))) { + return (await restPullRequest(number, cwd)).headRefOid; + } + + const { stdout } = await execFileAsync( + 'gh', + ['pr', 'view', String(number), '--json', 'headRefOid', '-q', '.headRefOid'], + { cwd, maxBuffer: MAX_BUFFER } + ); + return stdout.trim(); +}; + +/** + * Blob ids for many paths at one commit, from a single `git cat-file --batch-check`. + * @param {string} ref + * @param {string[]} paths + * @param {string} cwd + * @returns {Promise>} Null for a path absent at `ref`. + */ +export const blobShasAt = (ref, paths, cwd) => + new Promise((resolve, reject) => { + if (paths.length === 0) { + resolve(new Map()); + return; + } + + const proc = spawn('git', ['cat-file', '--batch-check'], { cwd }); + let out = ''; + + proc.stdout.on('data', (chunk) => (out += chunk)); + proc.on('error', reject); + proc.on('close', () => { + const lines = out.split('\n').filter(Boolean); + const shas = new Map(); + + paths.forEach((path, index) => { + const match = lines[index]?.match(/^([0-9a-f]{40}) blob /); + shas.set(path, match ? match[1] : null); + }); + + resolve(shas); + }); + + proc.stdin.end(paths.map((path) => `${ref}:${path}\n`).join('')); + }); + +/** + * Runs one GraphQL request through the gh CLI, reducing errors to what GitHub said. + * @param {object} options + * @param {string} options.query + * @param {Record} [options.variables] Sent as strings. + * @param {Record} [options.typed] Parsed by gh, so numbers and nulls keep their type. + * @param {string} options.cwd + * @param {boolean} [options.tolerant] For batched mutations: return partial data and the errors + * instead of throwing, since the other mutations in the batch still applied. + * @returns {Promise} The response data, or `{ data, errors }` when tolerant. + */ +const ghGraphql = async ({ query, variables = {}, typed = {}, cwd, tolerant = false }) => { + const args = ['api', 'graphql', '-f', `query=${query}`]; + for (const [name, value] of Object.entries(variables)) { + args.push('-f', `${name}=${value}`); + } + /* -F parses the value, which is how an Int stays an Int and a null stays null. */ + for (const [name, value] of Object.entries(typed)) { + args.push('-F', `${name}=${value === null || value === undefined ? 'null' : value}`); + } + + let stdout; + try { + ({ stdout } = await execFileAsync('gh', args, { cwd, maxBuffer: MAX_BUFFER })); + } catch (error) { + /* gh exits non-zero for a GraphQL error, but still prints the body, and that body + says which parts succeeded. Kept when the caller can use it. */ + stdout = String(error.stdout ?? ''); + + if (!tolerant || !stdout.trim()) { + /* gh puts the detail on stderr, and its own message repeats the entire command, + so only stderr is worth surfacing. */ + const detail = String(error.stderr ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + throw new Error(detail || 'the GitHub request failed'); + } + } + + const parsed = JSON.parse(stdout); + + if (tolerant) { + return { data: parsed.data ?? {}, errors: parsed.errors ?? [] }; + } + + if (parsed.errors?.length) { + throw new Error(parsed.errors.map((error) => error.message).join('; ')); + } + return parsed.data; +}; + +/* One lookup per pull request: the node id does not change, and a tick should not + pay for a subprocess to rediscover it. */ +const nodeIds = new Map(); + +/** + * The pull request's GraphQL node id, looked up once per pull request. + * @param {{ number: number | string, cwd: string }} options + * @returns {Promise} + */ +const pullRequestNodeId = async ({ number, cwd }) => { + const key = `${cwd}#${number}`; + if (!nodeIds.has(key)) { + const { stdout } = await execFileAsync('gh', ['pr', 'view', String(number), '--json', 'id'], { + cwd, + maxBuffer: MAX_BUFFER, + }); + nodeIds.set(key, JSON.parse(stdout).id); + } + return nodeIds.get(key); +}; + +const VIEWED_CHUNK = 50; + +/* + * GitHub asks that a single user's mutations be sent one at a time, so ticking a + * second directory queues behind the first rather than racing it. + */ +/** @type {Promise} */ +let viewedQueue = Promise.resolve(); + +/** + * Runs `work` once every earlier viewed-state write has finished, since GitHub asks + * for one user's mutations to be sent one at a time. + * @template T + * @param {() => Promise} work + * @returns {Promise} + */ +const queued = (work) => { + const result = viewedQueue.then(work, work); + viewedQueue = result.catch(() => {}); + return result; +}; + +/** + * Marks files viewed or unviewed on the pull request, 50 to a request. A path GitHub + * rejects is reported rather than thrown, so it cannot strand the paths behind it. + * @param {object} options + * @param {number | string} options.number + * @param {string[]} options.paths + * @param {boolean} options.viewed + * @param {string} options.cwd + * @returns {Promise<{ marked: number, refused?: string[] }>} + */ +export const setFilesViewed = async ({ number, paths, viewed, cwd }) => { + if (paths.length === 0) { + return { marked: 0 }; + } + + return queued(async () => { + const pullRequestId = await pullRequestNodeId({ number, cwd }); + const mutation = viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed'; + + let marked = 0; + /* Paths GitHub would not take. Collected rather than thrown, so one of them cannot + strand every path queued behind it. */ + const refused = []; + + for (let start = 0; start < paths.length; start += VIEWED_CHUNK) { + const chunk = paths.slice(start, start + VIEWED_CHUNK); + + /* One alias per file, so the whole chunk is a single round trip. Paths arrive as + variables rather than inlined, which keeps quoting out of the query text. */ + const declarations = chunk.map((_, index) => `$p${index}: String!`).join(', '); + const fields = chunk + .map( + (_, index) => `f${index}: ${mutation}(input: { pullRequestId: $id, path: $p${index} }) { clientMutationId }` + ) + .join('\n '); + + const variables = { id: pullRequestId }; + chunk.forEach((path, index) => { + variables[`p${index}`] = path; + }); + + const { data } = await ghGraphql({ + query: `mutation($id: ID!, ${declarations}) {\n ${fields}\n }`, + variables, + cwd, + tolerant: true, + }); + + /* An alias resolves to null exactly when its own mutation failed, which is a + truer count than assuming the chunk went through whole. */ + chunk.forEach((path, index) => { + if (data[`f${index}`]) { + marked += 1; + } else { + refused.push(path); + } + }); + } + + return { marked, refused }; + }); +}; + +/** + * Every review thread on the pull request, grouped by the file it is on. + * @param {{ number: number | string, cwd: string }} options + * @returns {Promise>} Empty when origin is not on GitHub. + */ +export const fetchReviewThreads = async ({ number, cwd }) => { + const slug = await remoteSlug(cwd); + if (!slug) { + return {}; + } + + const [OWNER, REPO] = slug.split('/'); + const byPath = new Map(); + let after = null; + + /* A guard rather than a limit anyone should reach: 20 pages is 2,000 threads. */ + for (let page = 0; page < 20; page++) { + const data = await ghGraphql({ + query: `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + path + isResolved + isOutdated + comments(first: 20) { + nodes { author { login } bodyHTML createdAt url viewerDidAuthor } + } + } + } + } + } + }`, + variables: { owner: OWNER, name: REPO }, + typed: { number, after }, + cwd, + }); + + const threads = data.repository.pullRequest.reviewThreads; + + for (const thread of threads.nodes) { + if (!thread.path || thread.comments.nodes.length === 0) { + continue; + } + + const list = byPath.get(thread.path) ?? []; + list.push({ + resolved: thread.isResolved, + outdated: thread.isOutdated, + comments: thread.comments.nodes.map((comment) => ({ + author: comment.author?.login ?? 'unknown', + /* GitHub renders the markdown; the client keeps a safe subset of it. */ + body: comment.bodyHTML, + url: comment.url, + createdAt: comment.createdAt, + mine: comment.viewerDidAuthor, + })), + }); + byPath.set(thread.path, list); + } + + if (!threads.pageInfo.hasNextPage) { + break; + } + after = threads.pageInfo.endCursor; + } + + return Object.fromEntries(byPath); +}; + +/** + * Posts comments as one review, so the pull request gets a single notification. + * Always submitted as COMMENT, never as an approval or a request for changes. + * @param {object} options + * @param {number | string} options.number + * @param {{ path: string, body: string }[]} options.comments + * @param {string} options.cwd + * @param {boolean} [options.dryRun] Build the review, then discard it. + * @returns {Promise} + */ +export const publishComments = async ({ number, comments, cwd, dryRun = false }) => { + const graphql = (query, variables) => ghGraphql({ query, variables, cwd }); + + const { stdout } = await execFileAsync('gh', ['pr', 'view', String(number), '--json', 'id'], { + cwd, + maxBuffer: MAX_BUFFER, + }); + const pullRequestId = JSON.parse(stdout).id; + + const { addPullRequestReview } = await graphql( + ` + mutation ($pullRequestId: ID!) { + addPullRequestReview(input: { pullRequestId: $pullRequestId }) { + pullRequestReview { + id + } + } + } + `, + { pullRequestId } + ); + + const pullRequestReviewId = addPullRequestReview.pullRequestReview.id; + + const discard = () => + graphql( + ` + mutation ($pullRequestReviewId: ID!) { + deletePullRequestReview(input: { pullRequestReviewId: $pullRequestReviewId }) { + clientMutationId + } + } + `, + { pullRequestReviewId } + ).catch(() => {}); + + /* The comment's own id, not the thread's, since deleting one later needs it. */ + const posted = []; + + try { + for (const { path, body } of comments) { + const { addPullRequestReviewThread } = await graphql( + ` + mutation ($pullRequestReviewId: ID!, $path: String!, $body: String!) { + addPullRequestReviewThread( + input: { pullRequestReviewId: $pullRequestReviewId, path: $path, body: $body, subjectType: FILE } + ) { + thread { + comments(first: 1) { + nodes { + id + } + } + } + } + } + `, + { pullRequestReviewId, path, body } + ); + + posted.push({ path, id: addPullRequestReviewThread.thread.comments.nodes[0]?.id ?? null }); + } + } catch (error) { + /* A half-populated pending review is invisible to everyone but its author and + impossible to find later, so a partial failure takes the whole thing back. */ + await discard(); + throw error; + } + + if (dryRun) { + await discard(); + return { url: null, count: comments.length, posted: [], dryRun: true }; + } + + const { submitPullRequestReview } = await graphql( + ` + mutation ($pullRequestReviewId: ID!) { + submitPullRequestReview(input: { pullRequestReviewId: $pullRequestReviewId, event: COMMENT }) { + pullRequestReview { + url + } + } + } + `, + { pullRequestReviewId } + ); + + return { url: submitPullRequestReview.pullRequestReview.url, count: comments.length, posted }; +}; + +/** + * Deletes one review comment from the pull request. + * @param {{ id: string, cwd: string }} options `id` is the comment's node id. + * @returns {Promise} + */ +export const deleteComment = ({ id, cwd }) => + ghGraphql({ + query: `mutation($id: ID!) { + deletePullRequestReviewComment(input: { id: $id }) { clientMutationId } + }`, + variables: { id }, + cwd, + }); + +let viewerLookup = null; + +/** + * The account gh is signed in as, looked up once per server. + * @param {string} cwd + * @returns {Promise} Null when gh has no credentials. + */ +export const authenticatedUser = (cwd) => { + viewerLookup ??= execFileAsync('gh', ['api', 'user', '--jq', '.login'], { cwd, maxBuffer: MAX_BUFFER }) + .then(({ stdout }) => stdout.trim() || null) + .catch(() => null); + + return viewerLookup; +}; + +/** + * The `owner/repo` that origin points at, from either the SSH or HTTPS form. + * @param {string} cwd + * @returns {Promise} Null when origin is not on GitHub. + */ +export const remoteSlug = async (cwd) => { + const url = await git(['remote', 'get-url', 'origin'], cwd).catch(() => ''); + const match = url.trim().match(/github\.com[:/](.+?)(?:\.git)?$/); + return match ? match[1] : null; +}; + +/** + * Whether a commit is on a remote branch as of the last fetch. A link to a commit that + * only exists locally would 404. + * @param {string} sha + * @param {string} cwd + * @returns {Promise} + */ +export const isOnRemote = async (sha, cwd) => { + const branches = await git(['branch', '-r', '--contains', sha], cwd).catch(() => ''); + return branches.trim().length > 0; +}; + +const PLATFORMS = new Set(['linux', 'darwin', 'win32']); +const BROWSERS = new Set(['Chrome', 'Safari', 'Firefox', 'Edge', 'chromium', 'webkit', 'firefox']); + +/* + * Test axes, longest first in each list, because the shorter values are suffixes of + * the longer ones: "md" ends "ionic-md", and "dark" ends "high-contrast-dark". + * + * The name carries theme and mode as a single token. They are separate axes: a mode + * is behaviour (ios or md) and a theme is appearance (ios, md or ionic), so the ionic + * theme runs on both modes and appears as "ionic-ios" and "ionic-md". + */ +const THEME_MODES = ['ionic-ios', 'ionic-md', 'ios', 'md']; +const PALETTES = ['high-contrast-dark', 'high-contrast', 'light', 'dark']; +const DIRECTIONS = new Set(['ltr', 'rtl']); + +/** + * Reads mode, theme, direction and palette off the end of a screenshot stem. The theme + * is written only for ionic, and an omitted palette means light. + * @param {string} stem The file name without browser, platform or extension. + * @returns {Axes} All null for a name that does not follow the convention. + */ +export const splitAxes = (stem) => { + const nothing = { mode: null, theme: null, direction: null, palette: null, name: null }; + let rest = stem; + let palette = null; + + for (const candidate of PALETTES) { + if (rest.endsWith(`-${candidate}`)) { + palette = candidate; + rest = rest.slice(0, -(candidate.length + 1)); + break; + } + } + + const tokens = rest.split('-'); + const direction = tokens[tokens.length - 1]; + + if (!DIRECTIONS.has(direction)) { + return nothing; + } + + rest = rest.slice(0, -(direction.length + 1)); + + const themeMode = THEME_MODES.find((candidate) => rest === candidate || rest.endsWith(`-${candidate}`)); + + if (!themeMode) { + return nothing; + } + + const [theme, mode] = themeMode.split('-'); + + return { + /* A bare "md" is both the theme and the mode. */ + mode: mode ?? theme, + theme, + direction, + /* An omitted palette means light; that is why it is omitted. */ + palette: palette ?? 'light', + /* What is left once every axis is stripped: the name the test passed to + screenshot(), which is how a screenshot is traced back to the line that took it. */ + name: rest === themeMode ? '' : rest.slice(0, -(themeMode.length + 1)), + }; +}; + +/** + * Splits a screenshot file name into its stem, browser and test axes. Read from the + * end, because project names like "Mobile Chrome" contain hyphens. + * @param {string} file + * @returns {{ stem: string, browser: string | null } & Axes} + */ +export const splitName = (file) => { + const tokens = file.replace(/\.png$/, '').split('-'); + + if (!PLATFORMS.has(tokens[tokens.length - 1]) || !BROWSERS.has(tokens[tokens.length - 2])) { + const stem = file.replace(/\.png$/, ''); + return { stem, browser: null, ...splitAxes(stem) }; + } + + /* Trailing tokens are the project name plus the platform: "Mobile-Chrome-linux". */ + const trailing = tokens[tokens.length - 3] === 'Mobile' ? 3 : 2; + const stem = tokens.slice(0, -trailing).join('-'); + + return { + stem, + browser: tokens.slice(-trailing, -1).join(' '), + ...splitAxes(stem), + }; +}; + +/** + * Everything the review shows about a screenshot path. + * @param {string} path Relative to the repository root. + * @returns {Described} + */ +export const describe = (path) => { + const parts = path.split('/'); + const file = parts[parts.length - 1]; + const dir = parts.slice(0, -1).join('/'); + + return { + file, + dir, + /* "textarea/test/slot" reads better than the full path. The trailing snapshots + folder is named after the spec and carries nothing the group does not. Anything + outside components keeps one level of context, so "css/test/a11y". */ + group: dir + .replace(/^core\/src\/components\//, '') + .replace(/^core\/src\//, '') + .replace(/\/[^/]+-snapshots$/, ''), + ...splitName(file), + }; +}; + +/* An absent side, for an addition or a deletion. */ +const NULL_SHA = '0000000000000000000000000000000000000000'; + +/* + * Tracing a screenshot back to the line that took it. + * + * Playwright keeps a spec's screenshots in "-snapshots", so the test file is the + * directory minus that suffix. Inside it, the name the snapshot was saved under is the + * argument to screenshot(), which is usually a literal and occasionally a template. + * The template case is kept by turning the interpolation into a wildcard, which is + * enough to place four out of five of the ones a literal search misses. + */ +const SCREENSHOT_CALL = /screenshot\(\s*[`'"]([^`'"]*)[`'"]/g; + +/** + * Every `screenshot()` call in a spec. A name built from a template literal gets a + * pattern with the interpolation as a wildcard. + * @param {string} source The spec's contents. + * @returns {{ line: number, raw: string, pattern: RegExp | null }[]} + */ +export const indexScreenshotCalls = (source) => { + const calls = []; + + source.split('\n').forEach((text, index) => { + for (const [, raw] of text.matchAll(SCREENSHOT_CALL)) { + const pattern = raw.includes('${') + ? new RegExp(`^${raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\$\\\{[^}]*\\\}/g, '.+')}$`) + : null; + calls.push({ line: index + 1, raw, pattern }); + } + }); + + return calls; +}; + +/** + * Sets `test` and `orphaned` on each entry from the spec that took it. `test.line` is + * null when the call cannot be placed, and `test` is null when the spec is gone. + * @param {{ entries: ManifestEntry[], ref: string, cwd: string }} options + * @returns {Promise} + */ +export const attachTests = async ({ entries, ref, cwd }) => { + const specs = new Map(); + + for (const dir of new Set(entries.map((entry) => entry.dir))) { + const path = dir.replace(/-snapshots$/, ''); + let calls = null; + + try { + calls = indexScreenshotCalls(await git(['show', `${ref}:${path}`], cwd)); + } catch { + /* No spec at this ref: the screenshots outlived the test that wrote them. */ + } + + specs.set(dir, { path, calls }); + } + + for (const entry of entries) { + const spec = specs.get(entry.dir); + + if (!spec?.calls) { + entry.test = null; + /* A screenshot being deleted is meant to have no spec; one that survives without + one will be regenerated forever with nothing asserting on it. */ + entry.orphaned = entry.status !== 'D'; + continue; + } + + const exact = spec.calls.filter((call) => call.raw === entry.name); + const loose = exact.length ? exact : spec.calls.filter((call) => call.pattern?.test(entry.name ?? '')); + + entry.test = { path: spec.path, line: loose.length === 1 ? loose[0].line : null }; + entry.orphaned = false; + } +}; + +/** + * Lists the screenshots that changed between the merge base and `head`. Three-dot, so + * the set holds still when the base moves, with each side's blob id from `--raw`. + * @param {object} options + * @param {string} options.base + * @param {string} options.head + * @param {string} [options.pathspec] + * @param {string} options.cwd + * @returns {Promise<{ mergeBase: string, entries: ManifestEntry[] }>} + */ +export const buildManifest = async ({ base, head, pathspec = DEFAULT_PATHSPEC, cwd }) => { + const mergeBase = (await git(['merge-base', base, head], cwd)).trim(); + const raw = await git(['diff', '--raw', '--abbrev=40', `${base}...${head}`, '--', pathspec], cwd); + + const entries = raw + .split('\n') + .filter(Boolean) + .map((line) => { + const [meta, ...paths] = line.split('\t'); + const [, , expectedSha, actualSha, status] = meta.split(' '); + + const renamed = paths.length > 1; + /* The new path is what the review is about; the old one is where its baseline + still lives, and both sides are needed to compare a renamed screenshot. */ + const path = renamed ? paths[1] : paths[0]; + + /* Described too, so the client can show where it moved from in the same terms + it shows everything else, rather than a raw path. */ + const previous = renamed ? describe(paths[0]) : null; + + return { + status: /** @type {ManifestFields['status']} */ (status[0]), + path, + oldPath: renamed ? paths[0] : null, + oldGroup: previous?.group ?? null, + oldStem: previous?.stem ?? null, + /* Git's similarity index, so a pure move reads differently from a move that + also changed the image. */ + similarity: renamed ? Number(status.slice(1)) : null, + expectedSha: expectedSha === NULL_SHA ? null : expectedSha, + actualSha: actualSha === NULL_SHA ? null : actualSha, + ...describe(path), + }; + }) + .sort((a, b) => a.path.localeCompare(b.path)); + + return { mergeBase, entries }; +}; + +/** + * Reads blobs through one long-lived `git cat-file --batch`, rather than spawning a + * process per image. + */ +export class BlobReader { + /** + * @param {string} cwd The repository to read from. + */ + constructor(cwd) { + this.queue = []; + this.buffer = Buffer.alloc(0); + this.expecting = null; + + this.proc = spawn('git', ['cat-file', '--batch'], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + this.proc.stdout.on('data', (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.#drain(); + }); + this.proc.on('exit', () => { + /* Fail any in-flight reads rather than leaving their promises pending. */ + while (this.queue.length) { + this.queue.shift().reject(new Error('git cat-file exited')); + } + }); + } + + /** + * Reads one blob. + * @param {string} ref + * @param {string} path + * @returns {Promise} Null when the path is absent at `ref`. + */ + read(ref, path) { + return new Promise((resolve, reject) => { + this.queue.push({ resolve, reject }); + this.proc.stdin.write(`${ref}:${path}\n`); + }); + } + + /** Resolves queued reads as their headers and bodies arrive on stdout. */ + #drain() { + for (;;) { + if (this.expecting === null) { + const newline = this.buffer.indexOf(0x0a); + if (newline === -1) { + return; + } + + const header = this.buffer.subarray(0, newline).toString(); + this.buffer = this.buffer.subarray(newline + 1); + + const match = header.match(/^[0-9a-f]{40,} \w+ (\d+)$/); + if (!match) { + /* Missing or ambiguous objects report on a single line with no body. */ + this.queue.shift()?.resolve(null); + continue; + } + + this.expecting = Number(match[1]); + } + + /* The body is followed by a trailing newline that is not part of the blob. */ + if (this.buffer.length < this.expecting + 1) { + return; + } + + const body = this.buffer.subarray(0, this.expecting); + this.buffer = this.buffer.subarray(this.expecting + 1); + this.expecting = null; + this.queue.shift()?.resolve(body); + } + } + + /** Stops the git process. */ + close() { + this.proc.stdin.end(); + this.proc.kill(); + } +} + +/** + * Reads width and height from the PNG header without decoding the image. + * @param {Buffer} buffer + * @returns {{ width: number, height: number } | null} Null when too short to be a PNG. + */ +export const pngSize = (buffer) => { + if (!buffer || buffer.length < 24) { + return null; + } + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +}; diff --git a/src/index.mjs b/src/index.mjs new file mode 100644 index 0000000..876d81d --- /dev/null +++ b/src/index.mjs @@ -0,0 +1,1139 @@ +/** + * Local reviewer for screenshot diffs: the server behind the page. + * + * GitHub's Files tab won't render pull requests with hundreds of binary files, which + * is every screenshot regeneration this repository produces. This serves the same + * before and after pairs out of the git object store instead. + * + * - The clone comes from --repo, else ../ionic-framework next to this repository, + * else a path typed in at the prompt. With no terminal to ask in, it exits. + * - A pull request (--pr) is pinned to its head commit at startup, and its base branch + * is fetched so the baseline is current. A range (--base, --head) is read locally + * and never contacts GitHub. + * - Signed out of gh, a pull request still opens: a yellow warning prints, teammates' + * comments are not shown, nothing can be posted, and viewed marks stay local. + * - Each pair is diffed once and cached by the two images' blob ids, so a push only + * diffs what it changed. Cache entries unused for 14 days are deleted at startup. + * - Viewed marks and comments are saved per pull request, or per range, in the + * clone's .git/snapshot-review. A mark survives a push only if neither image + * changed. A comment always survives, flagged stale if its screenshot changed. + * - Viewed marks are mirrored to the pull request's Files tab when signed in. + * Comments reach GitHub only through Post, and deleting a posted one removes it + * there first. + * - A newer push is looked for on load, on tab focus and on every mark, at most every + * 2 seconds for a range, 30 for a pull request, and 5 minutes signed out. Refresh + * rebuilds on it without a restart, and keeps the current review if that fails. + * - Port 4300, or the next free one up to 4320. + * + * Usage: + * npm start -- --pr 31321 + * npm start -- --repo ~/code/ionic-framework --pr 31321 + * npm start -- --base origin/main --head my-branch + * npm start -- --pr 31321 --path ':(top)core/src/components/textarea/**\/*.png' + */ +import { execFile } from 'node:child_process'; +import { readFile, mkdir, readdir, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { stdin, stdout } from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import { fileURLToPath } from 'node:url'; +import { parseArgs, styleText } from 'node:util'; +import sirv from 'sirv'; + +import { comparePng, pngSize } from './diff.mjs'; +import { + BlobReader, + DEFAULT_PATHSPEC, + blobShasAt, + attachTests, + authenticatedUser, + buildManifest, + git, + deleteComment, + fetchReviewThreads, + isOnRemote, + publishComments, + remoteSlug, + setFilesViewed, + headOfPullRequest, + repoRoot, + resolvePullRequest, + resolveRef, + stateDir, +} from './git.mjs'; + +/** @typedef {import('./git.mjs').ManifestEntry} ManifestEntry */ +/** @typedef {import('./git.mjs').Thread} Thread */ + +/** + * @typedef {object} Args + * @property {string} [repo] The checkout to review. + * @property {string} [pr] + * @property {string} [base] + * @property {string} [head] + * @property {string} [path] A git pathspec narrowing the set. + * @property {string} port + * @property {boolean} open + * @property {boolean} help + */ + +/** + * @typedef {object} Range + * @property {string} base + * @property {string} head + * @property {string} label Shown in the header and the startup banner. + * @property {string | null} url The pull request, when there is one. + * @property {'OPEN' | 'CLOSED' | 'MERGED' | null} prState + */ + +/** + * @typedef {object} ReviewFields + * @property {boolean} comparable Both sides exist. + * @property {{ width: number, height: number } | null} size + * @property {{ width: number, height: number } | null} expectedSize + * @property {{ width: number, height: number } | null} actualSize + * @property {number | null} ratio Changed pixels as a share of the canvas. + * @property {number | null} changed Changed pixels. + * @property {boolean} resized + * @property {{ expected: number | null, actual: number | null }} bytes + */ + +/** @typedef {ManifestEntry & ReviewFields} ReviewEntry */ + +/** + * @typedef {object} Freshness + * @property {boolean} moved + * @property {string} [head] The newer commit. + * @property {string[]} [changed] Of the paths asked about, those the newer commit changed. + */ + +/** + * @typedef {object} Store + * @property {string} file + * @property {{ carried: number, reset: number }} summary + * @property {() => ReviewState} read The state sent to the client. + * @property {() => { path: string, body: string }[]} unsentComments + * @property {(posted: { path: string, id: string | null }[]) => void} markPosted + * @property {(path: string) => string | null} postedIdFor + * @property {(path: string) => void} forgetComment + * @property {(patch: Patch) => { flipped: string[] }} update + */ + +/** + * @typedef {object} ReviewState + * @property {Record} viewed + * @property {Record} comments + * @property {string[]} stale + * @property {string[]} unsent + * @property {string[]} posted + */ + +/** + * One write from the client: a single path or a directory's worth. + * @typedef {object} Patch + * @property {string} [path] + * @property {string[]} [paths] + * @property {boolean} [viewed] + * @property {string} [comment] + */ + +/** + * @typedef {object} Review + * @property {Record} threads Teammates' comments, by path. + * @property {{ repo: string, expected: string | null, actual: string | null } | null} blobBase For GitHub links. + * @property {number} pruned + * @property {number} computed + * @property {number} reused + * @property {string} headSha + * @property {string} mergeBase + * @property {ReviewEntry[]} entries + * @property {Map} diffs + * @property {Store} store + * @property {Record} counts By status. + * @property {Map} baselinePaths Where a renamed screenshot's baseline lives. + * @property {Map} byPath + * @property {ReturnType} pushWatch + */ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLIENT_DIR = join(HERE, 'client'); +/* Resolved rather than walked to, so it is found wherever npm put node_modules. */ +const SLIDER_PKG = join(dirname(createRequire(import.meta.url).resolve('img-comparison-slider/package.json')), 'dist'); +const DEFAULT_PORT = 4300; + +/* Yellow only on a terminal, since styleText checks stdout before coloring. */ +const NO_ACCOUNT = styleText( + 'yellow', + [ + ' ⚠ Not signed in to GitHub. Run gh auth login, then restart.', + " The review works, but comments can't be posted and viewed marks stay local.", + ].join('\n') +); + +const usage = ` +Review screenshot diffs locally. + + --repo The ionic-framework checkout to review. Defaults to + ../ionic-framework next to the reviewer, and asks + when that is missing. + --pr Review a pull request. + --base Compare from this ref instead of a pull request base. + --head Compare to this ref. + --path Narrow the set with a git pathspec. + --port Defaults to ${DEFAULT_PORT}. + --no-open Do not open a browser. +`; + +/** + * Parses the command line. + * @returns {Args} + */ +const readArgs = () => { + const { values } = parseArgs({ + options: { + repo: { type: 'string' }, + pr: { type: 'string' }, + base: { type: 'string' }, + head: { type: 'string' }, + path: { type: 'string' }, + port: { type: 'string', default: String(DEFAULT_PORT) }, + open: { type: 'boolean', default: true }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowNegative: true, + }); + return values; +}; + +/** + * Resolves the arguments to the range under review: a pull request, or `--base` and + * `--head`. Given with `--pr`, `--base` and `--head` override the pull request's own ends. + * @param {Args} args + * @param {string} cwd + * @returns {Promise} + */ +const resolveRange = async (args, cwd) => { + if (args.pr) { + const { base, head, pr } = await resolvePullRequest(args.pr, cwd); + return { + base: args.base ?? base, + head: args.head ?? head, + label: `#${args.pr} ${pr.title}`, + url: pr.url, + prState: pr.state, + }; + } + + if (!args.base || !args.head) { + throw new Error('Pass --pr , or both --base and --head .'); + } + + for (const ref of [args.base, args.head]) { + if (!(await resolveRef(ref, cwd))) { + throw new Error(`Cannot resolve ref: ${ref}`); + } + } + + return { base: args.base, head: args.head, label: `${args.base}...${args.head}`, url: null, prState: null }; +}; + +/** + * Names the review's state file after the pull request, or the refs as typed, so marks + * survive new pushes. + * @param {Args} args + * @param {Range} range + * @returns {string} Safe to use as a file name. + */ +export const reviewKey = (args, range) => { + const raw = args.pr ? `pr-${args.pr}` : `${range.base}..${range.head}`; + return raw.replace(/[^a-z0-9._-]/gi, '_'); +}; + +/** + * Loads the review's viewed marks and comments. A mark survives only while both of its + * screenshot's blobs are unchanged; a comment survives regardless, flagged stale. + * @param {string} cwd + * @param {string} key From `reviewKey`. + * @param {ReviewEntry[]} entries + * @returns {Promise} + */ +export const createStore = async (cwd, key, entries) => { + const dir = await stateDir(cwd); + const file = join(dir, `${key}.json`); + + let stored = { entries: {} }; + try { + const parsed = JSON.parse(await readFile(file, 'utf8')); + if (parsed && typeof parsed === 'object' && parsed.entries) { + stored = parsed; + } + } catch { + /* No prior session for this review. */ + } + + const current = new Map(entries.map((entry) => [entry.path, entry])); + const state = { viewed: {}, comments: {}, stale: [], unsent: [], posted: [] }; + let carried = 0; + let reset = 0; + + for (const [path, record] of Object.entries(stored.entries)) { + const entry = current.get(path); + if (!entry) { + continue; + } + + const unchanged = record.expectedSha === entry.expectedSha && record.actualSha === entry.actualSha; + + if (record.comment) { + state.comments[path] = record.comment; + if (!unchanged) { + state.stale.push(path); + } + /* Never posted, or edited since it was. */ + if (record.comment !== record.postedComment) { + state.unsent.push(path); + } + } + + /* Still on the pull request, whatever the local text says now. */ + if (record.postedId) { + state.posted.push(path); + } + + if (record.viewed && unchanged) { + state.viewed[path] = true; + carried++; + } else if (record.viewed) { + reset++; + } + } + + /* Rewritten from the surviving records, so entries that left the range are dropped + rather than accumulating forever. */ + const live = {}; + /* A path with a comment still on the pull request is kept even once the local text + is gone, because its id is the only way to delete it later. */ + for (const path of new Set([...Object.keys(state.viewed), ...Object.keys(state.comments), ...state.posted])) { + const entry = current.get(path); + live[path] = { + viewed: Boolean(state.viewed[path]), + comment: state.comments[path] ?? '', + postedComment: stored.entries[path]?.postedComment ?? '', + postedId: stored.entries[path]?.postedId ?? null, + expectedSha: entry.expectedSha, + actualSha: entry.actualSha, + }; + } + stored = { version: 2, entries: live }; + + let pending = null; + const flush = async () => { + pending = null; + await mkdir(dir, { recursive: true }); + await writeFile(file, JSON.stringify(stored, null, 2)); + }; + + return { + file, + summary: { carried, reset }, + read: () => state, + + /** Comments never posted, or edited since they were. */ + unsentComments: () => state.unsent.map((path) => ({ path, body: state.comments[path] })), + + /** Called only after GitHub has accepted them, so a failure changes nothing. */ + markPosted(posted) { + for (const { path, id } of posted) { + const record = stored.entries[path]; + if (record) { + record.postedComment = record.comment; + record.postedId = id; + if (id && !state.posted.includes(path)) { + state.posted.push(path); + } + } + } + + const paths = posted.map((entry) => entry.path); + state.unsent = state.unsent.filter((path) => !paths.includes(path)); + if (!pending) { + pending = setTimeout(flush, 200); + } + }, + + /** The pull request comment behind a path, if one was ever published. */ + postedIdFor: (path) => stored.entries[path]?.postedId ?? null, + + /** Forgets a comment locally, and that it was ever on the pull request. */ + forgetComment(path) { + const record = stored.entries[path]; + if (record) { + record.comment = ''; + record.postedComment = ''; + record.postedId = null; + } + + delete state.comments[path]; + state.stale = state.stale.filter((candidate) => candidate !== path); + state.unsent = state.unsent.filter((candidate) => candidate !== path); + state.posted = state.posted.filter((candidate) => candidate !== path); + + if (record && !record.viewed) { + delete stored.entries[path]; + } + if (!pending) { + pending = setTimeout(flush, 200); + } + }, + /* A patch carries either one path or a list of them, because checking off a whole + directory touches every screenshot in it and should still be a single write. */ + update(patch) { + const paths = patch.paths ?? (patch.path ? [patch.path] : []); + /* Only the paths that actually flipped are worth mirroring: a directory tick + sends everything under it, most of which is already in that state. */ + const flipped = []; + + for (const path of paths) { + const entry = current.get(path); + if (!entry) { + continue; + } + + const record = stored.entries[path] ?? { + viewed: false, + comment: '', + postedComment: '', + postedId: null, + expectedSha: entry.expectedSha, + actualSha: entry.actualSha, + }; + + if (patch.viewed !== undefined) { + if (record.viewed !== Boolean(patch.viewed)) { + flipped.push(path); + } + record.viewed = Boolean(patch.viewed); + if (patch.viewed) { + state.viewed[path] = true; + } else { + delete state.viewed[path]; + } + } + + if (patch.comment !== undefined) { + record.comment = patch.comment || ''; + if (patch.comment) { + state.comments[path] = patch.comment; + } else { + delete state.comments[path]; + } + /* Rewriting a comment on a changed screenshot settles it. */ + state.stale = state.stale.filter((candidate) => candidate !== path); + + state.unsent = state.unsent.filter((candidate) => candidate !== path); + if (record.comment && record.comment !== record.postedComment) { + state.unsent.push(path); + } + } + + /* Anything touched now is judged against what is on screen now. */ + record.expectedSha = entry.expectedSha; + record.actualSha = entry.actualSha; + + if (record.viewed || record.comment || record.postedId) { + stored.entries[path] = record; + } else { + delete stored.entries[path]; + } + } + + if (!pending) { + pending = setTimeout(flush, 200); + } + + return { flipped }; + }, + }; +}; + +const LOCAL_THROTTLE = 2000; +const REMOTE_THROTTLE = 30000; +/* Anonymous callers get sixty GitHub requests an hour, and the remote throttle alone + would spend twice that. Five minutes leaves the budget for the review itself. */ +const ANONYMOUS_THROTTLE = 300000; + +/** + * Answers whether the head has moved since the review was built, asked on load, on tab + * focus and on every mark. Git or GitHub is asked at most once per throttle window: two + * seconds for a local range, thirty for a pull request, five minutes signed out. + * @param {{ args: Args, viewer: string | null, range: Range, cwd: string, reviewedAt: string }} options + * @returns {{ check: (paths: string[], entries: Map) => Promise }} + */ +const createFreshness = ({ args, viewer, range, cwd, reviewedAt }) => { + const throttle = args.pr ? (viewer ? REMOTE_THROTTLE : ANONYMOUS_THROTTLE) : LOCAL_THROTTLE; + let checkedAt = 0; + let latest = reviewedAt; + + const resolveLatest = async () => { + if (Date.now() - checkedAt < throttle) { + return latest; + } + checkedAt = Date.now(); + + try { + latest = (args.pr ? await headOfPullRequest(args.pr, cwd) : await resolveRef(range.head, cwd)) ?? latest; + } catch { + /* Offline, or gh is unhappy. Staying on the last answer is better than + interrupting a review with an error it cannot act on. */ + } + + return latest; + }; + + return { + /** + * Returns whether the head has moved and, of the paths given, which ones the + * newer commit actually changed. + */ + async check(paths, entries) { + const head = await resolveLatest(); + + if (!head || head === reviewedAt) { + return { moved: false }; + } + + const shas = await blobShasAt(head, paths, cwd).catch(() => new Map()); + const changed = paths.filter((path) => { + const entry = entries.get(path); + /* A path the new commit does not have counts as changed: it was deleted. */ + return entry ? shas.get(path) !== entry.actualSha : false; + }); + + return { moved: true, head, changed }; + }, + }; +}; + +/* + * The generated diff for a pair is decided entirely by the two images, so it is + * cached under their hashes rather than under the commits they came from. + * + * Keying by commit meant a push that rewrote four screenshots invalidated all two + * thousand, which is what made refreshing cost a full rebuild. By content, a refresh + * recomputes only what actually moved, and the cache dedupes across pushes, across + * pull requests, and across the many snapshots that are byte-identical to each other. + * + * Sharded two characters deep, the way git stores its own objects, so no single + * directory ends up with tens of thousands of entries. + */ +const DIFF_CACHE = 'diffs'; + +/** + * Where a pair's diff image and metrics are cached, keyed by the two blob ids. + * @param {string} root From `stateDir`. + * @param {string} expectedSha + * @param {string} actualSha + * @returns {{ dir: string, png: string, meta: string }} + */ +const diffCachePaths = (root, expectedSha, actualSha) => { + const dir = join(root, DIFF_CACHE, expectedSha.slice(0, 2)); + const stem = join(dir, `${expectedSha}-${actualSha}`); + return { dir, png: `${stem}.png`, meta: `${stem}.json` }; +}; + +/** + * Reads both sides of every screenshot and diffs each comparable pair, reusing any diff + * already cached for those two blobs. + * @param {object} options + * @param {ManifestEntry[]} options.entries + * @param {BlobReader} options.reader + * @param {string} options.mergeBase + * @param {string} options.head + * @param {string} options.cacheRoot + * @returns {Promise<{ entries: ReviewEntry[], diffs: Map, computed: number, reused: number }>} + * `diffs` maps a path to its cached diff image. + */ +const buildDiffs = async ({ entries, reader, mergeBase, head, cacheRoot }) => { + const results = {}; + const diffs = new Map(); + + let done = 0; + let computed = 0; + let reused = 0; + + for (const entry of entries) { + /* A renamed screenshot's baseline is still under its old path. */ + const expected = entry.status === 'A' ? null : await reader.read(mergeBase, entry.oldPath ?? entry.path); + const actual = entry.status === 'D' ? null : await reader.read(head, entry.path); + const comparable = Boolean(expected && actual && entry.expectedSha && entry.actualSha); + + let record = null; + + if (comparable) { + const paths = diffCachePaths(cacheRoot, entry.expectedSha, entry.actualSha); + + record = await readFile(paths.meta, 'utf8') + .then(JSON.parse) + .catch(() => null); + + if (record) { + /* Touched so that a pair still in use is not swept for being old. */ + const now = new Date(); + await Promise.all([utimes(paths.png, now, now).catch(() => {}), utimes(paths.meta, now, now).catch(() => {})]); + reused++; + } else { + const compared = comparePng(expected, actual); + record = { + ratio: compared.ratio, + changed: compared.changed, + resized: compared.resized, + size: compared.size, + expectedSize: compared.expectedSize, + actualSize: compared.actualSize, + }; + + await mkdir(paths.dir, { recursive: true }); + await writeFile(paths.png, compared.png); + await writeFile(paths.meta, JSON.stringify(record)); + computed++; + } + + diffs.set(entry.path, paths.png); + } + + results[entry.path] = { + ...entry, + comparable, + size: record?.size ?? pngSize(actual ?? expected), + expectedSize: record?.expectedSize ?? (expected ? pngSize(expected) : null), + actualSize: record?.actualSize ?? (actual ? pngSize(actual) : null), + ratio: record?.ratio ?? null, + changed: record?.changed ?? null, + resized: record?.resized ?? false, + bytes: { expected: expected?.length ?? null, actual: actual?.length ?? null }, + }; + + done++; + if (computed && done % 50 === 0) { + process.stdout.write(`\r diffing ${done}/${entries.length}`); + } + } + + if (computed) { + process.stdout.write(`\r diffed ${computed} pairs${' '.repeat(24)}\n`); + } + + return { entries: Object.values(results), diffs, computed, reused }; +}; + +/** + * Writes a complete response. + * @param {import('node:http').ServerResponse} res + * @param {number} status + * @param {string | Buffer} body + * @param {Record} [headers] + */ +const send = (res, status, body, headers = {}) => { + res.writeHead(status, { 'content-length': Buffer.byteLength(body), ...headers }); + res.end(body); +}; + +/** + * Reads a request body as JSON. + * @param {import('node:http').IncomingMessage} req + * @returns {Promise} An empty object for an empty body. + */ +const readBody = (req) => + new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString() || '{}')); + } catch (error) { + reject(error); + } + }); + req.on('error', reject); + }); + +/** + * Listens on `port`, moving up a port at a time while it is taken, to at most 20 above + * the default. + * @param {import('node:http').Server} server + * @param {number} port + * @returns {Promise} The port it got. + */ +const listen = (server, port) => + new Promise((resolve, reject) => { + const onListening = () => { + server.removeListener('error', onError); + resolve(port); + }; + /* A failed attempt's listener has to go too, or it fires when the next port + succeeds and reports the busy one. */ + const onError = (error) => { + server.removeListener('listening', onListening); + if (error.code === 'EADDRINUSE' && port < DEFAULT_PORT + 20) { + listen(server, port + 1).then(resolve, reject); + } else { + reject(error); + } + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, '127.0.0.1'); + }); + +const CACHE_TTL_DAYS = 14; + +/** + * Deletes cached diffs unused for 14 days, and anything left from the old commit-keyed + * layout. Review state files are never touched, since they cannot be regenerated. + * @param {string} root From `stateDir`. + * @returns {Promise} How many entries were removed. + */ +const pruneCache = async (root) => { + const cutoff = Date.now() - CACHE_TTL_DAYS * 24 * 60 * 60 * 1000; + let freed = 0; + + /* Anything at the top level that is not the shared cache or a review's state is a + leftover from the commit-keyed layout, which nothing can read any more. */ + for (const name of await readdir(root).catch(() => [])) { + if (name === DIFF_CACHE || name.endsWith('.json')) { + continue; + } + await rm(join(root, name), { recursive: true, force: true }).catch(() => {}); + freed++; + } + + const cacheDir = join(root, DIFF_CACHE); + + for (const shard of await readdir(cacheDir).catch(() => [])) { + const shardDir = join(cacheDir, shard); + const names = await readdir(shardDir).catch(() => []); + let left = names.length; + + for (const name of names) { + const path = join(shardDir, name); + const info = await stat(path).catch(() => null); + + if (!info || info.mtimeMs >= cutoff) { + continue; + } + + await rm(path, { force: true }).catch(() => {}); + left--; + /* Counted in pairs, since each diff is an image and its metrics. */ + if (name.endsWith('.png')) { + freed++; + } + } + + if (left === 0) { + await rm(shardDir, { recursive: true, force: true }).catch(() => {}); + } + } + + return freed; +}; + +/** + * Builds everything the server answers from, as one value that refresh swaps whole. + * Every blob is read at the resolved head, so a moving branch cannot mix two commits. + * @param {{ args: Args, viewer: string | null, range: Range, pathspec: string, cwd: string, reader: BlobReader }} options + * @returns {Promise} + */ +const buildReview = async ({ args, viewer, range, pathspec, cwd, reader }) => { + const manifest = await buildManifest({ base: range.base, head: range.head, pathspec, cwd }); + if (manifest.entries.length === 0) { + throw new Error(`No screenshots changed between ${range.base} and ${range.head}`); + } + + const headSha = await resolveRef(range.head, cwd); + const root = await stateDir(cwd); + + /* Read at the head, because the spec that took a screenshot is the one on the branch, + not whatever the working tree happens to be checked out to. */ + await attachTests({ entries: manifest.entries, ref: headSha, cwd }); + + const { entries, diffs, computed, reused } = await buildDiffs({ + entries: manifest.entries, + reader, + mergeBase: manifest.mergeBase, + head: headSha, + cacheRoot: root, + }); + + const store = await createStore(cwd, reviewKey(args, range), entries); + const pruned = await pruneCache(root); + + /* What teammates have already said, so their notes sit beside the screenshot they + are about. A failure here is not worth refusing to start over. + Narrowed to this range: a pull request collects comments on source files too, and + those have nowhere to appear here. */ + const allThreads = args.pr ? await fetchReviewThreads({ number: args.pr, cwd }).catch(() => ({})) : {}; + const inRange = new Set(entries.map((entry) => entry.path)); + const threads = Object.fromEntries(Object.entries(allThreads).filter(([path]) => inRange.has(path))); + + /* Links are only offered for commits GitHub can actually serve. */ + const slug = await remoteSlug(cwd); + const blobBase = slug + ? { + repo: slug, + expected: (await isOnRemote(manifest.mergeBase, cwd)) ? manifest.mergeBase : null, + actual: (await isOnRemote(headSha, cwd)) ? headSha : null, + } + : null; + + return { + threads, + blobBase, + pruned, + computed, + reused, + headSha, + mergeBase: manifest.mergeBase, + entries, + diffs, + store, + counts: entries.reduce((acc, entry) => ({ ...acc, [entry.status]: (acc[entry.status] ?? 0) + 1 }), {}), + /* The client only ever knows a screenshot by its current path, so the server + keeps the mapping back to where a renamed one's baseline lives. */ + baselinePaths: new Map(entries.filter((entry) => entry.oldPath).map((entry) => [entry.path, entry.oldPath])), + byPath: new Map(entries.map((entry) => [entry.path, entry])), + pushWatch: createFreshness({ args, viewer, range, cwd, reviewedAt: headSha }), + }; +}; + +/** + * Finds the checkout to review: `--repo`, else ionic-framework cloned next to the + * reviewer, else whatever path the developer types in. + * @param {Args} args + * @returns {Promise} The checkout's root. + */ +const findRepo = async (args) => { + const reviewer = await repoRoot(HERE).catch(() => null); + /** @param {string} path */ + const check = async (path) => { + const root = await repoRoot(path).catch(() => null); + if (!root) { + return { error: `Not a git repository: ${path}` }; + } + if (root === reviewer) { + return { error: 'That is the reviewer itself, not ionic-framework' }; + } + return { root }; + }; + + if (args.repo) { + const { root, error } = await check(args.repo); + if (error) { + throw new Error(`${error}. Pass --repo .`); + } + return root; + } + + const { root: sibling } = await check(join(dirname(HERE), '..', 'ionic-framework')); + if (sibling) { + return sibling; + } + + if (!stdin.isTTY) { + throw new Error('No ionic-framework next to the reviewer. Pass --repo .'); + } + + /* Pasted or dragged in, a path can arrive quoted, and the shell is not there to + expand a leading ~. */ + const prompt = createInterface({ input: stdin, output: stdout }); + try { + stdout.write('\n No ionic-framework found next to the reviewer.\n'); + for (;;) { + const answer = (await prompt.question(' Path to your ionic-framework checkout: ')) + .trim() + .replace(/^(['"])(.*)\1$/, '$2') + .replace(/^~(?=$|\/)/, homedir()); + if (answer) { + const { root, error } = await check(resolve(answer)); + if (root) { + return root; + } + stdout.write(` ${error}\n`); + } + } + } finally { + prompt.close(); + } +}; + +/** Parses the arguments, builds the review and serves it until interrupted. */ +export const main = async () => { + const args = readArgs(); + if (args.help) { + process.stdout.write(usage); + return; + } + + const cwd = await findRepo(args); + + /* Writing to a pull request is done as whoever gh is logged in as. With no account + there is nobody to post as, so the review runs read-only rather than failing on + each attempt. */ + const viewer = await authenticatedUser(cwd); + if (args.pr && !viewer) { + process.stdout.write(`\n${NO_ACCOUNT}\n`); + } + + let range = await resolveRange(args, cwd); + const pathspec = args.path ?? DEFAULT_PATHSPEC; + + const reader = new BlobReader(cwd); + let review = await buildReview({ args, viewer, range, pathspec, cwd, reader }); + + /* sirv handles static assets, including the slider web component served straight + out of node_modules, so nothing has to be copied or bundled. */ + const serveClient = sirv(CLIENT_DIR, { dev: true, etag: true }); + const serveSlider = sirv(SLIDER_PKG, { maxAge: 31536000, immutable: true }); + + const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + + try { + if (req.method === 'POST' && url.pathname === '/api/comments/publish') { + /* Only a pull request has somewhere to post to. A ref range does not. */ + if (!args.pr) { + return send( + res, + 400, + JSON.stringify({ error: 'Comments can only be posted when reviewing a pull request.' }), + { + 'content-type': 'application/json', + } + ); + } + + if (!viewer) { + return send(res, 400, JSON.stringify({ error: 'No GitHub account. Run gh auth login to post comments.' }), { + 'content-type': 'application/json', + }); + } + + const comments = review.store.unsentComments(); + if (comments.length === 0) { + return send(res, 200, JSON.stringify({ ok: true, count: 0 }), { 'content-type': 'application/json' }); + } + + const { dryRun } = await readBody(req).catch(() => ({})); + const result = await publishComments({ number: args.pr, comments, cwd, dryRun: Boolean(dryRun) }); + + /* Marked only once GitHub has them, so a failure leaves everything unsent. */ + if (!dryRun) { + review.store.markPosted(result.posted ?? []); + } + + return send(res, 200, JSON.stringify({ ok: true, ...result }), { 'content-type': 'application/json' }); + } + + if (req.method === 'POST' && url.pathname === '/api/comments/delete') { + const { path } = await readBody(req); + const postedId = review.store.postedIdFor(path); + + /* Removed from the pull request first. If that fails the local note stays, so + the two never disagree about what is published. */ + if (postedId) { + if (!args.pr || !viewer) { + return send( + res, + 400, + JSON.stringify({ error: 'No GitHub account to delete this from the pull request.' }), + { + 'content-type': 'application/json', + } + ); + } + await deleteComment({ id: postedId, cwd }); + } + + review.store.forgetComment(path); + + return send(res, 200, JSON.stringify({ ok: true, wasPosted: Boolean(postedId) }), { + 'content-type': 'application/json', + }); + } + + if (req.method === 'POST' && url.pathname === '/api/refresh') { + if (args.pr) { + await git(['fetch', 'origin', `pull/${args.pr}/head`], cwd).catch(() => {}); + } + + /* + * The range is resolved again, not reused. + * + * A pull request's head was pinned to a commit id when the server started, so + * rebuilding against the old range would faithfully rebuild the old review and + * report success. Refresh exists precisely to move to the newer push. + */ + range = await resolveRange(args, cwd); + + /* Built into a local first, so a failure leaves the running review intact and + the reviewer keeps whatever they were looking at. */ + const rebuilt = await buildReview({ args, viewer, range, pathspec, cwd, reader }); + review = rebuilt; + + return send(res, 200, JSON.stringify({ ok: true, head: review.headSha }), { + 'content-type': 'application/json; charset=utf-8', + }); + } + + if (url.pathname === '/api/freshness') { + /* Marking is no longer the only way to notice a push: a reviewer who has + finished marking would never have asked the question otherwise. */ + const freshness = await review.pushWatch.check([], review.byPath).catch(() => ({ moved: false })); + + return send(res, 200, JSON.stringify(freshness), { 'content-type': 'application/json' }); + } + + if (req.method === 'POST' && url.pathname === '/api/state') { + const patch = await readBody(req); + const { flipped } = review.store.update(patch); + + /* + * Mirrored onto the pull request so its Files tab agrees with what was ticked + * here, rather than the reviewer keeping two tallies. + * + * Awaited, because a tick that silently failed to reach GitHub is worse than + * one that takes a moment. Only viewed changes go; a comment is published + * deliberately through its own button. + */ + let mirrored = null; + if (args.pr && viewer && patch.viewed !== undefined && flipped.length > 0) { + mirrored = await setFilesViewed({ number: args.pr, paths: flipped, viewed: patch.viewed, cwd }) + .then(({ marked, refused }) => ({ ok: refused.length === 0, marked, refused })) + .catch((error) => ({ ok: false, error: error.message, refused: [] })); + } + + /* The mark is recorded either way. Whether it still applies is the separate + question answered here, against the paths the mark actually touched. */ + const touched = patch.paths ?? (patch.path ? [patch.path] : []); + const freshness = await review.pushWatch.check(touched, review.byPath).catch(() => ({ moved: false })); + + return send(res, 200, JSON.stringify({ ok: true, freshness, mirrored }), { + 'content-type': 'application/json', + }); + } + + if (url.pathname === '/api/manifest') { + return send( + res, + 200, + JSON.stringify({ + range: { ...range, mergeBase: review.mergeBase, pathspec }, + /* The client hides the publish control without one. */ + pr: args.pr ?? null, + prState: range.prState, + viewer, + blobBase: review.blobBase, + threads: review.threads, + head: review.headSha, + counts: review.counts, + entries: review.entries, + state: review.store.read(), + carriedOver: review.store.summary, + }), + { 'content-type': 'application/json; charset=utf-8' } + ); + } + + if (url.pathname === '/blob') { + const path = url.searchParams.get('path'); + const side = url.searchParams.get('side'); + + /* The diff is generated, so it comes off disk. Expected and actual are the + originals, read straight out of the object store. */ + const blob = + side === 'diff' + ? review.diffs.has(path) + ? await readFile(review.diffs.get(path)) + : null + : path + ? await reader.read( + side === 'expected' ? review.mergeBase : review.headSha, + side === 'expected' ? (review.baselinePaths.get(path) ?? path) : path + ) + : null; + + if (!blob) { + return send(res, 404, 'not found'); + } + return send(res, 200, blob, { + 'content-type': 'image/png', + 'cache-control': 'public, max-age=31536000, immutable', + }); + } + + if (url.pathname.startsWith('/vendor/')) { + req.url = url.pathname.slice('/vendor'.length); + return serveSlider(req, res, () => send(res, 404, 'not found')); + } + + serveClient(req, res, () => send(res, 404, 'not found')); + } catch (error) { + send(res, 500, JSON.stringify({ error: error.message }), { 'content-type': 'application/json' }); + } + }); + + const port = await listen(server, Number(args.port)); + const address = `http://localhost:${port}`; + + const withRatio = review.entries.filter((entry) => entry.ratio !== null); + const biggest = withRatio.reduce((max, entry) => Math.max(max, entry.ratio), 0); + + const orphaned = review.entries.filter((entry) => entry.orphaned); + + process.stdout.write( + [ + ``, + ` Snapshot review ${range.label}`, + ` ${review.entries.length} screenshots ${review.counts.M ?? 0} modified, ${review.counts.A ?? 0} added, ` + + `${review.counts.D ?? 0} removed` + + (review.counts.R ? `, ${review.counts.R} renamed` : ''), + ` ${withRatio.length} diffed largest change ${(biggest * 100).toFixed(2)}% of pixels`, + ` base ${review.mergeBase.slice(0, 10)} (merge base)`, + ...(review.store.summary.reset + ? [` ${review.store.summary.reset} reset, changed since they were reviewed`] + : []), + ...(review.reused ? [` ${review.reused} diffs reused from cache, ${review.computed} computed`] : []), + ...(Object.keys(review.threads).length + ? [` ${Object.keys(review.threads).length} screenshots already have review comments`] + : []), + ...(review.pruned ? [` ${review.pruned} stale cache ${review.pruned === 1 ? 'entry' : 'entries'} removed`] : []), + ...(orphaned.length + ? [ + ` ${orphaned.length} with no test, in ${new Set(orphaned.map((entry) => entry.group)).size} ` + + `${new Set(orphaned.map((entry) => entry.group)).size === 1 ? 'directory' : 'directories'}:`, + ...[...new Set(orphaned.map((entry) => entry.group))].map((group) => ` ${group}`), + ] + : []), + ``, + ...(args.pr && !viewer ? [NO_ACCOUNT, ``] : []), + ` ${address}`, + ``, + ].join('\n') + ); + + if (args.open) { + const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; + execFile(opener, [address], () => {}); + } + + const shutdown = () => { + reader.close(); + server.close(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +}; diff --git a/test/e2e/review.e2e.js b/test/e2e/review.e2e.js new file mode 100644 index 0000000..6f1c8dc --- /dev/null +++ b/test/e2e/review.e2e.js @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { createFixtureRepo } from '../fixtures/repo.js'; + +const CLI = fileURLToPath(new URL('../../src/cli.mjs', import.meta.url)); + +/* One server for the file, so the tests share it and run in order. */ +test.describe.configure({ mode: 'serial' }); + +let fixture; +let server; +let address; + +test.beforeAll(async () => { + fixture = createFixtureRepo(); + /* The fake gh comes first on PATH, so the review runs signed out and offline. */ + server = spawn( + process.execPath, + [CLI, '--repo', fixture.dir, '--base', 'main', '--head', 'feature', '--no-open', '--port', '4410'], + { + env: { ...process.env, PATH: `${fixture.bin}:${process.env.PATH}` }, + } + ); + + address = await new Promise((resolve, reject) => { + let output = ''; + server.stdout.on('data', (chunk) => { + output += chunk; + const match = output.match(/http:\/\/localhost:\d+/); + if (match) { + resolve(match[0]); + } + }); + server.on('exit', (code) => reject(new Error(`server exited with ${code}: ${output}`))); + }); +}); + +test.afterAll(() => { + server?.kill(); + fixture?.cleanup(); +}); + +test.beforeEach(async ({ page }) => { + await page.goto(address); + await expect(page.locator('.row').first()).toBeVisible(); +}); + +test('should list every changed snapshot under its directory', async ({ page }) => { + await expect(page.locator('.row')).toHaveCount(6); + await expect(page.locator('.group-head .group-name')).toHaveText(['badge/test/basic', 'chip/test/hint']); +}); + +test('should flag the snapshot whose spec was deleted', async ({ page }) => { + await page.getByLabel('Missing test only').check(); + + await expect(page.locator('.row')).toHaveCount(1); + await page.locator('.row').click(); + await expect(page.locator('.orphan')).toHaveText('No test writes this screenshot'); +}); + +test('should explain a snapshot whose encoding is the only change', async ({ page }) => { + await page.getByLabel('Encoding differs only').check(); + + await expect(page.locator('.row')).toHaveCount(1); + await page.locator('.row').click(); + await expect(page.locator('.notice')).toHaveText( + 'No pixel changed. Only the encoding differs, so there is nothing to see.' + ); +}); + +test('should keep a viewed mark across a reload', async ({ page }) => { + await page.locator('.row').first().click(); + const saved = page.waitForResponse((response) => response.url().endsWith('/api/state')); + await page.keyboard.press('v'); + await saved; + await expect(page.locator('#progressLabel')).toHaveText('1 / 6 viewed'); + + /* The store writes after a short debounce, so give it time before reloading. */ + await page.waitForTimeout(400); + await page.reload(); + await expect(page.locator('#progressLabel')).toHaveText('1 / 6 viewed'); +}); diff --git a/test/fixtures/repo.js b/test/fixtures/repo.js new file mode 100644 index 0000000..d1c3799 --- /dev/null +++ b/test/fixtures/repo.js @@ -0,0 +1,110 @@ +/* A throwaway ionic-framework lookalike with one of every kind of snapshot change. */ +import { execFileSync } from 'node:child_process'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { PNG } from 'pngjs'; + +const BADGE = 'core/src/components/badge/test/basic'; +const CHIP = 'core/src/components/chip/test/hint'; +const SNAPSHOTS = `${BADGE}/badge.e2e.ts-snapshots`; + +/** Paths of the snapshots the fixture changes, by what happens to each. */ +export const FIXTURE = { + modified: `${SNAPSHOTS}/badge-basic-md-ltr-Mobile-Chrome-linux.png`, + added: `${SNAPSHOTS}/badge-solid-round-md-ltr-Mobile-Chrome-linux.png`, + removed: `${SNAPSHOTS}/badge-old-md-ltr-Mobile-Chrome-linux.png`, + renamedFrom: `${SNAPSHOTS}/badge-moved-md-ltr-Mobile-Chrome-linux.png`, + renamedTo: `${SNAPSHOTS}/badge-renamed-md-ltr-Mobile-Chrome-linux.png`, + reencoded: `${SNAPSHOTS}/badge-basic-ios-ltr-Mobile-Chrome-linux.png`, + orphan: `${CHIP}/chip.e2e.ts-snapshots/chip-hint-md-ltr-Mobile-Chrome-linux.png`, + badgeSpec: `${BADGE}/badge.e2e.ts`, +}; + +/** The badge spec. One literal screenshot name and one built from a template. */ +export const BADGE_SPEC = `import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs().forEach(({ title, screenshot }) => { + test.describe(title('badge: basic'), () => { + test('should not have visual regressions', async ({ page }) => { + await expect(page).toHaveScreenshot(screenshot(\`badge-basic\`)); + }); + + test('should render each variant', async ({ page }) => { + await expect(page).toHaveScreenshot(screenshot(\`badge-\${variant}-round\`)); + }); + }); +}); +`; + +/** + * Encodes a solid PNG, optionally with a band of a second color. + * @param {[number, number, number]} rgb + * @param {{ band?: [number, number, number], deflateLevel?: number }} [options] + * @returns {Buffer} + */ +export const png = (rgb, { band, deflateLevel = 9 } = {}) => { + const image = new PNG({ width: 40, height: 20 }); + for (let y = 0; y < image.height; y += 1) { + for (let x = 0; x < image.width; x += 1) { + const color = band && y < 5 ? band : rgb; + const i = (image.width * y + x) << 2; + image.data.set([...color, 255], i); + } + } + return PNG.sync.write(image, { deflateLevel }); +}; + +/** + * Creates the fixture repository: `main` as the base and `feature` as the head. + * @returns {{ dir: string, bin: string, cleanup: () => void }} `bin` holds a fake gh that + * always fails, so nothing can reach GitHub. + */ +export const createFixtureRepo = () => { + const root = mkdtempSync(join(tmpdir(), 'visual-review-')); + const dir = join(root, 'repo'); + const bin = join(root, 'bin'); + + const git = (...args) => + execFileSync('git', ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.com', ...args], { + cwd: dir, + stdio: 'pipe', + }); + const write = (path, contents) => { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), contents); + }; + const remove = (path) => rmSync(join(dir, path)); + + mkdirSync(dir, { recursive: true }); + git('init', '-q', '-b', 'main'); + + write(FIXTURE.badgeSpec, BADGE_SPEC); + write(FIXTURE.modified, png([220, 40, 40])); + write(`${SNAPSHOTS}/badge-basic-md-ltr-Mobile-Firefox-linux.png`, png([220, 40, 40])); + write(FIXTURE.reencoded, png([40, 80, 220])); + write(FIXTURE.removed, png([90, 90, 90])); + write(FIXTURE.renamedFrom, png([200, 160, 20])); + write(`${CHIP}/chip.e2e.ts`, "await expect(page).toHaveScreenshot(screenshot('chip-hint'));\n"); + write(FIXTURE.orphan, png([20, 160, 90])); + git('add', '-A'); + git('commit', '-q', '-m', 'base'); + + git('checkout', '-q', '-b', 'feature'); + write(FIXTURE.modified, png([220, 40, 40], { band: [40, 200, 40] })); + write(FIXTURE.added, png([120, 40, 200])); + write(FIXTURE.reencoded, png([40, 80, 220], { deflateLevel: 0 })); + remove(FIXTURE.removed); + git('mv', FIXTURE.renamedFrom, FIXTURE.renamedTo); + remove(`${CHIP}/chip.e2e.ts`); + write(FIXTURE.orphan, png([20, 160, 90], { band: [240, 240, 240] })); + git('add', '-A'); + git('commit', '-q', '-m', 'feature'); + + mkdirSync(bin); + writeFileSync(join(bin, 'gh'), '#!/bin/sh\nexit 1\n'); + chmodSync(join(bin, 'gh'), 0o755); + + return { dir, bin, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +}; diff --git a/test/integration/manifest.test.js b/test/integration/manifest.test.js new file mode 100644 index 0000000..51110eb --- /dev/null +++ b/test/integration/manifest.test.js @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, test } from 'node:test'; +import { setTimeout as wait } from 'node:timers/promises'; + +import { attachTests, buildManifest } from '../../src/git.mjs'; +import { createStore } from '../../src/index.mjs'; +import { BADGE_SPEC, FIXTURE, createFixtureRepo } from '../fixtures/repo.js'; + +/* The store writes after a short debounce; this waits it out. */ +const FLUSH = 300; + +describe('manifest: changed snapshots', () => { + let fixture; + let entries; + const byPath = (path) => entries.find((entry) => entry.path === path); + + before(async () => { + fixture = createFixtureRepo(); + ({ entries } = await buildManifest({ base: 'main', head: 'feature', cwd: fixture.dir })); + await attachTests({ entries, ref: 'feature', cwd: fixture.dir }); + }); + + after(() => fixture.cleanup()); + + test('should list every changed snapshot with its status', () => { + assert.deepEqual(Object.fromEntries(entries.map((entry) => [entry.path, entry.status])), { + [FIXTURE.modified]: 'M', + [FIXTURE.added]: 'A', + [FIXTURE.removed]: 'D', + [FIXTURE.renamedTo]: 'R', + [FIXTURE.reencoded]: 'M', + [FIXTURE.orphan]: 'M', + }); + }); + + test('should record where a renamed snapshot came from', () => { + const renamed = byPath(FIXTURE.renamedTo); + assert.equal(renamed.oldPath, FIXTURE.renamedFrom); + assert.equal(renamed.similarity, 100); + assert.equal(renamed.expectedSha, renamed.actualSha); + }); + + test('should trace each snapshot to the line that took it', () => { + const lines = BADGE_SPEC.split('\n'); + const lineOf = (needle) => lines.findIndex((line) => line.includes(needle)) + 1; + + assert.deepEqual(byPath(FIXTURE.modified).test, { path: FIXTURE.badgeSpec, line: lineOf('`badge-basic`') }); + assert.deepEqual(byPath(FIXTURE.added).test, { path: FIXTURE.badgeSpec, line: lineOf('-round`') }); + }); + + test('should flag a snapshot whose spec is gone, but not one being deleted', () => { + assert.equal(byPath(FIXTURE.orphan).orphaned, true); + assert.equal(byPath(FIXTURE.orphan).test, null); + assert.equal(byPath(FIXTURE.removed).orphaned, false); + }); +}); + +describe('store: viewed marks', () => { + let fixture; + let entries; + + before(async () => { + fixture = createFixtureRepo(); + ({ entries } = await buildManifest({ base: 'main', head: 'feature', cwd: fixture.dir })); + }); + + after(() => fixture.cleanup()); + + test('should keep a mark only while its snapshot is unchanged', async () => { + const store = await createStore(fixture.dir, 'pr-1', entries); + store.update({ paths: [FIXTURE.modified, FIXTURE.added], viewed: true }); + await wait(FLUSH); + + /* A later push rewrites one of the two marked snapshots. */ + const pushed = entries.map((entry) => + entry.path === FIXTURE.added ? { ...entry, actualSha: 'f'.repeat(40) } : entry + ); + const reopened = await createStore(fixture.dir, 'pr-1', pushed); + + assert.deepEqual(reopened.summary, { carried: 1, reset: 1 }); + assert.deepEqual(Object.keys(reopened.read().viewed), [FIXTURE.modified]); + }); + + test('should report only the paths a mark actually flipped', async () => { + const store = await createStore(fixture.dir, 'pr-2', entries); + store.update({ paths: [FIXTURE.modified], viewed: true }); + + const { flipped } = store.update({ paths: [FIXTURE.modified, FIXTURE.added], viewed: true }); + assert.deepEqual(flipped, [FIXTURE.added]); + await wait(FLUSH); + }); +}); diff --git a/test/unit/parsing.test.js b/test/unit/parsing.test.js new file mode 100644 index 0000000..40845f1 --- /dev/null +++ b/test/unit/parsing.test.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { setLead } from '../../src/client/scripts/entries.js'; +import { kb, percent } from '../../src/client/scripts/format.js'; +import { indexScreenshotCalls, splitName } from '../../src/git.mjs'; +import { BADGE_SPEC } from '../fixtures/repo.js'; + +describe('names: screenshot files', () => { + test('should split browser, mode and direction from a plain name', () => { + assert.deepEqual(splitName('badge-basic-md-ltr-Mobile-Chrome-linux.png'), { + stem: 'badge-basic-md-ltr', + browser: 'Mobile Chrome', + mode: 'md', + theme: 'md', + direction: 'ltr', + palette: 'light', + name: 'badge-basic', + }); + }); + + test('should read the ionic theme apart from the mode it runs on', () => { + const { theme, mode, palette, name } = splitName('badge-hint-ionic-md-ltr-light-Mobile-Safari-linux.png'); + assert.deepEqual( + { theme, mode, palette, name }, + { theme: 'ionic', mode: 'md', palette: 'light', name: 'badge-hint' } + ); + }); + + test('should match the longest palette when a shorter one is its suffix', () => { + assert.equal(splitName('toggle-ios-rtl-high-contrast-dark-Mobile-Firefox-linux.png').palette, 'high-contrast-dark'); + }); + + test('should leave the axes empty for a name that does not follow the convention', () => { + const { browser, mode, direction } = splitName('hand-made.png'); + assert.deepEqual({ browser, mode, direction }, { browser: null, mode: null, direction: null }); + }); +}); + +describe('names: tracing to the spec', () => { + test('should place a literal name and a templated one on their own lines', () => { + const calls = indexScreenshotCalls(BADGE_SPEC); + const literal = calls.find((call) => call.raw === 'badge-basic'); + const templated = calls.find((call) => call.pattern?.test('badge-solid-round')); + + assert.equal(BADGE_SPEC.split('\n')[literal.line - 1].includes('`badge-basic`'), true); + assert.equal(BADGE_SPEC.split('\n')[templated.line - 1].includes('-round`'), true); + }); +}); + +describe('format: figures', () => { + test('should keep no change and almost no change apart', () => { + assert.equal(percent(0), '0%'); + assert.equal(percent(0.00001), '<0.01%'); + assert.equal(percent(0.0781), '7.81%'); + assert.equal(percent(null), ''); + }); + + test('should show sizes in kilobytes', () => { + assert.equal(kb(1536), '1.5 KB'); + assert.equal(kb(null), ''); + }); +}); + +describe('entries: browser sets', () => { + test('should let Chrome stand for a set when it is present', () => { + const set = [{ browser: 'Mobile Firefox' }, { browser: 'Mobile Chrome' }, { browser: 'Mobile Safari' }]; + assert.equal(setLead(set).browser, 'Mobile Chrome'); + }); + + test('should fall back to the first browser when Chrome is filtered out', () => { + assert.equal(setLead([{ browser: 'Mobile Safari' }, { browser: 'Mobile Firefox' }]).browser, 'Mobile Safari'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..af9fb7c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": false, + "target": "esnext", + "module": "nodenext", + "lib": ["esnext", "dom", "dom.iterable"], + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src/**/*.js", "src/**/*.mjs", "test/**/*.js", "*.config.js"] +}