diff --git a/.github/scripts/publish-benchmark.cjs b/.github/scripts/publish-benchmark.cjs new file mode 100644 index 00000000..84f0d475 --- /dev/null +++ b/.github/scripts/publish-benchmark.cjs @@ -0,0 +1,169 @@ +const { readFile } = require('node:fs/promises') + +const marker = '' +const branch = 'benchmark-reports' + +module.exports = async ({ github, context, core }) => { + const { comparePairs, formatComparison } = await import('../../benchmarks/comparison.js') + const repo = context.repo + const run = context.payload.workflow_run + let pulls = run.pull_requests + if (!pulls?.length) { + pulls = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, { + ...repo, + commit_sha: run.head_sha, + }) + } + const report = JSON.parse(await readFile('benchmark-data/comparison.json', 'utf8')) + const prs = [] + for (const pull of pulls) { + const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: pull.number }) + if ( + pr.state === 'open' && + pr.head.sha === run.head_sha && + pr.head.sha === report.candidate?.commit && + pr.base.sha === report.base?.commit + ) { + prs.push(pr) + } + } + if (!prs.length) { + core.info('No open PR matches these head and base commits; skipping stale results.') + return + } + + // Artifacts are untrusted data. Validate numeric inputs and escape display text. + const positiveInteger = (value) => Number.isSafeInteger(value) && value > 0 && value <= 100_000 + if ( + !positiveInteger(report.size) || + !positiveInteger(report.samples) || + !Number.isSafeInteger(report.warmup) || + report.warmup < 0 || + report.warmup > 100_000 || + !Array.isArray(report.pairs) || + !report.pairs.length || + report.pairs.length > 100 + ) { + throw new Error('Invalid benchmark configuration') + } + for (const pair of report.pairs) { + if ( + !Array.isArray(pair.order) || + pair.order.length !== 2 || + [...pair.order].sort().join(',') !== 'base,candidate' + ) { + throw new Error('Invalid benchmark execution order') + } + for (const name of ['base', 'candidate']) { + for (const metric of ['medianGenerationSyncMs', 'medianTotalToIdleMs']) { + if (!Number.isFinite(pair[name]?.[metric]) || pair[name][metric] < 0) { + throw new Error('Invalid benchmark timing') + } + } + } + } + const text = (value) => + String(value) + .slice(0, 120) + .replace(/[\\`*_[\]<>|#\r\n]/g, ' ') + report.title = text(report.title) + report.browserVersion = text(report.browserVersion) + report.cpuSlowdown = 20 + report.candidate.dirty = false + report.comparison = comparePairs(report.pairs) + if ( + report.comparison.some( + (row) => + !Number.isFinite(row.differenceMs) || + (row.changePercent !== null && !Number.isFinite(row.changePercent)) + ) + ) { + throw new Error('Invalid benchmark comparison') + } + + const path = `runs/${run.id}-${run.run_attempt || 1}` + const markdown = `${formatComparison(report)}\n[JSON data](comparison.json) · [Workflow run](${run.html_url})\n` + let parent + let baseTree + try { + const { data: ref } = await github.rest.git.getRef({ ...repo, ref: `heads/${branch}` }) + parent = ref.object.sha + const { data: commit } = await github.rest.git.getCommit({ ...repo, commit_sha: parent }) + baseTree = commit.tree.sha + } catch (error) { + if (error.status !== 404) throw error + } + const { data: tree } = await github.rest.git.createTree({ + ...repo, + ...(baseTree ? { base_tree: baseTree } : {}), + tree: [ + { path: `${path}/report.md`, mode: '100644', type: 'blob', content: markdown }, + { + path: `${path}/comparison.json`, + mode: '100644', + type: 'blob', + content: JSON.stringify(report, null, 2), + }, + ], + }) + const { data: commit } = await github.rest.git.createCommit({ + ...repo, + message: `Added benchmark report for run ${run.id}.`, + tree: tree.sha, + parents: parent ? [parent] : [], + }) + if (parent) { + await github.rest.git.updateRef({ + ...repo, + ref: `heads/${branch}`, + sha: commit.sha, + force: false, + }) + } else { + await github.rest.git.createRef({ ...repo, ref: `refs/heads/${branch}`, sha: commit.sha }) + } + + const url = `${context.serverUrl}/${repo.owner}/${repo.repo}/blob/${commit.sha}/${path}/report.md` + const change = (metric) => { + const value = report.comparison.find((row) => row.metric === metric).changePercent + return value === null ? 'n/a' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%` + } + const body = [ + marker, + '### Benchmark comparison', + '', + '| Timing | Change |', + '| --- | ---: |', + `| Synchronous generation | ${change('medianGenerationSyncMs')} |`, + `| Total time to idle | ${change('medianTotalToIdleMs')} |`, + '', + 'Negative means faster. Informational results; small changes can be measurement noise.', + '', + `[Full report](${url}) · [Workflow run](${run.html_url})`, + '', + `Head: \`${report.candidate.commit.slice(0, 12)}\` · Base: \`${report.base.commit.slice(0, 12)}\``, + ].join('\n') + for (const pr of prs) { + // Check again in case the PR changed while publishing. + const { data: current } = await github.rest.pulls.get({ ...repo, pull_number: pr.number }) + if ( + current.state !== 'open' || + current.head.sha !== report.candidate.commit || + current.base.sha !== report.base.commit + ) + continue + const comments = await github.paginate(github.rest.issues.listComments, { + ...repo, + issue_number: pr.number, + }) + const existing = comments.find( + (comment) => comment.user.login === 'github-actions[bot]' && comment.body?.includes(marker) + ) + if (existing) { + await github.rest.issues.updateComment({ ...repo, comment_id: existing.id, body }) + } else { + await github.rest.issues.createComment({ ...repo, issue_number: pr.number, body }) + } + } + core.info(`Published benchmark report: ${url}`) +} diff --git a/.github/scripts/publish-benchmark.test.cjs b/.github/scripts/publish-benchmark.test.cjs new file mode 100644 index 00000000..dc40e792 --- /dev/null +++ b/.github/scripts/publish-benchmark.test.cjs @@ -0,0 +1,139 @@ +const assert = require('node:assert/strict') +const { before, after, test } = require('node:test') +const { mkdtemp, mkdir, writeFile, rm } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const publish = require('./publish-benchmark.cjs') + +const head = 'a'.repeat(40) +const base = 'b'.repeat(40) +const originalDirectory = process.cwd() +let directory +before(async () => { + directory = await mkdtemp(join(tmpdir(), 'benchmark-publisher-test-')) + process.chdir(directory) + await mkdir('benchmark-data') +}) +after(async () => { + process.chdir(originalDirectory) + await rm(directory, { recursive: true, force: true }) +}) + +const fixture = async ({ existing = false, stale = false, invalid = false } = {}) => { + const calls = [] + const report = { + title: 'Element generation benchmark', + base: { commit: base }, + candidate: { commit: head }, + size: 1000, + warmup: 5, + samples: 20, + browserVersion: '156.0', + pairs: [ + { + order: ['base', 'candidate'], + base: { medianGenerationSyncMs: 100, medianTotalToIdleMs: 200 }, + candidate: { medianGenerationSyncMs: invalid ? 'bad' : 80, medianTotalToIdleMs: 220 }, + }, + ], + comparison: [{ changePercent: 999 }], // Recomputed rather than trusted. + } + await writeFile('benchmark-data/comparison.json', JSON.stringify(report)) + const method = (name, data) => async (args) => { + calls.push({ name, args }) + return { data } + } + const github = { + rest: { + pulls: { + get: method('pulls.get', { + number: 12, + state: 'open', + head: { sha: stale ? 'c'.repeat(40) : head }, + base: { sha: base }, + }), + }, + repos: { listPullRequestsAssociatedWithCommit: () => {} }, + issues: { + listComments: () => {}, + createComment: method('createComment', {}), + updateComment: method('updateComment', {}), + }, + git: { + getRef: existing + ? method('getRef', { object: { sha: 'parent' } }) + : async () => { + throw Object.assign(new Error('missing'), { status: 404 }) + }, + getCommit: method('getCommit', { tree: { sha: 'previous-tree' } }), + createTree: method('createTree', { sha: 'tree' }), + createCommit: method('createCommit', { sha: 'report-commit' }), + createRef: method('createRef', {}), + updateRef: method('updateRef', {}), + }, + }, + } + github.paginate = async () => + existing + ? [ + { + id: 42, + user: { login: 'github-actions[bot]' }, + body: '', + }, + ] + : [] + const context = { + repo: { owner: 'example', repo: 'blits' }, + serverUrl: 'https://github.com', + payload: { + workflow_run: { + id: 123, + run_attempt: 1, + head_sha: head, + pull_requests: [{ number: 12 }], + html_url: 'https://github.com/example/blits/actions/runs/123', + }, + }, + } + return { github, context, core: { info: () => {} }, calls } +} + +test('creates an isolated report branch and links its immutable commit with computed percentages', async () => { + const mock = await fixture() + await publish(mock) + const tree = mock.calls.find((call) => call.name === 'createTree').args + assert.equal(tree.base_tree, undefined) + assert.match(tree.tree[0].content, /Synchronous generation/) + assert.deepEqual(mock.calls.find((call) => call.name === 'createCommit').args.parents, []) + assert.equal( + mock.calls.find((call) => call.name === 'createRef').args.ref, + 'refs/heads/benchmark-reports' + ) + const body = mock.calls.find((call) => call.name === 'createComment').args.body + assert.match(body, /-20\.00%/) + assert.match(body, /\+10\.00%/) + assert.match(body, /blob\/report-commit\/runs\/123-1\/report.md/) +}) + +test('preserves previous reports and updates the existing bot comment', async () => { + const mock = await fixture({ existing: true }) + await publish(mock) + assert.equal( + mock.calls.find((call) => call.name === 'createTree').args.base_tree, + 'previous-tree' + ) + assert.deepEqual(mock.calls.find((call) => call.name === 'createCommit').args.parents, ['parent']) + assert.equal(mock.calls.find((call) => call.name === 'updateRef').args.force, false) + assert.equal(mock.calls.find((call) => call.name === 'updateComment').args.comment_id, 42) + assert.ok(!mock.calls.some((call) => call.name === 'createComment')) +}) + +test('skips stale results and rejects invalid timings before publication', async () => { + const stale = await fixture({ stale: true }) + await publish(stale) + assert.ok(!stale.calls.some((call) => call.name === 'createTree')) + const invalid = await fixture({ invalid: true }) + await assert.rejects(publish(invalid), /Invalid benchmark timing/) + assert.ok(!invalid.calls.some((call) => call.name === 'createTree')) +}) diff --git a/.github/workflows/publish-benchmark-results.yml b/.github/workflows/publish-benchmark-results.yml new file mode 100644 index 00000000..5018af5c --- /dev/null +++ b/.github/workflows/publish-benchmark-results.yml @@ -0,0 +1,43 @@ +name: Publish Benchmark Results + +on: + workflow_run: + workflows: [Run Benchmarks] + types: [completed] + +permissions: + contents: write + actions: read + pull-requests: write + +concurrency: + group: publish-benchmark-reports + cancel-in-progress: false + +jobs: + publish: + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Only execute trusted default-branch code, never the PR checkout/artifact. + - name: Checkout report publisher + uses: actions/checkout@v7 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Download benchmark data + uses: actions/download-artifact@v8 + with: + name: benchmark-results + path: benchmark-data + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish report and update PR comment + uses: actions/github-script@v7 + with: + script: | + const publish = require('./.github/scripts/publish-benchmark.cjs'); + await publish({ github, context, core }); diff --git a/.github/workflows/run-benchmarks.yml b/.github/workflows/run-benchmarks.yml new file mode 100644 index 00000000..8bf59fca --- /dev/null +++ b/.github/workflows/run-benchmarks.yml @@ -0,0 +1,67 @@ +name: Run Benchmarks + +on: + pull_request: + workflow_dispatch: + inputs: + base: + description: Baseline branch or commit + required: true + default: master + +permissions: + contents: read + +concurrency: + group: benchmarks-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + benchmark: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - name: Fetch PR base commit + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: git fetch origin "$BASE_SHA" + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.23.2 + cache: npm + cache-dependency-path: | + package-lock.json + benchmarks/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install benchmark dependencies + run: npm ci --prefix benchmarks + + - name: Install Chromium + working-directory: benchmarks + run: npx playwright install --with-deps chromium + + - name: Compare benchmarks and write workflow summary + env: + BASE_REF: ${{ github.event.pull_request.base.sha || inputs.base }} + CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: npm run bench:compare -- --base "$BASE_REF" --candidate "$CANDIDATE_SHA" --output results/comparison.json --pairs 5 + + - name: Upload benchmark results + uses: actions/upload-artifact@v7 + with: + name: benchmark-results + path: benchmarks/results/comparison.json + if-no-files-found: error + retention-days: 30 diff --git a/.npmignore b/.npmignore index 95036ae3..7ada5bf7 100644 --- a/.npmignore +++ b/.npmignore @@ -5,6 +5,7 @@ coverage src/components/*.orig.js # Development files +benchmarks/ .editorconfig .eslintignore .eslintrc.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c43b5a..a81696ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +# 2.14.0 + +_19 oct 2026_ + +- Added built-in profiler support to for both internal and app dev use +- Added benchmark for performance comparison (and GitHub workflow to run on PRs) + ## 2.13.0 _13 oct 2026_ diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 00000000..7f924016 --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +results/ diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..3084b081 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,65 @@ +# Element generation benchmark + +One workload: clear a keyed loop and generate Elements again. Data creation and clearing happen outside the measured generation time. The default run uses 1,000 Elements, five warmups, and 20 measured samples. Each sample verifies the number of live rendered Elements. + +Install dependencies in this folder with `npm install` and install Chromium with `npx playwright install chromium`. + +From the repository root: + +```sh +npm run bench +npm run bench -- --size 100 --samples 5 --warmup 2 +npm run bench -- --headed +npm run bench -- --output results/benchmark.json +npm run bench:serve +npm run bench:compare -- --base master +``` + +Relative `--output` paths resolve from the `benchmarks/` directory, so the example saves to `benchmarks/results/benchmark.json`. + +For browser inspection, open the URL printed by `bench:serve` and press **Enter**. The loop is cleared and recreated on every repetition, and the final sample stays visible. Results are logged in the browser console. Options can be supplied in the URL: + +```text +http://localhost:5173/?size=1000&warmup=5&samples=20 +``` + +Browser and CLI runs use the same workload. The CLI applies a 20x Chromium CPU slowdown; manual browser runs use the browser's current CPU settings. Use DevTools CPU throttling for a manual slowdown. + +Results include synchronous generation time (`generationSyncMs`) and elapsed time until the renderer completes an active → idle transition (`totalToIdleMs`). Both metrics report median, arithmetic average, minimum, and maximum in the browser console table and CLI JSON (for example, `medianGenerationSyncMs`, `averageGenerationSyncMs`, `minGenerationSyncMs`, and `maxGenerationSyncMs`). Each sample also waits for renderer idle after clearing the previous loop, outside the measured generation time. Idle means the renderer has no scene updates to process; this measurement includes frame scheduling and does not establish GPU completion. Each idle wait has a 30-second timeout. Warmup samples are excluded from all statistics. Compare repeated runs on the same machine with the same browser and settings. + +`npm run bench:profile` enables development profiling while running this workload. Profile timings should be compared separately from production benchmark timings. + +## Compare versions + +From the repository root: + +```sh +npm run bench:compare -- --base master +npm run bench:compare -- --base origin/dev +npm run bench:compare -- --base abc1234 --candidate def5678 +npm run bench:compare -- --base HEAD --size 100 --warmup 2 --samples 5 --pairs 2 +``` + +`--base` accepts a locally available branch, remote branch, or commit. Without `--candidate`, the candidate is the current working tree, including uncommitted and untracked source files, excluding ignored files. With `--candidate`, it is that exact commit or branch. Fetch remote references first if needed. Temporary snapshots are removed on success or failure; your branch and working tree are not modified. + +Both snapshots use the current benchmark harness, Vite plugins, installed dependencies (including the renderer), and Chromium. This compares Blits source changes, not dependency or build-tool version changes. Baselines that are incompatible with the current harness fail rather than silently running a different workload. + +Both production builds finish before measurement begins. Each run uses a fresh browser and its own warmups, with the same viewport and 20× CPU slowdown. The default is two pairs: base → candidate, then candidate → base. Use `--pairs` to increase repetitions, and `--headed` to see the browser. Profiling is not supported for comparisons. + +The console prints a Markdown comparison table and saves `results/comparison.json` by default. Override the path with `--output`; relative paths resolve from `benchmarks/`. JSON includes commit identities, working-tree dirty status, execution order, all samples and statistics, and paired differences. Negative percentages mean faster. Summary columns show medians of run medians; differences and percentages are calculated within each pair and then summarized with a median. Per-pair percentages remain visible to expose inconsistent results. Percent changes from a zero baseline are reported as unavailable. + +To estimate comparison noise, run the same commit on both sides: + +```sh +npm run bench:compare -- --base HEAD --candidate HEAD +``` + +## GitHub Actions + +The Run Benchmarks workflow compares the PR head SHA with the target branch's base SHA from the PR event, sequentially in the same job. It uses the PR's benchmark harness. For manual runs, supply a baseline branch or commit; the candidate is the selected workflow commit. + +It uploads a `benchmark-results` artifact containing `comparison.json` and writes configuration, aggregate comparison, and per-pair tables to the workflow summary. Results are informational without performance thresholds; build or benchmark execution errors still fail the job. + +After a successful PR run, Publish Benchmark Results saves a rendered Markdown report and JSON under `runs/-/` on the separate `benchmark-reports` branch. The branch is created automatically with report files only. Reports are publicly readable on GitHub while the repository is public and remain available after the Actions artifact expires. + +One PR comment is created and updated with the synchronous generation and time-to-idle percentage changes, plus an immutable full-report link. Outdated head/base results are skipped. Publishing runs from the default branch with write permissions, reads the benchmark artifact as data, and never executes PR code. The publisher must be merged into the default branch before it can run; repository rules must allow Actions to write the report branch and PR comments. No extra hosting or secrets are needed. Manual benchmark runs still produce an artifact and workflow summary but do not publish a PR comment. diff --git a/benchmarks/compare.js b/benchmarks/compare.js new file mode 100644 index 00000000..0e28d314 --- /dev/null +++ b/benchmarks/compare.js @@ -0,0 +1,129 @@ +import { execFileSync } from 'node:child_process' +import { cp, mkdir, mkdtemp, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildBenchmark, measureBenchmark, writeReport } from './runner.js' +import { comparePairs, formatComparison } from './comparison.js' +import { validateOptions } from './src/benchmark-utils.js' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const args = process.argv.slice(2) +const value = (name, fallback) => { + const index = args.indexOf(name) + if (index === -1) return fallback + if (!args[index + 1] || args[index + 1].startsWith('--')) { + throw new Error(`${name} requires a value`) + } + return args[index + 1] +} +const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim() +const commit = (ref) => { + try { + return git('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`) + } catch { + // Full fetches in CI usually provide origin/, not local branches. + return git('rev-parse', '--verify', '--end-of-options', `refs/remotes/origin/${ref}^{commit}`) + } +} + +const snapshot = async (destination, sha, temporary) => { + await mkdir(destination) + if (sha) { + const archive = join(temporary, 'source.tar') + git('archive', '--format=tar', `--output=${archive}`, sha) + execFileSync('tar', ['-xf', archive, '-C', destination]) + } else { + // Include uncommitted and untracked source, but no ignored files or dependencies. + const files = execFileSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { + cwd: root, + encoding: 'utf8', + } + ) + .split('\0') + .filter(Boolean) + for (const file of new Set(files)) { + if (file.startsWith('benchmarks/')) continue + const target = join(destination, file) + await mkdir(dirname(target), { recursive: true }) + try { + await cp(join(root, file), target, { dereference: false }) + } catch (error) { + if (error.code !== 'ENOENT') throw error // Deleted working-tree files stay deleted. + } + } + } + // Compare Blits source with identical installed dependencies and build tooling. + await rm(join(destination, 'node_modules'), { recursive: true, force: true }) + await symlink(join(root, 'node_modules'), join(destination, 'node_modules'), 'dir') +} + +const run = async () => { + const baseRef = value('--base') + if (!baseRef) throw new Error('Usage: npm run bench:compare -- --base ') + const candidateRef = value('--candidate') + const output = value('--output', 'results/comparison.json') + const pairCount = Number(value('--pairs', 2)) + const options = { + size: Number(value('--size', 1000)), + warmup: Number(value('--warmup', 5)), + samples: Number(value('--samples', 20)), + } + validateOptions(options) + if (!Number.isSafeInteger(pairCount) || pairCount < 1) + throw new Error('--pairs must be a positive integer') + if (args.includes('--profile')) + throw new Error('Comparisons use production mode; --profile is unsupported') + const base = { ref: baseRef, commit: commit(baseRef) } + const candidate = { + ref: candidateRef || 'working tree', + commit: commit(candidateRef || 'HEAD'), + dirty: !candidateRef && git('status', '--porcelain').length > 0, + } + const temporary = await mkdtemp(join(tmpdir(), 'blits-benchmark-')) + try { + const configs = {} + for (const [name, sha] of [ + ['base', base.commit], + ['candidate', candidateRef ? candidate.commit : undefined], + ]) { + console.log(`Preparing ${name}…`) + const source = join(temporary, name) + await snapshot(source, sha, temporary) + configs[name] = await buildBenchmark(source, resolve(temporary, `${name}-dist`)) + } + const pairs = [] + for (let index = 0; index < pairCount; index++) { + const order = index % 2 === 0 ? ['base', 'candidate'] : ['candidate', 'base'] + const pair = { order } + for (const name of order) { + console.log(`Pair ${index + 1}/${pairCount}: ${name}`) + pair[name] = await measureBenchmark(configs[name], options, args.includes('--headed')) + } + pairs.push(pair) + } + const report = { + title: pairs[0].candidate.title, + ...options, + base, + candidate, + cpuSlowdown: 20, + browserVersion: pairs[0].candidate.browserVersion, + pairs, + comparison: comparePairs(pairs), + } + const summary = formatComparison(report) + console.log(summary) + await writeReport(report, output, summary) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +run().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/benchmarks/comparison.js b/benchmarks/comparison.js new file mode 100644 index 00000000..3fe3f80c --- /dev/null +++ b/benchmarks/comparison.js @@ -0,0 +1,63 @@ +const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2 +} + +export const comparePairs = (pairs) => { + return ['medianGenerationSyncMs', 'medianTotalToIdleMs'].map((metric) => { + const differences = pairs.map(({ base, candidate }) => candidate[metric] - base[metric]) + const percentages = pairs.map(({ base }, index) => + base[metric] === 0 ? null : (differences[index] / base[metric]) * 100 + ) + return { + metric, + baseMs: median(pairs.map((pair) => pair.base[metric])), + candidateMs: median(pairs.map((pair) => pair.candidate[metric])), + differenceMs: median(differences), + changePercent: percentages.includes(null) ? null : median(percentages), + pairChangesPercent: percentages, + } + }) +} + +export const formatComparison = (report) => { + const number = (value) => (value === null ? 'n/a' : value.toFixed(2)) + const labels = { + medianGenerationSyncMs: 'Synchronous generation', + medianTotalToIdleMs: 'Total time to idle', + } + return [ + `## ${report.title}: comparison`, + '', + '| Configuration | Value |', + '| --- | --- |', + `| Base commit | ${report.base.commit} |`, + `| Candidate commit | ${report.candidate.commit} |`, + `| Candidate working tree changes | ${report.candidate.dirty} |`, + `| Size / warmup / samples per run | ${report.size} / ${report.warmup} / ${report.samples} |`, + `| Pairs | ${report.pairs.length} |`, + `| CPU slowdown | ${report.cpuSlowdown}× |`, + `| Chromium | ${report.browserVersion} |`, + '| Mode | production |', + '', + '| Median timing | Base (ms) | Candidate (ms) | Difference (ms) | Change (%) |', + '| --- | ---: | ---: | ---: | ---: |', + ...report.comparison.map( + (row) => + `| ${labels[row.metric]} | ${number(row.baseMs)} | ${number(row.candidateMs)} | ${number(row.differenceMs)} | ${number(row.changePercent)} |` + ), + '', + '| Pair | Order | Generation change (%) | Time to idle change (%) |', + '| --- | --- | ---: | ---: |', + ...report.pairs.map( + (pair, index) => + `| ${index + 1} | ${pair.order.join(' → ')} | ${report.comparison.map((row) => number(row.pairChangesPercent[index])).join(' | ')} |` + ), + '', + 'Negative changes mean faster. Differences and percentages are medians of paired comparisons; base and candidate columns are medians of run medians.', + '', + 'Both versions use the current harness and installed dependencies. Warmups are excluded. Time to idle includes synchronous generation and frame scheduling, not GPU completion. Results are informational; no performance threshold is applied.', + '', + ].join('\n') +} diff --git a/benchmarks/comparison.test.js b/benchmarks/comparison.test.js new file mode 100644 index 00000000..ded0a46f --- /dev/null +++ b/benchmarks/comparison.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { comparePairs, formatComparison } from './comparison.js' + +const timings = (value) => ({ + medianGenerationSyncMs: value, + medianTotalToIdleMs: value * 2, +}) + +test('compares within pairs before aggregating percentages', () => { + const rows = comparePairs([ + { base: timings(10), candidate: timings(20) }, + { base: timings(100), candidate: timings(90) }, + ]) + assert.deepEqual(rows[0], { + metric: 'medianGenerationSyncMs', + baseMs: 55, + candidateMs: 55, + differenceMs: 0, + changePercent: 45, + pairChangesPercent: [100, -10], + }) + assert.equal(rows[1].changePercent, 45) +}) + +test('identical timings produce no change and zero baselines have no percentage', () => { + assert.equal(comparePairs([{ base: timings(10), candidate: timings(10) }])[0].changePercent, 0) + const [row] = comparePairs([{ base: timings(0), candidate: timings(10) }]) + assert.equal(row.differenceMs, 10) + assert.equal(row.changePercent, null) +}) + +test('summary exposes run order and faster changes with a negative percentage', () => { + const pairs = [ + { + base: timings(100), + candidate: timings(80), + order: ['candidate', 'base'], + }, + ] + const summary = formatComparison({ + title: 'Example', + base: { commit: 'base-sha' }, + candidate: { commit: 'candidate-sha', dirty: true }, + size: 1000, + warmup: 5, + samples: 20, + cpuSlowdown: 20, + browserVersion: 'test', + pairs, + comparison: comparePairs(pairs), + }) + assert.match(summary, /candidate → base/) + assert.match(summary, /100\.00 \| 80\.00 \| -20\.00 \| -20\.00/) + assert.match(summary, /Candidate working tree changes \| true/) +}) diff --git a/benchmarks/index.html b/benchmarks/index.html new file mode 100644 index 00000000..1e0b1d27 --- /dev/null +++ b/benchmarks/index.html @@ -0,0 +1,14 @@ + + + + + Blits benchmark + + + +
+ + + diff --git a/benchmarks/package-lock.json b/benchmarks/package-lock.json new file mode 100644 index 00000000..80361e97 --- /dev/null +++ b/benchmarks/package-lock.json @@ -0,0 +1,2852 @@ +{ + "name": "@lightningjs/blits-benchmarks", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@lightningjs/blits-benchmarks", + "devDependencies": { + "@lightningjs/blits": "file:..", + "@lightningjs/msdf-generator": "^1.4.0", + "playwright": "^1.62.1", + "vite": "^8.2.2" + } + }, + "..": { + "name": "@lightningjs/blits", + "version": "2.12.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@lightningjs/renderer": "^3.6.0", + "magic-string": "^1.4.1" + }, + "bin": { + "blits": "bin/index.js" + }, + "devDependencies": { + "@babel/eslint-parser": "^7.28.6", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@eslint/eslintrc": "^3.3.4", + "@eslint/js": "^9.39.3", + "c8": "^12.0.0", + "eslint": "^9.39.3", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "fast-glob": "^3.3.3", + "global-jsdom": "^30.0.0", + "globals": "^17.12.0", + "husky": "^9.1.7", + "jsdom": "^30.1.0", + "lint-staged": "^15.5.2", + "prettier": "^3.6.2", + "sinon": "^22.1.0", + "tap-diff": "^0.1.1", + "tap-filter": "^1.0.3", + "tape": "^5.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@jimp/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", + "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/file-ops": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^21.3.3", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz", + "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz", + "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz", + "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz", + "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz", + "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz", + "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz", + "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz", + "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", + "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz", + "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz", + "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz", + "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz", + "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz", + "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz", + "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz", + "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz", + "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz", + "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz", + "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz", + "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz", + "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/types": "1.6.1", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz", + "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", + "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz", + "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz", + "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz", + "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@lightningjs/blits": { + "resolved": "..", + "link": true + }, + "node_modules/@lightningjs/msdf-generator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lightningjs/msdf-generator/-/msdf-generator-1.4.0.tgz", + "integrity": "sha512-AVmaU9lWu+7L3vPHVLEzbkGY0QBElH6vr2zivK+3qXQYTJOAPcf320YFSK81Wuu+VxMPm0v5yJ7FYuYjD1lEWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "chalk": "^6.0.1", + "fs-extra": "^11.2.0", + "msdf-bmfont-xml": "^2.8.0", + "opentype.js": "2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-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": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/arabic-persian-reshaper": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arabic-persian-reshaper/-/arabic-persian-reshaper-1.0.1.tgz", + "integrity": "sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.1.tgz", + "integrity": "sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/configstore": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/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-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "dev": true, + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.1.tgz", + "integrity": "sha512-li88H4QGRoNMX27jEmY8nR515oJVspAgC/WFMlhHfFV8RJ4QyGpHhXnwAe9gH5RIgKmdYRUmrOhQobcgEjIdSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-invalid-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-invalid-path/-/is-invalid-path-1.0.2.tgz", + "integrity": "sha512-6KLcFrPCEP3AFXMfnWrIFkZpYNBVzZAoBJJDEZKtI3LXkaDjM3uFMJQjxiizUuZTZ9Oh9FNv/soXbx5TcpaDmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/is-npm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jimp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", + "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/diff": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-gif": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-blur": "1.6.1", + "@jimp/plugin-circle": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-contain": "1.6.1", + "@jimp/plugin-cover": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-displace": "1.6.1", + "@jimp/plugin-dither": "1.6.1", + "@jimp/plugin-fisheye": "1.6.1", + "@jimp/plugin-flip": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/plugin-mask": "1.6.1", + "@jimp/plugin-print": "1.6.1", + "@jimp/plugin-quantize": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/plugin-rotate": "1.6.1", + "@jimp/plugin-threshold": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/js2xmlparser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-5.0.0.tgz", + "integrity": "sha512-ckXs0Fzd6icWurbeAXuqo+3Mhq2m8pOPygsQjTPh8K5UWgKaUgDSHrdDxAfexmT11xvBKOQ6sgYwPkYc5RW/bg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "xmlcreate": "^2.0.4" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/ky": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/latest-version": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-json": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/map-limit": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/maxrects-packer": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/maxrects-packer/-/maxrects-packer-2.7.3.tgz", + "integrity": "sha512-bG6qXujJ1QgttZVIH4WDanhoJtvbud/xP/XPyf6A69C9RdA61BM4TomFALCq2nrTa+tARRIBB4LuIFsnUQU2wA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msdf-bmfont-xml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/msdf-bmfont-xml/-/msdf-bmfont-xml-2.8.0.tgz", + "integrity": "sha512-VK6US7QqNhY9K5sq6TKlpKNlbBch1M2P1vLirf8mZLHK3j7X86fM4sqEyVnwCBYwZ/xiTbJeUWMEv5Ji+jQQMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "arabic-persian-reshaper": "^1.0.1", + "cli-progress": "^3.12.0", + "commander": "^14.0.0", + "handlebars": "^4.7.8", + "is-invalid-path": "^1.0.2", + "jimp": "^1.6.0", + "js2xmlparser": "^5.0.0", + "map-limit": "0.0.1", + "maxrects-packer": "^2.7.3", + "opentype.js": "^1.3.4", + "update-notifier": "^7.3.1" + }, + "bin": { + "msdf-bmfont": "cli.js" + } + }, + "node_modules/msdf-bmfont-xml/node_modules/opentype.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", + "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "string.prototype.codepointat": "^0.2.1", + "tiny-inflate": "^1.0.3" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/opentype.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz", + "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==", + "dev": true, + "license": "MIT", + "bin": { + "ot": "bin/ot" + } + }, + "node_modules/package-json": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, + "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": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "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.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pupa": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-xml-to-json": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz", + "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.12.2" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.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/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.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "dev": true, + "license": "MIT" + }, + "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-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strtok3": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz", + "integrity": "sha512-BkdixFFUt7r7LsX6aGNPVyYMLjW+M1USLlKPNbZsrZEmLFh5tGqhdfckKo6xgsZIJqdDD0CZGkeCJJwNWQIKQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uint8array-extras": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-notifier": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 00000000..e9ee5284 --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,18 @@ +{ + "name": "@lightningjs/blits-benchmarks", + "private": true, + "type": "module", + "scripts": { + "build": "vite build --mode production", + "serve": "vite dev --host", + "bench": "node runner.js", + "bench:compare": "node compare.js", + "bench:profile": "node runner.js --profile" + }, + "devDependencies": { + "@lightningjs/blits": "file:..", + "@lightningjs/msdf-generator": "^1.4.0", + "playwright": "^1.62.1", + "vite": "^8.2.2" + } +} diff --git a/benchmarks/public/.gitkeep b/benchmarks/public/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/benchmarks/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/benchmarks/public/fonts/OpenSans-Medium.ttf b/benchmarks/public/fonts/OpenSans-Medium.ttf new file mode 100644 index 00000000..a76d4ced Binary files /dev/null and b/benchmarks/public/fonts/OpenSans-Medium.ttf differ diff --git a/benchmarks/runner.js b/benchmarks/runner.js new file mode 100644 index 00000000..58db2463 --- /dev/null +++ b/benchmarks/runner.js @@ -0,0 +1,119 @@ +import { fileURLToPath, pathToFileURL } from 'node:url' +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { build, preview } from 'vite' +import { chromium } from 'playwright' +import { formatSummary } from './summary.js' +import viteConfig from './vite.config.js' + +const benchmarkRoot = fileURLToPath(new URL('.', import.meta.url)) +export const buildBenchmark = async (sourceRoot, outDir) => { + // Resolve public Blits imports against this snapshot; keep one toolchain. + const pkg = JSON.parse(await readFile(resolve(sourceRoot, 'package.json'), 'utf8')) + const aliases = Object.entries(pkg.exports) + .map(([name, entry]) => ({ + find: name === '.' ? '@lightningjs/blits' : `@lightningjs/blits/${name.slice(2)}`, + replacement: resolve(sourceRoot, typeof entry === 'string' ? entry : entry.default), + })) + .sort((a, b) => b.find.length - a.find.length) + const config = { + ...viteConfig, + configFile: false, + root: benchmarkRoot, + mode: 'production', + resolve: { + ...viteConfig.resolve, + alias: [ + ...aliases, + ...Object.entries(viteConfig.resolve.alias).map(([find, replacement]) => ({ + find, + replacement, + })), + ], + }, + build: { ...viteConfig.build, outDir }, + } + await build(config) + return config +} + +export const measureBenchmark = async (config, options, headed = false) => { + const server = await preview({ + ...config, + preview: { host: '127.0.0.1', port: 0 }, + }) + let browser + try { + browser = await chromium.launch({ headless: !headed }) + const page = await browser.newPage({ + viewport: { width: 1920, height: 1080 }, + }) + page.on('pageerror', (error) => console.error(error)) + const client = await page.context().newCDPSession(page) + await client.send('Emulation.setCPUThrottlingRate', { rate: 20 }) + const url = server.resolvedUrls.local[0] + await page.goto(config.mode === 'development' ? `${url}?profile` : url) + await page.waitForFunction(() => window.__BLITS_BENCH__?.ready === true) + const results = await page.evaluate((options) => window.__BLITS_BENCH__.run(options), options) + return { + ...results, + cpuSlowdown: 20, + browserVersion: browser.version(), + mode: config.mode, + } + } finally { + try { + await browser?.close() + } finally { + await server.close() + } + } +} + +export const writeReport = async (report, output, summary) => { + const json = `${JSON.stringify(report, null, 2)}\n` + console.log(json) + if (output) { + const outputPath = resolve(benchmarkRoot, output) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, json) + console.log(`Results saved to ${outputPath}`) + } + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile(process.env.GITHUB_STEP_SUMMARY, summary) + } +} + +const run = async () => { + const args = process.argv.slice(2) + const profile = args.includes('--profile') + const headed = args.includes('--headed') + const value = (name, fallback) => { + const index = args.indexOf(name) + return index === -1 ? fallback : args[index + 1] + } + const options = { + size: Number(value('--size', 1000)), + samples: Number(value('--samples', 20)), + warmup: Number(value('--warmup', 5)), + } + const output = value('--output', undefined) + if (args.includes('--output') && (!output || output.startsWith('--'))) { + throw new Error('--output requires a file path') + } + const config = { + root: benchmarkRoot, + mode: profile ? 'development' : 'production', + } + await build(config) + const report = await measureBenchmark(config, options, headed) + report.commit = process.env.GITHUB_SHA + await writeReport(report, output, formatSummary(report)) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + run().catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/benchmarks/src/Benchmark.js b/benchmarks/src/Benchmark.js new file mode 100644 index 00000000..0d8d1fec --- /dev/null +++ b/benchmarks/src/Benchmark.js @@ -0,0 +1,82 @@ +import Blits from '@lightningjs/blits' +import symbols from '@lightningjs/blits/symbols' +import { untilIdle, updateIdle, runBenchmark, getManualOptions } from './benchmark-utils.js' + +const columns = 34 + +export default Blits.Application({ + template: ` + + + + + + `, + state() { + return { items: [], benchmarkRunning: false } + }, + input: { + async enter(event) { + if (event.repeat || this.benchmarkRunning || !this.runBenchmark) return + event.preventDefault() + try { + await this.runBenchmark(getManualOptions()) + } catch (error) { + console.error(error) + } + }, + }, + hooks: { + idle(idle) { + updateIdle(idle) + }, + ready() { + const run = async (options = {}) => { + if (this.benchmarkRunning) return + this.benchmarkRunning = true + try { + return await runBenchmark({ + ...options, + title: 'Element generation benchmark', + setup: async ({ size }) => { + if (this.items.length > 0) { + await untilIdle(() => { + this.items = [] + }) + } else { + await untilIdle() + } + // Prepare data outside timing; each assignment starts from an empty loop. + return Array.from({ length: size }, (_, item) => ({ + id: `item-${item}`, + x: (item % columns) * 55, + y: Math.floor(item / columns) * 34, + color: item % 2 === 0 ? '#334155' : '#475569', + })) + }, + measure: async (items) => { + let start + let generationSyncMs + await untilIdle(() => { + start = performance.now() + this.items = items + generationSyncMs = performance.now() - start + }) + return { generationSyncMs, totalToIdleMs: performance.now() - start } + }, + verify: ({ size }) => { + const rendered = Object.values(this[symbols.children][1] || {}) + if (rendered.length !== size || rendered.some((element) => element.eol === true)) { + throw new Error(`Expected ${size} live Elements, found ${rendered.length}`) + } + }, + }) + } finally { + this.benchmarkRunning = false + } + } + this.runBenchmark = run + window.__BLITS_BENCH__ = { ready: true, run } + }, + }, +}) diff --git a/benchmarks/src/benchmark-utils.js b/benchmarks/src/benchmark-utils.js new file mode 100644 index 00000000..eacd7470 --- /dev/null +++ b/benchmarks/src/benchmark-utils.js @@ -0,0 +1,138 @@ +export const getManualOptions = () => { + const query = new URLSearchParams(window.location.search) + return { + size: Number(query.get('size') ?? 1000), + warmup: Number(query.get('warmup') ?? 5), + samples: Number(query.get('samples') ?? 20), + } +} + +let isIdle = false +let idleWaiter + +export const untilIdle = (update) => { + if (!update && isIdle) return Promise.resolve() + return new Promise((resolve, reject) => { + // Updates must produce their own active -> idle transition. + let sawActive = !update + const cleanup = () => { + clearTimeout(timeout) + idleWaiter = undefined + } + const timeout = setTimeout(() => { + cleanup() + reject(new Error('Renderer did not become idle within 30 seconds')) + }, 30_000) + idleWaiter = (idle) => { + if (!idle) sawActive = true + if (idle && sawActive) { + cleanup() + resolve() + } + } + try { + update?.() + } catch (error) { + cleanup() + reject(error) + } + }) +} + +export const updateIdle = (idle) => { + isIdle = idle + idleWaiter?.(idle) +} + +const median = (values) => { + const sorted = values.slice().sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2 +} + +export const validateOptions = ({ size, warmup, samples }) => { + for (const [name, value] of Object.entries({ size, warmup, samples })) { + if (!Number.isSafeInteger(value) || value < (name === 'warmup' ? 0 : 1)) { + throw new Error( + `${name} must be ${name === 'warmup' ? 'a non-negative' : 'a positive'} integer` + ) + } + } +} + +export const summarizeResults = ({ title, size, warmup, samples }, results) => { + const syncTimes = results.map((result) => result.generationSyncMs) + const idleTimes = results.map((result) => result.totalToIdleMs) + return { + title, + size, + warmup, + samples, + medianGenerationSyncMs: median(syncTimes), + averageGenerationSyncMs: syncTimes.reduce((sum, value) => sum + value, 0) / samples, + minGenerationSyncMs: Math.min(...syncTimes), + maxGenerationSyncMs: Math.max(...syncTimes), + medianTotalToIdleMs: median(idleTimes), + averageTotalToIdleMs: idleTimes.reduce((sum, value) => sum + value, 0) / samples, + minTotalToIdleMs: Math.min(...idleTimes), + maxTotalToIdleMs: Math.max(...idleTimes), + results, + } +} + +export const logResults = (summary) => { + const { title, size, warmup, samples, results } = summary + console.group(title) + console.table({ + size: { value: size }, + warmup: { value: warmup }, + samples: { value: samples }, + }) + console.table({ + 'Synchronous generation (ms)': { + median: summary.medianGenerationSyncMs, + average: summary.averageGenerationSyncMs, + min: summary.minGenerationSyncMs, + max: summary.maxGenerationSyncMs, + }, + 'Total time to idle (ms)': { + median: summary.medianTotalToIdleMs, + average: summary.averageTotalToIdleMs, + min: summary.minTotalToIdleMs, + max: summary.maxTotalToIdleMs, + }, + }) + console.log('Samples', results) + console.groupEnd() +} + +let running = false + +export const runBenchmark = async ({ + title = 'Benchmark', + size = 1000, + warmup = 5, + samples = 20, + setup, + measure, + verify, +}) => { + if (running) throw new Error('Benchmark is already running') + const options = { title, size, warmup, samples } + validateOptions(options) + running = true + try { + const results = [] + for (let index = 0; index < warmup + samples; index++) { + const prepared = await setup(options) + const result = await measure(prepared) + await verify(options) + if (index >= warmup) results.push(result) + } + const summary = summarizeResults(options, results) + logResults(summary) + return summary + } finally { + running = false + } +} diff --git a/benchmarks/src/main.js b/benchmarks/src/main.js new file mode 100644 index 00000000..22766726 --- /dev/null +++ b/benchmarks/src/main.js @@ -0,0 +1,19 @@ +import Blits from '@lightningjs/blits' +import profiler from '@lightningjs/blits/profiler' +import Benchmark from './Benchmark.js' + +const query = new URLSearchParams(window.location.search) +if (query.has('profile')) profiler.start({ frames: false }) +Blits.Launch(Benchmark, 'app', { + w: 1920, + h: 1080, + debugLevel: 0, + fonts: [ + { + family: 'opensans', + type: 'msdf', + file: 'fonts/OpenSans-Medium.ttf', + }, + ], + defaultFont: 'opensans', +}) diff --git a/benchmarks/summary.js b/benchmarks/summary.js new file mode 100644 index 00000000..df494f5a --- /dev/null +++ b/benchmarks/summary.js @@ -0,0 +1,28 @@ +export const formatSummary = (report) => { + const timingRow = (label, metric) => { + const values = ['median', 'average', 'min', 'max'].map((stat) => + report[`${stat}${metric}`].toFixed(2) + ) + return `| ${label} | ${values.join(' | ')} |` + } + return [ + `## ${report.title}`, + '', + '| Configuration | Value |', + '| --- | --- |', + `| Size | ${report.size} |`, + `| Warmup | ${report.warmup} |`, + `| Samples | ${report.samples} |`, + `| CPU slowdown | ${report.cpuSlowdown}× |`, + `| Chromium | ${report.browserVersion} |`, + `| Mode | ${report.mode} |`, + '', + '| Timing (ms) | Median | Average | Min | Max |', + '| --- | ---: | ---: | ---: | ---: |', + timingRow('Synchronous generation', 'GenerationSyncMs'), + timingRow('Total time to idle', 'TotalToIdleMs'), + '', + 'Warmups are excluded. Total time to idle includes synchronous generation and frame scheduling.', + '', + ].join('\n') +} diff --git a/benchmarks/vite.config.js b/benchmarks/vite.config.js new file mode 100644 index 00000000..524e85e7 --- /dev/null +++ b/benchmarks/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import { fileURLToPath } from 'node:url' +import blitsVitePlugins from '@lightningjs/blits/vite' + +export default defineConfig({ + base: './', + plugins: [...blitsVitePlugins], + resolve: { + mainFields: ['browser', 'module', 'jsnext:main', 'jsnext'], + alias: { + // launch.js also supports a sibling renderer checkout. Benchmarks use + // the installed dependency, including for its version metadata. + '../../renderer/package.json': fileURLToPath( + new URL('../node_modules/@lightningjs/renderer/package.json', import.meta.url) + ), + }, + }, + build: { outDir: 'dist', emptyOutDir: true }, +}) diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 8c8828d4..7d6ee0ec 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -47,3 +47,4 @@ - [Animation](/plugins/animation.md) - Performance - [Lazy loading](/performance/lazy-loading.md) + - [Profiling](/performance/profiling.md) diff --git a/docs/performance/profiling.md b/docs/performance/profiling.md new file mode 100644 index 00000000..b4b6a813 --- /dev/null +++ b/docs/performance/profiling.md @@ -0,0 +1,80 @@ +# Profiling + +Blits includes a small profiler that is used internally to find and address bottlenecks in the Blits codebase itself. You can also use it in App code to measure work that is specific to your application. + +Note that the profiler is tree-shaken from production builds. It is intended for local development and performance investigation. + +## Profiling App code + +Import the profiler and start a recording before the interaction or workload you want to inspect. + +```js +import profiler from '@lightningjs/blits/profiler' + +profiler.start() +``` + +Use `mark()` before the work and `markEnd()` afterwards. The supplied name groups calls in the report. + +```js +methods: { + updateProducts() { + profiler.mark('products.sort') + this.products = [...this.products].sort((a, b) => a.price - b.price) + profiler.markEnd('products.sort') + }, +} +``` + +When the workload is complete, stop the recording and print the report. + +```js +profiler.stop() +profiler.report() +``` + +The report shows all recorded marker timings, marker timings within retained frames, and the most expensive retained frames. Frame analysis also reports frame interval and instrumented-time percentiles. + +If the measured code can throw or return early, pair the markers with `try` and `finally`. + +```js +profiler.mark('products.filter') +try { + this.filteredProducts = this.products.filter(matchesSearch) +} finally { + profiler.markEnd('products.filter') +} +``` + +## The profile helper + +`profile()` is useful when wrapping a synchronous expression is more convenient. It calls `markEnd()` in a `finally` block, but changes the shape of the measured code. + +```js +import { profile } from '@lightningjs/blits/profiler' + +const sortedProducts = profile('products.sort', () => + [...this.products].sort((a, b) => a.price - b.price) +) +``` + +## Frame analysis + +Frame analysis is enabled by default. A frame is a renderer-frame sample bounded by consecutive `frameTick` events. The profiler retains the latest 300 complete frames. You can change the limit and target frame rate when needed, or disable frame analysis when it is not needed. + +```js +profiler.start({ + maxFrames: 600, + targetFps: 60, +}) +``` + +```js +profiler.start({ frames: false }) +``` + +The frame report helps distinguish a marker that is expensive per call from one that is cheap but runs many times per frame. + +## Browser console + +In development, Blits exposes the profiler as `__BLITS_PROFILER__` in the browser console. This is useful when you want to start, stop or report on a recording without adding temporary App code for that. The markers will need to be in the actual app code, of course. diff --git a/eslint.config.cjs b/eslint.config.cjs index f434e01e..4c7ab5cb 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -37,6 +37,7 @@ module.exports = defineConfig([ ...globals.browser, ...globals.node, globalThis: false, + __DEV__: false, }, parser: babelParser, diff --git a/index.d.ts b/index.d.ts index ddbcfee2..e2f35a76 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1111,6 +1111,15 @@ declare module '@lightningjs/blits' { * the Blits App */ export interface Settings { + /** + * Start the performance profiler when the App launches + * + * The profiler is also available on `globalThis.__BLITS_PROFILER__`, so it + * can be started and stopped from the devtools console + * + * @default false + */ + profiler?: boolean, /** * Width of the Application */ @@ -1477,6 +1486,98 @@ declare module '@lightningjs/blits' { } + export interface ProfilerEntry { + /** + * Number of times the measurement was recorded + */ + calls: number, + /** + * Total time spent, in milliseconds + */ + totalMs: number, + /** + * Slowest single measurement, in milliseconds + */ + maxMs: number, + /** + * Average time per call, in milliseconds + */ + averageMs: number + } + + export interface ProfilerFrameSummary { + medianMs: number, + p95Ms: number, + p99Ms: number, + maxMs: number + } + + export interface ProfilerFrameLabel { + calls: number, + totalMs: number + } + + export interface ProfilerFrameSnapshot { + frameCount: number, + retainedFrameLimit: number, + frameInterval: ProfilerFrameSummary, + instrumentedTime: ProfilerFrameSummary, + slowFramePercent: number, + slowestFrames: Array<{ + intervalMs: number, + instrumentedMs: number, + labels: Record + }>, + labels: Record + } + + export interface ProfilerSnapshot { + measurements: Record, + frames: ProfilerFrameSnapshot + } + + export interface Profiler { + /** + * Marks the start of a measurement + */ + mark(name: string): void, + /** + * Ends the most recent measurement started with the same name + */ + markEnd(name: string): void, + /** + * Clears previously collected measurements and starts recording + */ + start(options?: { frames?: boolean, maxFrames?: number, targetFps?: number }): void, + /** + * Stops recording and returns the collected measurements + */ + stop(): ProfilerSnapshot, + /** + * Clears all collected measurements, without changing the enabled state + */ + reset(): void, + /** + * Returns the measurements collected so far + */ + snapshot(): ProfilerSnapshot, + /** + * Returns frame-oriented results collected by default or with start({ frames: true }) + */ + frameSnapshot(): ProfilerFrameSnapshot, + /** + * Logs marker and frame results as console tables and returns the combined snapshot + */ + report(): ProfilerSnapshot, + /** + * Whether the profiler is currently recording + */ + readonly enabled: boolean + } + /** * Blits App Framework */ diff --git a/package-lock.json b/package-lock.json index c050e125..8782e8c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lightningjs/blits", - "version": "2.13.0", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lightningjs/blits", - "version": "2.13.0", + "version": "2.14.0", "license": "Apache-2.0", "dependencies": { "@lightningjs/renderer": "^3.7.0", diff --git a/package.json b/package.json index b6a77fdf..b02c406a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lightningjs/blits", - "version": "2.13.0", + "version": "2.14.0", "description": "Blits: The Lightning 3 App Development Framework", "bin": "bin/index.js", "exports": { @@ -49,6 +49,10 @@ "default": "./src/testing/index.js" }, "./symbols": "./src/lib/symbols.js", + "./profiler": { + "types": "./src/lib/profiler.d.ts", + "default": "./src/lib/profiler.js" + }, "./blitsFileConverter": "./src/lib/blitsfileconverter/blitsfileconverter.js", "./components": "./src/components/FPScounter.js" }, @@ -62,6 +66,10 @@ "postpublish": "node scripts/postpublish.js", "prepare": "husky", "pack-preview": "npm pack --dry-run", + "bench": "npm --prefix benchmarks run bench --", + "bench:compare": "npm --prefix benchmarks run bench:compare --", + "bench:profile": "npm --prefix benchmarks run bench:profile --", + "bench:serve": "npm --prefix benchmarks run serve", "release": "npm publish --access public" }, "types": "./index.d.ts", diff --git a/src/launch.js b/src/launch.js index f65d9f21..92685ad1 100644 --- a/src/launch.js +++ b/src/launch.js @@ -20,6 +20,11 @@ import { initLog, Log } from './lib/log.js' import engine from './engine.js' import blitsPackageInfo from '../package.json' with { type: 'json' } import { configurePlatform } from './platform.js' +import profiler from './lib/profiler.js' + +// Vite replaces __DEV__ with a boolean at build time. The typeof check keeps +// the source usable outside Vite (for example in the test runner). +const isDev = typeof __DEV__ !== 'undefined' && __DEV__ /** * @typedef {Object} Font @@ -93,6 +98,7 @@ async function rendererVersion() { * @property {boolean} [enableMouse] - Enable mouse support (hover and click-to-focus). Defaults to false. * @property {number} [maxFPS] - Maximum FPS * @property {function} [platform] - Platform factory for non-browser environments + * @property {boolean} [profiler] - Start the performance profiler on launch */ /** @@ -130,6 +136,12 @@ export default (App, target, settings) => { initLog() + if (isDev === true) { + // expose the profiler, so it can be controlled from the devtools console + globalThis.__BLITS_PROFILER__ = profiler + if (Settings.get('profiler', false) === true) profiler.start() + } + if (warnLegacyRendererPlatform === true) { Log.warn( 'Passing a renderer platform via settings.platform is deprecated. Use settings.rendererPlatform instead.' @@ -144,4 +156,7 @@ export default (App, target, settings) => { stage.element = engine.Element renderer = engine.Launch(App, target, settings) + if (isDev === true) { + profiler.setRenderer(renderer) + } } diff --git a/src/lib/profiler.d.ts b/src/lib/profiler.d.ts new file mode 100644 index 00000000..e79194b2 --- /dev/null +++ b/src/lib/profiler.d.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Profiler } from '@lightningjs/blits' + +export type { + Profiler, + ProfilerEntry, + ProfilerFrameLabel, + ProfilerFrameSummary, + ProfilerFrameSnapshot, + ProfilerSnapshot, +} from '@lightningjs/blits' + +/** Session-bound measurement token. Pass unchanged to profileEnd. */ +export interface ProfileToken { + readonly sessionId: number + readonly startTime: number +} + +export interface ProfilerFrameOptions { + frames?: boolean + maxFrames?: number + targetFps?: number +} + +/** + * Marks the start of a measurement. + * + * Returns `0` when the profiler is disabled. + */ +export function profileBegin(): ProfileToken | 0 + +/** + * Records a measurement started with `profileBegin` + */ +export function profileEnd(name: string, start: ProfileToken | 0): void + +/** + * Measures the execution time of a (synchronous) function + */ +export function profile(name: string, fn: () => T): T + +/** Attaches the profiler to a renderer that emits frameTick events. */ +export function setProfilerRenderer(renderer: unknown): void + +declare const profiler: Profiler +export default profiler diff --git a/src/lib/profiler.js b/src/lib/profiler.js new file mode 100644 index 00000000..342e196c --- /dev/null +++ b/src/lib/profiler.js @@ -0,0 +1,386 @@ +/* + * Copyright 2026 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Log } from './log.js' + +/** + * @typedef {Object} ProfilerEntry + * @property {number} calls - Number of times the measurement was recorded + * @property {number} totalMs - Total time spent, in milliseconds + * @property {number} maxMs - Slowest single measurement, in milliseconds + * @property {number} averageMs - Average time per call, in milliseconds + */ + +const stats = new Map() +const activeMarks = new Map() +let enabled = false +let sessionId = 0 +let activeSpan +let renderer +let frameTickHandler +let frameConfig +let activeFrame +let frameHistory = [] + +const hasPerformance = typeof globalThis.performance === 'object' + +const now = () => (hasPerformance === true ? globalThis.performance.now() : Date.now()) + +const percentile = (values, percent) => { + if (values.length === 0) return 0 + const index = Math.min(values.length - 1, Math.floor(values.length * percent)) + return values.slice().sort((a, b) => a - b)[index] +} + +const frameSummary = (values) => ({ + medianMs: percentile(values, 0.5), + p95Ms: percentile(values, 0.95), + p99Ms: percentile(values, 0.99), + maxMs: values.length === 0 ? 0 : Math.max(...values), +}) + +const clearFrames = () => { + activeFrame = undefined + frameHistory = [] +} + +const finishFrame = (data) => { + if (activeFrame === undefined || activeFrame.sessionId !== sessionId) return + activeFrame.intervalMs = typeof data.delta === 'number' ? data.delta : 0 + frameHistory.push(activeFrame) + if (frameHistory.length > frameConfig.maxFrames) frameHistory.shift() +} + +const beginFrame = (data) => { + finishFrame(data) + activeFrame = { + sessionId, + intervalMs: 0, + totalMs: 0, + labels: new Map(), + } +} + +const onFrameTick = (...args) => { + if (enabled !== true || frameConfig === undefined) return + const data = args[args.length - 1] + if (!data || typeof data.time !== 'number') return + beginFrame(data) +} + +/** + * Attaches optional frame collection to a renderer that emits `frameTick`. + * @param {{on?: Function, off?: Function}} nextRenderer RendererMain instance + * @returns {void} + */ +export const setProfilerRenderer = (nextRenderer) => { + if (renderer === nextRenderer) return + if (renderer && frameTickHandler && typeof renderer.off === 'function') { + renderer.off('frameTick', frameTickHandler) + } + renderer = nextRenderer + frameTickHandler = undefined + if (renderer && typeof renderer.on === 'function') { + frameTickHandler = onFrameTick + renderer.on('frameTick', frameTickHandler) + } +} + +/** + * Marks the start of a measurement. + * + * Returns `0` when the profiler is disabled, which keeps the overhead + * down to a single boolean check. + * + * @returns {{sessionId: number, startTime: number} | 0} Token to pass into {@link profileEnd} + */ +export const profileBegin = () => { + if (enabled !== true) return 0 + const token = { + sessionId, + startTime: now(), + parent: activeSpan, + frame: activeFrame, + } + activeSpan = token + return token +} + +/** + * Records a measurement started with {@link profileBegin}. + * + * @param {string} name - Label the measurement is aggregated under + * @param {{sessionId: number, startTime: number} | 0} start - Token returned by {@link profileBegin} + * @returns {void} + */ +export const profileEnd = (name, start) => { + if (enabled !== true || !start || start.sessionId !== sessionId) return + + let entry = stats.get(name) + if (entry === undefined) { + entry = { calls: 0, totalMs: 0, maxMs: 0 } + stats.set(name, entry) + } + + const elapsed = now() - start.startTime + start.closed = true + entry.calls++ + entry.totalMs += elapsed + if (elapsed > entry.maxMs) entry.maxMs = elapsed + + if (start.frame && start.frame.sessionId === sessionId) { + let frameEntry = start.frame.labels.get(name) + if (frameEntry === undefined) { + frameEntry = { calls: 0, totalMs: 0 } + start.frame.labels.set(name, frameEntry) + } + frameEntry.calls++ + frameEntry.totalMs += elapsed + if (start.parent === undefined || start.parent.frame !== start.frame) + start.frame.totalMs += elapsed + } + + if (activeSpan === start) { + activeSpan = start.parent + while (activeSpan !== undefined && activeSpan.closed === true) activeSpan = activeSpan.parent + } +} + +const mark = (name) => { + if (enabled !== true) return + + let marks = activeMarks.get(name) + if (marks === undefined) { + marks = [] + activeMarks.set(name, marks) + } + marks.push(profileBegin()) +} + +const markEnd = (name) => { + if (enabled !== true) return + + const marks = activeMarks.get(name) + if (marks === undefined) return + + const start = marks.pop() + if (marks.length === 0) activeMarks.delete(name) + profileEnd(name, start) +} + +/** + * Measures the execution time of a (synchronous) function. + * + * @template T + * @param {string} name - Label the measurement is aggregated under + * @param {() => T} fn - Function to measure + * @returns {T} The return value of `fn` + */ +export const profile = (name, fn) => { + if (enabled !== true) return fn() + const start = profileBegin() + try { + return fn() + } finally { + profileEnd(name, start) + } +} + +const measurementsSnapshot = () => + Object.fromEntries( + [...stats].map(([name, entry]) => [name, { ...entry, averageMs: entry.totalMs / entry.calls }]) + ) + +const frameSnapshot = () => { + const config = frameConfig || { maxFrames: 0, targetFrameMs: 1000 / 60 } + const intervals = frameHistory.map((frame) => frame.intervalMs) + const instrumented = frameHistory.map((frame) => frame.totalMs) + const labels = new Map() + for (const frame of frameHistory) { + for (const [name, entry] of frame.labels) { + let total = labels.get(name) + if (total === undefined) { + total = { calls: 0, totalMs: 0 } + labels.set(name, total) + } + total.calls += entry.calls + total.totalMs += entry.totalMs + } + } + const frameCount = frameHistory.length + const slowFrames = intervals.filter((value) => value > config.targetFrameMs * 1.5).length + const slowestFrames = frameHistory + .slice() + .sort((a, b) => b.totalMs - a.totalMs) + .slice(0, 10) + .map((frame) => ({ + intervalMs: frame.intervalMs, + instrumentedMs: frame.totalMs, + labels: Object.fromEntries(frame.labels), + })) + return { + frameCount, + retainedFrameLimit: config.maxFrames, + frameInterval: frameSummary(intervals), + instrumentedTime: frameSummary(instrumented), + slowFramePercent: frameCount === 0 ? 0 : (slowFrames / frameCount) * 100, + slowestFrames, + labels: Object.fromEntries( + [...labels].map(([name, entry]) => [ + name, + { + ...entry, + callsPerFrame: frameCount === 0 ? 0 : entry.calls / frameCount, + averageMsPerFrame: frameCount === 0 ? 0 : entry.totalMs / frameCount, + }, + ]) + ), + } +} + +const snapshot = () => ({ + measurements: measurementsSnapshot(), + frames: frameSnapshot(), +}) + +const report = () => { + const result = snapshot() + const measurements = result.measurements + const frameLabels = result.frames.labels + const slowFrames = Object.fromEntries( + result.frames.slowestFrames.map((frame, index) => [ + `#${index + 1}`, + { + intervalMs: frame.intervalMs, + instrumentedMs: frame.instrumentedMs, + labels: Object.entries(frame.labels) + .sort(([, a], [, b]) => b.totalMs - a.totalMs) + .map( + ([name, entry]) => + `${name}: ${entry.totalMs.toFixed(2)} ms (${entry.calls} ${ + entry.calls === 1 ? 'call' : 'calls' + })` + ) + .join(' | '), + }, + ]) + ) + + Log.info('Profiler summary') + console.table({ + 'Measurements tracked': Object.keys(measurements).length, + 'Frames analyzed': result.frames.frameCount, + 'Median frame interval (ms)': result.frames.frameInterval.medianMs, + 'p95 frame interval (ms)': result.frames.frameInterval.p95Ms, + 'Median instrumented time (ms)': result.frames.instrumentedTime.medianMs, + 'p95 instrumented time (ms)': result.frames.instrumentedTime.p95Ms, + 'Slow frames (%)': result.frames.slowFramePercent, + }) + if (Object.keys(measurements).length) { + Log.info('Profiler: all marker timings') + console.table(measurements) + } + if (Object.keys(frameLabels).length) { + Log.info('Profiler: marker timings within retained frames') + console.table(frameLabels) + } + if (Object.keys(slowFrames).length) { + Log.info('Profiler: most expensive retained frames') + console.table(slowFrames) + } + return result +} + +/** + * Lightweight, opt-in profiler used to measure performance bottlenecks + * in Blits internals and in App code. + */ +const profiler = { + /** + * Clears previously collected measurements and starts recording + * @returns {void} + */ + start(options = {}) { + sessionId++ + stats.clear() + activeMarks.clear() + activeSpan = undefined + frameConfig = + options.frames !== false + ? { + maxFrames: options.maxFrames || 300, + targetFrameMs: 1000 / (options.targetFps || 60), + } + : undefined + clearFrames() + enabled = true + }, + /** + * Marks the start of a measurement + * @param {string} name - Label the measurement is aggregated under + * @returns {void} + */ + mark, + /** + * Ends the most recent measurement started with the same name + * @param {string} name - Label passed to {@link mark} + * @returns {void} + */ + markEnd, + /** + * Stops recording and returns the collected measurements + * @returns {Object.} + */ + stop() { + enabled = false + activeMarks.clear() + activeSpan = undefined + return snapshot() + }, + /** + * Clears collected measurements and pending spans, without changing the enabled state + * @returns {void} + */ + reset() { + sessionId++ + stats.clear() + activeMarks.clear() + activeSpan = undefined + clearFrames() + }, + /** + * Returns the measurements collected so far + * @returns {Object.} + */ + snapshot, + report, + setRenderer: setProfilerRenderer, + /** + * Returns frame-oriented measurements collected by default or with start({ frames: true }). + * @returns {object} + */ + frameSnapshot, + /** + * Whether the profiler is currently recording + * @returns {boolean} + */ + get enabled() { + return enabled + }, +} + +export default profiler diff --git a/src/lib/profiler.test.js b/src/lib/profiler.test.js new file mode 100644 index 00000000..eaad0c4b --- /dev/null +++ b/src/lib/profiler.test.js @@ -0,0 +1,287 @@ +/* + * Copyright 2026 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import test from 'tape' +import profiler, { profile, profileBegin, profileEnd } from './profiler.js' +import { initLog } from './log.js' +import Settings from '../settings.js' + +const fakeRenderer = () => { + let handler + return { + on(event, callback) { + if (event === 'frameTick') handler = callback + }, + off(event, callback) { + if (event === 'frameTick' && handler === callback) handler = undefined + }, + frame(time, delta) { + handler(this, { time, delta }) + }, + } +} + +test('Profiler - rejects measurements from outside the current session', (assert) => { + const disabled = profileBegin() + profiler.start() + profileEnd('disabled', disabled) + const previous = profileBegin() + profiler.start() + profileEnd('previous', previous) + const stopped = profileBegin() + profiler.stop() + profileEnd('stopped', stopped) + profiler.start() + profileEnd('restarted', stopped) + assert.deepEqual(profiler.stop().measurements, {}, 'only current-session spans may be recorded') + assert.end() +}) + +test('Profiler - reset invalidates pending spans and preserves enabled state', (assert) => { + profiler.start() + const pending = profileBegin() + profiler.mark('named') + profiler.mark('named') + profile('recorded', () => 42) + profiler.reset() + profileEnd('pending', pending) + profiler.markEnd('named') + profiler.markEnd('named') + assert.equal(profiler.enabled, true) + assert.deepEqual( + profiler.snapshot().measurements, + {}, + 'reset clears results and all pending spans' + ) + profileEnd('fresh', profileBegin()) + assert.equal(profiler.stop().measurements.fresh.calls, 1, 'new spans still record') + profiler.reset() + assert.equal(profiler.enabled, false, 'reset does not enable a stopped profiler') + assert.end() +}) + +test('Profiler - wrappers cannot record across session boundaries', (assert) => { + for (const changeSession of [() => profiler.start(), () => profiler.reset()]) { + profiler.start() + assert.equal( + profile('stale', () => { + changeSession() + return 42 + }), + 42 + ) + assert.deepEqual(profiler.stop().measurements, {}, 'finally ignores a stale session token') + } + assert.end() +}) + +test('Profiler - disabled by default', (assert) => { + assert.equal(profiler.enabled, false, 'profiler should be disabled') + assert.equal(profileBegin(), 0, 'profileBegin should return 0 when disabled') + + profileEnd('disabled', 0) + assert.deepEqual(profiler.snapshot().measurements, {}, 'no measurements should be recorded') + assert.end() +}) + +test('Profiler - records measurements when started', (assert) => { + profiler.start() + assert.equal(profiler.enabled, true, 'profiler should be enabled') + + profileEnd('render', profileBegin()) + profileEnd('render', profileBegin()) + profileEnd('update', profileBegin()) + + const result = profiler.stop() + + assert.equal(profiler.enabled, false, 'profiler should be disabled after stop') + assert.deepEqual( + Object.keys(result.measurements).sort(), + ['render', 'update'], + 'should record both labels' + ) + assert.equal(result.measurements.render.calls, 2, 'render should be recorded twice') + assert.equal(result.measurements.update.calls, 1, 'update should be recorded once') + assert.equal( + result.measurements.render.averageMs, + result.measurements.render.totalMs / 2, + 'average should be total divided by the number of calls' + ) + assert.ok( + result.measurements.render.maxMs <= result.measurements.render.totalMs, + 'max should not exceed the total' + ) + assert.end() +}) + +test('Profiler - named marks record measurements', (assert) => { + profiler.start() + + profiler.mark('render') + profiler.mark('render') + profiler.markEnd('render') + profiler.markEnd('render') + profiler.markEnd('missing') + + const result = profiler.stop() + assert.equal(result.measurements.render.calls, 2, 'should record both marks with the same name') + assert.equal(result.measurements.missing, undefined, 'an unmatched mark end should be ignored') + assert.end() +}) + +test('Profiler - named marks do not record when disabled', (assert) => { + profiler.reset() + profiler.mark('disabled') + profiler.markEnd('disabled') + + assert.deepEqual(profiler.snapshot().measurements, {}, 'no measurements should be recorded') + assert.end() +}) + +test('Profiler - start clears previous measurements', (assert) => { + profiler.start() + profileEnd('first', profileBegin()) + profiler.start() + + assert.deepEqual(profiler.snapshot().measurements, {}, 'previous measurements should be cleared') + + profileEnd('second', profileBegin()) + profiler.reset() + assert.deepEqual(profiler.snapshot().measurements, {}, 'reset should clear measurements') + assert.equal(profiler.enabled, true, 'reset should not change the enabled state') + + profiler.stop() + assert.end() +}) + +test('Profiler - profile() wraps a function', (assert) => { + profiler.start() + + const result = profile('calculate', () => 42) + assert.equal(result, 42, 'should return the result of the wrapped function') + assert.equal(profiler.snapshot().measurements.calculate.calls, 1, 'should record a measurement') + + assert.throws( + () => + profile('failing', () => { + throw new Error('oops') + }), + /oops/, + 'should rethrow errors from the wrapped function' + ) + assert.equal( + profiler.snapshot().measurements.failing.calls, + 1, + 'should record a measurement when throwing' + ) + + profiler.stop() + + assert.equal( + profile('ignored', () => 'value'), + 'value', + 'should pass through when disabled' + ) + assert.equal( + profiler.snapshot().measurements.ignored, + undefined, + 'should not record when disabled' + ) + assert.end() +}) + +test('Profiler - frame analysis records bounded complete frames', (assert) => { + const renderer = fakeRenderer() + profiler.setRenderer(renderer) + profiler.start({ maxFrames: 2, targetFps: 60 }) + renderer.frame(100, 16) + profile('outer', () => profile('inner', () => 42)) + renderer.frame(120, 20) + profileEnd('work', profileBegin()) + renderer.frame(150, 30) + profileEnd('work', profileBegin()) + renderer.frame(190, 40) + + const frames = profiler.frameSnapshot() + assert.equal( + frames.frameCount, + 2, + 'starts frame analysis by default and keeps the configured frames' + ) + assert.equal(frames.frameInterval.maxMs, 40, 'uses the closing renderer frame interval') + assert.equal(frames.labels.work.calls, 2, 'attributes spans to frames') + assert.equal(frames.slowFramePercent, 100, 'counts intervals over 1.5 target frame times') + assert.equal(frames.slowestFrames.length, 2, 'keeps expensive frame attribution for inspection') + const table = console.table + const info = console.info + const tables = [] + const titles = [] + Settings.set('debugLevel', 1) + initLog() + console.table = (value) => tables.push(value) + console.info = (...args) => titles.push(args.at(-1)) + const result = profiler.report() + console.table = table + console.info = info + assert.equal(result.frames.frameCount, 2, 'includes frames in the combined snapshot') + assert.equal( + result.measurements.work.calls, + 2, + 'includes marker measurements in the combined snapshot' + ) + assert.equal(tables.length, 4, 'prints summary, markers, frame labels, and slow frames') + assert.match( + tables[3]['#1'].labels, + /work: \d+\.\d{2} ms \(1 call\)/, + 'shows each expensive frame label contribution' + ) + assert.deepEqual( + titles, + [ + 'Profiler summary', + 'Profiler: all marker timings', + 'Profiler: marker timings within retained frames', + 'Profiler: most expensive retained frames', + ], + 'labels each report table' + ) + profiler.stop() + profiler.setRenderer(undefined) + assert.end() +}) + +test('Profiler - out-of-order marks do not hide later frame totals', (assert) => { + const renderer = fakeRenderer() + profiler.setRenderer(renderer) + profiler.start() + renderer.frame(100, 16) + + profiler.mark('first') + profiler.mark('second') + profiler.markEnd('first') + profiler.markEnd('second') + profiler.mark('independent') + setTimeout(() => { + profiler.markEnd('independent') + renderer.frame(120, 20) + const frame = profiler.frameSnapshot().slowestFrames[0] + assert.ok(frame.instrumentedMs >= 10, 'includes work after the out-of-order marks close') + assert.ok(frame.labels.independent.totalMs >= 10, 'records the independent marker in the frame') + profiler.stop() + assert.end() + }, 20) +}) diff --git a/src/router/router.test.js b/src/router/router.test.js index 17445f67..96cef0d4 100644 --- a/src/router/router.test.js +++ b/src/router/router.test.js @@ -2578,3 +2578,69 @@ test('reuseComponent reuses the same view instance when returning to the route', stage.element = originalElement }) + +test('Edge case: async hook failure with empty history should not cause endless loop', async (assert) => { + const originalElement = stage.element + stage.element = ({ parent }) => ({ + populate() {}, + set() {}, + destroy() {}, + parent, + }) + + const TestComponent = Component('TestComponent', { + template: '', + code: { + render: () => ({ + elms: [ + { + [symbols.holder]: { destroy: () => {} }, + node: {}, + }, + ], + cleanup: () => {}, + }), + effects: [], + }, + }) + + const host = { + [symbols.parent]: { + [symbols.routes]: [ + { + path: '/page1', + component: TestComponent, + hooks: { + async before() { + throw new Error('Hook failed') + }, + }, + }, + ], + [symbols.routerHooks]: { + async beforeEach() { + throw new Error('BeforeEach hook failed') + }, + }, + }, + [symbols.children]: [{}], + [symbols.props]: {}, + } + + to('/page1') + + try { + await navigate.call(host) + } catch { + // Expected to fail + } + + // Wait a bit to ensure no endless loop + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Verify navigation state is properly reset + assert.equal(state.navigating, false, 'Navigation state should be reset after hook failure') + assert.ok(true, 'Should not cause endless loop when hook fails with empty history') + + stage.element = originalElement +})